데이터 타입과 그 위에서 동작하는 연산을 어떻게 확장할 것인가라는 ‘표현 문제’를 소개하고, OOP와 FP에서의 양상, 방문자 패턴의 응용과 한계, 그리고 Clojure의 멀티메서드와 프로토콜을 통한 해법을 살펴본다.
프로그래밍의 장인은 거의 예외 없이 다양한 데이터 타입과 그 데이터 위에서 동작하는 연산/알고리즘에 관심을 가진다 [1]. 그러니 데이터 타입과 연산을 위한 추상을 설계하는 일이 소프트웨어 엔지니어와 프로그래밍 언어 설계자의 마음을 오래 전부터 사로잡아 왔다는 건 그리 놀라운 일이 아니다.
그런데 나는 최근에야 내 커리어에서 여러 번 마주친 소프트웨어 설계 문제에 붙은 이름을 접했다. 너무나 근본적인 문제라 전에 이름을 못 봤다는 게 의아할 정도다. 문제를 간단히 서술해 보자.
데이터 타입의 집합과, 이 타입들에 작동하는 연산의 집합이 있다고 하자. 때로는 새로운 연산을 추가하고 모든 타입에서 제대로 동작하게 만들어야 하고, 때로는 새로운 타입을 추가하고 모든 연산이 그 타입에서도 제대로 동작하게 만들어야 한다. 그러나 어떤 때에는 둘 다 추가해야 하는데, 바로 여기에 문제가 있다. 대부분의 주류 언어는 기존 코드를 변경하지 않고 기존 시스템에 새로운 타입과 새로운 연산을 동시에 추가하는 데 적절한 도구를 제공하지 않는다. 이를 “표현 문제(expression problem)”라고 한다. 이 문제와 그 해결책을 공부하면 객체지향과 함수형 프로그래밍의 근본적인 차이, 그리고 인터페이스나 다중 디스패치 같은 개념을 깊이 있게 이해하는 데 큰 도움이 된다.
늘 그렇듯 예제는 컴파일러/인터프리터 세계에서 가져오겠다. 변명하자면, 표현 문제의 역사적 원전을 살펴보면(아래의 역사적 관점 참고) 거기에서도 이 예제가 쓰인다.
간단한 식(expression) 평가기를 설계해 보자. 표준적인 인터프리터 디자인 패턴을 따르면, 우리가 할 수 있는 몇 가지 연산을 가진 식의 트리 구조가 있다. C++에서는 식 트리의 모든 노드가 구현해야 하는 인터페이스가 있다:
cppclass Expr { public: virtual std::string ToString() const = 0; virtual double Eval() const = 0; };
이 인터페이스는 현재 식 트리에서 할 수 있는 연산이 두 가지(평가와 문자열 표현 얻기)임을 보여준다. 전형적인 리프(leaf) 노드는 다음과 같다:
cppclass Constant : public Expr { public: Constant(double value) : value_(value) {} std::string ToString() const { std::ostringstream ss; ss << value_; return ss.str(); } double Eval() const { return value_; } private: double value_; };
그리고 전형적인 합성(composite) 식은 다음과 같다:
cppclass BinaryPlus : public Expr { public: BinaryPlus(const Expr& lhs, const Expr& rhs) : lhs_(lhs), rhs_(rhs) {} std::string ToString() const { return lhs_.ToString() + " + " + rhs_.ToString(); } double Eval() const { return lhs_.Eval() + rhs_.Eval(); } private: const Expr& lhs_; const Expr& rhs_; };
여기까지는 모두 꽤 기본적인 내용이다. 이 설계는 얼마나 확장 가능할까? 살펴보자. 새로운 식 타입(“변수 참조”, “함수 호출” 등)을 추가하고 싶다면 꽤 쉽다. Expr을 상속하는 클래스를 추가로 정의하고 Expr 인터페이스(ToString과 Eval)를 구현하면 된다.
하지만 식 트리에 적용할 수 있는 새로운 ‘연산’을 추가하고 싶다면 어떻게 될까? 지금은 Eval과 ToString이 있지만, “타입 체크”, “직렬화”, “기계어로 컴파일” 등 다른 연산이 추가될 수 있다.
새로운 연산을 추가하는 일은 새로운 타입을 추가하는 것만큼 쉽지 않다는 게 드러난다. Expr 인터페이스를 바꿔야 하고, 결과적으로 존재하는 모든 식 타입에 새로운 메서드(들)를 지원하도록 변경해야 한다. 원래 코드를 우리가 통제하지 못하거나 다른 이유로 변경이 어렵다면 곤란해진다.
다시 말해, 객체지향 설계의 주요 원칙 중 하나인 오래된 ‘개방-폐쇄 원칙’을 위반해야 한다. 이는 다음과 같이 정의된다:
소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에는 열려 있어야 하며, 수정에는 닫혀 있어야 한다
여기서 우리가 만난 문제가 바로 ‘표현 문제’이며, 위 예제는 이것이 객체지향 프로그래밍에 어떻게 적용되는지를 보여준다.
흥미롭게도 표현 문제는 함수형 언어도 물고 늘어진다. 어떻게 그런지 보자.
업데이트 2018-02-05: 새 글에서 Haskell에서의 문제와 해법을 더 깊이 다룬다.
객체지향은 기능을 객체(타입)에 모으는 경향이 있다. 함수형 언어는 다른 각도로 케이크를 자른다. 타입은 얇은 데이터 컨테이너로 두고, 대부분의 기능은 그 위에서 동작하는 함수(연산)에 모은다. 함수형 언어도 표현 문제를 피하지 못하는데, 단지 다른 방식으로 드러날 뿐이다.
이를 보이기 위해 Haskell에서 식 평가기/문자열화기를 어떻게 작성하는지 보자. Haskell은 타입에 대한 패턴 매칭 덕분에 이런 코드가 특히 간결해 함수형 프로그래밍의 좋은 표본이다:
haskellmodule Expressions where data Expr = Constant Double | BinaryPlus Expr Expr stringify :: Expr -> String stringify (Constant c) = show c stringify (BinaryPlus lhs rhs) = stringify lhs ++ " + " ++ stringify rhs evaluate :: Expr -> Double evaluate (Constant c) = c evaluate (BinaryPlus lhs rhs) = evaluate lhs + evaluate rhs
이제 새로운 연산인 타입 체크를 추가하고 싶다고 하자. 그저 새로운 함수 typecheck를 추가하고, 알려진 모든 식 종류에 대해 동작을 정의하면 된다. 기존 코드를 수정할 필요가 없다.
반면 새로운 타입(예: “함수 호출”)을 추가하고 싶다면 곤란해진다. 이제 기존의 모든 함수가 이 새로운 타입을 처리하도록 수정해야 한다. 즉, 같은 문제를 다른 각도에서 맞닥뜨리게 된다.
표현 문제를 시각화하면, 이것이 OOP와 FP에 어떻게 다르게 적용되는지, 그리고 잠재적 해법이 어떤 모습일지 이해하는 데 도움이 된다.
다음의 2차원 표(“행렬”)는 행에 타입을, 열에 연산을 둔다. 행렬의 셀(row, col)은 타입 row에 대해 연산 col이 구현되었으면 체크되어 있다:

객체지향 언어에서는 새로운 타입을 추가하기는 쉽지만 새로운 연산을 추가하기는 어렵다:

반면 함수형 언어에서는 새로운 연산을 추가하기는 쉽지만 새로운 타입을 추가하기는 어렵다:

표현 문제는 새삼스러운 게 아니며 아마 초창기부터 함께했을 것이다. 프로그램의 복잡도가 그리 높지 않은 수준에 도달하자마자 고개를 내민다.
표현(problem)이라는 이름은 Philip Wadler가 Java에 제네릭을 추가하는 문제를 다루던 메일링 리스트에 보낸 이메일에서 유래했음이 거의 확실하다(1990년대의 일이다).
그 이메일에서 Wadler는 Krishnamurthi, Felleisen, Friedman의 논문 “Synthesizing Object-Oriented and Functional Design to Promote Re-Use”를 가리키며, 이 논문이 문제와 제안된 해법을 더 이른 시기에 다루었다고 한다. 아주 훌륭한 논문이니 일독을 권한다. Krishnamurthi 등은 참고문헌에서 Algol에서의 문제 변형을 1975년까지 거슬러 올라가는 논문들로 연결해 둔다.
지금까지는 표현 ‘문제’에 초점을 맞췄는데, 이제 충분히 이해되었길 바란다. 하지만 제목에는 ‘해결’도 있으니 그쪽으로 가 보자.
객체지향 언어에서 표현 문제를 일종의 ‘부분적’(곧 왜 ‘부분적’이라 하는지 설명한다)으로 풀 수도 있다. 먼저, 방문자(visitor) 패턴으로 문제를 옆으로 뒤집을 수 있는지 보자. 방문자 패턴은 이런 종류의 문제에서 아주 흔하고 합당한 이유가 있다. 코드를 재구성해 어떤 차원에서는 변경을 쉽게(하지만 다른 차원에서는 어렵게) 만들어준다.
위의 C++ 예제를 방문자 패턴으로 다시 쓰려면 새로운 “visitor” 인터페이스를 추가한다:
cppclass ExprVisitor { public: virtual void VisitConstant(const Constant& c) = 0; virtual void VisitBinaryPlus(const BinaryPlus& bp) = 0; };
그리고 Expr 인터페이스를 이렇게 바꾼다:
cppclass Expr { public: virtual void Accept(ExprVisitor* visitor) const = 0; };
이제 식 타입은 실제 계산을 방문자에게 위임한다:
cppclass Constant : public Expr { public: Constant(double value) : value_(value) {} void Accept(ExprVisitor* visitor) const { visitor->VisitConstant(*this); } double GetValue() const { return value_; } private: double value_; }; // ... BinaryPlus도 비슷하게 // // void Accept(ExprVisitor* visitor) const { // visitor->VisitBinaryPlus(*this); // } // // ... 등등
평가를 위한 예시 방문자는 다음과 같다 [2]:
cppclass Evaluator : public ExprVisitor { public: double GetValueForExpr(const Expr& e) { return value_map_[&e]; } void VisitConstant(const Constant& c) { value_map_[&c] = c.GetValue(); } void VisitBinaryPlus(const BinaryPlus& bp) { bp.GetLhs().Accept(this); bp.GetRhs().Accept(this); value_map_[&bp] = value_map_[&(bp.GetLhs())] + value_map_[&(bp.GetRhs())]; } private: std::map<const Expr*, double> value_map_; };
주어진 데이터 타입 집합에 대해 새로운 방문자를 추가하는 일은 쉽고 다른 코드를 수정할 필요가 없다. 반면 새로운 타입을 추가하는 것은 문제적이다. ExprVisitor 인터페이스에 새로운 순수 가상 메서드를 추가해야 하고, 그에 따라 모든 방문자를 업데이트해 구현해야 하기 때문이다.
즉, 표현 문제를 옆으로 돌려 놓은 셈이다. OOP 언어를 쓰고 있지만 이제는 타입을 추가하기가 어렵고 연산을 추가하기가 쉬워졌다. 함수형 접근과 딱 같다. 이것이 가능하다는 점이 무척 흥미롭다. 서로 다른 추상과 패러다임의 힘, 그리고 그것이 문제를 완전히 다른 빛에서 다시 생각하게 해 준다는 점을 잘 보여준다.
그러니 아직 아무것도 ‘해결’하진 못했다. 다만 우리가 맞닥뜨린 문제의 성질을 바꿨을 뿐이다. 걱정 마라. 이것은 실제 해법으로 가기 위한 디딤돌이다.
다음은 Krishnamurthi 등 논문에서 제안된 확장 방문자 패턴을 따르는 C++ 해법의 코드 발췌다. 이 코드를 깊이 이해하려면 논문(특히 3장을) 읽어보길 강력 권한다. 컴파일해 실행할 수 있는 완전한 C++ 예제는 여기에 있다.
방문자 패턴으로 새로운 방문자(연산)를 추가하는 것은 쉽다. 우리의 도전은 기존 코드를 크게 뒤흔들지 않고 새로운 ‘타입’을 추가하는 것이다. 어떻게 하는지 보자.
원래 방문자 패턴에 작은 설계 변경을 가하자. 곧 명백해질 이유로 Evaluator에 가상 상속을 사용한다:
cppclass Evaluator : virtual public ExprVisitor { // .. 나머지는 동일 };
이제 새로운 타입 FunctionCall을 추가하자:
cpp// 새로(“확장된”) 추가하는 식이다. class FunctionCall : public Expr { public: FunctionCall(const std::string& name, const Expr& argument) : name_(name), argument_(argument) {} void Accept(ExprVisitor* visitor) const { ExprVisitorWithFunctionCall* v = dynamic_cast<ExprVisitorWithFunctionCall*>(visitor); if (v == nullptr) { std::cerr << "Fatal: visitor is not ExprVisitorWithFunctionCall\n"; exit(1); } v->VisitFunctionCall(*this); } private: std::string name_; const Expr& argument_; };
기존 방문자를 수정하고 싶지 않으므로, 함수 호출을 위해 Evaluator를 확장하는 새로운 방문자를 만든다. 하지만 그 전에 새 타입을 지원하도록 ExprVisitor 인터페이스를 확장해야 한다:
cppclass ExprVisitorWithFunctionCall : virtual public ExprVisitor { public: virtual void VisitFunctionCall(const FunctionCall& fc) = 0; };
마지막으로 새로운 타입을 지원하면서 Evaluator를 확장하는 새로운 평가기를 작성한다:
cppclass EvaluatorWithFunctionCall : public ExprVisitorWithFunctionCall, public Evaluator { public: void VisitFunctionCall(const FunctionCall& fc) { std::cout << "Visiting FunctionCall!!\n"; } };
다중 상속, 가상 상속, 동적 타입 검사… 꽤 하드코어한 C++을 써야 하지만 방법이 없다. 불행히도 C++에서 어떤 클래스가 한 인터페이스를 구현하는 동시에 다른 클래스에서 기능을 상속받는다는 아이디어를 표현하는 유일한 방법이 다중 상속이다. 여기서 우리가 원하는 것은 평가기(EvaluatorWithFunctionCall)가 Evaluator의 모든 기능을 상속받으면서, 동시에 ExprVisitorWithFunctionCall 인터페이스도 구현하는 것이다. Java라면 대략 다음처럼 쓸 수 있다:
javaclass EvaluatorWithFunctionCall extends Evaluator implements ExprVisitor { // ... }
하지만 C++에서 우리가 가진 도구는 ‘가상’ 다중 상속이다. 가상 상속은 Evaluator와 ExprVisitorWithFunctionCall의 기저에 있는 ExprVisitor가 동일하며 EvaluatorWithFunctionCall 안에 한 번만 존재해야 함을 컴파일러가 파악하도록 하는 데 필수적이다. 가상이 없다면 컴파일러는 EvaluatorWithFunctionCall이 ExprVisitor 인터페이스를 구현하지 않았다고 불평할 것이다.
이것은 ‘해결’이긴 하다. 우리는 일종의 방식으로 새로운 타입 FunctionCall을 추가했고, 이제 기존 코드를 바꾸지 않고(이 접근을 예상해 미리 가상 상속을 설계에 녹여 두었다고 가정한다면) 방문할 수 있다. 여기서 또 ‘일종의’라는 말을 썼다… 이제 왜 그런지 설명할 때다.
이 접근에는 내 생각에 여러 단점이 있다:
그래, 프로그래밍은 어렵다. 나는 전통적인 OOP의 한계와 그것이 이 예제에서 어떻게 드러나는지 [3]에 대해 끝없이 이야기할 수도 있겠다. 대신, 메서드 정의를 타입 본문에서 분리하고 다중 디스패치를 지원하는 언어에서 표현 문제를 어떻게 해결할 수 있는지 보여주겠다.
이 글에서 보인 표현 문제를 Clojure의 내장 기능만으로 해결하는 방법은 여럿 있다. 가장 단순한 것, 멀티메서드부터 시작하자.
먼저 타입을 레코드로 정의한다:
clojure(defrecord Constant [value]) (defrecord BinaryPlus [lhs rhs])
그 다음 evaluate를 그 인자의 타입에 따라 디스패치하는 멀티메서드로 정의하고, Constant와 BinaryPlus에 대한 메서드 구현을 추가한다:
clojure(defmulti evaluate class) (defmethod evaluate Constant [c] (:value c)) (defmethod evaluate BinaryPlus [bp] (+ (evaluate (:lhs bp)) (evaluate (:rhs bp))))
이제 식을 평가할 수 있다:
user=> (use 'expression.multimethod)
nil
user=> (evaluate (->BinaryPlus (->Constant 1.1) (->Constant 2.2)))
3.3000000000000003
새로운 연산을 추가하는 것도 쉽다. stringify를 추가해 보자:
clojure(defmulti stringify class) (defmethod stringify Constant [c] (str (:value c))) (defmethod stringify BinaryPlus [bp] (clojure.string/join " + " [(stringify (:lhs bp)) (stringify (:rhs bp))]))
시험해 보자:
user=> (stringify (->BinaryPlus (->Constant 1.1) (->Constant 2.2)))
"1.1 + 2.2"
새로운 타입을 추가하는 건 어떨까? FunctionCall을 추가한다고 하자. 먼저 새 타입을 정의한다. 단순화를 위해 FunctionCall의 func 필드는 그냥 Clojure 함수다. 실제 코드에서는 우리가 해석하는 언어의 어떤 함수 객체일 수 있다:
clojure(defrecord FunctionCall [func argument])
그리고 FunctionCall에 대해 evaluate와 stringify가 어떻게 동작하는지 정의한다:
clojure(defmethod evaluate FunctionCall [fc] ((:func fc) (evaluate (:argument fc)))) (defmethod stringify FunctionCall [fc] (str (clojure.repl/demunge (str (:func fc))) "(" (stringify (:argument fc)) ")"))
한번 돌려 보자(전체 코드는 여기):
user=> (def callexpr (->FunctionCall twice (->BinaryPlus (->Constant 1.1)
(->Constant 2.2))))
#'user/callexpr
user=> (evaluate callexpr)
6.6000000000000005
user=> (stringify callexpr)
"expression.multimethod/twice@52e29c38(1.1 + 2.2)"
Clojure의 표현 문제 행렬은 다음과 같음이 분명할 것이다:

우리는 기존 코드를 건드리지 않고 새 연산을 추가할 수 있다. 또한 기존 코드를 건드리지 않고 새 타입도 추가할 수 있다. 우리가 추가하는 코드는 문제의 연산/타입을 처리하기 위한 ‘새 코드’뿐이다. 기존의 연산과 타입은 소스 접근 권한이 없는 서드파티 라이브러리에서 온 것일 수도 있다. 그래도 우리는 원본 소스를 건드리거나(혹은 보거나) 하지 않고도 새로운 연산과 타입으로 그것들을 확장할 수 있다 [4].
나는 예전에 Clojure의 다중 디스패치에 대해 쓴 적이 있고, 바로 위 절에서도 defmulti/defmethod 구문을 어떻게 쓰는지 또 하나의 예를 보았다. 그런데 이게 정말 다중 디스패치인가? 아니다! 사실 ‘단일’ 디스패치다. 우리의 연산(evaluate와 stringify)은 ‘하나’의 인자, 즉 식의 타입에 대해 디스패치할 뿐이다 [5].
그렇다면 진짜로 다중 디스패치를 쓰지 않는데도 Clojure에서 이렇게 우아하게 표현 문제를 해결하게 해 주는 비법은 무엇일까? 답은 ‘열린 메서드(open methods)’다. C++/Java와 Clojure의 메서드 정의 방식에는 결정적 차이가 있다. C++/Java에서 메서드는 반드시 클래스의 일부여야 하고, 그 본문(혹은 적어도 선언)이 클래스의 몸체에 있어야 한다. 클래스의 소스 코드를 바꾸지 않고 클래스에 메서드를 추가할 수 없다.
Clojure에서는 가능하다. 사실 데이터 타입과 멀티메서드가 서로 직교하는 존재이므로, 이것은 설계에 따른 것이다. 메서드는 타입의 속성이 아니라 1급 시민으로 타입 밖에서 산다. 우리는 타입에 ‘메서드를 추가’하는 게 아니라, 그 타입에 ‘작용하는 새 메서드’를 추가한다. 이는 타입의 코드를 어떤 식으로도 수정할 필요가 없다(심지어 그 코드에 접근할 필요도 없다).
다른 인기 있는 언어들 중 일부는 중간 길을 택한다. Python, Ruby, JavaScript 같은 언어에서는 메서드가 타입에 속하지만, 클래스가 생성된 이후에도 그 클래스의 메서드를 동적으로 추가/삭제/교체할 수 있다. 이 기법은 흔히 ‘몽키 패칭(monkey patching)’이라 불린다. 처음에는 매력적이지만, 조심하지 않으면 유지보수 악몽으로 이어질 수 있다. 그러므로 Python에서 표현 문제를 마주한다면, 나는 몽키 패칭에 의존하기보다 프로그램을 위한 다중 디스패치 메커니즘을 도입하는 편을 선호하겠다.
Clojure의 멀티메서드는 매우 일반적이고 강력하다. 너무 일반적이기 때문에, 가장 흔한 경우—즉 유일한 메서드 인자의 타입에 기반한 단일 디스패치—에는 성능이 최적이 아닐 수 있다. 주목하라. 바로 이 글에서 내가 사용하는 디스패치가 딱 그렇다. 그래서 Clojure 1.2부터 사용자 코드는 ‘프로토콜’을 정의하고 사용할 수 있게 되었다. 이는 이전에는 내장 타입에만 허용되던 언어 기능이다.
프로토콜은 호스트 플랫폼(Clojure의 경우 대부분 Java)의 빠른 가상 디스패치를 활용하므로, 런타임 다형성을 구현하는 매우 효율적인 방법이다. 게다가 프로토콜은 멀티메서드의 유연성을 어느 정도 유지하면서도 표현 문제를 우아하게 해결해 준다. 흥미롭게도 이는 Clojure 설계자들이 처음부터 염두에 둔 것이었다. Clojure의 프로토콜 문서는 그 능력 중 하나로 다음을 적는다:
[...] 서로 다른 주체가 타입, 프로토콜, 그리고 타입 위의 프로토콜 구현을 독립적으로 확장할 수 있게 하여 ‘표현 문제’를 피한다. [...] 래퍼/어댑터 없이 그렇게 한다
Clojure 프로토콜은 흥미로운 주제라 더 시간을 들이고 싶지만 이미 글이 길어졌다. 그래서 더 자세한 논의는 다음으로 미루고, 여기서는 프로토콜을 사용해 우리가 논의하는 표현 문제를 어떻게 해결할 수 있는지만 보여주겠다.
타입 정의는 그대로다:
clojure(defrecord Constant [value]) (defrecord BinaryPlus [lhs rhs])
연산마다 멀티메서드를 정의하는 대신, 이제는 ‘프로토콜’을 정의한다. 프로토콜은 Java, C++, Go 같은 언어의 인터페이스로 생각할 수 있다 — 타입이 인터페이스에 선언된 메서드 집합을 정의하면 그 인터페이스를 구현한 것이다. 이 점에서 Clojure의 프로토콜은 Java보다 Go의 인터페이스에 더 가깝다. 타입을 정의할 때 미리 어떤 인터페이스를 구현하는지 선언할 필요가 없기 때문이다.
evaluate 한 메서드로 이루어진 Evaluatable 프로토콜부터 시작하자:
clojure(defprotocol Evaluatable (evaluate [this]))
또 하나의 프로토콜 Stringable을 정의하자:
clojure(defprotocol Stringable (stringify [this]))
이제 우리의 타입이 이 프로토콜을 구현하도록 만들 수 있다:
clojure(extend-type Constant Evaluatable (evaluate [this] (:value this)) Stringable (stringify [this] (str (:value this)))) (extend-type BinaryPlus Evaluatable (evaluate [this] (+ (evaluate (:lhs this)) (evaluate (:rhs this)))) Stringable (stringify [this] (clojure.string/join " + " [(stringify (:lhs this)) (stringify (:rhs this))])))
extend-type 매크로는 더 일반적인 extend에 대한 편의 래퍼로, 주어진 타입에 대해 여러 프로토콜을 구현하도록 해 준다. 형제 매크로인 extend-protocol은 하나의 프로토콜을 여러 타입에 대해 한 번의 호출로 구현하게 해 준다 [6].
새로운 데이터 타입을 추가하는 것은 꽤 분명하게 쉽다 — 위에서 한 것처럼 각 새 데이터 타입에 대해 extend-type을 사용해 현재 프로토콜을 구현하면 된다. 그렇다면 새 프로토콜을 추가하고 기존 모든 데이터 타입이 이를 구현하도록 만드는 건 어떻게 할까? 역시 쉽다. 기존 코드를 수정할 필요가 없기 때문이다. 새 프로토콜은 다음과 같다:
clojure(defprotocol Serializable (serialize [this]))
그리고 이것이 현재 지원되는 데이터 타입들에 대한 구현이다:
clojure(extend-protocol Serializable Constant (serialize [this] [(type this) (:value this)]) BinaryPlus (serialize [this] [(type this) (serialize (:lhs this)) (serialize (:rhs this))]))
이번에는 하나의 프로토콜을 여러 데이터 타입에 확장하므로 extend-protocol이 더 편리한 매크로다.
Clojure 해법에서 정의한 프로토콜(인터페이스)이 매우 작다는 점을 알아챘을 것이다 — 메서드 하나로 이뤄져 있다. 기존 프로토콜에 메서드를 추가하는 것은 훨씬 더 문제가 많다(Clojure에서 이를 하는 방법을 나는 알지 못한다). 그러므로 프로토콜을 작게 유지하는 것이 좋은 생각이다. 이 지침은 다른 맥락에서도 등장한다. 예컨대 Go에서는 인터페이스를 아주 최소로 유지하는 것이 모범 사례다.
우리의 C++ 해법에서 Expr 인터페이스를 분할하는 것도 좋은 생각일 수 있지만, 표현 문제에는 도움이 되지 않는다. 클래스가 어떤 인터페이스를 구현하는지를 정의한 뒤에는 바꿀 수 없기 때문이다. Clojure에서는 가능하다.
[1] “데이터의 타입”과 “연산”은 현대 프로그래머에게 꽤 자명한 용어일 것이다. Philip Wadler는 표현 문제를 논하면서(本文의 “역사적 관점” 절 참고) 이를 각각 “데이터타입”과 “함수”라 부른다. Fred Brooks의 명저 ‘맨먼스 미신’(1975)의 유명한 인용: “흐름도를 보여 주고 테이블을 감추면 나는 계속 혼란스러울 것이다. 테이블을 보여 주면 흐름도는 보지 않아도 된다. 그것들은 자명할 테니.”
[2] 방문자에 유지되는 Expr* -> Value 맵에서 Visit* 메서드 간에 데이터가 전달되는 특이한 방식을 보라. 이는 서로 다른 방문자에서 Visit* 메서드가 서로 다른 타입을 반환하도록 만들 수 없기 때문이다. 예컨대 Evaluator에서는 double을 반환하고 싶지만, Stringifier에서는 아마 std::string을 반환하고 싶을 것이다. 불행히도 C++에서는 템플릿과 가상 함수를 쉽게 섞을 수 없으므로 C 스타일의 void*를 반환하거나 여기서 내가 쓰는 방식에 의존해야 한다.
흥미롭게도 Krishnamurthi 등은 그들이 사용하는 Java 변종에서 같은 문제를 맞닥뜨리고, 이를 해결하기 위해 몇 가지 언어 확장을 제안한다. Philip Wadler는 자신이 제안한 Java 제네릭을 그의 접근에서 사용한다.
[3] 잠시 참지 못하고 한마디만: 내 생각에 상속은 아주 좁은 범위에서만 좋다. 하지만 C++ 같은 언어는 타입 확장의 주된 메커니즘으로 상속을 치켜세운다. 그러나 상속은 인터페이스 구현 같은 많은 다른 용례에서 깊이 결함이 있다. 이 점에서 Java가 조금 낫지만, 결국 클래스의 일차성과 그 ‘닫힘’은 표현 문제 같은 많은 작업을 깔끔하게 표현하기 어렵게 만든다.
[4] 사실 Clojure 구현과 표준 라이브러리에는 사용자가 사용자 정의 타입에 대해 확장할 수 있는 프로토콜이 많이 존재한다. 내장 타입에 대해 사용자 작성 프로토콜과 멀티메서드를 확장하는 것은 사소하다. 연습 삼아 java.lang.Long에 대한 evaluate 구현을 추가해 보라. 그러면 내장 정수도 Constant로 감쌀 필요 없이 우리의 식 트리에 참여할 수 있다.
[5] 참고로, Clojure에서 표현 문제의 다중 디스패치 해법을 ‘구성’할 수는 있다. 핵심 아이디어는 타입과 연산 두 가지에 대해 디스패치하는 것이다. 재미 삼아 내가 작성한 프로토타입을 여기서 볼 수 있다. 하지만 이 글에서 제시한 접근—연산마다 자체 멀티메서드를 두는 것—이 더 낫다고 생각한다.
[6] 예리한 독자는 표현 문제 행렬과의 멋진 연결고리를 알아챘을 것이다. extend-type은 행렬에 ‘새로운 행’을 통째로 추가할 수 있고, extend-protocol은 ‘열’을 추가한다. extend는 셀 하나만 추가한다.