Enumerable 프로토콜

Enumerable 프로토콜

Enum 모듈과 Stream 모듈이 사용하는 **프로토콜(protocol)**이에요. Enum 모듈의 함수를 호출하면, 그 첫 번째 인자는 보통 이 프로토콜을 구현한 컬렉션이어야 해요.

예를 들어 Enum.map([1, 2, 3], &(&1 * 2))라는 표현은 내부적으로 Enumerable.reduce/3를 호출해요. 이때 매핑 함수 &(&1 * 2)를 컬렉션의 각 원소에 적용하고, 그 결과를 누적 리스트(accumulated list)에 쌓아가면서 매핑된 리스트를 만들어 내는 식이죠.

출처: Enumerable protocol

본문

어떻게 동작하나요

내부적으로 Enum.map/2는 대략 다음처럼 구현되어 있어요.

def map(enumerable, fun) do
  reducer = fn x, acc -> {:cont, [fun.(x) | acc]} end
  Enumerable.reduce(enumerable, {:cont, []}, reducer) |> elem(1) |> :lists.reverse()
end

여기서 눈여겨볼 점은, 사용자가 넘겨준 함수가 리듀서(reducer) 함수로 감싸진다는 거예요. 그 리듀서 함수는 매 단계마다 태그가 붙은 튜플(tagged tuple)을 돌려줘야 하고, 마지막에 Enumerable.reduce/3result/0 타입의 값을 돌려줘요.

이 프로토콜은 태그가 붙은 튜플을 사용해서 리듀서 함수와 프로토콜을 구현한 데이터 타입 사이에서 정보를 주고받아요. 덕분에 파일 같은 리소스를 효율적으로 열거할 수 있으면서도, 열거가 끝나면 반드시 리소스가 닫히는 것도 보장해요. 또 열거를 **중단(suspend)**할 수도 있어서, 여러 열거형을 번갈아가며 섞어야 할 때(Enum.zip/1, Enum.zip/2 같은 경우) 유용해요.

이 프로토콜은 네 가지 함수를 구현하도록 요구해요. reduce/3, count/1, member?/2, 그리고 slice/1이에요. 이 중 핵심은 reduce/3 함수이고, 나머지 함수들은 선형 시간보다 더 빠르게 특정 성질을 구현할 수 있는 자료 구조를 위한 최적화 경로로 존재해요.

리스트 기본 구현 위임하기

때로는 struct 안에 들어 있는 리스트에 대해 이 프로토콜을 구현하고 싶을 수 있어요. 그럴 땐 reduce/3 구현에서 Enumerable.List 모듈에 위임하고, 나머지 함수들은 단순하게 구현하면 돼요.

defimpl Enumerable, for: CustomStruct do
  def count(struct), do: {:ok, length(struct.items)}
  def member?(struct, value), do: {:ok, value in struct.items}
  def slice(struct), do: {:error, __MODULE__}
  def reduce(struct, acc, fun), do: Enumerable.List.reduce(struct.items, acc, fun)
end

주요 타입

  • acc/0 — 각 단계의 누적 값이에요. {:cont, acc}, {:halt, acc}, {:suspend, acc} 중 하나의 태그를 가진 튜플이에요. :cont는 계속 진행, :halt는 즉시 멈춤, :suspend는 즉시 중단을 뜻해요. 리듀서가 :suspend 누적 값을 돌려주면 호출하는 쪽에서 명시적으로 처리해야 하고, 절대 그대로 새어 나가면 안 돼요.
@type acc() :: {:cont, term()} | {:halt, term()} | {:suspend, term()}
  • continuation/0 — 부분 적용된 리듀스 함수예요. 열거가 중단되면 결과로 돌려받는 클로저로, 새 누적 값을 받아 결과를 돌려줘요. 리듀스 함수가 꼬리 재귀 방식으로 정의되어 있으면 모든 상태가 인자로 넘어가므로 이 연속체를 쉽게 만들 수 있어요.
@type continuation() :: (acc() -> result())
  • reducer/0 — 리듀서 함수예요. 열거형의 원소와 누적 값 내용을 받아 호출하고, 다음 열거 단계의 누적 값을 돌려줘요.
@type reducer() :: (element :: term(), element_acc :: term() -> acc())
  • result/0 — 리듀스 연산의 결과예요. 열거가 끝에 도달해 끝났거나({:done, acc}), 태그가 붙은 누적 값에 의해 멈추거나 중단됐을 수 있어요({:halted, acc} / {:suspended, acc, continuation}). :halt가 주어지면 :halted 튜플을 돌려줘야 하고, :suspend가 주어지면 연속체와 함께 :suspended 튜플을 돌려줘야 해요.
@type result() ::
  {:done, term()} | {:halted, term()} | {:suspended, term(), continuation()}
  • t/0 — 이 프로토콜을 구현하는 모든 타입이에요. t(_element)는 특히 문서화에서 유용한데, Enumerable.t(integer())처럼 원소 타입을 명시할 수 있어요.

  • to_list_fun/0 — 열거형을 받아 리스트로 돌려주는 함수예요.

@type to_list_fun() :: (t() -> [term()])

네 가지 함수

count(enumerable) — 열거형 안의 원소 개수를 돌려줘요. 전체를 순회하지 않고도 개수를 빠르게 알 수 있다면 {:ok, count}를 돌려주고, 그럴 수 없다면 {:error, __MODULE__}를 돌려줘요. 그러면 reduce/3 기반의 선형 시간 기본 알고리즘이 사용돼요.

member?(enumerable, element) — 원소가 열거형 안에 존재하는지 확인해요. 전체를 순회하지 않고 ===/2로 소속 여부를 확인할 수 있다면 {:ok, boolean}을 돌려주고, 아니면 {:error, __MODULE__}를 돌려줘요. 가드(guard) 밖에서 in 연산자와 not in 연산자는 이 함수를 사용해서 동작해요.

reduce(enumerable, acc, fun) — 열거형을 하나의 원소로 줄여줘요. Enum의 대부분 연산이 이 함수를 기반으로 구현돼요. 주어진 리듀서 함수를 각 원소에 적용하고, 반환된 누적 값에 따라 진행해요. 리스트의 구현 예시는 다음과 같아요.

def reduce(_list, {:halt, acc}, _fun), do: {:halted, acc}
def reduce(list, {:suspend, acc}, fun), do: {:suspended, acc, &reduce(list, &1, fun)}
def reduce([], {:cont, acc}, _fun), do: {:done, acc}
def reduce([head | tail], {:cont, acc}, fun), do: reduce(tail, fun.(head, acc), fun)

slice(enumerable) — 데이터 구조를 연속적으로 잘라내는 함수를 돌려줘요. 세 가지 중 하나를 돌려줄 수 있어요. 경계를 알고 있고 앞 원소들을 모두 순회하지 않고 접근할 수 있으면 {:ok, size, slicing_fun}, 리스트로 변환해서 접근해야 하면 {:ok, size, to_list_fun}, 효율적으로 잘라낼 수 없으면 {:error, __MODULE__}이에요.

여기서 반환하는 size 값은 경계 검사에 사용되기 때문에, 크기를 구하는 게 저렴하고 빠르며 상수 시간일 때만 :ok를 돌려주는 게 아주 중요해요. 그렇지 않으면 Enum.at(enumerable, 0)처럼 사소한 연산조차 너무 비싸져 버려요.

더 알아보기

  • Enum 모듈이 이 프로토콜을 어떻게 활용하는지 살펴보면 동작 원리가 더 선명해져요.
  • 지연 열거를 제공하는 Stream 모듈과 함께 보면 suspension(중단)의 쓰임새를 이해할 수 있어요.
  • defimpl을 통한 커스텀 타입 구현은 Kernel 문서의 프로토콜 관련 내용을 참고해 보세요.