언어 AST와 IR을 구현할 때 태그드 유니온과 패턴 매칭이 생각만큼 결정적인 차이를 만들지 않는다는 주장과, 그 한계 및 다른 맥락에서의 장점을 살펴본다.
태그드 유니온과 그 위에서의 패턴 매칭에 대해 강한 의견을 가진 프로그래밍 언어 엔지니어들 사이에서, 특히 널리 퍼진 견해 하나는 태그드 유니온의 가치와, 그 위에서의 패턴 매칭에 대한 언어 차원의 지원이 중요하다는 것이다. 예를 들어 우리가 Sorbet을 C++로 작성하기로 결정했을 때, 태그드 유니온과 패턴 매칭을 네이티브로 지원하지 않는 언어를 사용하는 것을 고려한다는 사실 자체에 놀라움과 충격을 자주 들었다. 반대로 OCaml이 이 둘을 훌륭하게 지원한다는 점은, 컴파일러를 작성하기에 OCaml이 뛰어난 이유 중 하나로 종종 언급된다.
지금까지 나는 Go, C++, Java, Rust, Haskell 등 다양한 언어로 장난감 수준부터 프로덕션 수준까지 여러 컴파일러를 작업해왔다. 그 경험을 바탕으로 한 내 숙고된 의견은 이것이다: 태그드 유니온과 그에 대한 패턴 매칭은 언어의 AST와 IR을 구현하는 데 있어 심각하게 과대평가되어 있다. 있으면 좋긴 하지만, 컴파일러나 타입체커 같은 시스템을 개발하는 과정에서 실질적으로 큰 차이를 만드는 경우는 드물다.
태그드 유니온은 실제로 IR을 _구성_하는 매우 자연스러운 방법이다. 대부분의 IR 구조에 아주 자연스럽게 대응한다. 보통 IR은 서로 겹치지 않는 유한한 노드 타입들의 세계로 이루어져 있고, 그 노드들을 순회하면서 구체 타입에 따라 다르게 동작하는 코드가 많이 생기기 때문이다.
하지만 내 주장의 핵심은, 이런 구성을 다른 언어에서도 충분히 에뮬레이션하기 쉽다는 점이다. Java나 C++ 같은 객체지향 언어에서는 AST를 추상 베이스 클래스와 서브클래스들의 집합으로 구현하고, 매칭은 단순한 타입 테스트로 하면 된다. Java에서는 instanceof, C++에서는 dynamic_cast를 쓰면 된다. Go에서는 private interface method 트릭을 쓰거나, 경우에 따라서는 그냥 interface{}를 쓰고 타입 스위치로 디스패치할 수 있다.
나는 이런 접근이 네이티브 패턴 매칭만큼 동일하게 인체공학적이라고 주장하는 것은 아니다. 하지만 내 경험상 충분히 괜찮아서, 그 차이가 쟁점이 되지 않는 수준이다. if를 직접 써야 하니 코드가 조금 더 길어질 때는 있지만, 시스템의 형태를 근본적으로 바꾸지는 않으며, 의미 있는 안전성을 아주 많이 잃는 일도 드물다.
이런 시스템을 설계할 때 태그드 인터페이스와 패턴 매칭이라는 _아이디어_에 노출되어 본 경험은 매우 가치 있다고 생각한다. 구체적인 예를 하나 들면, 방문자 패턴은 95%의 경우 완전한 쓰레기이고, instanceof를 사용한 단순한 재귀적 하강으로 대체하는 편이 더 낫다. 그리고 그 대체는, 코드를 “태그드 유니온 위에서의 패턴 매칭을 에뮬레이션한다”라고 생각할 때 훨씬 자연스럽다. if문과 타입 테스트를 뭔가 본질적으로 죄악시하는 객체지향적 사고방식에 갇혀 있을 때보다 말이다.
하지만 거기에 도달하기 위해 실제로 패턴 매칭과 태그드 인터페이스가 필요한 것은 아니다. 패턴을 알아차린 다음 에뮬레이션하면 Just Fine™하다.
패턴 매칭의 한계
다음으로는 반대쪽 측면, 즉 패턴 매칭을 지원하는 언어에서의 패턴 매칭의 한계에 대한 이야기다. 내 경험상, 패턴 매칭은 종종 우리가 바라는 만큼 강력하거나 편리하지 않게 끝난다. 몇 가지 구체적인 예를 들면:
Rust에서는 IR이 자식 노드를 인라인으로(또는 Box로) 담는 대신, 자식에 대한 포인터를 담는 순간 패턴 매칭이 깨지기 시작한다(그리고 Box에 대한 패턴 매칭조차도 여전히 불안정하다). 내 경험상 IR이 Rc나 Arc를 사용해서 노드들 사이에 IR 서브트리를 공유하는 변환을 지원하는 경우가 매우 흔한데, Rust에서는 그렇게 하는 순간 중첩 패턴 매칭을 할 수 있는 능력을 어쨌든 전부 잃어버린다! 이 불평은 Rust에만 해당하는 이야기지만, 더 넓은 종류의 문제를 상징적으로 보여준다. 즉 (대부분의 언어에서) 패턴 매칭은 강력한 의미에서 확장 가능하거나 커스터마이즈 가능한 구조가 아니라는 점이다.
이 문제가 드러나는 또 다른 방식은, 패턴이 일급이 아니라는 점이 종종 답답하다는 것이다. 패턴을 여기저기 전달할 수도 없고, 실행 중에 패턴을 조합해 만들어낼 수도 없다. LLVM에는 C++에서 어느 정도 패턴 매칭의 사용감을 제공하기 위해 A Lot Of C++을 사용한 자체 제작 패턴 매칭 프레임워크가 있다. 여기의 멋진 기능 하나는 패턴이 _일급 객체_라는 점이다. 하위 패턴들로부터 패턴을 조합해 만들고, 전달하고, 조작할 수 있다. 또한 [m_OneUse](https://llvm.org/doxygen/namespacellvm_1_1PatternMatch.html#aca6428e7fec51ba0b55594f469eb9691) 같은 매처도 지원하는데, 이는 타입 이외의 속성에 대해서도 일급 방식으로 매칭할 수 있게 해준다.
내게는 이것이, 설령 C++에 언어 차원의 패턴 매칭이 있다 하더라도 이런 복잡하고 타입에만 의존하지 않는 매처들을 지원하기 위해서는 어쨌든 우리만의 패턴 매칭 시스템을 발명해야 했을 것이라는 설득력 있는 근거처럼 보인다. 그렇다면 그런 언어 지원으로 우리가 실제로 얻게 되는 것은 무엇일까?
여기서 다시 한 번 강조하고 싶다. 이 의견은 범위가 한정되어 있다: 나는 IR을 구현하는 맥락에서 태그드 유니온이 과대평가되었다고 생각한다. 나는 언어의 다른 용도들에 대해서는 태그드 유니온의 엄청난 팬이고, 더 널리 보급되었으면 좋겠다. 다만 소스 코드를 다루는 컴파일러 및 유사 프로그램들에 대해 태그드 유니온이 킬러 기능이라고는 생각하지 않는다.
특히 태그드 유니온이 사용성 및/또는 안전성 측면에서 체급 이상으로 큰 역할을 한다고 생각하는 지점들을 몇 가지 짚고 싶다:
나는 Rust의 Result<T, E> 타입을 정말 사랑한다. 이것은 내가 생각하기에 단연 가장 좋아하는 오류 처리 모델이다. Go의 (SomeType*, error) 형태의 2값 반환은 이를 에뮬레이션하려는 시도로 볼 수 있는데, 그 둘을 하나의 값으로 접어서 여기저기 전달할 수 있다는 점은 정말 훌륭하다. Go에서는 채널을 통해 “결과+오류” 튜플을 보내기 위해 type fooResult struct { foo *Foo; err error } 같은 것을 직접 타이핑하는 내 자신을 자주 발견한다. 이런 경우에 대한 더 나은 지원이 있었으면 좋겠고, 오류를 패턴 매칭으로 다루거나 유니온의 한 팔(variant)을 잘못된 자리에서 실수로 사용하지 않게 하는 데에도 더 좋은 지원이 있었으면 한다.
일급 태그드 유니온과 패턴 매칭이 진짜로 빛나는 두 번째 지점은, 에뮬레이션하기가 정말 어려운(컴파일러만이 제공할 수 있는) 독특한 장점 중 하나인 포괄성(exhaustivity) 검사다. 모든 가능성을 고려했음을 정적으로 단언할 수 있다는 것은 매우 강력하며, 특히 대규모 코드베이스에서 어떤 데이터 타입의 정의를 소유한 팀과 그 타입을 사용하는 팀(들)이 서로 다를 수 있을 때 더욱 그렇다. 새 케이스를 추가해야 할 때, 컴파일러가 새 variant를 고려하기 위해 코드를 업데이트해야 할 가능성이 있는 모든 지점을 자동으로 알려줄 수 있다는 것은 대단히 강력하다.
컴파일러 맥락에서도 나는 가끔 이 기능이 그립다. 새 AST 노드를 추가해야 하는데 코드베이스가 if문으로 패턴 매칭을 에뮬레이션하고 있다면, 업데이트가 필요한 곳을 찾기 위해 grep 및/또는 테스트 스위트에 의존해야 해서 답답할 수 있다. 하지만 다시 말해, 실제로는 그렇게까지 부담스럽다고 느낀 적이 많지 않다. 또한 컴파일러는 테스트(퍼징, 프로퍼티 기반 테스트 같은 정교한 전략 포함)에 매우 잘 맞는 편이고, 이는 실수가 프로덕션에 나가기 전에 발견되도록 도와줄 수 있다.