Elixir의 with 파헤치기

Posted on Saturday, 18 Jan 2020elixir study

Elixir 프로그래밍 언어에는 여러 번의 패턴 매칭을 시도할 때 유용하게 사용할 수 있는 with라는 구문이 있습니다. 이번 포스트는 이것이 실제로 어떻게 동작하는지 알기 위해 Elixir 프로그래밍 언어의 밑바닥부터 조사해 보면서 알게 된 내용들을 정리한 글입니다.

질문

한 가지 예를 들기 위해서 with를 사용해서 임의의 map에서 :width 키의 값과 :height 키의 값을 찾아 직사각형의 넓이를 구하는 코드를 작성해 보겠습니다.

with {:ok, width} when is_number(width) <- Map.fetch(map, :width),
     {:ok, height} when is_number(height) <- Map.fetch(map, :height) do
  {:ok, width * height}
else
  :error -> {:error, "missing value"}
  _ -> {:error, "invalid value"}
end

위의 식을 실행하면 다음과 같은 결과가 나올 수 있습니다.

  • map%{width: 3, height: 2.5}를 포함하고 있다면 {:ok, 7.5}가 반환됩니다.
  • :width 키나 :height 키 중 하나라도 없다면 {:error, "missing value"}가 반환됩니다.
  • :width 키의 값이나 :height 키의 값 중 하나라도 수가 아니라면 {:error, "invalid value"}가 반환됩니다.
  • 위의 예제 코드에서 그럴 일은 없겠지만, 만약 Map.fetch/2에서 쌩뚱맞은 값을 반환하면 WithClauseError 예외가 발생합니다.

여기서 제가 가진 질문은 정말 어처구니 없을 정도로 간단합니다.

“어떻게 그렇게 될까요?”

with는 Special Form이다

우선 제가 가장 먼저 알게 된 것은 with는 Elixir 언어의 “special form”이라는 것입니다. 그렇다면 special form이란 무엇일까요? 저도 special form의 일반적인 정의는 자세히 모릅니다만, Elixir의 special form들을 정의해 놓은 Kernel.SpecialForms의 문서에서는 special form을 다음과 같이 한 마디로 설명하고 있습니다.

Kernel.SpecialForms

Special forms are the basic building blocks of Elixir, and therefore cannot be overridden by the developer.

이 문장만 봐서는 special form이 어떤 건지 잘 와닿지 않습니다. 그런데 Kernel.SpecialForms의 소스 코드를 들여다 보면 신기하게도 각각의 special form에 대한 껍데기 정도만 정의되어 있고 실제 동작은 구현되어 있지 않습니다. 이 모듈에 정의된 매크로들을 억지로 호출하려고 하면 오류만 발생할 뿐입니다.

** (RuntimeError) Elixir's special forms are expanded by the compiler and must not be invoked directly

이제 어느정도 감이 잡힙니다. Elixir의 special form이란, 일반적인 Elixir의 함수나 매크로로는 그 동작을 정의할 수 없는, 따라서 그 동작이 Elixir 컴파일러의 소스에 직접 구현되어 있는 것으로, Elixir가 Elixir로서 있을 수 있도록 하기 위해 반드시 필요한 문법적 요소를 가리키는 것이었습니다.

이제 with의 구현을 어디서 찾아볼 수 있을지 알 것 같습니다.

with가 실행되기까지

Elixir 코드가 실행되는 과정

Elixir의 핵심 부분은 Erlang 프로그래밍 언어로 작성되어 있습니다. Erlang으로 작성된 Elixir의 소스 코드는 GitHub에 공개되어 있는 소스 코드를 기준으로 lib/elixir/src 디렉토리에 저장되어 있습니다. 이 중에서 elixir.erl 파일이 Elixir의 entry point가 정의되어 있는 파일인데, 이 파일의 내용을 잘 살펴보면 텍스트로 된 Elixir 프로그램의 소스 코드가 어떻게 실행되는지를 알 수 있습니다.

  1. :elixir_tokenizer.tokenize/3 함수로 주어진 소스 코드를 토큰으로 분리합니다.

  2. :elixir_parser.parse/1 함수가 앞에서 생성된 토큰들을 바탕으로 구문 분석을 수행해 Elixir AST를 생성합니다.

    이 과정에서 생성된 AST가 바로 우리가 IEx에서 quote를 했을 때 볼 수 있는 Elixir AST인 것이죠.

  3. :elixir_expand.expand/2 함수가 앞에서 생성된 Elixir AST를 확장합니다.

    이 때 연산자나 가드 등의 일부 함수가 Erlang 라이브러리에 정의된 함수로 인라인되고, defmacro 등으로 정의된 매크로가 확장되며, special form의 AST가 확장됩니다.

  4. :elixir_erl_pass.translate/2 함수가 Elixir AST를 Erlang의 parse tree라고 할 수 있는 Erlang abstract format으로 변환합니다.

  5. 마지막으로 Erlang의 stdlib 애플리케이션에 구현된 :erl_eval.expr 함수를 호출함으로써 Elixir 소스 코드로부터 생성된 Erlang 코드를 실제로 실행시킵니다.

여기서 우리가 특히 눈여겨 봐야 할 과정은 바로 3번과 4번 과정입니다. with을 사용한 코드의 AST가 어떤 규칙으로 확장되고 어떤 Erlang 코드로 변환되는지를 알면 마침내 with을 정복할 수 있는 것이죠!

with 구문 AST의 확장

with 구문을 나타내는 AST는 다른 일반적인 Elixir 식과는 다른 특별한 규칙에 따라 확장됩니다. AST의 확장을 담당하는 :elixir_expand.expand/2 함수에는 이 with 구문을 위한 별도의 clause가 정의되어 있으며, 여기서 :elixir_clauses 모듈의 with/3 함수를 호출해 실제로 AST를 처리합니다. 그럼 이 특별한 규칙을 알아보기 위해서 맨 처음에 제시한 코드의 AST를 살펴봅시다.

# 코드의 가독성을 위해서, 깊게 알 필요가 없는 AST는 `quote` 식으로 대체합니다.

{:with, [],
 [
   # 1단계-1
   {:<-, [],
    [
      quote(do: {:ok, width} when is_number(width)),
      quote(do: Map.fetch(map, :width))
    ]},
   # 1단계-2
   {:<-, [],
    [
      quote(do: {:ok, height} when is_number(height)),
      quote(do: Map.fetch(map, :height))
    ]},
   [
     # 2단계
     do: {:ok, quote(do: width * height)},
     # 3단계
     else: [
       {:->, [], [[:error], {:error, "missing value"}]},
       {:->, [], [[quote(do: _)], {:error, "invalid value"}]}
     ]
   ]
 ]}

with 구문의 확장은 크게 세 단계에 걸쳐서 진행됩니다. 지금부터 각각의 단계를 하나씩 알아봅시다.

패턴 매칭 식의 확장

가장 먼저, with 키워드와 do 키워드 사이에 있는 식들을 :elixir_expand.expand/2 함수로 하나씩 확장합니다. 그런데 이 과정에서 약간의 코드 최적화와 프로그래머가 코드를 리팩토링할 수 있도록 힌트를 만들어 주는 작업도 같이 수행됩니다.

한 가지 예로, 다음과 같은 with 구문을 봅시다.

with {:ok, radius} when is_number(radius) <- Map.fetch(map, :radius),
     double_radius <- radius * 2, # (*)
     pi = 3.1415926535 do
  {:ok, double_radius * pi}
else
  :error -> {:error, "missing value"}
  _ -> {:error, "invalid value"}
end

여기서 (*) 표시한 두번째 줄은 화살표가 있는 패턴 매칭 식이긴 한데, 화살표의 좌변이 그냥 단순한 변수 하나로 이루어져 있습니다. with에서 화살표 식은 우변의 식을 평가한 후 그 값을 왼쪽의 패턴에 매칭시켜서, 매칭이 실패하면 그 값을 그대로 반환하거나 else에 있는 clause를 실행하도록 하는 역할을 갖고 있는데, 이 경우에는 패턴 매칭이 실패할 수가 없으므로 무의미한 화살표 식이 되는 것입니다. Elixir 컴파일러는 이러한 식을 =를 사용한 단순한 패턴 매칭 식으로 변환합니다.

- double_radius <- radius * 2
+ double_radius = radius * 2

또 한 가지 더, Elixir 컴파일러가 이러한 식들을 하나하나 처리할 때 HasMatch라는 플래그를 사용합니다. 이 HasMatch 플래그의 초기 값은 false로, with 키워드와 do 키워드 사이에 “유의미한” 화살표 식이 적어도 하나 이상 있다면 HasMatchtrue로 변경합니다. 이 플래그의 값이 어떻게 쓰이는지에 대해서는 아래 “else 블록의 확장” 부분에서 설명합니다.

do 블록의 확장

이 부분은 간단합니다. 앞에서 보여드린 예제 with 구문의 AST에 나와있는 것처럼, with의 맨 마지막 인자는 키워드 리스트인데, 여기에 {:do, expr...}이 있으면 해당 식을 :elixir_expand.expand/2 함수로 확장시키고, 없으면 컴파일 시간에 with 구문에 do 블록이 없다는 오류를 발생시킵니다.

else 블록의 확장

else 블록을 확장할 때도 마찬가지로 with의 맨 마지막 인자인 키워드 리스트로부터 {:else, expr...}을 찾는데, with 구문에서 else 블록은 필수가 아닌 선택 사항이므로, 만약 키워드 리스트에 :else 키가 없다면 아무런 추가적인 처리 없이 이 과정을 끝냅니다.

만약 else 블록이 존재한다면 :elixir_expand.expand/2 함수로 각각의 else clause를 확장하는데, 확장을 하기 전에 앞에서 설명한 HasMatch 플래그를 사용해서 else 블록이 필요한지 여부를 확인합니다. 만약에 with 키워드와 do 키워드 사이에 유의미한 화살표 패턴 매칭 식이 없는데 else 블록이 작성된 경우에는 이 else 블록이 실행될 수가 없기 때문에 컴파일 시간에 경고 메시지를 보여주는 것입니다. Elixir 코드를 작성하는 사람은 이 메시지를 보고 불필요한 else 블록을 지우거나 with을 사용하지 않는 코드로 리팩토링할 수 있겠지요.

이렇게 do 블록과 else 블록을 확장하고 나서 키워드 리스트에 다른 키워드가 남아있는지를 확인합니다. with 구문에는 do 블록과 else 블록만을 사용할 수 있기 때문에 만약에 정의되지 않은 옵션이나 블록이 들어있는 경우에는 컴파일 오류를 발생시킵니다.

이 모든 과정이 끝난 뒤의 예제 AST의 모습을 한 번 구경해 볼까요?

# 코드의 가독성을 위해서, 깊게 알 필요가 없는 AST는 `quote` 식으로 대체합니다.

{:with, [],
 [
   {:<-, [],
    [
      # Kernel.is_number/1 함수는 :erlang.is_number/1 함수로 인라인되었습니다.
      quote(do: {:ok, width} when :erlang.is_number(width)),
      # Map.fetch/2 함수는 :maps.find/2 함수로 인라인되었습니다.
      quote(do: :maps.find(:width, map))
    ]},
   # 유의미한 화살표 식을 찾았기 때문에 HasMatch 내부 변수를 true로 변경합니다.
   {:<-, [],
    [
      quote(do: {:ok, height} when :erlang.is_number(height)),
      quote(do: :maps.find(:height, map))
    ]},
   [
     # Kernel.*/2 함수는 :erlang.*/2 함수로 인라인되었습니다.
     do: {:ok, quote(do: :erlang.*(width, height))},
     # HasMatch 내부 변수가 true이기 때문에 컴파일 경고가 출력되지 않습니다.
     else: [
       {:->, [], [[:error], {:error, "missing value"}]},
       {:->, [], [[quote(do: _)], {:error, "invalid value"}]}
     ]
   ]
 ]}

Erlang 코드로의 변환

이제 마지막으로, 위의 Elixir AST를 Erlang abstract format으로 변환하기만 하면 이를 바로 Erlang VM의 바이트코드로 컴파일해서 실행할 수 있게 됩니다. 앞서 설명드렸다시피 Elixir AST를 Erlang abstract format으로 변환하는 것은 :elixir_erl_pass.translate/2 함수의 역할인데, 이 함수도 :elixir_expand.expand/2와 마찬가지로 with 구문의 AST를 위한 별도의 function clause가 정의되어 있습니다.

with 구문은 결과적으로 Erlang의 case 식으로 변환되는데, 그럼 지금부터 with 구문이 Erlang 코드로 변환되는 과정을 한 번 살펴보겠습니다.

else 블록의 변환

이번에는 Elixir AST를 확장할 때와는 다르게 else 블록의 AST를 가장 먼저 Erlang 코드로 변환합니다. :elixir_erl_pass.translate_with_else/3 함수에서는 with 구문의 else 블록을 다음과 같이 세 가지 경우로 나누어서 처리하고 있습니다.

첫번째로, else 블록이 존재하지 않는 경우입니다. else 블록이 없는 경우에는 패턴 매칭이 실패한 첫번째 term을 그대로 반환하고 with 구문의 실행을 종료하도록 해야 합니다. 따라서 다음과 같은 case clause가 만들어집니다.

% Generated Erlang code:
Var1 ->
    Var1

두번째로, else 블록이 다음과 같이 단순한 형태로 이루어져 있는 경우입니다.

# with(...) do ...
else
  x -> some_expression
end

이런 경우에는 with 구문에서 패턴 매칭이 실패하더라도 WithClauseError 예외가 발생할 일이 없기 때문에, 주어진 else 블록을 그대로 사용해서 다음과 같은 case clause를 생성합니다.

% Generated Erlang code:
Var1 ->
    SomeExpression

마지막으로, 아래와 같이 else 블록의 모양이 앞의 두 경우에 해당하지 않는 경우입니다.

# with(...) do ...
else
  {:error, reason} -> {:error, reason}
  :error -> {:error, :unknown_error}
end

이런 경우에는 else 블록에 정의된 clause에 매칭되지 않는 값이 넘어올 수도 있기 때문에 WithClauseError 예외를 발생하는 코드를 추가합니다. 결과적으로 다음과 같이 case 구문이 포함된 case clause가 생성됩니다.

% Generated Erlang code:
Var1 ->
    case Var1 of
        {error, Var2} ->
            {error, Var2};
        error ->
            {error, unknown_error};
        Var3 ->
            % ** (WithClauseError) no with clause matching: <term>
            error({with_clause, Var3})
    end

이렇게 생성된 case clause를 ElseClause라고 하겠습니다.

with Clause와 do 블록의 변환

이제 대망의 마지막 단계입니다. 이 단계가 끝나면 with 구문으로부터 변환된 Erlang 코드가 완성됩니다. with 구문에는 중간에 화살표(<-)가 있는 패턴 매칭 식과, 그렇지 않은 다른 모든 식을 포함할 수 있기 때문에 이번에는 크게 두 가지 경우로 나누어 생각해 볼 수 있겠습니다.

먼저 화살표(<-)가 없는 일반적인 식들은 특별한 처리 과정 없이 바로 :elixir_erl_pass.translate/2 함수를 통해 바로 Erlang 코드로 변환됩니다.

반면에 화살표가 있는 패턴 매칭 식은 각각 하나의 case 구문으로 변환됩니다. 이 case 구문은 화살표의 좌변에 있는 패턴과 일치하는 경우와 그렇지 않은 경우의 두 가지 case clause로 구성됩니다.

여기서 첫번째 case clause의 안에, 남아있는 with 구문의 식들을 하나씩 Erlang 코드로 변환해서 삽입합니다. 따라서 with 구문에 화살표 식이 여러 개 있으면 case 구문이 여러 번 중첩되어 있는 코드 구조가 만들어지겠죠. 만약 모든 식을 다 변환했다면 가장 안쪽에 있는 case 구문의 첫번째 clause에 do 블록을 Erlang 코드로 변환해서 삽입합니다.

두번째 case clause는 화살표의 좌변에 있는 패턴과 일치하지 않는 경우이므로 바로 앞 단계에서 생성한 ElseClause를 그대로 이용해서 만듭니다.

지금까지 설명한 모든 과정을 전부 거치고 나면 맨 처음에 보여드린 Elixir 코드는 다음과 같은 Erlang 코드로 변환됩니다. 이 코드는 곧바로 Erlang VM의 바이트코드로 변환되어 실행될 수 있습니다.

% Generated Erlang code:
case maps:find(width, Var1) of
    {ok, Var4} when is_number(Var4) ->
        case maps:find(height, Var1) of
            {ok, Var5} when is_number(Var5) ->
                {ok, Var4 * Var5};
            Var2 ->
                case Var2 of
                    error ->
                        {error, <<"missing value">>};
                    _ ->
                        {error, <<"invalid value">>};
                    Var3 ->
                        error({with_clause, Var3})
                end
        end;
    Var2 ->
        case Var2 of
            error ->
                {error, <<"missing value">>};
            _ ->
                {error, <<"invalid value">>};
            Var3 ->
                error({with_clause, Var3})
        end
end

비슷하면서도 다른 Erlang 코드의 모습에 익숙하지 않은 분들을 위해 위의 코드를 Elixir로 직역(?)한 코드도 보여드리겠습니다.

case :maps.find(:width, var1) do
  {:ok, var4} when :erlang.is_number(var4) ->
    case :maps.find(:height, var1) do
      {:ok, var5} when :erlang.is_number(var5) ->
        {:ok, :erlang.*(var4, var5)}

      var2 ->
        case var2 do
          :error -> {:error, "missing value"}
          _ -> {:error, "invalid value"}
          # 사실상 (raise WithClauseError, term: var3)과 동일합니다.
          var3 -> :erlang.error({:with_clause, var3})
        end
    end

  var2 ->
    case var2 do
      :error -> {:error, "missing value"}
      _ -> {:error, "invalid value"}
      var3 -> :erlang.error({:with_clause, var3})
    end
end

마무리

이번에는 Elixir의 소스 코드를 자세히 살펴보며 with special form이 Elixir 컴파일러에 의해 실행 가능한 Erlang 코드로 변환되는 과정을 알아보고 글로 정리해 보았습니다. Elixir의 Kernel.SpecialForms 모듈에는 with 말고도 다양한 Elixir special form들이 정의되어 있습니다. 여러분도 Elixir의 소스 코드를 탐험하면서 여러분이 평소에 궁금해 했던 Elixir 프로그래밍 언어의 구성 요소를 자세히 알아보고, Elixir의 안쪽에 깊게 파고들어가면서 얻을 수 있는 즐거움을 느껴보셨으면 합니다.

보너스: with와 Dialyzer

Elixir의 with 구문을 사용하다 보면 이따금씩 원인을 파악하기 힘들고 쉽게 해결할 수 없는 Dialyzer 경고가 발생하기도 합니다. 아래 코드는 제가 진행하고 있는 정적 웹사이트 생성기 프로젝트 “Serum”의 소스 코드의 일부입니다.

@spec Serum.Template.Storage.get(binary(), :include | :template) ::
        Serum.Template.t() | nil

@spec Keyword.keyword?(term()) :: boolean()

@spec Serum.Renderer.render_fragment(Serum.Template.t(), keyword()) ::
        {:ok, binary()} | {:error, term()}

with %Serum.Template{} = template <- Serum.Template.Storage.get(name, :include),
     true <- Keyword.keyword?(args),
     {:ok, html} <- Serum.Renderer.render_fragment(template, args: args) do
  html
else
  nil -> raise "include not found: \"#{name}\""
  false -> raise "'args' must be a keyword list, got: #{inspect(args)}"
  {:error, _} = error -> raise Serum.Result.get_message(error, 0)
end

이 코드를 아무리 쳐다봐도 잘못된 곳을 찾을 수 없었지만, Dialyzer는 항상 with 구문에 대해 3개의 경고를 출력했었고, 이를 없애기 위한 모든 노력은 허사였습니다. 그런데 이번에 with 구문이 어떻게 Erlang 코드로 변환되는지를 알게 되고 나서 문제의 원인을 곧바로 깨달았습니다.

with 구문의 첫번째 화살표 식만 case 구문으로 변환해 보면 여러분도 금방 문제점을 눈치챌 수 있을 겁니다.

case Serum.Template.Storage.get(name, :include) do
  %Serum.Template{} = template ->
    # ...

  x ->
    case x do
      nil -> raise "include not found: \"#{name}\""
      false -> raise "'args' must be a keyword list, got: #{inspect(args)}"
      {:error, _} = error -> raise Serum.Result.get_message(error, 0)
    end
end

Serum.Template.Storage.get/2의 typespec에 따르면, 이 함수가 반환할 수 있는 값은 Serum.Template.t()nil의 두 가지 뿐입니다. 그런데 전자의 경우는 이미 첫번째 case clause에서 커버하고 있으므로, 두번째 case clause에 들어갈 수 있는 값은 nil밖에 없는데, 이 안에 있는 case 구문의 clause들은 각각 nil, false, {:error, _}이므로 이 셋 중 두 clause는 절대로 매칭될 수 없는 것이죠. Elixir의 with 구문은 여러 번의 패턴 매칭을 간결하게 할 수 있도록 도와주는 편리한 문법이지만, 이런 문제가 발생할 가능성도 숨겨두고 있었던 것입니다.

이런 문제는 with를 다른 조건부 분기 구문으로 변환함으로써 간단하게 해결할 수 있습니다. 여러 개의 구문이 중첩된 모습이 보기 싫다면 여러 개의 함수를 만들어서 코드를 리팩토링하면 되겠죠.

case Serum.Template.Storage.get(name, :include) do
  %Serum.Template{} = template ->
    if Keyword.keyword?(args) do
      case Serum.Renderer.render_fragment(template, args: args) do
        {:ok, html} -> html
        {:error, _} = error -> raise Serum.Result.get_message(error, 0)
      end
    else
      raise "'args' must be a keyword list, got: #{inspect(args)}"
    end

  nil ->
    raise "include not found: \"#{name}\""
end