타입스펙 참조

타입스펙 참조 (Typespecs reference)

Elixir는 동적 타입 언어라서 타입 명세가 컴파일러의 코드 최적화에 쓰이진 않지만, 그 가치는 다른 데 있어요. 문서화와 정적 분석 도구가 그 주인공이죠. 이번 장에서는 타입스펙(typespec)이라는 표기법으로 함수와 타입을 선언하는 법을 하나씩 살펴볼게요.

출처: Elixir 공식 가이드

본문

타입스펙은 집합론적 타입이 아니에요 (Typespecs are not set-theoretic types)

Elixir는 집합론적 타입(set-theoretic types)에 기반한 자체 타입 시스템을 구현하는 중이에요. 다음 문서에서 설명하는 타입스펙은 Erlang에 기반해 타입과 명세를 선언하는 별도의 표기법이에요. 집합론적 타입 작업이 진행됨에 따라 타입스펙은 점진적으로 사라질 수 있어요.

Elixir는 동적 타입 언어이므로, 타입 명세가 컴파일러에 의해 코드를 최적화하거나 수정하는 데 쓰이는 일은 없어요. 그래도 타입 명세를 쓰는 건 유용한데, 그 이유는 다음과 같아요.

  • 문서를 제공해요. 예를 들어 ExDoc 같은 도구는 문서에 타입 명세를 보여줘요.
  • Dialyzer 같은 도구가 타입스펙으로 코드를 분석해서 타입 불일치와 잠재적 버그를 찾게 해줘요.

타입 명세(보통 타입스펙이라 부르죠)는 다음 속성들을 이용해 다양한 맥락에서 정의돼요.

  • @type
  • @opaque
  • @typep
  • @spec
  • @callback
  • @macrocallback

추가로 @typedoc으로 커스텀 @type 정의를 문서화할 수 있어요.

타입과 타입스펙을 정의하는 방법은 아래 "사용자 정의 타입"과 "명세 정의하기" 하위 섹션을 참고하세요.

간단한 예시 (A simple example)

defmodule StringHelpers do
  @typedoc "A word from the dictionary"
  @type word() :: String.t()

  @spec long_word?(word()) :: boolean()
  def long_word?(word) when is_binary(word) do
    String.length(word) > 8
  end
end

위 예시에서:

  • word()라는 새 타입을 선언했는데, 이는 문자열 타입(String.t())과 동일해요.
  • @typedoc으로 이 타입을 설명했고, 이 설명은 생성되는 문서에 포함돼요.
  • long_word?/1 함수가 word() 타입의 인자를 받고, 불리언(boolean(), 즉 true 또는 false)을 반환한다고 명세했어요.

타입과 그 문법 (Types and their syntax)

Elixir가 타입 명세에 제공하는 문법은 Erlang의 것과 비슷해요. Erlang에 제공되는 대부분의 내장 타입(예: pid())은 pid()(또는 그냥 pid)처럼 같은 방식으로 표현돼요. list(integer) 같은 매개변수화된 타입도 지원되고, Enum.t() 같은 원격(remote) 타입도 지원돼요. 정수와 원자 리터럴도 타입으로 허용돼요(예: 1, :atom, false). 그 밖의 모든 타입은 미리 정의된 타입들의 합집합(union)으로 만들어져요. 어떤 타입은 문법 표기로도 선언할 수 있는데, 리스트는 [type], 튜플은 {type1, type2, ...}, 바이너리는 <<_ * _>> 같은 형태예요.

타입의 합집합을 나타내는 표기는 파이프 |예요. 예를 들어 type :: atom() | pid() | tuple()이라는 타입스펙은 원자이거나 pid 또는 튜플일 수 있는 type 타입을 만들어요. 다른 언어에서는 이를 보통 합타입(sum type) 이라고 불러요.

집합론적 타입과의 차이 (Differences with set-theoretic types)

아래 타입들은 몇 가지 유사점을 공유하지만, 집합론적 타입 시스템의 새 타입들과 일대일로 대응되지는 않아요.

예를 들어 integer() 타입의 부분집합(양수, 범위, 리터럴 등)을 지원할 계획은 없어요.

게다가 집합론적 타입은 교집합(intersection)과 부정(negation)을 포함한 완전한 집합 연산 범위를 지원해요.

기본 타입 (Basic types)

type ::
      any()                     # 최상위 타입, 모든 항(term)의 집합
      | none()                  # 바닥 타입, 어떤 항도 담지 않음
      | atom()
      | map()                   # 모든 맵
      | pid()                   # 프로세스 식별자
      | port()                  # 포트 식별자
      | reference()
      | tuple()                 # 모든 크기의 튜플

                                ## 숫자
      | float()
      | integer()
      | neg_integer()           # ..., -3, -2, -1
      | non_neg_integer()       # 0, 1, 2, 3, ...
      | pos_integer()           # 1, 2, 3, ...

                                                                      ## 리스트
      | list(type)                                                    # 정상 리스트 ([]로 끝남)
      | nonempty_list(type)                                           # 비어 있지 않은 정상 리스트
      | maybe_improper_list(content_type, termination_type)           # 정상 또는 비정상 리스트
      | nonempty_improper_list(content_type, termination_type)        # 비정상 리스트
      | nonempty_maybe_improper_list(content_type, termination_type)  # 비어 있지 않은 정상 또는 비정상 리스트

      | Literals                # "리터럴" 섹션에서 설명
      | BuiltIn                 # "내장 타입" 섹션에서 설명
      | Remotes                 # "원격 타입" 섹션에서 설명
      | UserDefined             # "사용자 정의 타입" 섹션에서 설명

리터럴 (Literals)

다음 리터럴들도 타입스펙에서 지원돼요.

type ::                               ## 원자
      :atom                           # 원자: :foo, :bar, ...
      | true | false | nil            # 특수 원자 리터럴

                                      ## 비트스트링
      | <<>>                          # 빈 비트스트링
      | <<_::size>>                   # size는 0 또는 양의 정수
      | <<_::_*unit>>                 # unit은 1부터 256까지의 정수
      | <<_::size, _::_*unit>>

                                      ## (익명) 함수
      | (-> type)                     # 0-arity, type 반환
      | (type1, type2 -> type)        # 2-arity, type 반환
      | (... -> type)                 # 모든 arity, type 반환

                                      ## 정수
      | 1                             # 정수
      | 1..10                         # 1부터 10까지의 정수

                                      ## 리스트
      | [type]                        # type 요소를 원하는 개수만큼 가진 리스트
      | []                            # 빈 리스트
      | [...]                         # nonempty_list(any())의 축약
      | [type, ...]                   # nonempty_list(type)의 축약
      | [key: value_type]             # value_type 값을 가진 선택적 키 :key가 있는 키워드 리스트

                                              ## 맵
      | %{}                                   # 빈 맵
      | %{key: value_type}                    # value_type 값을 가진 필수 키 :key가 있는 맵
      | %{key_type => value_type}             # 필수 키-값 쌍을 가진 맵
      | %{required(key_type) => value_type}   # 필수 키-값 쌍을 가진 맵
      | %{optional(key_type) => value_type}   # 선택적 키-값 쌍을 가진 맵
      | %SomeStruct{}                         # 모든 필드가 임의 타입인 구조체
      | %SomeStruct{key: value_type}          # value_type 값을 가진 필수 키 :key가 있는 구조체

                                      ## 튜플
      | {}                            # 빈 튜플
      | {:ok, type}                   # 원자와 임의 타입으로 이루어진 두 요소 튜플

내장 타입 (Built-in types)

다음 타입들은 Elixir가 위의 기본 타입과 리터럴 타입 위에 제공하는 단축어예요.

내장 타입 정의
term() any()
arity() 0..255
as_boolean(t) t
binary() <<_::_*8>>
nonempty_binary() <<_::8, _::_*8>>
bitstring() <<_::_*1>>
nonempty_bitstring() <<_::1, _::_*1>>
boolean() true | false
byte() 0..255
char() 0..0x10FFFF
charlist() [char()]
nonempty_charlist() [char(), ...]
fun() (... -> any)
function() fun()
identifier() pid() | port() | reference()
iodata() iolist() | binary()
iolist() maybe_improper_list(byte() | binary() | iolist(), binary() | [])
keyword() [{atom(), any()}]
keyword(t) [{atom(), t}]
list() [any()]
nonempty_list() nonempty_list(any())
maybe_improper_list() maybe_improper_list(any(), any())
nonempty_maybe_improper_list() nonempty_maybe_improper_list(any(), any())
mfa() {module(), atom(), arity()}
module() atom()
no_return() none()
node() atom()
number() integer() | float()
struct() %{:__struct__ => atom(), optional(atom()) => any()}
timeout() :infinity | non_neg_integer()

as_boolean(t)는 주어진 값이 불리언으로 취급될 것임을 알려주기 위한 표기예요. 여기서 nilfalsefalse로, 그 외의 모든 것은 true로 평가돼요. 예를 들어 Enum.filter/2는 다음과 같은 명세를 가져요: filter(t, (element -> as_boolean(term))) :: list.

원격 타입 (Remote types)

모듈은 자기만의 타입도 정의할 수 있고, Elixir의 모듈도 예외가 아니에요. 예를 들어 Range 모듈은 범위를 나타내는 t/0 타입을 정의하는데, 이 타입은 Range.t/0으로 참조할 수 있어요. 비슷한 방식으로 문자열은 String.t/0이고, 이런 식이에요.

맵 (Maps)

맵의 키 타입은 겹칠 수 있고, 겹치면 가장 왼쪽 키가 우선해요. 허용된 맵 키에 없는 키를 담고 있으면 그 맵 값은 이 타입에 속하지 않아요.

맵에 미리 정의되지 않은 키가 허용된다는 것을 나타내고 싶다면, 맵 타입을 optional(any) => any로 끝내는 게 일반적이에요.

참고로 map()의 문법적 표현은 %{optional(any) => any)이지 %{}가 아니에요. %{} 표기는 빈 맵에 대한 싱글턴 타입을 지정해요.

키워드 리스트 (Keyword Lists)

keyword()keyword(t) 너머로, 기대되는 키워드 리스트의 명세를 조합하는 것이 도움이 될 수 있어요. 예를 들어:

@type option :: {:name, String.t} | {:max, pos_integer} | {:min, pos_integer}
@type options :: [option()]

이렇게 하면 이 옵션들만 허용되고, 필수인 건 없으며, 순서도 상관없다는 것이 명확해져요.

또한 기존 타입과의 조합도 가능해요. 예를 들어:

@type option :: {:my_option, String.t()} | GenServer.option()

@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts) do
  {my_opts, gen_server_opts} = Keyword.split(opts, [:my_option])
  GenServer.start_link(__MODULE__, my_opts, gen_server_opts)
end

다음 명세 문법들은 동일해요.

@type options [{:name, String.t} | {:max, pos_integer} | {:min, pos_integer}]

@type options [name: String.t, max: pos_integer, min: pos_integer]

사용자 정의 타입 (User-defined types)

@type, @typep, @opaque 모듈 속성으로 새 타입을 정의할 수 있어요.

@type type_name :: type
@typep type_name :: type
@opaque type_name :: type

@typep로 정의한 타입은 비공개예요. @opaque로 정의한 타입은 타입의 내부 구조가 보이지 않지만, 타입 자체는 공개인 타입이에요.

타입은 매개변수로 변수를 정의해서 매개변수화할 수 있어요. 이 변수들은 그다음 타입을 정의하는 데 쓸 수 있죠.

@type dict(key, value) :: [{key, value}]

명세 정의하기 (Defining a specification)

함수의 명세는 다음과 같이 정의할 수 있어요.

@spec function_name(type1, type2) :: return_type

가드(guard)를 이용해 함수에 인자로 주어지는 타입 변수를 제한할 수 있어요.

@spec function(arg) :: [arg] when arg: atom

변수를 두 개 이상 지정하고 싶다면 쉼표로 구분해요.

@spec function(arg1, arg2) :: {arg1, arg2} when arg1: atom, arg2: integer

제한이 없는 타입 변수는 var를 써서 정의할 수도 있어요.

@spec function(arg) :: [arg] when arg: var

이 가드 표기는 @spec, @callback, @macrocallback에서만 동작해요.

타입스펙에서 arg_name :: arg_type 문법으로 인자에 이름을 붙일 수도 있어요. 문서에서 같은 타입의 여러 인자(또는 타입 정의의 같은 타입 여러 요소)를 구분할 때 특히 유용해요.

@spec days_since_epoch(year :: integer, month :: integer, day :: integer) :: integer
@type color :: {red :: integer, green :: integer, blue :: integer}

명세는 일반 함수처럼 오버로드될 수 있어요.

@spec function(integer) :: atom
@spec function(atom) :: integer

동작 (Behaviours)

Elixir(와 Erlang)의 동작(behaviour)은 컴포넌트의 일반적인 부분(이것이 동작 모듈이 됨)과 구체적인 부분(이것이 콜백 모듈이 됨)을 분리하고 추상화하는 방법이에요.

동작 모듈은 해당 동작을 구현하는 콜백 모듈들이 반드시 내보내야 하는 함수와 매크로 집합(콜백이라 부름)을 정의해요. 이 "인터페이스"가 컴포넌트의 구체적인 부분을 식별해 주죠. 예를 들어 GenServer 동작과 함수들은 "서버" 프로세스가 구현하고 싶어할 메시지 전달(보내기·받기)과 오류 보고를 추상화해서, 이 서버 프로세스가 수행해야 할 동작 같은 구체적인 부분과 분리해요.

각각 구조화된 데이터를 파싱하는 파서들을 여러 개 구현하고 싶다고 해 볼게요. 예를 들어 JSON 파서와 MessagePack 파서요. 이 두 파서는 같은 방식으로 동작해요. 둘 다 parse/1 함수와 extensions/0 함수를 제공하죠. parse/1 함수는 구조화된 데이터의 Elixir 표현을 반환하고, extensions/0 함수는 각 데이터 타입에 쓸 수 있는 파일 확장자 목록(JSON 파일의 .json 같은)을 반환해요.

Parser 동작을 만들 수 있어요.

defmodule Parser do
  @doc """
  Parses a string.
  """
  @callback parse(String.t) :: {:ok, term} | {:error, atom}

  @doc """
  Lists all supported file extensions.
  """
  @callback extensions() :: [String.t]
end

위 예시에서 볼 수 있듯이, 콜백을 정의하는 것은 그 콜백에 대한 명세를 정의하는 문제예요. 그 명세는 다음으로 이루어져요.

  • 콜백 이름(예시의 parse 또는 extensions)
  • 콜백이 받아야 하는 인자(String.t)
  • 콜백 반환 값의 기대 타입

Parser 동작을 채택한 모듈은 @callback 속성으로 정의된 모든 함수를 구현해야 해요. 보시다시피 @callback은 함수 이름뿐 아니라 위에서 본 @spec 속성과 같은 함수 명세를 기대해요.

동작 구현하기 (Implementing behaviours)

동작을 구현하는 건 간단해요.

defmodule JSONParser do
  @behaviour Parser

  @impl Parser
  def parse(str), do: {:ok, "some json " <> str} # ... JSON 파싱

  @impl Parser
  def extensions, do: [".json"]
end
defmodule CSVParser do
  @behaviour Parser

  @impl Parser
  def parse(str), do: {:ok, "some csv " <> str} # ... CSV 파싱

  @impl Parser
  def extensions, do: [".csv"]
end

주어진 동작을 채택한 모듈이 그 동작이 요구하는 콜백 중 하나를 구현하지 않으면 컴파일 시점 경고가 생성돼요.

또한 @impl로 주어진 동작의 올바른 콜백을 명시적인 방식으로 구현하고 있는지 확인할 수도 있어요. 예를 들어 다음 파서는 parseextensions를 모두 구현해요. 하지만 오타 때문에 BADParserparse/1이 아니라 parse/0을 구현하고 있어요.

defmodule BADParser do
  @behaviour Parser

  @impl Parser
  def parse, do: {:ok, "something bad"}

  @impl Parser
  def extensions, do: ["bad"]
end

이 코드는 실수로 parse/1 대신 parse/0을 구현하고 있다는 사실을 알려주는 경고를 생성해요. @impl에 대해 더 자세히 읽으려면 모듈 문서를 참고하세요.

동작 사용하기 (Using behaviours)

동작이 유용한 이유는 모듈을 인자로 전달할 수 있고, 그다음 동작에 명시된 함수 중 어떤 것이든 콜백으로 호출할 수 있기 때문이에요. 예를 들어 파일 이름을 받고, 여러 파서를 받고, 확장자에 따라 그 파일을 파싱하는 함수를 만들 수 있어요.

@spec parse_path(Path.t(), [module()]) :: {:ok, term} | {:error, atom}
def parse_path(filename, parsers) do
  with {:ok, ext} <- parse_extension(filename),
       {:ok, parser} <- find_parser(ext, parsers),
       {:ok, contents} <- File.read(filename) do
    parser.parse(contents)
  end
end

defp parse_extension(filename) do
  if ext = Path.extname(filename) do
    {:ok, ext}
  else
    {:error, :no_extension}
  end
end

defp find_parser(ext, parsers) do
  if parser = Enum.find(parsers, fn parser -> ext in parser.extensions() end) do
    {:ok, parser}
  else
    {:error, :no_matching_parser}
  end
end

CSVParser.parse(...)처럼 어떤 파서든 직접 호출할 수도 있어요.

모듈에 동적 디스패치를 하기 위해 동작을 정의할 필요는 없지만, 이 두 기능은 자주 함께 쓰여요.

선택적 콜백 (Optional callbacks)

선택적 콜백은 콜백 모듈이 원하면 구현할 수 있지만 필수는 아닌 콜백이에요. 보통 동작 모듈은 설정(configuration)에 따라 그 콜백을 호출해야 하는지 알거나, function_exported?/3 또는 macro_exported?/3로 콜백이 정의됐는지 확인해요.

로드되지 않은 모듈 (Unloaded modules)

function_exported?/3(및 macro_exported?/3)는 모듈이 로드되지 않았으면 로드하지 않아요. 그리고 Elixir는 기본적으로(릴리즈를 제외하고) 모듈을 지연(lazily) 로드해요. 그래서 실무에서는 함수/매크로가 내보내졌는지 확인하기 전에 Code.ensure_loaded?/1을 먼저 호출하게 돼요. 예시는 function_exported?/3 문서를 참고하세요.

선택적 콜백은 @optional_callbacks 모듈 속성으로 정의할 수 있는데, 이 속성은 함수 또는 매크로 이름을 키로, arity를 값으로 하는 키워드 리스트여야 해요. 예를 들어:

defmodule MyBehaviour do
  @callback vital_fun() :: any
  @callback non_vital_fun() :: any
  @macrocallback non_vital_macro(arg :: any) :: Macro.t
  @optional_callbacks non_vital_fun: 0, non_vital_macro: 1
end

Elixir 표준 라이브러리의 선택적 콜백 예시 하나는 GenServer.format_status/1이에요.

동작 검사하기 (Inspecting behaviours)

@callback@optional_callbacks 속성은 정의 모듈에서 사용 가능한 behaviour_info/1 함수를 만드는 데 쓰여요. 이 함수로 해당 모듈이 정의한 콜백과 선택적 콜백을 가져올 수 있어요.

예를 들어, 위 "선택적 콜백"에서 정의한 MyBehaviour 모듈에 대해:

MyBehaviour.behaviour_info(:callbacks)
#=> [vital_fun: 0, "MACRO-non_vital_macro": 2, non_vital_fun: 0]
MyBehaviour.behaviour_info(:optional_callbacks)
#=> ["MACRO-non_vital_macro": 2, non_vital_fun: 0]

iex를 쓸 때는 IEx.Helpers.b/1 헬퍼도 사용할 수 있어요.

함정 (Pitfalls)

타입스펙을 쓸 때 알려진 함정들이 몇 가지 있는데, 아래에 차례로 설명할게요.

string() 타입 (The string() type)

Elixir는 string() 타입의 사용을 권장하지 않아요. string() 타입은 Erlang 문자열을 가리키는데, Elixir에서는 "charlist"라고 알려져 있어요. Erlang 문자열은 Elixir 문자열, 즉 UTF-8로 인코딩된 바이너리를 가리키지 않아요. 혼동을 피하기 위해, string() 타입을 쓰려고 하면 Elixir는 경고를 내보내요. 상황에 따라 charlist(), nonempty_charlist(), binary(), String.t()를 쓰거나, 이 타입들의 여러 리터럴 표현 중 하나를 쓰면 돼요.

참고로 String.t()binary()는 분석 도구 관점에서는 동일해요. 다만 문서를 읽는 사람에게는, String.t()가 UTF-8로 인코딩된 바이너리임을 암시해요.

오류를 발생시키는 함수 (Functions which raise an error)

타입스펙이 함수가 오류를 발생시킬 수 있다는 것을 나타낼 필요는 없어요. 어떤 함수든 유효하지 않은 입력이 주어지면 언제든 실패할 수 있으니까요. 과거에는 Elixir 표준 라이브러리가 이를 나타내기 위해 no_return()을 쓰기도 했지만, 그런 사용은 제거됐어요.

no_return() 타입은 값을 반환하지만 그 목적이 "부수 효과"인 함수(예: IO.puts/1)에도 쓰이지 않아야 해요. 그런 경우 기대되는 반환 타입은 :ok이에요.

대신 no_return()은 값을 절대 반환할 수 없는 함수의 반환 타입으로 쓰여야 해요. 여기에는 receive를 영원히 반복 호출하는 함수, 오류를 발생시키기 위해 존재하는 함수, VM을 종료하는 함수가 포함돼요.

더 알아보기

  • Software Bill of Materials (이전 장)
  • Unicode syntax (다음 장)
  • Dialyzer 문서 (Erlang 공식 문서)