[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #15] 인터프리터(Interpreter) 패턴: 파서와 해석 로직을 Ranges, Concepts로 선언적으로 구성하기

이전 글에서는 커맨드(Command) 패턴을 모던 C++20 이상 관점에서 재해석하며, 상속 기반 명령 클래스 대신 람다, Concepts, std::function, std::expected, coroutine, Ranges, std::format 등을 활용해 더 간결하고 확장성 높은 명령 시스템을 구현하는 방법을 살펴보았습니다. 이번 글에서는 행동(Behavioral) 패턴 중 인터프리터(Interpreter) 패턴을 다룹니다.

인터프리터 패턴은 언어(DSL, 간단한 명령어 집합)나 표현(Expression)을 해석하는 로직을 객체로 캡슐화하는 패턴입니다. 전통적으로는 상속 기반 Expression 인터페이스, 각각의 구체 표현 클래스(NonterminalExpression, TerminalExpression)로 언어 구조를 모델링했지만, 이는 클래스 증가와 유지보수 어려움을 야기합니다. 모던 C++20 이상에서는 std::variant, std::visit, Ranges, Concepts, std::expected 등 기능을 활용해 언어 해석 과정을 함수형/선언적 스타일로 재구성할 수 있습니다.

패턴 소개: 인터프리터(Interpreter)

의도:

  • 언어(DSL)나 표현식을 정의하고, 그 표현식을 해석하는 로직을 객체로 캡슐화.
  • 문법 규칙마다 클래스를 만드는 대신, 이제는 값 기반 다형성과 함수 합성으로 문법 처리 가능.
  • 예: 간단한 수식 해석(정수 덧셈, 곱셈), 간단한 명령어 스크립트 해석.

전통적 구현 문제점:

  • Expression 인터페이스 + Nonterminal, TerminalExpression 상속 계층 증가
  • 새로운 규칙 추가 시 클래스 증가
  • 파서, 해석 로직 분산, 에러 처리나 비동기, 파이프라인 처리 어려움

기존 C++ 스타일 구현 (C++11/14/17 이전, 전통적 방식)

예를 들어 간단한 수식(덧셈, 곱셈) 해석:

#include <iostream>
#include <memory>
#include <string>

// 전통적 인터페이스
struct Expression {
    virtual ~Expression() = default;
    virtual int interpret() = 0;
};

// 정수 표현 (Terminal)
struct NumberExpression : Expression {
    int value;
    NumberExpression(int v) : value(v) {}
    int interpret() override {
        return value;
    }
};

// 덧셈 표현 (Nonterminal)
struct AddExpression : Expression {
    std::unique_ptr<Expression> left, right;
    AddExpression(std::unique_ptr<Expression> l, std::unique_ptr<Expression> r)
        : left(std::move(l)), right(std::move(r)) {}
    int interpret() override {
        return left->interpret() + right->interpret();
    }
};

int main() {
    // (3 + 4)
    auto expr = std::make_unique<AddExpression>(
        std::make_unique<NumberExpression>(3),
        std::make_unique<NumberExpression>(4)
    );
    std::cout << expr->interpret() << "\n"; // 7
}

문제점:

  • Expression 인터페이스, 여러 구체 클래스 필요
  • 언어 구조 변화 시 클래스 증가
  • 에러 처리나 추가 기능(로그, 비동기?) 어렵고 유지보수성 낮음

모던 C++20 이상의 개선: std::variant, std::visit, Concepts, Ranges, std::expected

1. std::variant로 문법 트리 표현

값 기반 다형성을 사용해 구문 트리를 std::variant로 표현. 예를 들어 Number, Add 표현을 구조체로 정의:

#include <variant>
#include <string>
#include <expected>
#include <iostream>
#include <format>

// 표현식 타입 정의
struct Number {
    int value;
};

struct Add {
    // Add는 두 개의 하위 표현식
    // 하위 표현식도 variant이므로 재귀적 정의 필요
};

using ExpressionVariant = std::variant<Number, /*추가 연산*/ Add>;

추가로 std::shared_ptr나 std::unique_ptr와 std::variant를 조합해 재귀 구조를 표현할 수 있다. C++17부터 std::variant 직접 재귀 불가능하므로 포인터 사용:

struct ExpressionNode;
using ExpressionNodePtr = std::shared_ptr<struct ExpressionNode>;

struct Add {
    ExpressionNodePtr left;
    ExpressionNodePtr right;
};

struct ExpressionNode {
    std::variant<Number, Add> expr;
};

비교:

  • 전통적: 클래스 상속 트리 필요
  • C++11/14/17: 일부 std::unique_ptr 사용 가능하나 variant, visit 미지원
  • C++20: std::variant, std::visit로 값 기반 다형성, 상속 필요 없음

2. Concepts로 해석 함수 인터페이스 정의

해석 함수 interpret()가 std::expected<int,std::string> 반환한다고 가정:

template<typename I>
concept InterpreterConcept = requires(I& interp, const ExpressionNodePtr& node) {
    { interp.interpret(node) } -> std::convertible_to<std::expected<int,std::string>>;
};

3. 해석(Interpret) 로직

std::visit를 사용해 각 케이스별 처리:

std::expected<int,std::string> interpretExpression(const ExpressionNodePtr& node);

std::expected<int,std::string> handleNumber(const Number& num) {
    return num.value;
}

std::expected<int,std::string> handleAdd(const Add& add) {
    auto leftVal = interpretExpression(add.left);
    if(!leftVal) return std::unexpected(leftVal.error());
    auto rightVal = interpretExpression(add.right);
    if(!rightVal) return std::unexpected(rightVal.error());
    return *leftVal + *rightVal;
}

std::expected<int,std::string> interpretExpression(const ExpressionNodePtr& node) {
    return std::visit([](auto& expr) -> std::expected<int,std::string> {
        using T = std::decay_t<decltype(expr)>;
        if constexpr (std::is_same_v<T,Number>) {
            return handleNumber(expr);
        } else if constexpr (std::is_same_v<T,Add>) {
            return handleAdd(expr);
        } else {
            return std::unexpected("Unknown expression type");
        }
    }, node->expr);
}

비교:

  • 전통적: 가상 함수 interpret() 호출, 상속 기반 클래스 증가
  • 모던 C++: std::visit와 if constexpr로 각 케이스 처리, class 증가 없음
  • std::expected로 에러 처리 명확

4. 파서(Parser)와 Ranges 활용

예를 들어, 입력 문자열을 토큰으로 분리하고, 이를 Ranges로 변환 후 파이프라인 처리 가능. 여기서는 간단히 토큰 리스트를 가정:

#include <ranges>
#include <vector>
#include <string_view>

// 가상의 파서: "3 + 4" → Number(3), Add, Number(4)
auto parseExpression(const std::vector<std::string_view>& tokens) -> std::expected<ExpressionNodePtr,std::string> {
    // 매우 단순한 가상 로직: [ "3", "+", "4" ]
    if (tokens.size() != 3) return std::unexpected("Invalid expression");
    int left, right;
    try {
        left = std::stoi(std::string(tokens[0]));
        right = std::stoi(std::string(tokens[2]));
    } catch(...) {
        return std::unexpected("Invalid number");
    }

    if (tokens[1] != "+") return std::unexpected("Only '+' supported");

    auto leftNode = std::make_shared<ExpressionNode>(ExpressionNode{Number{left}});
    auto rightNode = std::make_shared<ExpressionNode>(ExpressionNode{Number{right}});
    ExpressionNodePtr root = std::make_shared<ExpressionNode>(ExpressionNode{Add{leftNode, rightNode}});
    return root;
}

비교:

  • 전통적: 파서 작성 시 별도 클래스 필요, 토큰 처리 로직 분산
  • C++11/14/17: 람다, std::function 가능하지만 Ranges 부재 → 파이프라인 처리 제한적
  • C++20: Ranges로 토큰 필터링, 변환 가능. std::expected로 파싱 실패 명확화

토큰 필터링 예 (조건부 파싱)

std::vector<std::string_view> tokens = {"3", "+", "4"};
auto processed = tokens 
    | std::views::filter([](auto sv){ return !sv.empty(); });

auto exprNode = parseExpression(std::vector(processed.begin(), processed.end()));
if(!exprNode) {
    std::cerr << "Parse error: " << exprNode.error() << "\n";
} else {
    auto val = interpretExpression(*exprNode);
    if(!val) std::cerr << "Interpret error: " << val.error() << "\n";
    else std::cout << "Result: " << *val << "\n"; // Result: 7
}

설명:

  • Ranges로 토큰 전처리, filter로 빈 토큰 제거 등 가능
  • std::expected로 파싱/해석 에러 처리 간결

비동기 해석: Coroutine

비동기 해석 시 coroutine 사용 가능. 예를 들어, 원격에서 스크립트를 가져와 해석해야 한다면 co_await로 비동기 I/O 후 parse/interpret:

// 가상 예: co_parseExpression(co_await로 원격 토큰 로딩)

비교:

  • 전통적: 비동기 처리 시 별도 쓰레드, 예외 처리 복잡
  • C++11/14/17: Promise/Future 써야 하고 코드 복잡
  • C++20: coroutine으로 자연스러운 비동기 흐름, Concepts+expected로 타입 안전한 결과 처리

로깅(데코레이터) 적용

std::format으로 해석 전후 로깅 추가 가능:

auto makeLoggingInterpreter = [](auto baseFunc) {
    return [baseFunc](const ExpressionNodePtr& node) -> std::expected<int,std::string> {
        std::cout << "[LOG] Interpreting expression...\n";
        auto res = baseFunc(node);
        if(!res) {
            std::cout << std::format("[LOG] Error: {}\n", res.error());
        } else {
            std::cout << std::format("[LOG] Result: {}\n", *res);
        }
        return res;
    };
};

// 사용 예:
auto loggingInterpret = makeLoggingInterpreter(interpretExpression);
auto result = loggingInterpret(*exprNode);
if(!result) std::cerr << "Error: " << result.error() << "\n";
else std::cout << "Final result: " << *result << "\n";

비교:

  • 전통적: 로깅 추가 시 Decorator 패턴으로 클래스 증가
  • 모던 C++: 람다 장식으로 로깅 기능 추가, 상속 없음, 훨씬 단순

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적 (C++98/03):

  • 인터프리터를 Expression 추상 클래스 + Nonterminal/Terminal 클래스 상속으로 구현
  • 문법 변경 시 클래스 증가, 코드 유지보수 어려움
  • 에러 처리, 비동기 처리, 로깅, 파이프라인 구성 모두 복잡

C++11/14/17:

  • 람다, std::unique_ptr 활용으로 일부 개선
  • 여전히 Concepts, std::expected, coroutine, Ranges 부재 → 정적 타입 제약, 명확한 에러 처리, 비동기 친화적 설계 한계
  • Undo/Redo나 로깅, 파이프라인 등 고급 요구사항 구현 번거로움 다소 완화되나 근본적 한계 존재

C++20 이상(모던 C++):

  • Concepts로 인터프리터 함수 형식 제약 명확히 정의, 상속 없이 타입 안전한 인터페이스 제공
  • std::variant와 std::visit로 값 기반 다형성, 다양한 표현식 처리 상속 없이 구현
  • std::expected로 에러 처리 명확, std::format으로 로깅 용이
  • Ranges로 토큰 파이프라인, 조건부 파싱 처리
  • coroutine으로 비동기 해석, 함수 합성으로 로깅, Undo/Redo, 비동기 등 기능 추가 쉬움
  • 유지보수성, 확장성, 타입 안전성, 코드 가독성 모두 대폭 향상

결론

인터프리터(Interpreter) 패턴은 언어나 표현식을 해석하는 로직을 객체화하지만, 전통적 구현에서는 상속 기반 Expression 클래스 계층이 복잡성을 야기했습니다. C++20 이상에서는 std::variant, std::visit, Concepts, std::expected, Ranges, coroutine, std::format 등을 활용해 상속 없이도 언어 해석 로직을 선언적이고 유지보수성 높게 재구성할 수 있습니다. 에러 처리, 파이프라인 처리, 비동기 지원, 로깅, 문법 확장 모두 간결한 코드로 구현할 수 있으며, 전통적 구현 대비 훨씬 유연하고 강력한 인터프리터 패턴을 만들 수 있습니다.

반응형