Access behaviour

Access behaviour

Elixir에서 데이터 구조의 키에 접근하는 방식은 꽤나 다양한데요. Access 모듈은 data[key] 같은 문법으로 자료 구조 안의 어떤 타입의 키든 동적으로 꺼내 쓸 수 있게 해 주는 behaviour를 정의해요. 처음엔 문법이 낯설 수 있지만, 배우고 나면 중첩된 구조를 다룰 때 정말 편해져요.

출처: Access behaviour

본문

키 기반 접근

Access는 기본적으로 키워드 리스트(Keyword)와 맵(Map)을 지원해요. 키워드 리스트는 원자 키만, 맵은 어떤 타입의 키든 허용하죠. 두 경우 모두 키가 없으면 nil을 돌려줘요.

iex> keywords = [a: 1, b: 2]
iex> keywords[:a]
1
iex> keywords[:c]
nil

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

iex> star_ratings = %{1.0 => "★", 1.5 => "★☆", 2.0 => "★★"}
iex> star_ratings[1.5]
"★☆"

이 문법은 아무리 중첩해도 쓸 수 있어서 편리해요.

iex> keywords = [a: 1, b: 2]
iex> keywords[:c][:unknown]
nil

nil에 무언가에 접근하면 nil 자신이 돌아오기 때문에 이런 일이 가능해요.

iex> nil[:a]
nil

맵과 구조체

맵 안의 원자 키에 접근할 때는 map[key]보다 map.key를 쓰는 게 더 좋아요. map.key는 키가 없거나 mapnil이면 예외를 던지거든요. 키가 미리 정해져 있다면 이 예외가 일어날 일이 없어서 더 안전하죠.

구조체(struct)도 맵인데다 키가 정해져 있기 때문에 struct.key 문법만 허용하고 struct[key] 접근은 되지 않아요.

정리하면 map[key]는 느슨해서 없는 키에 nil을 주고, map.key는 엄격해서 nil이거나 키가 없으면 예외를 던져요.

이 간극을 메우기 위해 Elixir는 get_in/1get_in/2를 제공하는데, 이 함수들은 nil이 끼어 있어도 중첩된 자료 구조를 끝까지 탐색할 수 있어요.

iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}}
iex> get_in(users["john"].age)
27
iex> get_in(users["unknown"].age)
nil

사용자를 못 찾아도 get_in/1nil을 돌려줘요. get_in/1 바깥에서 nil.age에 접근하려 하면 예외가 나요.

get_in/2는 한 걸음 더 나아가 서로 다른 접근자를 섞어 쓸 수 있게 해 줘요. 예를 들어 :name:languages 키를 가진 사용자 맵에서 모든 프로그래밍 언어 이름에 접근해 볼게요.

  iex> languages = [
  ...>   %{name: "elixir", type: :functional},
  ...>   %{name: "c", type: :procedural}
  ...> ]
  iex> user = %{name: "john", languages: languages}
  iex> get_in(user, [:languages, Access.all(), :name])
  ["elixir", "c"]

이 모듈은 튜플과 리스트 같은 다른 구조를 탐색하는 편의 함수도 제공해요. 다음에서 보듯이 중첩 자료 구조를 갱신하는 데도 쓸 수 있어요.

맵이 구조화된 데이터이면서 동시에 키-값 저장소로 쓰이는 이중성에 대해 더 알고 싶다면 Map 모듈을 살펴보세요.

중첩 자료 구조 갱신

접근 문법은 Kernel.put_in/2, Kernel.update_in/2, Kernel.get_and_update_in/2, Kernel.pop_in/1 매크로와 함께 써서 중첩 자료 구조의 값을 바꾸는 데도 쓰여요.

iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}}
iex> put_in(users["john"].age, 28)
%{"john" => %{age: 28}, "meg" => %{age: 23}}

앞 섹션에서 본 것처럼 Kernel.put_in/3, Kernel.update_in/3, Kernel.pop_in/2, Kernel.get_and_update_in/3 함수로 중첩된 커스텀 접근자도 만들 수 있어요. :name:languages 키를 가진 사용자 맵에서 모든 언어 이름을 대문자로 바꿔볼게요.

iex> languages = [
...>   %{name: "elixir", type: :functional},
...>   %{name: "c", type: :procedural}
...> ]
iex> user = %{name: "john", languages: languages}
iex> update_in(user, [:languages, Access.all(), :name], &String.upcase/1)
%{
  name: "john",
  languages: [
    %{name: "ELIXIR", type: :functional},
    %{name: "C", type: :procedural}
  ]
}

구현 가능한 접근자 중 일부는 key/1, key!/1, elem/1, all/0에서 확인할 수 있어요.

타입 (Types)

@type access_fun(data, current_value) ::
   get_fun(data) |  get_and_update_fun(data, current_value)

@type container() ::  keyword() |  struct() |  map()

@type get_and_update_fun(data, current_value) :: (:get_and_update,
                                            data,
                                            ( term() ->  term()) ->
                                              {current_value,
                                               new_data ::  container()}
                                              | :pop)

@type get_fun(data) :: (:get, data, ( term() ->  term()) -> new_data ::  container())

@type key() ::  any()

@type nil_container() :: nil

@type t() ::  container() |  nil_container() |  any()

@type value() ::  any()

콜백 (Callbacks)

@callback fetch(term ::  t(),  key()) :: {:ok,  value()} | :error

주어진 term에서 key 아래의 값을 꺼내기 위해 호출돼요. 키가 있으면 {:ok, value}, 없으면 :error를 돌려줘야 해요.

Access 모듈의 많은 함수가 내부적으로 이 함수를 호출해요. 대괄호 접근 문법(structure[key])을 쓸 때도 쓰이는데, 구조체를 정의한 모듈이 구현한 fetch/2 콜백이 {:ok, value}를 주면 value를, :error를 주면 nil을 돌려줘요. 구현 예시는 Map.fetch/2Keyword.fetch/2를 참고하세요.

@callback get_and_update(data,  key(), ( value() | nil ->
                               {current_value, new_value ::  value()} | :pop)) ::
  {current_value, new_data :: data}
when current_value:  value(), data:  container()

key 아래의 값을 꺼내면서 동시에 갱신하기 위해 호출돼요. data에서 key 아래의 값(없으면 nil)을 fun에 넘기고, fun{current_value, new_value} 또는 :pop을 돌려줘야 해요. {current_value, new_value}를 주면 콜백은 {current_value, new_data}를 돌려주고, :pop을 주면 {value, new_data}를 돌려줘야 해요. 예시는 Map.get_and_update/3Keyword.get_and_update/3에서 볼 수 있어요.

@callback pop(data,  key()) :: { value(), data} when data:  container()

주어진 자료 구조에서 key 아래의 값을 "pop"하기 위해 호출돼요. 키가 있으면 {value, new_data}를, 없으면 {value, data}를 돌려줘요(이때 value는 구현에 따라 정의돼요). 예시는 Map.pop/3Keyword.pop/3에서 확인하세요.

함수 (Functions)

@spec all() ::  access_fun(data ::  list(), current_value ::  list())

리스트의 모든 요소에 접근하는 함수를 돌려줘요. 이 함수는 보통 Kernel.get_in/2, Kernel.get_and_update_in/3 등의 접근자로 넘겨요.

iex> list = [%{name: "john"}, %{name: "mary"}]
iex> get_in(list, [Access.all(), :name])
["john", "mary"]
iex> get_and_update_in(list, [Access.all(), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{["john", "mary"], [%{name: "JOHN"}, %{name: "MARY"}]}
iex> pop_in(list, [Access.all(), :name])
{["john", "mary"], [%{}, %{}]}

짝수를 버리고 홀수에 2를 곱해보는 예시도 볼게요.

iex> require Integer
iex> get_and_update_in([1, 2, 3, 4, 5], [Access.all()], fn num ->
...>   if Integer.is_even(num), do: :pop, else: {num, num * 2}
...> end)
{[1, 2, 3, 4, 5], [2, 6, 10]}

접근하는 구조가 리스트가 아니면 오류가 나요.

iex> get_in(%{}, [Access.all()])
** (RuntimeError) Access.all/0 expected a list, got: %{}
@spec at( integer()) ::  access_fun(data ::  list(), current_value ::  term())

리스트의 index(0부터 시작) 위치 요소에 접근하는 함수를 돌려줘요. 리스트의 인덱스 조회는 선형 시간이 걸린다는 점을 기억하세요. 리스트가 클수록 오래 걸려요. 그래서 인덱스 기반 연산보다는 Enum 모듈의 다른 함수를 쓰는 걸 선호해요.

iex> list = [%{name: "john"}, %{name: "mary"}]
iex> get_in(list, [Access.at(1), :name])
"mary"
iex> get_in(list, [Access.at(-1), :name])
"mary"
iex> get_and_update_in(list, [Access.at(0), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"john", [%{name: "JOHN"}, %{name: "mary"}]}
iex> get_and_update_in(list, [Access.at(-1), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"mary", [%{name: "john"}, %{name: "MARY"}]}

at/1는 리스트나 리스트 안의 키를 pop하는 데도 써요.

iex> list = [%{name: "john"}, %{name: "mary"}]
iex> pop_in(list, [Access.at(0)])
{%{name: "john"}, [%{name: "mary"}]}
iex> pop_in(list, [Access.at(0), :name])
{"john", [%{}, %{name: "mary"}]}

인덱스가 범위를 벗어나면 nil을 돌려주고 갱신 함수는 호출되지 않아요.

iex> list = [%{name: "john"}, %{name: "mary"}]
iex> get_in(list, [Access.at(10), :name])
nil
iex> get_and_update_in(list, [Access.at(10), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{nil, [%{name: "john"}, %{name: "mary"}]}

접근하는 구조가 리스트가 아니면 오류가 나요.

iex> get_in(%{}, [Access.at(1)])
** (RuntimeError) Access.at/1 expected a list, got: %{}
@spec at!( integer()) ::  access_fun(data ::  list(), current_value ::  term())

at/1과 같지만 인덱스가 범위를 벗어나면 Enum.OutOfBoundsError를 던져요. (since 1.11.0)

iex> get_in([:a, :b, :c], [Access.at!(2)])
:c
iex> get_in([:a, :b, :c], [Access.at!(3)])
** (Enum.OutOfBoundsError) out of bounds error at position 3 when traversing enumerable [:a, :b, :c]
@spec elem( non_neg_integer()) ::  access_fun(data ::  tuple(), current_value ::  term())

튜플의 해당 인덱스 요소에 접근하는 함수를 돌려줘요. index가 범위를 벗어나면 예외가 나요. 튜플에서는 요소를 pop할 수 없어서 pop을 시도하면 오류가 나요.

iex> map = %{user: {"john", 27}}
iex> get_in(map, [:user, Access.elem(0)])
"john"
iex> get_and_update_in(map, [:user, Access.elem(0)], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"john", %{user: {"JOHN", 27}}}
iex> pop_in(map, [:user, Access.elem(0)])
** (RuntimeError) cannot pop data from a tuple

접근하는 구조가 튜플이 아니면 오류가 나요.

iex> get_in(%{}, [Access.elem(0)])
** (RuntimeError) Access.elem/1 expected a tuple, got: %{}
@spec fetch( container(),  term()) :: {:ok,  term()} | :error
@spec fetch( nil_container(),  any()) :: :error

컨테이너(맵, 키워드 리스트, Access behaviour를 구현한 구조체)에서 주어진 키의 값을 꺼내요. 키가 있으면 {:ok, value}, 없으면 :error를 돌려줘요.

iex> Access.fetch(%{name: "meg", age: 26}, :name)
{:ok, "meg"}

iex> Access.fetch([ordered: true, on_timeout: :exit], :timeout)
:error
@spec fetch!( container(),  term()) ::  term()

fetch/2와 같지만 값을 직접 돌려주고, 키가 없으면 KeyError 예외를 던져요. (since 1.10.0)

iex> Access.fetch!(%{name: "meg", age: 26}, :name)
"meg"
@spec filter(( term() ->  boolean())) ::
   access_fun(data ::  list(), current_value ::  list())

주어진 조건을 만족하는 리스트의 모든 요소에 접근하는 함수를 돌려줘요. (since 1.6.0)

iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Access.filter(&(&1.salary > 20)), :name])
["francine"]
iex> get_and_update_in(list, [Access.filter(&(&1.salary <= 20)), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{["john"], [%{name: "JOHN", salary: 10}, %{name: "francine", salary: 30}]}

조건에 맞는 게 없으면 빈 리스트를 돌려주고 갱신 함수는 호출되지 않아요.

iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Access.filter(&(&1.salary >= 50)), :name])
[]
iex> get_and_update_in(list, [Access.filter(&(&1.salary >= 50)), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{[], [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]}
@spec find(( term() ->  as_boolean( term()))) ::
   access_fun(data ::  list(), current_value ::  term())

주어진 조건을 만족하는 리스트의 첫 번째 요소에 접근하는 함수를 돌려줘요. (since 1.17.0)

iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Access.find(&(&1.salary > 20)), :name])
"francine"
iex> get_and_update_in(list, [Access.find(&(&1.salary <= 40)), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"john", [%{name: "JOHN", salary: 10}, %{name: "francine", salary: 30}]}

조건에 맞는 게 없으면 nil을 돌려주고 갱신 함수는 호출되지 않아요.

iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Access.find(&(&1.salary >= 50)), :name])
nil
iex> get_and_update_in(list, [Access.find(&(&1.salary >= 50)), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{nil, [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]}
@spec get( container(),  term(),  term()) ::  term()
@spec get( nil_container(),  any(), default) :: default when default: var

컨테이너에서 주어진 키의 값을 꺼내요. 키가 있으면 값을, 없으면 default를 돌려줘요.

iex> Access.get(%{name: "john"}, :name, "default name")
"john"
iex> Access.get(%{name: "john"}, :age, 25)
25

iex> Access.get([ordered: true], :timeout)
nil
@spec get_and_update(data,  key(), ( value() | nil ->
                               {current_value, new_value ::  value()} | :pop)) ::
  {current_value, new_data :: data}
when data:  container(), current_value: var

컨테이너(맵, 키워드 리스트, Access behaviour를 구현한 구조체)에서 주어진 키의 값을 얻고 갱신해요. funkey의 값(없으면 nil)을 받아 {current_value, new_value} 튜플을 돌려줘야 해요. :pop을 돌려주면 현재 값을 컨테이너에서 제거하고 돌려준다는 뜻이에요.

iex> Access.get_and_update([a: 1], :a, fn current_value ->
...>   {current_value, current_value + 1}
...> end)
{1, [a: 2]}
@spec key( key(),  term()) ::
   access_fun(data ::  struct() |  map(), current_value ::  term())

맵/구조체에서 주어진 키에 접근하는 함수를 돌려줘요. 키가 없으면 기본값을 사용해요. 이건 기본값을 정하고 없는 키를 안전하게 탐색하는 데 쓸 수 있어요.

iex> get_in(%{}, [Access.key(:user, %{}), Access.key(:name, "meg")])
"meg"

갱신 함수를 쓸 때도 유용해요. 탐색하면서 기본값을 채워넣는 식으로요.

iex> put_in(%{}, [Access.key(:user, %{}), Access.key(:name)], "Mary")
%{user: %{name: "Mary"}}

iex> map = %{user: %{name: "john"}}
iex> get_in(map, [Access.key(:unknown, %{}), Access.key(:name, "john")])
"john"
iex> get_and_update_in(map, [Access.key(:user), Access.key(:name)], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"john", %{user: %{name: "JOHN"}}}
iex> pop_in(map, [Access.key(:user), Access.key(:name)])
{"john", %{user: %{}}}
@spec key!( key()) ::  access_fun(data ::  struct() |  map(), current_value ::  term())

key/2와 비슷하지만, 키가 없으면 예외를 던지는 함수를 돌려줘요. 키가 미리 정해지지 않아 동적으로 접근해야 할 때 유용해요. 점 표기법으로는 필드를 제거할 수 없으니 이럴 때 key!/1을 쓰면 좋아요.

iex> map = %{user: %{name: "john"}}
iex> get_in(map, [Access.key!(:user), Access.key!(:name)])
"john"
iex> get_and_update_in(map, [Access.key!(:user), Access.key!(:name)], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{"john", %{user: %{name: "JOHN"}}}
iex> pop_in(map, [Access.key!(:user), Access.key!(:name)])
{"john", %{user: %{}}}
iex> get_in(map, [Access.key!(:user), Access.key!(:unknown)])
** (KeyError) key :unknown not found in:
...
@spec pop(data,  key()) :: { value(), data} when data:  container()

컨테이너(맵, 키워드 리스트, Access behaviour를 구현한 구조체)에서 주어진 키의 항목을 제거해요. 키와 연결된 값과 갱신된 컨테이너를 담은 튜플을 돌려줘요. 키가 없으면 값은 nil이에요.

iex> Access.pop(%{name: "Elixir", creator: "Valim"}, :name)
{"Elixir", %{creator: "Valim"}}

iex> Access.pop([name: "Elixir", creator: "Valim"], :name)
{"Elixir", [creator: "Valim"]}

iex> Access.pop(%{name: "Elixir", creator: "Valim"}, :year)
{nil, %{creator: "Valim", name: "Elixir"}}
@spec slice( Range.t()) ::  access_fun(data ::  list(), current_value ::  list())

주어진 범위 안에 있는 리스트의 모든 항목에 접근하는 함수를 돌려줘요. 범위는 Enum.slice/2와 같은 규칙으로 정규화돼요. (since 1.14)

iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}, %{name: "vitor", salary: 25}]
iex> get_in(list, [Access.slice(1..2), :name])
["francine", "vitor"]
iex> get_and_update_in(list, [Access.slice(1..3//2), :name], fn prev ->
...>   {prev, String.upcase(prev)}
...> end)
{["francine"], [%{name: "john", salary: 10}, %{name: "FRANCINE", salary: 30}, %{name: "vitor", salary: 25}]}

조건에 맞는 게 없으면 빈 리스트를 돌려주고 갱신 함수는 호출되지 않아요. 범위의 step이 음수면 오류가 나요.

iex> get_in(%{}, [Access.slice(2..10//3)])
** (ArgumentError) Access.slice/1 expected a list, got: %{}

iex> get_in([], [Access.slice(2..10//-1)])
** (ArgumentError) Access.slice/1 does not accept ranges with negative steps, got: 2..10//-1
@spec values() ::  access_fun(data ::  map() |  keyword(), current_value ::  list())

맵이나 키워드 리스트의 모든 값에 접근하는 함수를 돌려줘요. (since 1.19.0)

iex> users = %{"john" => %{age: 27}, "meg" => %{age: 23}}
iex> get_in(users, [Access.values(), :age]) |> Enum.sort()
[23, 27]
iex> update_in(users, [Access.values(), :age], fn age -> age + 1 end)
%{"john" => %{age: 28}, "meg" => %{age: 24}}
iex> put_in(users, [Access.values(), :planet], "Earth")
%{"john" => %{age: 27, planet: "Earth"}, "meg" => %{age: 23, planet: "Earth"}}

접근자 함수에서 :pop을 돌려주면 맵이나 키워드 리스트에서 해당 키와 값을 제거할 수 있어요.

iex> require Integer
iex> numbers = [one: 1, two: 2, three: 3, four: 4]
iex> get_and_update_in(numbers, [Access.values()], fn num ->
...>   if Integer.is_even(num), do: :pop, else: {num, to_string(num)}
...> end)
{[1, 2, 3, 4], [one: "1", three: "3"]}

더 알아보기