컬렉션: 리스트와 튜플

컬렉션: 리스트와 튜플

엘릭서에서 가장 많이 쓰는 두 가지 컬렉션 데이터 타입이 **리스트(list)**와 **튜플(tuple)**이에요. 둘 다 불변(immutable)이라 값이 절대 바뀌지 않고, 대신 새 값을 만들어내요. 이 장에서는 리스트의 head·tail과 튜플의 특징, 그리고 언제 무엇을 골라야 하는지, sizelength의 차이까지 정리할게요.

출처: Lists and tuples — Elixir 공식 getting-started 가이드

본문

이 장에서는 엘릭서에서 가장 많이 쓰이는 두 가지 컬렉션 데이터 타입, 리스트와 튜플을 배워요.

(연결) 리스트

엘릭서는 대괄호로 값의 리스트를 나타내요. 값은 어떤 타입이든 될 수 있어요.

iex> [1, 2, true, 3]
[1, 2, true, 3]
iex> length([1, 2, 3])
3

두 리스트는 ++ 연산자로 이어붙이고, -- 연산자로 뺄 수 있어요.

iex> [1, 2, 3] ++ [4, 5, 6]
[1, 2, 3, 4, 5, 6]
iex> [1, true, 2, false, 3, true] -- [true, false]
[1, 2, 3, true]

리스트 연산자는 기존 리스트를 절대 수정하지 않아요. 요소를 이어붙이거나 제거하면 새 리스트를 돌려주죠. 이런 엘릭서 데이터 구조를 *불변(immutable)*이라 불러요. 불변성의 장점 하나는 코드가 더 명확해진다는 점이에요. 데이터를 자유롭게 넘겨도, 누군가 메모리에서 그 값을 바꾸지 않는다는 보장이 있어요 — 단지 변환할 뿐이죠.

튜토리얼 내내 리스트의 head와 tail을 많이 이야기할 거예요. head는 리스트의 첫 요소, tail은 나머지 부분이에요. hdtl 함수로 얻을 수 있어요. 리스트를 변수에 넣고 head와 tail을 꺼내 볼게요.

iex> list = [1, 2, 3]
iex> hd(list)
1
iex> tl(list)
[2, 3]

빈 리스트의 head나 tail을 얻으려 하면 오류가 나요.

iex> hd([])
** (ArgumentError) argument error

때때로 리스트를 만들면 ~c가 앞에 붙은 인용된 값으로 돌려줄 때가 있어요. 예를 들면 이렇죠.

iex> [11, 12, 13]
~c"\v\f\r"
iex> [104, 101, 108, 108, 111]
~c"hello"

엘릭서는 출력 가능한 ASCII 숫자 리스트를 보면 그걸 charlist(문자 리스트)로 출력해요. charlist는 기존 Erlang 코드와 연동할 때 꽤 흔해요. IEx에서 값이 정확히 뭔지 헷갈릴 때는 i로 그 값에 대한 정보를 얻을 수 있어요.

iex> i ~c"hello"
Term
  i ~c"hello"
Data type
  List
Description
  ...
Raw representation
  [104, 101, 108, 108, 111]
Reference modules
  List
Implemented protocols
  ...

charlist에 대해 더 자세히는 "Binaries, strings, and charlists" 장에서 다룰 거예요.

작은따옴표 문자열 {: .info}

엘릭서에서 'hello'로도 charlist를 만들 수 있어요. 하지만 이 표기는 엘릭서 v1.15부터 soft-deprecated 되었고, 앞으로 버전에서 경고를 낼 거예요. ~c"hello"를 쓰는 편이 좋아요.

튜플

엘릭서는 중괄호로 튜플을 정의해요. 리스트처럼 튜플도 어떤 값이든 담을 수 있어요.

iex> {:ok, "hello"}
{:ok, "hello"}
iex> tuple_size({:ok, "hello"})
2

튜플은 메모리에 요소를 연속적으로 저장해요. 그래서 인덱스로 요소에 접근하거나 튜플 크기를 얻는 것은 빠른 연산이에요. 인덱스는 0부터 시작해요.

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> elem(tuple, 1)
"hello"
iex> tuple_size(tuple)
2

튜플의 특정 인덱스에 요소를 넣을 수도 있어요. put_elem을 쓰면 되죠.

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> put_elem(tuple, 1, "world")
{:ok, "world"}
iex> tuple
{:ok, "hello"}

put_elem이 새 튜플을 돌려줬다는 점을 주목해요. tuple 변수에 저장된 원래 튜플은 수정되지 않았어요. 리스트와 마찬가지로 튜플도 불변해요. 튜플의 모든 연산은 새 튜플을 돌려주지, 주어진 튜플을 바꾸지 않아요.

리스트 vs 튜플

리스트와 튜플의 차이는 무엇일까요?

리스트는 메모리에 **연결 리스트(linked list)**로 저장돼요. 각 요소가 자신의 값과 다음 요소를 가리키는 포인터를 갖고 리스트 끝까지 이어지죠. 그래서 리스트의 길이를 아는 것은 선형 연산이에요. 크기를 알아내려면 전체 리스트를 훑어야 하니까요.

마찬가지로 리스트 이어붙이기 성능은 왼쪽 리스트의 길이에 따라 달라져요.

iex> list = [1, 2, 3]
[1, 2, 3]

# This is fast as we only need to traverse `[0]` to prepend to `list`
iex> [0] ++ list
[0, 1, 2, 3]

# This is slow as we need to traverse `list` to append 4
iex> list ++ [4]
[1, 2, 3, 4]

반면 튜플은 메모리에 연속적으로 저장돼요. 그래서 크기를 얻거나 인덱스로 요소에 접근하는 건 빠르죠. 그 대신 튜플의 요소를 갱신·추가하는 것은 새 튜플을 메모리에 만들어야 해서 비싸요.

iex> tuple = {:a, :b, :c, :d}
{:a, :b, :c, :d}
iex> put_elem(tuple, 2, :e)
{:a, :b, :e, :d}

다만 요소 자체는 복사되지 않아요. 튜플을 갱신하면 교체된 항목을 제외한 모든 항목은 옛 튜플과 새 튜플이 공유해요. 이 규칙은 엘릭서의 대부분의 데이터 구조에 적용돼요. 이 덕분에 언어가 수행해야 할 메모리 할당이 줄어들고, 불변 의미론 덕분에 가능한 일이에요.

이런 성능 특성이 데이터 구조의 용도를 결정해요. 요약하면, 리스트는 돌려줄 요소의 개수가 달라질 수 있을 때, 튜플은 크기가 고정돼 있을 때 써요. String 모듈의 두 예시를 볼게요.

iex> String.split("hello world")
["hello", "world"]
iex> String.split("hello beautiful world")
["hello", "beautiful", "world"]

String.split 함수는 문자열을 공백 문자마다 문자열의 리스트로 쪼개요. 돌려주는 요소의 개수가 입력에 따라 달라지니 리스트를 쓰는 거예요.

반면 String.split_at은 주어진 위치에서 문자열을 두 부분으로 쪼개요. 입력 크기와 무관하게 항상 두 항목을 돌려주므로 튜플을 써요.

iex> String.split_at("hello world", 3)
{"hel", "lo world"}
iex> String.split_at("hello world", -4)
{"hello w", "orld"}

튜플과 원자를 조합해 **태그된 튜플(tagged tuple)**을 만드는 것도 아주 흔해요. 연산이 성공할 수도 실패할 수도 있을 때 유용한 반환 값이죠. 예를 들어 File.read는 주어진 경로의 파일 내용을 읽는 함수로, 파일이 있을 수도 없을 수도 있어요. 태그된 튜플을 돌려주죠.

iex> File.read("path/to/existing/file")
{:ok, "... contents ..."}
iex> File.read("path/to/unknown/file")
{:error, :enoent}

File.read에 준 경로가 있으면 첫 요소가 :ok 원자이고 두 번째가 파일 내용인 튜플을 돌려줘요. 경로가 없으면 :error와 오류 설명이 담긴 튜플을 돌려주죠. 곧 배우겠지만, 엘릭서는 태그된 튜플에 패턴 매칭을 해 성공·실패 두 경우를 쉽게 처리하게 해줘요.

엘릭서가 이런 규칙을 꾸준히 따르기 때문에, 언어를 배우고 쓸수록 리스트와 튜플 선택은 더 명확해져요. 엘릭서는 종종 올바른 선택을 하도록 이끌어 주죠. 예를 들어 튜플 항목에 접근하는 elem 함수가 있어요.

iex> tuple = {:ok, "hello"}
{:ok, "hello"}
iex> elem(tuple, 1)
"hello"

하지만 리스트는 요소 개수를 미리 알 수 없는 경우가 많아서, head를 제외하고 리스트의 임의 항목에 접근하는 내장 방법이 없어요.

size일까 length일까?

데이터 구조의 요소를 셀 때 엘릭서도 간단한 규칙을 따라요. 연산이 상수 시간(값이 미리 계산됨)이면 함수 이름이 size, 선형(입력이 커질수록 계산이 느려짐)이면 length예요. 기억하기 좋은 팁으로, "length"와 "linear"는 둘 다 "l"로 시작하죠.

예를 들어 지금까지 byte_size(문자열의 바이트 수), tuple_size(튜플 크기), length(리스트 길이), String.length(문자열의 그래핌 수) 네 개의 세는 함수를 봤어요. 문자열의 바이트 수를 얻는 byte_size는 저렴한 연산이에요. 반면 Unicode 그래핌 수를 얻는 String.length는 문자열 전체를 순회해야 해서 비쌀 수 있어요.

이제 언어의 기본 데이터 타입에 익숙해졌으니, 더 복잡한 데이터 구조를 다루기 전에 코드를 짜는 중요한 구성 요소를 배워볼게요.

더 알아보기