Protocol behaviour

Protocol behaviour

프로토콜(protocol) 작업을 위한 참고 자료와 함수를 제공하는 모듈이에요. 프로토콜은 그 구현체들이 정의해야 할 API를 명시합니다. 프로토콜은 Kernel.defprotocol/2로 정의하고, 구현은 Kernel.defimpl/3로 정의해요.

출처: Protocol

본문

예시(Example)

Elixir에는 데이터 구조에 항목이 몇 개 있는지 확인하는 두 가지 명사가 있어요: lengthsize. length는 정보를 계산해야 함을 뜻합니다. 예를 들어 length(list)는 길이를 계산하려고 리스트 전체를 순회해야 해요. 반면 tuple_size(tuple)byte_size(binary)는 튜플·바이너리 크기에 의존하지 않는데, 크기 정보가 데이터 구조에 미리 계산되어 있기 때문이에요.

Elixir는 tuple_size, byte_size, map_size 같은 특정 함수를 포함하지만, 가끔은 타입에 관계없이 데이터 구조의 크기를 가져올 수 있으면 좋겠어요. Elixir에서 프로토콜을 쓰면 다른 모양·타입에서 동작하는 다형적(polymorphic) 코드를 작성할 수 있습니다. size 프로토콜을 다음과 같이 구현할 수 있어요.

defprotocol Size do
  @doc "Calculates the size (and not the length!) of a data structure"
  def size(data)
end

이제 프로토콜을 호환 구현을 가질 수 있는 모든 데이터 구조에 대해 구현할 수 있습니다.

defimpl Size, for: BitString do
  def size(binary), do: byte_size(binary)
end

defimpl Size, for: Map do
  def size(map), do: map_size(map)
end

defimpl Size, for: Tuple do
  def size(tuple), do: tuple_size(tuple)
end

마지막으로 Size 프로토콜을 사용해서 올바른 구현을 호출할 수 있어요.

Size.size({1, 2})
# => 2
Size.size(%{key: :value})
# => 1

리스트에는 size 정보가 없고 그 값을 length로 계산해야 하므로 리스트에 대해서는 구현하지 않았던 점에 주목하세요.

프로토콜을 구현하는 데이터 구조는 그 프로토콜에 정의된 모든 함수의 첫 번째 인자여야 합니다.

모든 Elixir 타입에 대해 프로토콜을 구현할 수 있어요.

  • 구조체(Structs) — 아래 "Protocols and Structs" 섹션 참고
  • Tuple
  • Atom
  • List
  • BitString
  • Integer
  • Float
  • Function
  • PID
  • Map
  • Port
  • Reference
  • Any — 아래 "Fallback to Any" 섹션 참고

Protocols와 Structs

프로토콜의 진짜 이점은 구조체와 섞일 때 나와요. 예를 들어 Elixir는 MapSet처럼 구조체로 구현된 많은 데이터 타입을 제공합니다. 그런 타입에도 Size 프로토콜을 구현할 수 있어요.

defimpl Size, for: MapSet do
  def size(map_set), do: MapSet.size(map_set)
end

구조체에 프로토콜을 구현할 때, defimpl/3 호출이 구조체를 정의하는 모듈 안에 있으면 :for 옵션을 생략할 수 있어요.

defmodule User do
  defstruct [:email, :name]

  defimpl Size do
    # two fields
    def size(%User{}), do: 2
  end
end

주어진 타입에 대한 프로토콜 구현을 찾지 못하면, Any로 폴백하도록 설정하지 않았다면 프로토콜을 호출할 때 예외가 발생합니다. 기존 구현 위에 구현을 만드는 편의 기능도 있는데, 프로토콜 파생(deriving)에 대해서는 defstruct/1을 참고하세요.

Any로의 폴백(Fallback to Any)

어떤 경우에는 모든 타입에 대한 기본 구현을 제공하는 게 편할 수 있어요. 프로토콜 정의에서 @fallback_to_any 속성을 true로 설정하면 됩니다.

defprotocol Size do
  @fallback_to_any true
  def size(data)
end

이제 Size 프로토콜을 Any에 대해 구현할 수 있어요.

defimpl Size, for: Any do
  def size(_), do: 0
end

다만 위 구현이 합리적이지 않다고 말할 수도 있어요. 예를 들어 PID나 정수가 크기 0을 가진다고 말하는 건 말이 안 되죠. 그게 @fallback_to_any가 opt-in 동작인 이유 중 하나예요. 대부분의 프로토콜에서는 프로토콜이 구현되지 않았을 때 오류를 던지는 것이 적절한 동작입니다.

여러 구현(Multiple implementations)

프로토콜은 여러 타입에 대해 한 번에 구현할 수도 있어요.

defprotocol Reversible do
  def reverse(term)
end

defimpl Reversible, for: [Map, List] do
  def reverse(term), do: Enum.reverse(term)
end

defimpl/3 안에서는 @protocol로 구현 중인 프로토콜에, @for로 정의 대상 모듈에 접근할 수 있어요.

타입(Types)

프로토콜을 정의하면 자동으로 t라는 이름의 0-arity 타입이 정의되고, 다음과 같이 쓸 수 있어요.

@spec print_size(Size.t()) :: :ok
def print_size(data) do
  result =
    case Size.size(data) do
      0 -> "data has no items"
      1 -> "data has one item"
      n -> "data has #{n} items"
    end

  IO.puts(result)
end

@spec은 주어진 프로토콜을 구현할 수 있는 모든 타입이 그 함수의 유효한 인자 타입임을 표현합니다.

설정(Configuration)

프로토콜을 설정하는 데 다음 모듈 속성을 사용할 수 있어요.

  • @fallback_to_any — true이면 프로토콜 디스패치가 any로 폴백되게 함
  • @undefined_impl_description — 구현 조회가 실패할 때 Protocol.UndefinedError에서 쓸 추가 설명 문자열. 이 옵션은 @fallback_to_any가 true로 설정되지 않은 경우에만 적용됩니다

Consolidation (통합)

프로토콜 디스패치를 빠르게 하기 위해, 모든 프로토콜 구현이 사전에 알려져 있을 때(보통 프로젝트의 모든 Elixir 코드가 컴파일된 후), Elixir는 protocol consolidation이라는 기능을 제공합니다. Consolidation은 프로토콜을 그 구현에 직접 연결해서, 통합된 프로토콜의 함수를 호출하는 것이 두 개의 원격 함수(올바른 구현을 찾는 함수 하나와 그 구현을 호출하는 함수 하나)를 호출하는 것과 동등하게 만듭니다.

프로토콜 consolidation은 기본적으로 컴파일 중 모든 Mix 프로젝트에 적용됩니다. 이는 테스트 중에 문제가 될 수 있어요. 예를 들어 테스트 중에 프로토콜을 구현하고 싶다면, 프로토콜이 이미 통합되어 있으므로 그 구현은 효과가 없습니다. 한 가지 가능한 해결책은 mix.exs에 테스트 환경에 특화된 컴파일 디렉터리를 포함하는 것입니다.

def project do
  ...
  elixirc_paths: elixirc_paths(Mix.env())
  ...
end

defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]

그런 다음 테스트 환경에 특화된 구현을 test/support/some_file.ex 안에서 정의할 수 있어요.

또 다른 접근 방식은 mix.exs에서 테스트 중 프로토콜 consolidation을 비활성화하는 것입니다.

def project do
  ...
  consolidate_protocols: Mix.env() != :test
  ...
end

Mix.install/2을 쓰고 있다면 consolidate_protocols 옵션을 넘기면 됩니다.

Mix.install(
  deps,
  consolidate_protocols: false
)

다만 이렇게 하면 코드 성능에 영향을 줄 수 있으므로 권장되지는 않습니다.

마지막으로, 모든 프로토콜은 elixirc 컴파일러가 설정한 옵션과 관계없이 debug_infotrue로 설정된 채 컴파일된다는 점을 기억하세요. debug info는 consolidation에 사용되며, 전역으로 설정되지 않았다면 consolidation 후 제거됩니다.

콜백(Callbacks)

deriving(module, term) (optional)

@macrocallback __deriving__(module(), term()) :: Macro.t()

커스텀 deriving을 위해 프로토콜 작성자가 구현해야 하는 선택적 콜백입니다. 주어진 모듈에 대해 프로토콜을 구현하는 quoted expression을 반환해야 해요. 예시는 Protocol.derive/3를 참고하세요.

protocol(atom)

@callback __protocol__(:consolidated?) :: boolean()
@callback __protocol__(:functions) :: [{atom(), arity()}]
@callback __protocol__(:impls) :: {:consolidated, [module()]} | :not_consolidated
@callback __protocol__(:module) :: module()

모든 프로토콜 정의에서 사용 가능한 함수로, 프로토콜 메타데이터를 반환합니다.

impl_for(term)

@callback impl_for(term()) :: module() | nil

모든 프로토콜 정의에서 사용 가능한 함수로, 주어진 term에 대한 구현을 반환하거나 nil을 반환합니다. @fallback_to_any가 true이면 nil은 결코 반환되지 않습니다.

impl_for!(term)

@callback impl_for!(term()) :: module()

모든 프로토콜 정의에서 사용 가능한 함수로, 주어진 term에 대한 구현을 반환하거나 예외를 던집니다. @fallback_to_any가 true이면 절대 예외를 던지지 않아요.

함수(Functions)

assert_impl!(protocol, base)

@spec assert_impl!(module(), module()) :: :ok

주어진 모듈이 로드되었고 주어진 프로토콜의 구현인지 확인합니다. 맞으면 :ok을, 아니면 ArgumentError를 던집니다.

assert_protocol!(module)

@spec assert_protocol!(module()) :: :ok

주어진 모듈이 로드되었고 프로토콜인지 확인합니다. 맞으면 :ok을, 아니면 ArgumentError를 던집니다.

consolidate(protocol, types)

@spec consolidate(module(), [module()]) ::
  {:ok, binary()} | {:error, :not_a_protocol} | {:error, :no_beam_info}

프로토콜과 구현 리스트를 받아 주어진 프로토콜을 통합(consolidate)합니다. 통합은 abstract format에서 프로토콜 impl_for를 빠른 조회 규칙을 갖도록 바꿔서 일어나요. 보통 통합 중 사용할 구현 리스트는 extract_impls/2의 도움으로 가져옵니다. 프로토콜 바이트코드의 업데이트된 버전을 반환합니다. 튜플의 첫 요소가 :ok이면 프로토콜이 통합되었음을 뜻합니다. 주어진 바이트코드나 프로토콜 구현이 통합되었는지는 프로토콜 속성을 분석해서 확인할 수 있어요.

Protocol.consolidated?(Enumerable)

이 함수는 어느 시점에도 프로토콜을 로드하지 않으며, 컴파일된 모듈의 새 바이트코드도 로드하지 않아요. 하지만 각 구현은 가능해야 하며 로드될 것입니다.

consolidated?(protocol)

@spec consolidated?(module()) :: boolean()

프로토콜이 통합되었으면 true를 반환합니다.

derive(protocol, module, options \ []) (macro)

주어진 옵션으로 module에 대한 protocol을 파생(derive)합니다. 프로토콜을 파생할 때마다 Elixir는 프로토콜이 Protocol.__deriving__/2 콜백을 구현했는지 확인해요. 구현했다면 콜백이 호출되고 구현 모듈을 정의해야 합니다. 그렇지 않으면 단순히 Any 구현을 가리키는 구현이 자동으로 파생됩니다.

예시
defprotocol Derivable do
  @impl true
  defmacro __deriving__(module, options) do
    # If you need to load struct metadata, you may call:
    # struct_info = Macro.struct_info!(module, __CALLER__)

    quote do
      defimpl Derivable, for: unquote(module) do
        def ok(arg) do
          {:ok, arg, unquote(options)}
        end
      end
    end
  end

  def ok(arg)
end

프로토콜이 정의되면 두 가지 방법으로 파생할 수 있습니다. 첫째는 구조체를 정의할 때 @derive 모듈 속성을 쓰는 것입니다.

defmodule ImplStruct do
  @derive [Derivable]
  defstruct a: 0, b: 0
end

Derivable.ok(%ImplStruct{})
#=> {:ok, %ImplStruct{a: 0, b: 0}, []}

구조체가 이미 정의되어 있다면 이 매크로를 호출할 수 있어요.

require Protocol
Protocol.derive(Derivable, ImplStruct, :oops)
Derivable.ok(%ImplStruct{a: 1, b: 1})
#=> {:ok, %ImplStruct{a: 1, b: 1}, :oops}

extract_impls(protocol, paths)

@spec extract_impls(module(), [charlist() | String.t() | {charlist(), [charlist()]}]) ::
  [atom()]

주어진 경로에서 주어진 프로토콜에 대해 구현된 모든 타입을 추출합니다. 경로는 charlist 또는 문자열일 수 있어요. 내부적으로 charlist로 처리되므로 리스트로 넘기면 추가 변환을 피할 수 있습니다. 구현체를 로드하지 않습니다.

예시
# Get Elixir's ebin directory path and retrieve all protocols
iex> path = Application.app_dir(:elixir, "ebin")
iex> mods = Protocol.extract_impls(Enumerable, [path])
iex> List in mods
true

extract_protocols(paths)

@spec extract_protocols([charlist() | String.t() | {charlist(), [charlist()]}]) :: [
  atom()
]

주어진 경로에서 모든 프로토콜을 추출합니다. 경로는 charlist 또는 문자열일 수 있어요. 내부적으로 charlist로 처리되므로 리스트로 넘기면 추가 변환을 피할 수 있습니다. 어떤 프로토콜도 로드하지 않습니다.

예시
# Get Elixir's ebin directory path and retrieve all protocols
iex> path = Application.app_dir(:elixir, "ebin")
iex> mods = Protocol.extract_protocols([path])
iex> Enumerable in mods
true

더 알아보기