메타프로그래밍 놀이터: do-notation

Posted on Thursday, 23 May 2019elixir macro series 

저는 Elixir 프로그래밍 언어에 깊은 애착(?)을 갖고 있습니다. 제가 살면서 처음으로 배운 함수형 프로그래밍 언어이기도 하고, 기반 플랫폼인 Erlang/OTP의 특징인 동시성과 슈퍼바이저 프로세스를 비롯한 장애 허용 시스템 등이 매력적으로 다가왔던 언어입니다. 하지만 제가 가장 좋아하는 Elixir의 특징은 바로 강력한 메타프로그래밍 지원입니다. Elixir의 매크로는 C/C++의 매크로와는 다르게, 단순히 컴파일에 앞서 소스 코드의 문자열을 치환하는 정도에서 멈추지 않고, 언어의 모양을 바꾸거나, 더 나아가 새로운 DSL(Domain Specific Language)을 만들 수 있도록 해 줄 정도로 강력합니다.

우리는 이러한 Elixir 매크로의 강력함을 바탕으로 재미있는 것들을 많이 만들어 낼 수 있지만, 한편 매크로의 과도한 사용은 다른 사람들이 직관적으로 코드를 이해하는 것을 방해할 수도 있습니다. 그래서 기획한 것이 바로 “메타프로그래밍 놀이터” 입니다. 앞으로 저는 Elixir 매크로를 가지고 놀면서, 공개 프로젝트에 도입하기는 좀 곤란하지만 여러분과 공유하고 싶은 코드를 만들어 냈을 때 제목 앞에 “메타프로그래밍 놀이터”를 붙여서 포스트를 작성하려고 합니다.

두서가 조금 길었습니다. 메타프로그래밍 놀이터의 첫번째 주제는 바로 Haskell 프로그래밍 언어에 있는 do-notation을 Elixir 매크로로 구현해 보는 것입니다.

Monad

do-notation을 이해하기 위해서는 Haskell의 Monad 타입 클래스에 대한 대략적인 이해가 필요합니다. 저는 Haskell을 제대로 공부한 적은 없지만, 오랫동안 인터넷을 돌아다닌 경험에 의하면 Haskell을 처음 배우는 사람들이 가장 어려워하는 것이 바로 이 Monad가 아닌가 싶습니다. 방금 “대략적인 이해가 필요하다”고 말했는데, 이게 대략적으로만 이해해도 충분한 것인지, 아니면 애초에 이게 대략적인 이해가 가능한 것인지는 저도 잘 모르겠습니다(…). 그래도 최대한 간단하게, 적어도 이 글의 나머지 내용을 이해할 수 있을 정도로 설명해 보겠습니다. 만약 부정확한 정보가 있어도 너그럽게 봐 주시고, 이건 정말 아니다 싶은 것은 댓글로 알려 주시기 바랍니다…….

대부분의 프로그램의 동작 과정에는 성공할 수도 있고 실패할 수도 있는 동작이 필연적으로 존재합니다. 프로그래밍 언어마다 어떤 동작이 성공했거나 실패한 경우를 처리하는 방법에 대한 컨벤션이 각각 있습니다만, 그러한 방법들은 크게 다음과 같이 분류할 수 있겠습니다.

  1. 예외 발생

    def function_that_might_fail do
      if something_went_wrong? do
        raise "oh, no!"
      else
        42
      end
    end
  2. 비어 있는 (또는 무효한 값) 반환

    def function_that_might_fail do
      if something_went_wrong? do
        nil
      else
        42
      end
    end
  3. 반환 값으로 성공 및 실패 여부를 판별할 수 있는 타입 사용

    def function_that_might_fail do
      if something_went_wrong? do
        {:error, "oh, no!"}
      else
        {:ok, 42}
      end
    end

Haskell에서는 보통 성공할 수도 있고 실패할 수도 있는 동작을 List(위의 2번 예에 해당)나 EitherMaybe 등(위의 3번 예에 해당)으로 표현합니다.

이제 이러한 동작들이 여러 개 모여서 하나의 큰 절차를 구성한다고 가정해 봅시다. 이 절차를 이루는 각각의 동작은 성공할 수도 있고 실패할 수도 있기 때문에, 일련의 절차를 제대로 수행하기 위해서는 하나의 동작을 수행하고 나서 이 동작이 성공했는지 또는 실패했는지를 확인해야 합니다.

def a_massive_procedure(foo) do
  case action_one(foo) do
    {:ok, value1} ->
      case action_two(value1) do
        {:ok, value2} ->
          case action_three(value1, value2) do
            {:ok, value3} -> {:ok, value3}
            {:error, _} = error -> error
          end

        {:error, _} = error ->
          error
      end

    {:error, _} = error ->
      error
  end
end

“Bind” 연산자

이렇게 복잡한 코드를 피하기 위해서 사용하는 것이 바로 Monad입니다. Monad 타입 클래스를 구현하는 모든 타입들은 >>=(bind)라는 이항 연산자를 구현해야 하는데, Elixir와 비슷하게 생긴 의사 코드로 다음과 같이 구현할 수 있습니다.

# 주의: 실제 Elixir 컴파일러는 >>= 연산자를 인식하지 않습니다.
# 리스트의 경우
def list >>= fun
def []   >>= fun, do: []
def xs   >>= fun, do: for x <- xs, y <- fun.(x), do: y

# {:ok, 값}, {:error, 오류_정보}의 경우
def result          >>= fun
def {:error, error} >>= fun, do: {:error, error}
def {:ok, value}    >>= fun, do: fun.(value)

자세히 보면 패턴 매칭을 통해 성공한 동작을 나타내는 값과 실패한 동작을 나타내는 값을 구별한 후에, 성공한 동작을 나타내는 값에 대해서만 그 안에 “포장된” 값에 함수를 적용한다는 것을 알 수 있습니다. 이 연산자를 이용해서 위의 case 기반 코드를 다시 작성해 봅시다.

# 주의: 실제 Elixir 컴파일러는 >>= 연산자를 인식하지 않습니다.
def a_massive_procedure(foo) do
  action_one(foo) >>= (fn value1 ->
    action_two(value1) >>= (fn value2 ->
      action_three(value1, value2) >>= (fn value3 ->
        {:ok, value3}
      end)
    end)
  end)
end

do-notation과 Elixir의 with

동작이 실패한 경우를 감지하고 걸러내는 것은 이제 >>= 연산자의 역할이기 때문에 코드가 한결 짧아진 것을 볼 수 있습니다. 하지만 익명 함수가 중첩되어 있는 구조 때문에 여전히 가독성이 좋다고는 할 수 없겠습니다. 이를 보완하기 위해서 Haskell에서 제공하는 do-notation이라는 syntactic sugar를 사용하면 코드가 매우 간결해집니다.

do
  value1 <- action_one foo
  value2 <- action_two value1
  value3 <- action_three value1 value2
  return value3

Haskell의 Monaddo-notation은 성공거나 실패할 수 있는 일련의 사이드 이펙트들을 어떻게 하면 최대한 간결하게 표현할 수 있는지를 고민하다가 탄생한 것임을 알 수 있습니다.

그런데, 이미 Elixir에도 Haskell의 do-notation을 사용하는 것과 비슷한 효과를 얻을 수 있는 with라는 구문이 있습니다.

def a_massive_procedure(foo) do
  with {:ok, value1} <- action_one(foo),
       {:ok, value2} <- action_two(value1),
       {:ok, value3} <- action_three(value1, value2) do
    {:ok, value3}
  else
    {:error, _} = error -> error
  end
end

이런 좋은 문법이 이미 있는데 왜 굳이 메타프로그래밍을 해서 do-notation을 만들고 싶냐고요? 잘 굴러가는 동그란 바퀴가 떡하니 있어도 ★ 모양의 바퀴를 만들고 그걸 또 자랑하는 곳이 바로 여기, “메타프로그래밍 놀이터”이니까요.

만들어 봅시다!

Elixir에서 do-notation을 구현하기 위해 필요한 것은 Haskell의 >>= 연산자에 대응하는 bind/2함수와, do-notation으로 작성된 코드를 연쇄적인 bind/2 함수 호출식으로 변환해주는 매크로입니다. Haskell에서는 Monad 타입 클래스를 구현하는 모든 타입에 대해 >>= 연산자를 사용할 수 있지만, 우리가 만들 bind/2 함수는 편의를 위해 {:ok, value}{:error, error}의 두 가지 모양의 값만 지원하도록 하겠습니다. 이는 나중에 프로토콜을 사용하여 다른 타입의 값에 대해서도 사용할 수 있도록 확장할 수 있습니다.

bind/2 함수

bind/2 함수를 구현하는 것은 쉽습니다. 이미 위에서 “Elixir와 비슷한 의사 코드”로 구현한 것을 보여드린 적이 있습니다.

@type result(type) :: {:ok, type} | {:error, String.t()}
@type bind_fun(type) :: (type -> result(type))

@spec bind(result(term()), bind_fun(term())) :: result(term())
def bind(result, fun)
def bind({:ok, value}, fun), do: fun.(value)
def bind({:error, _} = error, _fun), do: error

이 함수를 간단하게 테스트 해 보겠습니다.

iex> my_div = fn
...>   _, 0 -> {:error, "division by zero"}
...>   x, y -> {:ok, x / y}
...> end
#Function<...>
iex> bind({:ok, 5}, &my_div.(&1, 2))
{:ok, 2.5}
iex> bind({:ok, 5}, &my_div.(&1, 0))
{:error, "division by zero"}
iex> bind({:error, "foo"}, &my_div.(&1, 10))
{:error, "foo"}

run_m/1 매크로

지금부터 본격적인 메타프로그래밍 시간입니다. 😆 Haskell의 do-notation에서 사용하는 do 키워드는 Elixir의 do ~ end 키워드와 충돌하기 때문에 임의로 run_m/1이라는 이름을 붙여 보았습니다. 코드의 모양을 바꾸는 메타프로그래밍을 할 때는 우선 “Before”와 “After”를 생각한 다음에 작업을 시작하는 것이 편한 것 같습니다. 이것을 여기에도 한 번 적용해 보겠습니다.

# Before - 아래와 같은 코드를...
run_m do
  value1 <- action_one(foo)
  value2 <- action_two(value1)
  some_side_effect(foo, value1)
  value3 <- action_three(value1, value2)
  return value3
end
# After - ...아래와 같은 코드로 바꾸는 run_m/1 매크로를 작성하기
bind(action_one(foo), fn value1 ->
  bind(action_two(value1), fn value2 ->
    bind(some_side_effect(foo, value1), fn _ ->
      bind(action_three(value1, value2), fn value3 ->
        {:ok, value3}
      end)
    end)
  end)
end)

매크로 정의

run_m/1이라는 매크로는 이름 뒤에 붙어 있는 /1에서 알 수 있듯이 매개변수가 하나 있는 함수인데, 이 매개변수에 들어가는 인자는 바로 do ~ end로 표현된 블록 식입니다. 이를 Elixir의 syntactic sugar가 제거된 상태로 표현하면 다음과 같은 Elixir term이 됩니다.

[{:do, ( expr1; expr2; ... )}]

Elixir의 블록은 quote를 통해 AST로 변환되었을 때 다음과 같은 형태를 갖습니다. 따라서 리스트의 요소에 접근함으로써 우리가 원하는 코드를 만들어낼 수 있습니다.

{:__block__, [...],
 [
   quoted_expr1,
   quoted_expr2,
   ...
 ]}

이를 이용하여 run_m/1 매크로의 정의 부분을 작성할 수 있습니다.

defmacro run_m([do: {:__block__, _, exprs}]) when is_list(exprs) do
  # TODO: exprs를 지지고 볶아서 요리하기
  raise "not implemented"
end

매크로 구현

앞에서 보여드린 “Before” 코드와 “After” 코드의 가장 큰 차이점은, “Before” 코드에서는 수행해야 할 동작들이 일렬로 나열되어 있는 반면에, “After” 코드에서는 익명 함수에 의해 중첩되어 있다는 것입니다. 맨 처음에 수행될 동작이 가장 바깥에 있고, 가장 나중에 수행될 동작은 가장 깊이 중첩된 함수 식 안에 있습니다.

Enum.reduce/3 함수를 이용하면 이런 모양의 구조를 쉽게 만들 수 있습니다. 다만 Enumerable의 맨 처음에 있는 요소가 가장 안쪽에 들어간다는 것에 주의하세요.

iex> Enum.reduce(1..5, 0, fn x, acc -> [x, acc] end)
[5, [4, [3, [2, [1, 0]]]]]

이제 do 블록 내에 있는 각각의 식을 어떻게 처리해야 하는지만 알아내면 끝날 것 같습니다. 블록의 내부를 살펴보면 크게 왼쪽 화살표 (<-)로 이루어진 식과 평범해 보이는 함수 호출 식으로 이루어져 있음을 알 수 있습니다.

먼저, 왼쪽 화살표 식은 화살표의 우변에 있는 동작이 성공했을 때 그 동작의 결과로 얻은 값을 좌변의 패턴에 매치시키는 식입니다. 이런 경우에는 화살표의 우변에 있는 식을 bind/2 함수의 첫번째 인자로 전달하고, 화살표의 좌변에 있는 패턴을 bind/2 함수의 두번째 인자로 전달할 익명 함수에 사용하면 되겠습니다.

# Before:
pattern <- expression

# After:
bind(exprerssion, fn pattern -> ... end)

다음으로 평범한 함수 호출 식은 해당 동작이 성공했을 때 다음 식을 실행하도록 하며, 아래의 식을 간략하게 표현한 것입니다.

_ <- some_side_effect(foo, value1)

단지 해당 동작의 결과로 얻은 값을 사용하지 않겠다는 뜻이지, some_side_effect/2 함수도 마찬가지로 {:ok, value} 또는 {:error, error}를 반환해야 합니다.

# Before:
expression

# After:
bind(expression, fn _ -> ... end)

이를 바탕으로 run_m/1 매크로를 완성할 수 있습니다!

defmacro run_m([do: {:__block__, _, exprs}]) when is_list(exprs) do
  [expr | exprs] = Enum.reverse(exprs)

  Enum.reduce(exprs, expr, fn
    {:<-, _, [left, right]}, acc ->
      quote(do: bind(unquote(right), fn unquote(left) -> unquote(acc) end))

    x, acc ->
      quote(do: bind(unquote(x), fn _ -> unquote(acc) end))
  end)
end

보너스: return/1

앞의 예제에서 return value3이라는 코드를 보시고 “분명 Elixir에는 return 이라는 키워드가 없을 텐데……?” 하는 생각을 하셨을 겁니다. 여기서 return은 대부분의 명령형 프로그래밍 언어에서 사용하는 return 문과는 다르게, 단순히 인자로 주어진 값을 {:ok, ...} 튜플에 감싸주는 역할을 합니다. 따라서 do 블록의 중간에 return을 넣는다고 해서 일련의 동작이 중간에 끝나거나 하지는 않습니다. 이 return은 함수나 매크로로 간단히 구현할 수 있습니다.

defmacro return(expr), do: quote(do: {:ok, unquote(expr)})

마무리

아래 asciicast는 지금까지 Elixir 매크로로 구현한 do-notation을 직접 사용해 보면서 시연하는 영상입니다. (저 같이) Haskell 프로그래밍 언어가 생소한 분들은 다른 세계의 오류 처리 방식을 맛보기할 수 있는 좋은 시간이 되셨을 겁니다. 그럼 다음에도 재미있는 Elixir 메타프로그래밍 포스트로 찾아뵙겠습니다.

마지막으로, 쉽고 재미있는 함수형 프로그래밍 언어 Elixir에 많은 관심 부탁드립니다. 😅💦