Do you want to pick up from where you left of?
Take me there

스펙과 타입

이번 수업에서 @spec@type 구문을 공부해보도록 하겠습니다. @spec이 문서화 도구가 코드를 분석해서 문서화에 힘을 실어주는 구문이라면, @type은 읽고 이해하기에 더 쉬운 코드를 쓸 수 있게 도와주는 구문입니다.

소개

여러분들이 작성한 함수의 인터페이스를 설명하고 싶어 할 때가 종종 있습니다. 물론 이런 내용을 @doc 주석 안에서 설명할 수도 있겠지만, 이런 정보는 다른 개발자들에게만 보일 뿐이지 컴파일할 때 쓰이는 부분은 아닙니다. Elixir에 있는 @spec을 사용해서, 함수의 명세를 작성하고 컴파일러가 확인할 수 있도록 할 수 있습니다.

하지만 때로는 함수의 명세가 너무 크고 복잡해질 수 있습니다. 단순화를 위해, 커스텀 타입 도입을 고려할 수 있습니다. Elixir에는 @type 주석으로 커스텀 타입을 정의할 수 있습니다. 한편 Elixir는 여전히 동적 언어입니다. 이 말인즉슨, 타입에 관련된 모든 정보는 컴파일러가 확인하지 않을 것이며, 다른 도구에서만 사용할 것입니다.

스펙

Java나 Ruby를 사용해보신 분들이라면 specification을 interface처럼 생각하실 수 있습니다. Specification에서 함수의 인자나 리턴값이 어떤 타입일지를 정의합니다.

함수를 정의하는 코드 바로 위에 @spec을 쓰고, 그 뒤에 파라미터의 타입을 파라미터로 호출하듯 함수의 이름과 파라미터 타입, :: 뒤에 리턴되는 값을 적어줍니다.

아래 예시를 한번 살펴보도록 하지요.

@spec sum_product(integer) :: integer
def sum_product(a) do
  [1, 2, 3]
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
end

전부 다 괜찮아 보이고, 함수를 호출하면 올바른 값이 리턴되겠지요. 하지만 Enum.sum 함수는 @spec에서 예상했던 integer가 아니라 number를 리턴합니다. 이런 부분에서 버그가 생겨날 수 있어요! 코드를 정적 분석해주는 Dialyzer 같은 도구를 사용해서 이런 종류의 버그를 찾아낼 수 있습니다. 이런 정적 분석 도구를 사용하는 법에 대해서는 다른 수업에서 다루어 보겠습니다.

커스텀 타입

스펙을 작성하는 것도 좋지만, 때로는 우리들이 구현한 함수가 간단한 함수나 컬렉션보다 복잡한 자료 구조를 처리해야 할 수도 있습니다. 이런 함수를 @spec으로 정의한다면, 다른 개발자들이 이해하거나 수정하기가 힘들어질 수 있습니다. 종종 함수가 많은 파라메터를 필요로 하거나, 복잡한 데이터를 리턴해야 할 때가 있습니다. 하지만 파라메터 목록이 길어질수록 코드의 품질이 떨어질 가능성도 점점 커집니다. Ruby나 Java 같은 객체 지향 언어를 사용했더라면, 간편하게 클래스를 구현해서 문제를 해결할 수 있었을 것입니다. Elixir에서는 클래스가 없고, 대신에 타입을 정의해서 언어를 확장할 수 있습니다.

막 설치를 끝내고 난 Elixir에는 integerpid 같은 기본적인 타입이 있는데요. 공식 문서(Types and Their Syntax)에서 사용할 수 있는 타입의 전체 목록을 찾아볼 수 있습니다.

커스텀 타입 정의하기

추가적으로 파라미터를 도입하는 쪽으로 sum_times 함수를 수정해보겠습니다.

@spec sum_times(integer, %Examples{first: integer, last: integer}) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

Range 모듈 안에 있는 구조체를 단순하게 만들어, Examples 모듈 안에 firstlast 필드를 가진 구조체를 도입하였습니다. 구조체는 모듈에서 한번 이야기해 본 적이 있었습니다. 그런데, 코드의 여러 부분에서 Examples 구조체에 대한 스펙을 정의해야 하는 상황을 상상해 봅시다. 길고 복잡한 스펙을 쓰자니 성가실뿐더러, 이 부분에서 버그가 생겨날 가능성이 커질 수도 있습니다. 이 문제를 해결하기 위해서 @type을 사용할 수 있습니다.

Elixir에서 타입을 지정하는 방법에는 세 가지가 있습니다.

이제 타입을 정의해 봅시다.

defmodule Examples do
  defstruct first: nil, last: nil

  @type t(first, last) :: %Examples{first: first, last: last}

  @type t :: %Examples{first: integer, last: integer}
end

구조체 %Examples{first: first, last: last}를 나타내는 타입 t(first, last)를 정의하였습니다. 여기에서 타입이 인수를 취할 수 있다는 걸 알 수 있지만, 이번에는 타입 t도 정의한 데다가 이번에는 %Examples{first: integer, last: integer} 구조체를 나타내는 타입입니다.

어떻게 다를까요? 첫번째는 아무 타입이나 가질 수 있는 두 키를 가진 Example 구조체입니다. 한편 두번째는 키가 정수(integer)인 구조체를 나타냅니다. 다시 말해 아래처럼 코드를 작성하면

@spec sum_times(integer, Examples.t()) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

아래 코드와 같은 의미가 됩니다.

@spec sum_times(integer, Examples.t(integer, integer)) :: integer
def sum_times(a, params) do
  for i <- params.first..params.last do
    i
  end
  |> Enum.map(fn el -> el * a end)
  |> Enum.sum()
  |> round
end

타입 문서화하기

마지막으로 타입을 문서화하는 방법을 다루어 보겠습니다. 문서화 수업에서 함수와 모듈을 문서화할 때에는 @doc@moduledoc을 사용한다는 것을 배웠었지요. 타입을 문서화할 때에는 @typedoc을 사용할 수 있습니다.

defmodule Examples do
  @typedoc """
      integer인 :first와 integer인 :last를 갖고 있는 Examples 구조체를 대표하는 타입.
  """
  @type t :: %Examples{first: integer, last: integer}
end

@typedoc 주석은 @doc이나 @moduledoc과 비슷합니다.

강의에 실수가 있거나 기여하고 싶은 부분이 있으신가요? GitHub에서 이 강의를 수정해보세요!