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는 키가 없거나 map이 nil이면 예외를 던지거든요. 키가 미리 정해져 있다면 이 예외가 일어날 일이 없어서 더 안전하죠.
구조체(struct)도 맵인데다 키가 정해져 있기 때문에 struct.key 문법만 허용하고 struct[key] 접근은 되지 않아요.
정리하면 map[key]는 느슨해서 없는 키에 nil을 주고, map.key는 엄격해서 nil이거나 키가 없으면 예외를 던져요.
이 간극을 메우기 위해 Elixir는 get_in/1과 get_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/1은 nil을 돌려줘요. 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/2와 Keyword.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/3나 Keyword.get_and_update/3에서 볼 수 있어요.
@callback pop(data, key()) :: { value(), data} when data: container()
주어진 자료 구조에서 key 아래의 값을 "pop"하기 위해 호출돼요. 키가 있으면 {value, new_data}를, 없으면 {value, data}를 돌려줘요(이때 value는 구현에 따라 정의돼요). 예시는 Map.pop/3나 Keyword.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를 구현한 구조체)에서 주어진 키의 값을 얻고 갱신해요. fun은 key의 값(없으면 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"]}
더 알아보기
- Map 모듈: 맵의 구조적/키-값 저장소 이중성과 접근자 구현
- Kernel.get_in/2: 중첩 자료 구조 탐색 매크로
- Keyword 모듈: 키워드 리스트와 접근자 예시
- Kernel.put_in/2: 중첩 자료 구조 갱신