#elixirlang 매크로 코드 디버깅 팁

2 minute read

Metaprogramming Elixir 책에서 간단한 예제를 가져왔다. HTML DSL(Domain-specific language)를 매크로로 구현하는 예제를 가져오고 싶었지만 멋진 만큼 설명이 많이 필요했다. 그래서 이거 매크로로 만들어서 어디에 쓸 거야? 하는 unless 매크로를 가져왔다.

defmodule ControlFlow do
  defmacro unless(expression, do: block) do
    quote do
      if !unquote(expression), do: unquote(block)
      end
    end
  end
require ControlFlow

ControlFlow.unless 2 == 5 do
  "block entered"
end
"block entered"

참이면 블럭을 실행하는 if 매크로와 반대로 거짓일 때 블럭을 실행하는 unless 매크로를 만들었다.

AST(abstract syntax tree) 출력

이 매크로는 어떤 AST를 만들어내는 걸까? 매크로 함수가 만드는 AST를 출력하는 방법으로 디버깅을 해볼 수 있다.

defmodule ControlFlow do
  defmacro unless(expression, do: block) do
    ast =
      quote do
      if !unquote(expression), do: unquote(block)
      end

      IO.inspect(ast)
      ast
    end
  end

IO.inspect/2 함수를 사용해 quote 함수로 만든 AST를 출력할 수 있다. 위의 예제를 실행해보면

{:if, [context: ControlFlow, import: Kernel],
 [
   {:!, [context: ControlFlow, import: Kernel], [{:==, [line: 3], [2, 5]}]},
   [do: "block entered"]
 ]}

AST를 볼 수 있다. elixir는 AST를 튜플로 구성한다. {함수, 메타 데이터, 인자} 형식이다. {:!, [context: ControlFlow, import: Kernel], [{:==, [line: 3], [2, 5]}]} 코드를 AST 형식에 맞게 분해하면 다음과 같다.

  • 함수: :!
  • 메타 데이터: [context: ControlFlow, import: Kernel]
  • 인자: [{:==, [line: 3], [2, 5]}]

Kernel에 있는 함수 !/1 함수를 실행한다. 인자는 [{:==, [line: 3], [2, 5]}] 표현식(expression)이다.

Macro.to_string/2 함수를 사용해 AST를 코드처럼 출력

defmodule ControlFlow do
  defmacro unless(expression, do: block) do
    ast =
      quote do
      if !unquote(expression), do: unquote(block)
      end

      IO.puts(Macro.to_string(ast))
      ast
    end
  end

Macro.to_string/2 함수를 사용해서 AST를 코드처럼 출력해볼 수 있다.

if(!(2 == 5)) do
  "block entered"
end

메타 데이터가 빠지고 코드처럼 출력해서 더 보기 편하다. 짧은 매크로는 AST로 출력하나 Macro.to_string/2 함수를 사용해서 출력하나 비슷하지만 긴 매크로는 확실히 메타데이터가 빠지고 코드처럼 출력하는 Macro.to_string/2 함수를 사용하면 보기가 더 편하다.

quote/2 매크로와 Macro.expand_once/2 함수를 사용

macro 함수를 수정하지 않고 확인할 방법도 있다. 라이브러리의 매크로를 디버깅할 때, 유용하다.

require ControlFlow

quote do
  ControlFlow.unless 2 == 5 do
    "block entered"
  end
end
{
 {:., [], [{:__aliases__, [alias: false], [:ControlFlow]}, :unless]},
 [],
 [{:==, [context: Elixir, import: Kernel], [2, 5]}, [do: "block entered"]]
}

quote/2 함수를 사용해서 AST 자체를 보는 방법이다. logical negation(NOT) 연산자가 적용된 결과가 AST에 바로 나오지 않는다. elixir AST 튜플 구성 요소 중 함수 부분인 첫 번째 요소를 보면 {:., [], [{:__aliases__, [alias: false], [:ControlFlow]}, :unless]} ControlFlow 모듈의 unless 함수를 호출하는 것까지만 AST에 나온다.

Macro.expand/2 혹은 Macro.expand_once/2 함수를 사용하면 매크로를 실행한(expand) 결과를 볼 수 있다. Macro.expand/2 함수를 호출하면 매크로가 하나도 남아있지 않을 때까지 매크로를 실행한다. 정보가 많아서 원하는 게 바로 안 보일 수 있으니 원하는 AST가 보일 때까지 Macro.expand_once/2 함수를 호출하는 방식을 선호한다.

require ControlFlow

quote do
  ControlFlow.unless 2 == 5 do
    "block entered"
  end
end
|> Macro.expand_once(__ENV__)
{:if, [context: ControlFlow, import: Kernel],
 [
   {:!, [context: ControlFlow, import: Kernel],
    [{:==, [context: Elixir, import: Kernel], [2, 5]}]},
   [do: "block entered"]
 ]}

매크로를 실행하려면 컴파일 환경 정보가 필요하다. __ENV__ 매크로를 인자로 넘겨 현재 컴파일 환경 정보를 사용한다. 이제 매크로 코드 안에서 직접 AST를 출력한 것과 똑같은 결과가 나온다. 여기에 Macro.to_string/1 함수도 똑같이 사용해서 좀 더 익숙한 문자열로 결과를 볼 수 있다.

참고