표현 문제(Expression Problem)가 무엇인지, Rust에서 겉보기에 어떻게 이를 회피할 수 있는지, 그 해법이 왜 실제로는 잘 작동하지 않는지, 그리고 Rust다운 대안은 무엇일 수 있는지를 설명한다. 제네릭, 트레이트, 트레이트 객체, enum, 그리고 Unsize 등을 통해 데이터 타입과 연산의 도메인 경계를 재구성하고, 고수준 크레이트가 조합을 오케스트레이션하는 설계를 제시한다.
며칠 전, 소프트웨어 설계에서 가끔 부딪히는 난제인 표현 문제(Expression Problem)에 대한 Hacker News 토론을 우연히 보게 되었습니다. 몇몇 댓글에서는 Rust가 트레이트 객체 덕분에 이 문제를 완전히 피한다고 했고, 저도 처음엔 동의했는데, 지금 와서 보니 그리 단순하지만은 않더군요.
이 글의 목표는 표현 문제가 무엇인지, Rust가 이를 어떻게 겉보기에는 피해 가는지, 왜 그 해법이 실제로는 성립하지 않는지, 그리고 Rust다운 해결책이 어떤 모습일지 설명하는 것입니다.
제가 단순하게 설명을 잘 못하는 편이라 처음 논쟁을 촉발했던 Eli Bendersky의 글을 먼저 읽어보시길 권합니다. 여기서도 요지는 반복하겠지만, 어떤 해법이 통하고 어떤 해법이 통하지 않는지에 대한 직관을 주기 위해 좀 더 추상적으로 다루겠습니다.
여러 _데이터 타입_과 그 타입들에 수행되는 여러 _연산_이 있고, 그 대응 관계는 다대다라고 가정합시다. 즉, 각 연산은 각 데이터 타입에 대해 수행될 수 있습니다.
표현 문제는 종종 프로그래밍 언어를 만들 때 부각되므로, PLT 쪽 예시를 들어봅시다. _데이터 타입_은 정수, 문자열, 배열, 더하기 같은 다양한 AST 노드들이고, _연산_은 문자열화(stringify), 덤프(dump), 인터프리트(interpret), 분석(analyze) 같은 것들입니다. 각 연산의 동작은 적용되는 데이터 타입에 따라 달라집니다.
단순화를 위해, 각 연산의 구현은 정확히 하나의 데이터 타입만 처리한다고 가정하겠습니다. 다시 말해 데이터 타입이 N개, 연산이 M개면, 정확히 N×M개의 동작을 정의해야 합니다.
코드베이스가 커지면 일부를 다른 모듈이나, 심지어 라이브러리로 분리하고 싶어질 겁니다. 혹은 다른 라이브러리가 여러분의 언어에 기능을 추가할 수 있게 하고 싶을 수도 있죠.
그렇게 하려면 외부 코드가 새로운 데이터 타입과 연산을 프로그램에 추가할 수 있어야 합니다. 표현 문제는 대부분의 언어에서 API를 새 데이터 타입을 추가하기 쉽거나 새 연산을 추가하기 쉽도록 설계하는 것은 간단하지만, 두 가지를 동시에 가능하게 만드는 건 이상하리만치 어렵다고 말합니다.
만약 확장성에 신경 쓰지 않는다면, 프로그램을 어떻게 설계하겠습니까?
두 가지 방법이 떠오릅니다. 첫 번째는 enum 기반입니다:
enum AstNode {
Integer(i32),
Str(String),
Array(Vec<AstNode>),
Add(AstNode, AstNode),
..
}
fn stringify(node: AstNode) -> String {
match node { .. }
}
fn dump(node: AstNode, fmt: &mut Formatter<'_>) {
match node { .. }
}
fn interpret(node: AstNode) -> Value {
match node { .. }
}
..
fn parse(code: &str) -> AstNode {
..
}
두 번째는 trait 기반입니다:
struct Integer(i32);
struct Str(String);
struct Array(Vec<Box<dyn AstNode>>);
struct Add(Box<dyn AstNode>, Box<dyn AstNode>);
..
trait AstNode {
fn stringify(self) -> String;
fn dump(self, fmt: &mut Formatter<'_>);
fn interpret(self) -> Value;
..
}
impl AstNode for Integer {
fn stringify(self) -> String { .. }
fn dump(self, fmt: &mut Formatter<'_>) { .. }
fn interpret(self) -> Value { .. }
}
..
fn parse(code: &str) -> Box<dyn AstNode> {
..
}
enum 기반 구현에서는 외부 코드가 새로운 연산을 추가하는 건 쉽습니다. 함수만 정의하면 되죠. 하지만 새로운 데이터 타입을 추가하려면 기존 enum을 수정해야 하는데, 그 enum이 아예 다른 크레이트에 정의되어 있을 수도 있습니다.
trait 기반 구현에서는 외부 코드가 새로운 데이터 타입을 추가하는 건 쉽습니다. struct를 정의하고 해당 트레이트를 구현하면 됩니다. 하지만 새로운 연산을 추가하려면 기존 trait를 수정해야 하고, 그 trait 역시 다른 크레이트에 정의되어 있을 수 있죠.
HN에서 논의된 “해결책”은 단일 AstNode 트레이트를 연산마다 하나씩 여러 개로 쪼개는 것입니다. 이렇게 하면 데이터 타입과 연산이 직교화됩니다. 전자는 타입으로, 후자는 트레이트로 표현됩니다. Rust는 로컬 타입에 외부 트레이트를, 외부 타입에 로컬 트레이트를 각각 구현할 수 있으므로, 어떤 크레이트든 새 데이터 타입을 추가할 수 있고, 어떤 크레이트든 새 연산을 추가할 수 있습니다.
struct Integer(i32);
struct Str(String);
..
trait Stringify {
fn stringify(self) -> String;
}
trait Dump {
fn dump(self, fmt: &mut Formatter<'_>);
}
..
impl Stringify for Integer {
fn stringify(self) -> String { .. }
}
impl Dump for Integer {
fn dump(self, fmt: &mut Formatter<'_>) { .. }
}
..
문제가 보이시나요?
제가 보여주지 않은 것이 있습니다. 아니, 두 가지가 있습니다. 먼저 명백한 것부터: Array의 정의는 어떻게 생겼을까요? 더 이상 단일 트레이트가 없으니 struct Array(Vec<Box<dyn AstNode>>);라고 할 수 없습니다. 그렇다면 어떤 트레이트들을 나열해야 할까요?
struct Array(Vec<Box<dyn Stringify + Dump + ..>>);
무엇을 고르든, 데이터 타입의 정의는 그 타입에서 수행 가능한 연산 목록을 하드코딩하게 됩니다. dyn 애너테이션에 선언되지 않은 트레이트의 메서드를 사용하는 것은 불가능하므로, 외부 크레이트가 기존 데이터 타입에 새 연산을 추가하는 것도 불가능합니다. 사실상 단일 트레이트를 두는 것보다 나을 게 없고, 단지 근본 문제를 더 감추는 꼴입니다.
물론 enum으로 돌아갈 수도 있지만, 그 경우 각 데이터 타입의 정의가 가능한 모든 데이터 타입의 목록을 하드코딩하게 되고, 똑같은 문제가 생깁니다.
이제 다른 문제를 얘기해 봅시다.
예전에 단일 enum이 있었을 때, parse 함수는 그 enum을 반환했습니다. 단일 트레이트가 있었을 때는 Box<dyn AstNode>를 반환했죠. 그런데 지금은 무엇을 반환해야 할까요?
상식과 달리, 저는 enum을 반환하는 게 실제로는 _좋다_고 주장합니다. 이렇게 생각해 봅시다.
함수가 AST 노드를 _인자_로 받을 때는, 그 위에 _연산_을 적용해 소비합니다. 함수는 구체적인 데이터 타입엔 관심이 없고, 중요한 건 주어진 연산 집합을 지원하느냐뿐입니다.
함수가 AST 노드를 _반환_할 때는, 구체적인 _데이터 타입_을 초기화해 구성합니다. 함수는 이후에 여러분이 이 데이터에 어떤 연산을 적용하든 관심이 없고, 소비자가 이 특정 데이터 타입 집합을 처리할 줄 아느냐만 중요합니다.
다시 말해, 함수의 인자는 _연산 도메인_에 있고, 반환값은 _데이터 타입 도메인_에 있습니다.
enum을 반환하면 parse가 소비자들이 상대해야 할 데이터 타입을 철저히 명시할 수 있고, 소비자는 그 모든 타입에 대해 필요한 연산이 구현되어 있는지를 정적으로 검증할 수 있습니다.
좋은 점은 이 구분 덕분에, 각 배출 타입이 각 요구 연산을 지원하는지 검증할 책임이 누구에게 있는지가 분명해진다는 겁니다.
제가 두 개의 크레이트 mylang-parse와 mylang-analyze를 가지고 있다고 합시다. 파싱 크레이트에 새로운 문법을 추가하면서 새로운 데이터 타입을 추가했는데, mylang-analyze에 이 타입을 처리하는 법을 가르치는 걸 잊었다면, 두 크레이트 어느 쪽도 잘못한 것은 아닙니다. 문제는 parse의 반환값을 analyze의 입력으로 넘길 때, 즉 노드를 _데이터 타입 도메인_에서 _연산 도메인_으로 옮기려고 할 때 비로소 드러납니다. Rust에서는 바로 이 지점에서 컴파일러가 타입이 트레이트를 구현했는지 검사합니다.
핵심은 dump(parse(code))라는 한 줄이 두 크레이트의 _바깥_에 등장한다는 것입니다. 즉, 제가 거짓말을 했고 사실은 세 번째 크레이트 mylang-cli가 있다는 뜻입니다. 이 크레이트는 mylang-parse와 mylang-analyze에 의존하며 둘의 관계를 선언합니다.
만약 mylang-parse가 parse를 업데이트해 새로운 데이터 타입을 반환하도록 했다면, 이는 호환성 파괴 변경이어야 합니다. 누군가가 의존하는 모든 연산을 이 새 타입이 구현했다는 증거가 없기 때문입니다. 그리고 parse가 enum을 반환한다면 실제로 그렇게 됩니다. enum에 변형(variant)을 추가하는 것은 파괴적 변경인 반면, 타입에 트레이트를 구현하는 것은 그렇지 않기 때문입니다.
mylang-analyze가 analyze의 인자에 새로운 연산 구현을 요구하도록 변경한다면, 이것 역시 파괴적 변경이어야 합니다. analyze가 impl Op1 + Op2를 받는다고 할 때, 여기에 새 트레이트를 추가하면 함수 시그니처가 바뀌는데, 이것 또한 파괴적 변경으로 간주됩니다.
이런 일이 생길 때마다 타입 체크는 상위 mylang-cli 크레이트에서 실패합니다. 상위 크레이트는 어느 한 쪽 업데이트를 되돌리거나, 다른 쪽 크레이트를 업그레이드하거나, 빠진 구현을 직접 추가하는 등의 책임을 집니다.
물론 이런 도메인 경계를 넘는 일은 투박합니다. enum을 Box<dyn Trait>로 변환하는 일은 지저분하고 보일러플레이트가 가득하지만, 이론적으로는 가능하긴 합니다.
하지만 더 시급한 문제가 있습니다. 함수가 _연산 도메인_의 노드를 받고 _데이터 타입 도메인_의 노드를 반환한다고 가정하면, 어떤 노드 변환기(심지어 “항등” 함수조차)도 이 경계를 반대 방향으로 넘어야 하며, 특정 트레이트를 구현하는 임의의 타입을 특정 데이터 타입으로 변환해야 합니다. 이건 명백히 불가능하고 의미론적으로도 무의미합니다.
여기서 “항등” 얘기는 단순한 예시 이상의 의미가 있습니다. core::convert::identity가 제네릭을 사용해 이 문제를 비켜가는 방식을 보세요. 서명(signature)은 다음과 같습니다:
fn identity<T>(x: T) -> T;
사실상 입력과 출력이 같은 _데이터 타입_이라는 뜻입니다. 따라서 동일한 _연산_을 지원해야 한다는 사실은 자동으로 따라옵니다. Rust가 제네릭 트레이트 매개변수를 지원한다면, 다음과 같은 대체 서명을 상상해 볼 수 있겠습니다:
fn identity<trait Trait>(x: impl Trait) -> impl Trait;
즉, 입력과 출력이 같은 _연산_을 지원한다는 뜻입니다. 이번에는 대응이 뒤바뀝니다. Trait = Is<T>라고 치환할 수 있기에, 입력과 출력이 같은 _데이터 타입_이어야 한다는 결론을 이끌어낼 수 있습니다. 여기서 Is<T>는 오직 T에만 구현되는 트레이트입니다.
제네릭은 두 도메인을 하나로 합칠 수 있게 해줍니다.
이게 해법의 핵심입니다. enum을 반환하고 트레이트 객체를 받는 대신, 함수들은 제네릭 매개변수 Node를 받아, where 절에서 이 Node가 요구되는 _연산_을 지원한다(Node: Operation)거나, 이 Node가 주어진 구체적 _데이터 타입_을 담을 수 있다(Node: From<DataType>)고 주장(assert)해야 합니다. 함수 시그니처는 다음과 같아집니다:
fn analyze<Node: Stringify + Dump + Statistics + ..>(node: Node) -> String;
// 이것은 `enum`을 반환하는 것과 비교하면 조금 길지만, enum의 변형들은 본질적으로
// 시그니처의 일부이기도 하다는 점을 생각해 보세요.
fn parse<Node: From<Integer> + From<Str> + ..>(code: &str) -> Node;
TryInto<DataType> 같은 바운드를 상상해 타입 다운캐스트에도 사용할 수 있습니다. 예컨대, 각 ord("<character>") 호출을 숫자로 최적화하고 싶다면, 시그니처는 다음과 같을 겁니다:
fn transform<Node>(node: Node) -> Node
where
Node: GetChildren, // 재귀적으로 처리
Node: TryInto<FunctionCall> + TryInto<Str>, // 함수 호출과 문자열을 인식
Node: From<Integer>; // 숫자 생성
재귀적 데이터 타입도 마찬가지입니다. 어떤 연산도 요구하지 않고 Node를 제네릭 매개변수로 받도록 해야 합니다:
struct Array<Node>(Vec<Node>);
연산 모델링은 조금 더 까다롭습니다. 노드들을 소모만 하는 연산은 API를 단순하게 유지할 수 있습니다:
trait Stringify {
fn stringify(self) -> String;
}
impl Stringify for Integer { .. }
impl<Node: Stringify> Stringify for Array<Node> { .. }
일부 연산은 메서드 시그니처에 Node를 언급해야 할 수도 있습니다(예: 새로운 데이터를 만드는 연산, 즉 parse를 돕는 보조 연산). 이 경우 메서드 자체를 Node에 대해 제네릭으로 만들 수 없습니다. 왜냐하면 매개변수화된 데이터 타입(예: Array<T>)이 제네릭 매개변수 Node의 어떤 선택에 대해서도(심지어 T와 다른 경우까지) 그 메서드를 구현해야 하기 때문입니다.
대신, Node를 트레이트의 연관 타입(associated type)으로 두거나, 트레이트를 Node에 대해 매개변수화할 수 있습니다. 후자가 더 직교적입니다. 기본 타입인 Str 같은 것도 Node와 무관하게 Operation<Node>를 구현할 수 있기 때문입니다.
trait Optimize<Node> {
fn optimize(self) -> Node;
}
요약하면, 최종 사용자(즉, 상위 수준 크레이트)가 데이터 타입과 연산의 포괄 목록을 결정하고, 그것을 하위 크레이트로 내려보내도록 하자는 아이디어입니다. 이 상위 크레이트는 오케스트레이터로서, 각 구성 요소가 구체적 표현을 하드코딩하지 않게 해줍니다. 이 개념을 직관적으로 구현하면 다음과 같을 수 있습니다:
use mylang_parse::data_types::*;
use mylang_analyze::operations::*;
..
enum AstNode {
Integer(Integer),
Str(Str),
Array(Array<AstNode>),
..
}
impl From<Integer> for AstNode { .. }
impl From<Str> for AstNode { .. }
impl From<Array<AstNode>> for AstNode { .. }
..
impl Stringify for AstNode {
fn stringify(self) -> String {
match self {
Self::Integer(node) => node.stringify(),
Self::Str(node) => node.stringify(),
Self::Array(node) => node.stringify(),
..
}
}
}
..
그렇다고 해서 이 포괄 목록이 반드시 소스 코드에 적나라하게 드러나야 한다는 뜻은 아닙니다. 사실, 이런 지저분한 것들은 추론될 수도 있습니다. 결국 불투명 타입 AstNode를 parse와 dump에 넘긴다면, Rust는 AstNode가 만족해야 하는 트레이트 바운드의 포괄 목록을 알고 있을 겁니다. From<T> 형태의 바운드는 enum 쪽 구멍을 채우고, 다른 바운드는 impl 디스패치 쪽 구멍을 채우겠죠.
이 문제는 지금까지 다룬 것들과는 근본적으로 다릅니다. 그동안의 문제는 모두 의미론적, 즉 언어의 기능과 무관하게 일반적으로는 풀 수 없는 문제였습니다(호환성 이슈, 공변성 문제 등). 이번 것은 단지 Rust가 어떤 패턴을 아직 지원하지 않는다는 점일 뿐입니다.
하지만 꽤 가까이 갈 수는 있습니다. 불행히도 방법이 두 가지가 있는데, 과거에 무언가를 두 가지 방식으로 할 수 있었던 때를 떠올리면, 둘 다 그다지 잘 되지 않았습니다.
탐욕적(greedy) 해법부터 시작해 봅시다. 어차피 연산 목록을 이미 하드코딩하고 있으니, 데이터 타입의 이름을 직접 쓰는 일만이라도 피하기 위해 트레이트 객체를 써봅시다:
struct AstNode(Box<dyn Stringify<AstNode> + ..>);
impl<T> From<T> for AstNode where T: Stringify<AstNode> + .. + 'static {
fn from(value: T) -> Self {
Self(Box::new(value))
}
}
impl Stringify for AstNode {
fn stringify(self) -> String {
self.0.stringify()
}
}
..
여전히 데이터 타입은 _존재_합니다. 여전히 호환성과 타입 체크 통과 여부에 영향을 미칩니다. 단지 사실상 암묵적이 되었을 뿐입니다.
이제 연산 이름의 반복을 줄여봅시다. 다음과 같이 정의하고 싶어질 겁니다:
type AstNode = dyn Stringify<AstNode> + ..;
…이렇게 하면 해당 트레이트들을 자동으로 모두 구현하고, Box 래퍼를 소비자 쪽으로 내려보낼 수 있을 겁니다. 하지만 Rust는 재귀적 타입 별칭을 지원하지 않으므로 새타입 struct로 감싸야 하는데, 새타입은 트레이트 구현을 포워딩하지 않습니다.
주어진 도구를 엉뚱한 데 적용하려는 다소 절박한 시도로, Node: Operation 바운드를 Node: Deref<Target: Operation>으로 바꾸고 struct AstNode에 Deref를 구현할 수 있습니다:
trait Operations<Node> = Stringify<Node> + ..; // 이것이 일반 트레이트 + blanket impl이라고 가정합시다
struct AstNode(Box<dyn Operations<AstNode>>);
impl<T> From<T> for AstNode where T: Operations<AstNode> + 'static {
fn from(value: T) -> Self {
Self(Box::new(value))
}
}
impl Deref for AstNode {
type Target = dyn Operations<AstNode>;
fn deref(&self) -> &Self::Target {
&*self.0
}
}
// ... `DerefMut`도 비슷하게, 그리고 `DerefMove`에 대한 일종의 폴리필도 필요할 겁니다.
사실상 트레이트를 dyn을 통해 타입으로 끌어올리고, dyn Trait1: Trait2로 슈퍼트레이트 관계를 흉내 내는 셈입니다.
하지만 여전히 연산 이름을(설령 한 번만이라도) 명시하고 있습니다. 곰곰이 생각해 보면, 이는 예전에 parse가 enum을 반환하도록 할지, 아니면 Node: From<Integer> + From<Str> + ..를 받도록 할지 실험했을 때와 비슷합니다. 포괄 목록이 반드시 나쁜 아키텍처는 아닙니다. 그렇지 않았다면, 어차피 암묵적으로 포워딩되었을 내용을 명시적으로 주석 달아둔 것일 뿐일 수도 있습니다.
사실, 나열된 연산이 모두 상위 크레이트에서 스스로 사용하는 것이라면 큰 문제가 아닙니다. 이 목록과 같은 크레이트 내의 함수 호출 사이에 직접적 대응이 있다면, 다소 지저분하긴 해도 유지보수 위험이나 표현력의 빈틈으로까지 보기는 어렵습니다.
정말 피하고 싶은 것은, 다른 크레이트에서만 사용하는 내부 연산이 이 목록으로 새어나오는 것입니다. (물론 여전히 근본적으로는 호환 가능한 추상화 경계가 아니며, 우리가 할 수 있는 일은 의존성이 업데이트될 때 이 목록을 암묵적으로 갱신하는 것뿐입니다.) 이를 완화하려면 각 크레이트가 자신의 API가 사용하는 연산 목록을 내보내도록 하면 됩니다. 예를 들어 mylang-analyze가 본질적으로 다음과 같은 것을 내보낼 수 있습니다:
trait Operations<Node> = Stringify<Node> + ..; // 다시, 이것이 트레이트 + blanket impl이라고 가정합시다
…그리고 상위 크레이트는 이렇게 쓸 수 있습니다:
trait Operations<Node> = mylang_analyze::Operations<Node> + ..; // 여기도 동일
이제는 의존성 목록만 하드코딩하면 됩니다. 이건 정말 괜찮은 수준입니다.
하지만 변형이 잔뜩 있는 enum + 잔뜩의 impl 콤보를 단순화하는 또 다른 방법이 있습니다. 이번에는 트레이트 이름을 전혀 명시하지 않길 기대하면서, 먼저 Deref 트릭을 적용해 impl들을 단순화해 봅니다.
우리는 AstNode의 소비자가 타겟 타입, 즉 Deref::Target을 직접 제공하길 원합니다. 우리가 그 타입을 스스로 이름 붙일 수 없기 때문이죠. 이 추상화는 AsRef라고 부르며, 우리가 하려는 일은 다음과 같습니다:
enum AstNode {
Integer(Integer),
Str(Str),
Array(Array<AstNode>),
..
}
impl From<Integer> for AstNode { .. }
impl From<Str> for AstNode { .. }
impl From<Array<AstNode>> for AstNode { .. }
..
impl<trait Trait> AsRef<dyn Trait> for AstNode
where
Integer: Trait,
Str: Trait,
Array<AstNode>: Trait,
..
{
fn as_ref(&self) -> &dyn Trait {
match self {
Self::Integer(node) => node,
Self::Str(node) => node,
Self::Array(node) => node,
..
}
}
}
물론 Rust는 제네릭 트레이트 매개변수를 지원하지 않지만, 나이틀리 Rust에는 일반적인 _트레이트 객체_로의 강제(coercion) 개념을 표현할 수 있는 Unsize가 있습니다:
#![feature(unsize)]
impl<T: ?Sized> AsRef<T> for AstNode
where
Integer: Unsize<T>,
Str: Unsize<T>,
Array<AstNode>: Unsize<T>,
..
{
fn as_ref(&self) -> &T {
match self {
Self::Integer(node) => node,
Self::Str(node) => node,
Self::Array(node) => node,
..
}
}
}
정확한 데이터 타입을 명시하는 대신, 그것을 제공하는 크레이트를 명시할 수도 있습니다:
enum AstNode {
Parsed(mylang_parse::Node),
Extension1(mylang_extension1::Node),
..
}
// ^ 여기서는 다이아몬드 상속(diamond inheritance)을 피하도록 주의해야 합니다. 예컨대
// `mylang_somecrate::Node`가 자신이 의존하는 타입들을 그냥 다시 나열하는 게 아니라,
// 그 크레이트 안에서 정의된 데이터 타입만을 독점적으로 나열하도록 요구하는 식으로요.
impl From<mylang_parse::Node> for AstNode { .. }
impl From<mylang_extension1::Node> for AstNode { .. }
..
이제, 앞서의 해법과 마찬가지로 데이터 타입이나 연산을 하드코딩하지 않고, 의존 크레이트만 하드코딩하고 있습니다. 차이는 새 데이터 타입을 제공하는 크레이트 목록을 하드코딩하느냐, 새로운 연산을 요구하는 크레이트 목록을 하드코딩하느냐에 있습니다.
우리는 데이터 타입이나 연산 중 하나를 철저히 하드코딩하도록 강요하는 설계에서 출발했습니다. 그러다 제네릭을 사용해 모든 데이터 타입과 연산을 지원하는 만능 AstNode 타입을 아래로 전파하도록 바꿨고, 각 모듈은 자신이 어떤 데이터 타입을 생산하고 어떤 연산을 소비하는지 제네릭 바운드로만 명시하면 되게 했습니다. 그런 다음, 이 만능 타입의 정의를 단순화하려고 다시 제네릭을 써서, 데이터 타입을 제공하는 크레이트나 연산을 소비하는 크레이트 중 한쪽만 열거하도록 만들었습니다.
그렇다면… 왜 이런 해법이 처음부터 자명하지 않았고, 왜 여전히 “양자택일”이 남아 있을까요?
전자의 질문에 답하자면, 데이터 타입/연산이 어디에 하드코딩되어 있었는지를 보세요. Node를 크레이트 계층 아래로 내리기 전에는, 프로그램에서 어느 순간 사용될 수 있는 데이터 타입의 전체 범위를 각 의존성—정확히는 그들의 공통 의존성(예: mylang-core)—에 하드코딩해야 했습니다. 마찬가지로 어느 크레이트가 사용할지도 모르는 연산들이, 새로운 데이터 타입을 만드는 모든 크레이트에, 혹은 계층의 맨 아래에 하드코딩되어 있었습니다. 데이터 타입/연산 대신 크레이트를 나열하도록 enum이나 trait를 단순 병합하는 방식은 도움이 되지 않습니다. 각 크레이트가 나머지 모든 크레이트의 이름을 하드코딩하게 만들 뿐이니까요. 이건 개선이 아닙니다.
후자의 질문에 관해서는, AstNode가 데이터 타입 생산자와 연산 소비자 사이의 브로커 역할을 한다는 점을 생각해 보세요. 어떤 데이터 타입이 특정 연산을 구현하지 않는다면, 이 두 연결 중 하나는 끊어져야 합니다. 데이터 타입을 제공하는 크레이트 vs 연산을 소비하는 크레이트를 하드코딩하는 선택은, 어느 연결을 끊을지 임의로 고르는 것과 같습니다. 이상적으로는 AstNode를 아예 제거하고 생산자를 소비자에 직접 연결하고 싶습니다. 동적 타입 언어에서는 그냥 그렇게 할 수 있습니다. 모든 크레이트가 합의하는 단일 정적 타입을 고를 필요가 없으니까요. 하지만 Rust는 정적 타입 언어이므로, 우리는 이런 중개자 타입을 제공할 수밖에 없습니다.
이건 기묘한 패턴입니다. _작동_하긴 합니다. 아마도요. 하지만 제가 Unsize를 여기에 적용할 수 있음을 깨닫기 전이 더 행복했던 것 같기도 합니다. 오버엔지니어링처럼 느껴지고, 아마 실제로도 그럴 겁니다. Rust가 필요한 기능을 네이티브하게 지원하기 전까지는, 이런 글루 코드를 유지하고 사람들에게 AstNode 같은 타입을 어떻게 다뤄야 하는지 가르치는 비용이 직교성이 주는 이점보다 쉽게 더 커질 수 있다고 봅니다.
그러니 솔직히 말해, 굳이 이렇게 하지 않아도 됩니다. 관용적인 해법을 원한다면, 아마 동적 타입 언어를 찾아보는 편이 좋을 겁니다. 하지만 언제나 최대한의 표현력이 필요한 건 아니고, 때로는 과감히 편향된 선택을 받아들이는 편이 낫습니다. 적어도 Rust에서는, 그게 앞으로 나아갈 최선의 길이라고 생각합니다.