`Collectable`
Collectable
데이터 구조를 "채워 넣을" 수 있게 해주는 프로토콜이에요.
Enum.into/2 함수는 이 프로토콜을 사용해서 열거 가능한 값을 컬렉션에 넣습니다:
iex> Enum.into([a: 1, b: 2], %{})
%{a: 1, b: 2}
왜 Collectable인가?
Enumerable 프로토콜은 컬렉션에서 값을 꺼내올 때 유용해요. 그런데 다양한 값을 다루기 위해 Enumerable 프로토콜의 함수들은 모양(shape)을 유지하지 않습니다. 예를 들어 맵에 Enum.map/2를 적용하면 항상 리스트가 반환돼요.
이 설계는 의도된 겁니다. Enumerable은 무한 컬렉션이나 리소스, 고정된 모양을 가진 구조까지 지원하도록 만들어졌기 때문이에요. 예를 들어 Range는 오직 범위의 시작·끝·간격만 저장하는 고정된 모양이라서, 거기에 값을 채워 넣는다는 것 자체가 말이 안 됩니다.
Collectable 모듈은 Enumerable 프로토콜이 남긴 이 빈틈을 메우기 위해 설계됐어요. Collectable.into/1은 Enumerable.reduce/3의 반대로 볼 수 있습니다. Enumerable의 함수들이 값을 꺼내는 것이라면, Collectable.into/1은 그 값들을 다시 구조로 모아들이는 일을 하죠.
Examples
Collectable 프로토콜을 수동으로 쓰는 방법을 보기 위해, MapSet의 간단한 구현으로 놀아볼게요.
iex> {initial_acc, collector_fun} = Collectable.into(MapSet.new())
iex> updated_acc = Enum.reduce([1, 2, 3], initial_acc, fn elem, acc ->
...> collector_fun.(acc, {:cont, elem})
...> end)
iex> collector_fun.(updated_acc, :done)
MapSet.new([1, 2, 3])
프로토콜이 어떻게 구현되는지도 MapSet의 간단한 구현으로 다시 볼게요. 이 구현에서 "수집"한다는 것은 곧 MapSet.put/2를 통해 요소를 집합에 넣는 일이에요.
defimpl Collectable, for: MapSet do
def into(map_set) do
collector_fun = fn
map_set_acc, {:cont, elem} ->
MapSet.put(map_set_acc, elem)
map_set_acc, :done ->
map_set_acc
_map_set_acc, :halt ->
:ok
end
initial_acc = map_set
{initial_acc, collector_fun}
end
end
이제 Enum.into/2를 호출할 수 있어요:
iex> Enum.into([1, 2, 3], MapSet.new())
MapSet.new([1, 2, 3])
Halting (중단)
컬렉션이 정상적으로 끝나지 않을 때는 :halt 플래그가 전달되며, 이때는 기존 리소스(소켓, 파일 핸들 등)를 정리하는 데 사용해야 해요.
halt에 주어지는 accumulator가 이전 {:cont, elem} 호출에서 반환된 accumulator의 최신 버전이라는 보장은 없다는 점에 주의하세요. 따라서 수집된 결과를 중단하려는 리소스 안에서 직접 추적해야 합니다.
이건 의도된 설계예요. halt가 항상 최신 accumulator와 함께 호출되도록 보장하려면, halt를 구현하지 않는 순수 collectable이 비싸지기 때문입니다. 반대로 halt를 구현해야 하는 collectable은 어차피 상태를 추적해야 하므로, 그 부담은 그것들(구현하는 쪽)에 지워져 있는 것이죠.
출처:
Collectable
본문
Elixir에는 데이터 구조를 넣고 빼는 두 가지 방향의 약속이 있어요. 값을 꺼내는 쪽이 Enumerable, 값을 모아 넣는 쪽이 바로 Collectable입니다. Enum.into/2가 대표 사례인데, 열거 가능한 무언가를 컬렉션 안에 넣는 작업이죠.
이 프로토콜이 핵심으로 제공하는 함수는 into/1이에요.
@spec into(t()) ::
{initial_acc :: term(), collector :: (term(), command() -> t() | term())}
into/1은 초기 accumulator와 "collector(수집자)" 함수, 이 두 가지를 돌려줍니다. collectable을 받아서, 이를 함수에 전달될 초기 accumulator로 사용할 수 있게 해주죠.
collector 함수는 어떤 항(term)과 command를 받아서, {:cont, term} 명령이 올 때마다 그 항을 collectable accumulator에 넣습니다. 명령의 종류는 :cont, :done, :halt 세 가지예요.
@type command() :: {:cont, term()} | :done | :halt
{:cont, term}— 요소를 하나 더 넣는다는 명령:done— 더 이상 넣을 값이 없다는 명령. 리소스를 닫거나 값을 정규화할 필요가 있을 때 유용하며, 이때는 collectable을 반환해야 해요.:halt— 주입이 갑자기 중단되었을 때의 명령. 이때 함수는 어떤 값을 반환해도 되며, 그 값은 사용되지 않아요.
into/1을 손으로 쓰는 흐름을 다시 짚어볼게요. 먼저 Collectable.into(MapSet.new())로 초기 accumulator와 collector를 얻고, Enum.reduce/3로 요소를 하나씩 {:cont, elem} 명령으로 넣습니다. 마지막에 :done을 보내 닫으면 MapSet.new([1, 2, 3])가 완성되죠.
프로토콜을 새로 구현할 때는 위 MapSet 예시처럼 defimpl Collectable, for: MyType로 시작해, {:cont, elem}, :done, :halt 세 명령을 각각 처리하면 됩니다. 이렇게 구현해 두면 비로소 Enum.into/2가 해당 타입을 대상으로 자연스럽게 동작해요.
더 알아보기
Enum.into/2와 함께 동작하는 방식은Enum모듈 문서에서 확인하세요.- 이런 격차를 메우는 프로토콜의 대칭 설계에 관심이 있다면
Enumerable프로토콜 문서도 함께 보면 좋아요. defimpl로 프로토콜을 구현하는 문법은Protocol문서에서 다룹니다.