키워드 리스트와 맵

키워드 리스트와 맵

이제 연관 데이터 구조(associative data structures)에 대해 이야기해 볼게요. 연관 데이터 구조는 특정 키에 어떤 값을 연결해 주는 구조예요. 언어마다 딕셔너리, 해시, 연관 배열 등 다양한 이름으로 부르죠.

Elixir에는 키워드 리스트(keyword list)와 맵(map)이라는 두 가지 대표적인 연관 데이터 구조가 있어요.

출처: Keyword lists and maps

본문

키워드 리스트

키워드 리스트는 함수에 옵션을 전달할 때 쓰는 데이터 구조예요. 실제로 쓸 만한 상황을 보여드릴게요.

숫자로 된 문자열을 나누고 싶다고 해요. 처음에는 String.split/2에 문자열 두 개를 인자로 넘길 수 있어요.

iex> String.split("1 2 3 4", " ")
["1", "2", "3", "4"]

만약 최대 2번만 나누고 싶다면? String.split/3parts 옵션을 결과의 최대 항목 수로 설정할 수 있어요.

iex> String.split("1 2 3 4", " ", [parts: 3])
["1", "2", "3 4"]

3개로 나뉘었고, 마지막 항목에는 나누지 않은 나머지가 그대로 들어 있어요.

이번에는 입력 사이에 추가 공백이 있다고 상상해 볼게요.

iex> String.split("1  2  3  4", " ", [parts: 3])
["1", "", "2  3  4"]

추가 공백 때문에 출력에 빈 항목이 생겼어요. 다행히 trim 옵션을 true로 주면 제거할 수 있어요.

iex> String.split("1  2  3  4", " ", [parts: 3, trim: true])
["1", "2", " 3  4"]

[parts: 3], [parts: 3, trim: true]가 키워드 리스트예요. 키워드 리스트가 함수의 마지막 인자일 때는 대괄호를 생략하고 쓸 수 있어요.

iex> String.split("1  2  3  4", " ", parts: 3, trim: true)
["1", "2", " 3  4"]

이름 그대로 키워드 리스트는 그냥 리스트예요. 구체적으로는 2개의 항목으로 된 튜플로 이루어진 리스트인데, 첫 번째 요소(키)는 아톰이고 두 번째 요소는 어떤 값이든 될 수 있어요. 아래 두 표현은 같아요.

iex> [{:parts, 3}, {:trim, true}] == [parts: 3, trim: true]
true

키워드 리스트가 중요한 이유는 세 가지 특별한 특징 때문이에요.

  • 키는 반드시 아톰이에요.
  • 키는 개발자가 지정한 순서를 유지해요.
  • 키를 두 번 이상 줄 수 있어요.

예를 들어 Elixir에서 함수를 import할 때 키가 반복될 수 있다는 점을 활용해요.

iex> import String, only: [split: 1, split: 2]
String
iex> split("hello world")
["hello", "world"]

키워드 리스트는 리스트이므로 리스트에 쓸 수 있는 모든 연산을 사용할 수 있어요. ++로 새 값을 추가할 수도 있죠.

iex> list = [a: 1, b: 2]
[a: 1, b: 2]
iex> list ++ [c: 3]
[a: 1, b: 2, c: 3]
iex> [a: 0] ++ list
[a: 0, a: 1, b: 2]

대괄호 문법으로 키워드 리스트의 값을 읽으면 처음 매칭된 키의 값을 돌려줘요. 이것은 Access 모듈이 정의하는 접근 문법이라고도 불러요.

iex> list[:a]
1
iex> list[:b]
2

키워드 리스트에 패턴 매칭을 쓸 수는 있지만, 실제로는 잘 안 해요. 리스트 패턴 매칭은 개수와 순서가 정확히 맞아야 하기 때문이에요.

iex> [a: a] = [a: 1]
[a: 1]
iex> a
1
iex> [a: a] = [a: 1, b: 2]
** (MatchError) no match of right hand side value: [a: 1, b: 2]

게다가 키워드 리스트는 선택적 인자로 자주 쓰여서 어떤 키가 없을 수도 있어요. 그런 상황에서는 매칭이 불가능하죠. 한마디로 키워드 리스트에는 패턴 매칭을 하지 않는 게 좋아요.

키워드 리스트를 다루기 위해 Elixir는 Keyword 모듈을 제공해요. 다만 키워드 리스트는 그냥 리스트라서 선형 성능 특성을 그대로 가져요. 리스트가 길수록 키를 찾거나 항목 수를 세는 데도 오래 걸려요. 키-값 구조에 많은 키를 저장해야 한다면 곧 배울 맵을 써야 해요.

do-블록과 키워드

키워드는 주로 선택적 값을 전달하는 데 쓰여요. 사실 우리는 앞의 장에서 이미 키워드를 썼어요. if/2 매크로를 볼까요.

iex> if true do
...>   "This will be seen"
...> else
...>   "This won't"
...> end
"This will be seen"

이 예에서 doelse 블록은 사실 키워드 리스트를 이루고 있어요. 블록 문법은 키워드 리스트 위에 얹힌 문법적 편의일 뿐이에요. 위 코드는 이렇게 다시 쓸 수 있어요.

iex> if(true, do: "This will be seen", else: "This won't")
"This will be seen"

두 문법 모두 주의 깊게 보세요. 두 번째 예는 정확히 String.split/3처럼 키워드 리스트를 쓰는데, 각 키-값 쌍을 쉼표로 구분하고 각 키 뒤에 :를 붙여요. 반면 do-블록에서는 do, else, end 같은 단어를 쓰고 새 줄로 구분하죠. 블록을 작성할 때는 이 블록 문법이 유용해요. 대부분 블록 문법을 쓰겠지만, 둘이 동등하다는 건 알아두면 좋아요.

여기서 중요한 점은 두 문법이 같다는 사실, 즉 언어를 표현하는 데 데이터 구조가 몇 개면 충분하다는 거예요. 이 주제는 나중에 optional syntax메타 프로그래밍에서 다시 다룰게요.

키-값 쌍으로서의 맵

키-값 쌍을 저장해야 한다면 맵이 Elixir의 기본 데이터 구조예요. 맵은 %{} 문법으로 만들어요.

iex> map = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> map[:a]
1
iex> map[2]
:b
iex> map[:c]
nil

키워드 리스트와 비교하면 두 가지 차이가 보여요.

  • 맵은 어떤 값이든 키로 쓸 수 있어요.
  • 맵은 자체적인 내부 순서가 있어요. 같은 키를 가진 맵이라도 순서가 같다는 보장이 없어요.

키워드 리스트와 달리 맵은 패턴 매칭에 아주 유용해요. 맵을 패턴으로 쓰면 주어진 값의 일부분(subset)에 항상 매칭돼요.

iex> %{} = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> %{:a => a} = %{:a => 1, 2 => :b}
%{2 => :b, :a => 1}
iex> a
1
iex> %{:c => c} = %{:a => 1, 2 => :b}
** (MatchError) no match of right hand side value: %{2 => :b, :a => 1}

패턴에 있는 키가 주어진 맵에 존재하기만 하면 매칭돼요. 따라서 빈 맵은 모든 맵에 매칭돼요.

Map 모듈은 Keyword 모듈과 매우 비슷한 API를 제공하면서 키를 추가·제거·업데이트하는 편의 함수를 제공해요.

iex> Map.get(%{:a => 1, 2 => :b}, :a)
1
iex> Map.put(%{:a => 1, 2 => :b}, :c, 3)
%{2 => :b, :a => 1, :c => 3}
iex> Map.to_list(%{:a => 1, 2 => :b})
[{2, :b}, {:a, 1}]

미리 정의된 키를 가진 맵

지금까지는 키를 언제든 추가·제거할 수 있는 키-값 구조로 맵을 썼어요. 그런데 키 집합이 미리 정해진 맵을 만드는 것도 흔해요. 값은 업데이트할 수 있어도 새 키는 추가·제거되지 않아요. 이는 다루는 데이터의 모양을 알고 있을 때 유용한데, 다른 키가 나온다면 그건 다른 곳에서 실수가 있었다는 뜻일 가능성이 커요. 이런 경우 키는 대부분 아톰이에요.

iex> map = %{:name => "John", :age => 23}
%{name: "John", age: 23}

아톰 키의 맵은 키워드 리스트와 같은 key: value 문법으로도 쓸 수 있어요.

iex> map = %{name: "John", age: 23}
%{name: "John", age: 23}

키가 아톰이면 map.key 문법으로 접근할 수도 있어요.

iex> map.name
"John"
iex> map.agee
** (KeyError) key :agee not found in: %{name: "John", age: 23}

키를 업데이트하는 문법도 있는데, 아직 정의되지 않은 키라면 예외를 던져요.

iex> %{map | name: "Mary"}
%{name: "Mary", age: 23}
iex> %{map | agee: 27}
** (KeyError) key :agee not found in: %{name: "John", age: 23}

이 연산들은 키가 없으면 예외를 던지고, 컴파일러가 가능할 때는 미리 감지해 경고까지 할 수 있다는 큰 장점이 있어요. 그래서 빠른 피드백을 받고 버그·오타를 일찍 잡는 데 유용해요. 이것은 나중에 배울 "구조체(Structs)"라는 Elixir 기능을 뒷받침하는 문법이기도 해요.

Elixir 개발자들은 맵을 다룰 때 Map 모듈의 함수보다 map.key 문법과 패턴 매칭을 선호하는데, 단정적(assertive)인 프로그래밍 스타일로 이끌기 때문이에요. José Valim의 블로그 글에서 Elixir로 단정적 코드를 쓰면 더 간결하고 빠른 소프트웨어를 얻는 방법을 예시와 함께 설명해요.

중첩 데이터 구조

맵 안에 맵이, 맵 안에 키워드 리스트가 들어 있는 경우가 많아요. Elixir는 get_in/1, put_in/2, update_in/2 같은 매크로로 중첩 구조를 편리하게 다룰 수 있게 해주면서도 불변성은 유지해요.

예를 들어 이런 구조가 있다고 해요.

iex> users = [
  john: %{name: "John", age: 27, languages: ["Erlang", "Ruby", "Elixir"]},
  mary: %{name: "Mary", age: 29, languages: ["Elixir", "F#", "Clojure"]}
]

john의 나이에 접근하려면 이렇게 써요.

iex> users[:john].age
27

같은 문법으로 값을 업데이트할 수도 있어요.

iex> users = put_in(users[:john].age, 31)

update_in/2 매크로도 비슷하지만 값이 어떻게 바뀔지 제어하는 함수를 넘길 수 있어요. 예를 들어 Mary의 언어 목록에서 "Clojure"를 빼 보죠.

iex> users = update_in(users[:mary].languages, fn languages -> List.delete(languages, "Clojure") end)

요약

Elixir에서 키-값 저장소를 다룰 때 쓰는 두 가지 데이터 구조가 있어요. Access 모듈과 패턴 매칭과 함께, 복잡하고 중첩된 데이터 구조를 다루는 풍부한 도구 세트를 제공해요.

이 장을 마무리하며 기억해야 할 점이에요.

  • 함수에 선택적 값을 전달할 때는 키워드 리스트를 써요.
  • 일반적인 키-값 구조에는 맵을 써요.
  • 키 집합이 미리 정해진 데이터를 다룰 때는 맵을 써요.

더 알아보기

  • Keyword 모듈: 키워드 리스트를 다루는 함수
  • Map 모듈: 맵을 다루는 함수
  • structs 문서: 미리 정의된 키를 가진 맵을 강화하는 구조체
  • Access 모듈: 접근 문법 정의