C/C++ 디자인 패턴 (22) — 방문자(Visitor)

수식 파서가 만든 구문 트리(AST)에 평가, 출력, 최적화, 타입 검사 같은 연산이 계속 늘어난다고 하자. 연산마다 노드 클래스에 가상 함수를 추가하면, 연산 하나를 넣을 때마다 모든 노드 클래스를 고쳐야 하고 노드 클래스는 서로 무관한 연산 코드로 비대해진다. 방문자(Visitor)는 연산을 노드 밖의 별도 객체로 빼서, 노드 클래스를 바꾸지 않고 새 연산을 추가하게 한다. 대신 노드 종류를 추가하는 일은 어려워진다. 이 글에서는 이중 디스패치(double dispatch)로 동작하는 고전 방문자, 노드 추가 비용, std::variant 대안, C의 종류별 함수 표를 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
Visitor노드 종류마다 visit() 선언Visitor
ConcreteVisitor연산 하나를 모든 노드 종류에 대해 구현Evaluator, Printer, Depth
Element방문자를 받아들이는 accept()Node
ConcreteElementaccept()에서 자기 타입에 맞는 visit() 호출Num, Add, Mul

이중 디스패치

C++의 가상 함수는 호출 대상 객체 하나의 동적 타입으로만 분기한다. 방문자는 node.accept(visitor)에서 노드 타입으로 한 번, 그 안의 visitor.visit(*this)에서 방문자 타입으로 한 번 분기해서 “(노드 타입, 연산)” 조합을 고른다.

// ast.hpp — 노드 계층(Element)과 방문자 인터페이스
#pragma once
#include <memory>

struct Num; struct Add; struct Mul;

struct Visitor {
	virtual ~Visitor() = default;
	virtual void visit(const Num &) = 0;
	virtual void visit(const Add &) = 0;
	virtual void visit(const Mul &) = 0;
#ifdef WITH_NEG
	virtual void visit(const struct Neg &) = 0;     // 새 노드 종류를 추가하면
#endif
};

struct Node {
	virtual ~Node() = default;
	virtual void accept(Visitor &v) const = 0;     // 첫 번째 디스패치: 노드 타입
};

struct Num : Node {
	double value;
	explicit Num(double v) : value(v) {}
	void accept(Visitor &v) const override { v.visit(*this); }   // 두 번째: 방문자 타입
};
struct Add : Node {
	std::unique_ptr<Node> l, r;
	Add(std::unique_ptr<Node> a, std::unique_ptr<Node> b) : l(std::move(a)), r(std::move(b)) {}
	void accept(Visitor &v) const override { v.visit(*this); }
};
struct Mul : Node {
	std::unique_ptr<Node> l, r;
	Mul(std::unique_ptr<Node> a, std::unique_ptr<Node> b) : l(std::move(a)), r(std::move(b)) {}
	void accept(Visitor &v) const override { v.visit(*this); }
};
// visitors.cpp — 노드 클래스를 건드리지 않고 연산(방문자)을 세 개 추가한다
#include <algorithm>
#include <iostream>
#include <string>
#include "ast.hpp"

struct Evaluator : Visitor {
	double result = 0;
	double eval(const Node &n) { n.accept(*this); return result; }
	void visit(const Num &n) override { result = n.value; }
	void visit(const Add &n) override { result = eval(*n.l) + eval(*n.r); }
	void visit(const Mul &n) override { result = eval(*n.l) * eval(*n.r); }
};

struct Printer : Visitor {
	std::string out;
	void visit(const Num &n) override { out += std::to_string(int(n.value)); }
	void visit(const Add &n) override { out += "("; n.l->accept(*this); out += " + "; n.r->accept(*this); out += ")"; }
	void visit(const Mul &n) override { n.l->accept(*this); out += " * "; n.r->accept(*this); }
};

struct Depth : Visitor {
	int d = 0, best = 0;
	void enter(const Node &a, const Node &b) { ++d; best = std::max(best, d); a.accept(*this); b.accept(*this); --d; }
	void visit(const Num &) override { best = std::max(best, d); }
	void visit(const Add &n) override { enter(*n.l, *n.r); }
	void visit(const Mul &n) override { enter(*n.l, *n.r); }
};

int main()
{
	// (2 + 3) * (4 + 1)
	auto expr = std::make_unique<Mul>(
		std::make_unique<Add>(std::make_unique<Num>(2), std::make_unique<Num>(3)),
		std::make_unique<Add>(std::make_unique<Num>(4), std::make_unique<Num>(1)));

	Printer p; expr->accept(p);
	Evaluator e;
	Depth d; expr->accept(d);
	std::cout << p.out << " = " << e.eval(*expr) << "  (depth " << d.best << ")\n";
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o visitors visitors.cpp && ./visitors
(2 + 3) * (4 + 1) = 25  (depth 2)

출력, 평가, 깊이 계산 세 연산을 추가했지만 ast.hpp의 노드 클래스는 한 줄도 바뀌지 않았다. 연산에 필요한 상태(out, result, d)도 방문자 안에 있다.

노드 종류를 추가하면

반대로 부호 반전 노드(Neg)를 추가하면 Visitor 인터페이스에 visit(const Neg&)가 생기고, 기존 방문자 전부가 이를 구현할 때까지 컴파일되지 않는다.

$ g++ -std=c++20 -DWITH_NEG -c visitors.cpp 2>&1 | grep -E 'error|pure' | head -4
visitors.cpp:37:17: error: cannot declare variable ‘p’ to be of abstract type ‘Printer’
visitors.cpp:15:8: note:   because the following virtual functions are pure within ‘Printer’:
visitors.cpp:38:19: error: cannot declare variable ‘e’ to be of abstract type ‘Evaluator’
visitors.cpp:7:8: note:   because the following virtual functions are pure within ‘Evaluator’:
변경노드에 가상 함수방문자
새 연산 추가모든 노드 클래스 수정방문자 클래스 하나 추가
새 노드 추가노드 클래스 하나 추가모든 방문자 수정(컴파일러가 알려 줌)

그래서 방문자는 노드 종류는 안정적이고 연산이 자주 늘어나는 곳(컴파일러의 AST, 문서 객체 모델, 직렬화)에 맞는다.

std::variant: accept 없는 방문자

노드 집합이 닫혀 있다면 노드를 std::variant로 두고 std::visit에 오버로드 람다를 넘기면 된다. 노드에 accept()도, 공통 기반 클래스도 필요 없다.

// variant_ast.cpp — 닫힌 노드 집합이라면 std::variant + std::visit가 accept/visit 보일러플레이트를 대신한다
#include <iostream>
#include <memory>
#include <string>
#include <variant>

struct Num; struct Add; struct Mul; struct Neg;
using Expr = std::variant<Num, Add, Mul, Neg>;
using P = std::unique_ptr<Expr>;

struct Num { double v; };
struct Add { P l, r; };
struct Mul { P l, r; };
struct Neg { P e; };                                   // 새 노드: accept()를 쓸 필요 없다

template <class... F> struct overloaded : F... { using F::operator()...; };

double eval(const Expr &e)
{
	return std::visit(overloaded{
		[](const Num &n) { return n.v; },
		[](const Add &n) { return eval(*n.l) + eval(*n.r); },
		[](const Mul &n) { return eval(*n.l) * eval(*n.r); },
		[](const Neg &n) { return -eval(*n.e); },
	}, e);
}

std::string show(const Expr &e)
{
	return std::visit(overloaded{
		[](const Num &n) { return std::to_string(int(n.v)); },
		[](const Add &n) { return "(" + show(*n.l) + " + " + show(*n.r) + ")"; },
		[](const Mul &n) { return show(*n.l) + " * " + show(*n.r); },
		[](const Neg &n) { return "-" + show(*n.e); },
	}, e);
}

template <class T, class... A> P mk(A &&...a) { return std::make_unique<Expr>(T{std::forward<A>(a)...}); }

int main()
{
	// -(2 + 3) * 4
	P expr = mk<Mul>(mk<Neg>(mk<Add>(mk<Num>(2.0), mk<Num>(3.0))), mk<Num>(4.0));
	std::cout << show(*expr) << " = " << eval(*expr) << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -fsanitize=address -o variant_ast variant_ast.cpp && ./variant_ast
-(2 + 3) * 4 = -20

Neg를 추가할 때 고친 곳은 variant 선언과 각 연산 함수의 람다 하나씩이다. 람다를 빠뜨리면 고전 방문자처럼 컴파일 에러가 나므로 누락 검출 장점은 그대로다.

C 구현: 노드 종류별 함수 표

C에서는 노드가 종류 태그를 갖고, 연산 하나를 “태그 → 처리 함수” 배열로 만든다. 노드 구조체는 그대로 두고 배열을 하나 더 만들면 연산이 추가된다.

/* visit.c — C 방문자: 노드는 태그를 갖고, 연산마다 "노드 종류 → 처리 함수" 표를 만든다 */
#include <stdio.h>

enum kind { NUM, ADD, MUL, NKIND };

struct node {
	enum kind kind;
	double value;                 /* NUM */
	const struct node *l, *r;     /* ADD, MUL */
};

struct visitor {                  /* 연산 하나 = 노드 종류별 함수 표 */
	double (*on[NKIND])(const struct visitor *v, const struct node *n);
};

static double accept(const struct visitor *v, const struct node *n) { return v->on[n->kind](v, n); }

/* 평가 연산 */
static double ev_num(const struct visitor *v, const struct node *n) { (void)v; return n->value; }
static double ev_add(const struct visitor *v, const struct node *n) { return accept(v, n->l) + accept(v, n->r); }
static double ev_mul(const struct visitor *v, const struct node *n) { return accept(v, n->l) * accept(v, n->r); }
static const struct visitor evaluator = { { [NUM] = ev_num, [ADD] = ev_add, [MUL] = ev_mul } };

/* 노드 수 세기 연산: 노드 정의는 그대로 두고 추가 */
static double cnt_leaf(const struct visitor *v, const struct node *n) { (void)v; (void)n; return 1; }
static double cnt_bin(const struct visitor *v, const struct node *n) { return 1 + accept(v, n->l) + accept(v, n->r); }
static const struct visitor counter = { { [NUM] = cnt_leaf, [ADD] = cnt_bin, [MUL] = cnt_bin } };

int main(void)
{
	/* (2 + 3) * (4 + 1) */
	struct node n2 = { NUM, 2, 0, 0 }, n3 = { NUM, 3, 0, 0 }, n4 = { NUM, 4, 0, 0 }, n1 = { NUM, 1, 0, 0 };
	struct node a1 = { ADD, 0, &n2, &n3 }, a2 = { ADD, 0, &n4, &n1 };
	struct node m = { MUL, 0, &a1, &a2 };

	printf("value = %g, nodes = %g\n", accept(&evaluator, &m), accept(&counter, &m));
	return 0;
}
$ gcc -std=c11 -Wall -Wextra -O2 -o visit visit.c && ./visit
value = 25, nodes = 7

평가와 노드 수 세기가 같은 accept()로 실행된다. 표에서 빠진 종류는 NULL 함수 포인터가 되어 호출 시 바로 죽으므로, 새 종류를 추가하면 모든 표를 확인해야 하는 점은 C++ 방문자와 같다.

주의사항

  • 캡슐화가 약해진다: 방문자가 연산을 하려면 노드의 데이터에 접근해야 한다. 예제처럼 public 멤버를 두거나 접근자를 만들어야 하므로, 노드 내부를 숨기기 어렵다.
  • 반환값 전달이 어색하다: visit()가 void라서 Evaluator는 결과를 멤버에 담고 꺼낸다. 연산이 많다면 std::variant 방식이나 반환 타입을 템플릿 인자로 받는 방문자가 깔끔하다.
  • 순회 책임의 위치: 예제는 방문자가 자식을 직접 방문한다. 순회 순서가 여러 연산에서 같다면 노드의 accept()나 별도 순회기로 옮겨 중복을 줄인다.
  • 깊은 트리의 재귀: 방문은 대개 재귀라 아주 깊은 트리에서 스택이 넘칠 수 있다(컴포지트 편 실측 참고).
  • 노드가 자주 바뀌면 쓰지 않는다: 위 표처럼 노드 추가 비용이 크다. 노드 종류가 열려 있고 연산이 고정적이라면 노드의 가상 함수가 낫다.

마무리

방문자는 “타입 계층은 고정, 연산은 확장”이라는 방향을 선택하는 패턴이다. C++17 이후에는 닫힌 타입 집합이라면 std::variant가 같은 일을 더 적은 코드로 한다. 다음 글은 시리즈의 마지막으로, 간단한 언어의 문법을 클래스 계층으로 표현해 해석하는 인터프리터(Interpreter)를 다룬다.

참고

답글 남기기