精读C++20设计模式------行为型设计模式:解释器模式
前言
笔者的这个更多是整理出来的内容,我没用过解释器模式,或者说,即使我真设计过一点DSL,因为犯不着那么麻烦,我也就没有采用如此刻板的解释器模式
如果你跳起来说------嘿这个我熟悉啊,我天天写的Python就是解释器语言,我说你跳。。。对了。还真是!我们的Python解释器还真就是解释器模式的产物。不过,我们往往真不用去写一个解释器语言,我们更多的是想在涉及到需要做DSL的时候,我们才会利用这个设计模式解决问题。
解释器模式(Interpreter Pattern)在行为型设计模式家族中有一种独特的位置:它把语言的语法规则和执行逻辑以类与对象的形式编码到程序中,使得我们可以把某种小型语言(DSL)嵌入到应用里,并对输入文本做解析与求值。解释器模式并不一定意味着要实现完整的编译器,它更适合表达规则明确、语法相对简单的场景,例如配置表达式、过滤规则、搜索查询语法或简单计算器。
解释器模式是什么
解释器模式的核心是把"语法"的每一种构成元素(终结符与非终结符)对应到程序里的类,每个类负责"解释(interpret)"或"求值(evaluate)"自己所代表的语法片段。调用方不需要知道具体的语法细节,只需把文本交给解释器,解释器把文本转换成抽象语法树(AST)或者链式对象,然后执行解释动作,得出结果。换句话说,解释器把一门小型语言的语法与语义"面向对象化"------语法规则是类的组合,语义是类的方法实现。
基本样例:解析整数
我们先从最简单的场景开始:输入是一个十进制整数的字符串,我们想把它解释成一个整数值。这个例子既可以用标准库的 std::stoi
/std::from_chars
,也可以用解释器模式的"类化"实现来表达"终结符"的含义。
下面代码给出一个很轻量的解释器实现:Expression
作为抽象基类,Number
表示终结符(数字文本)。interpret
方法把上下文(这里是字符串)转换成数值结果。
cpp
// int_interpreter.cpp
#include <iostream>
#include <memory>
#include <string>
#include <charconv>
struct Context {
std::string input;
Context(std::string s): input(std::move(s)) {}
};
struct Expression {
virtual ~Expression() = default;
virtual long long interpret(const Context& ctx) const = 0;
};
struct Number : Expression {
size_t pos{0};
Number(size_t p): pos(p) {}
long long interpret(const Context& ctx) const override {
const char* str = ctx.input.c_str() + pos;
long long val = 0;
auto [ptr, ec] = std::from_chars(str, str + ctx.input.size() - pos, val);
if (ec != std::errc()) {
throw std::runtime_error("invalid number");
}
return val;
}
};
int main() {
Context ctx("12345");
std::unique_ptr<Expression> expr = std::make_unique<Number>(0);
std::cout << expr->interpret(ctx) << "\n"; // 12345
}
最初我们只需要把数字解析为值,所以 Number
直接从文本解析并返回数值。随着需求增加,我们把 Context
明确为解释器上下文(保存输入、当前位置、错误信息等),并把 Expression
作为统一接口,从而允许更多表达式类(例如二元运算)插入到体系当中。
样例进阶:数值表达式求值(支持 + - * / 和括号)
接下来我们实现一个更完整的解释器:把文本表达式解析为 AST,并对 AST 求值。我们使用经典的**递归下降解析(recursive descent parsing)**来把输入字符串转换成节点对象。节点类型包括 NumberNode
、BinaryOpNode
(加、减、乘、除)。每个节点实现 evaluate()
返回数值。
示例代码(较完整):
cpp
// expr_interpreter.cpp
#include <iostream>
#include <memory>
#include <string>
#include <cctype>
#include <stdexcept>
struct Node {
virtual ~Node() = default;
virtual long long evaluate() const = 0;
};
struct NumberNode : Node {
long long value;
NumberNode(long long v): value(v) {}
long long evaluate() const override { return value; }
};
struct BinaryNode : Node {
char op;
std::unique_ptr<Node> left, right;
BinaryNode(char o, std::unique_ptr<Node> l, std::unique_ptr<Node> r)
: op(o), left(std::move(l)), right(std::move(r)) {}
long long evaluate() const override {
long long a = left->evaluate();
long long b = right->evaluate();
switch (op) {
case '+': return a + b;
case '-': return a - b;
case '*': return a * b;
case '/':
if (b == 0) throw std::runtime_error("division by zero");
return a / b;
}
throw std::runtime_error("unknown operator");
}
};
class Parser {
public:
Parser(std::string s): input(std::move(s)), pos(0) {}
std::unique_ptr<Node> parse() {
auto node = parseExpression();
skipSpaces();
if (pos != input.size()) throw std::runtime_error("unexpected input");
return node;
}
private:
std::string input;
size_t pos;
void skipSpaces() {
while (pos < input.size() && std::isspace((unsigned char)input[pos])) ++pos;
}
std::unique_ptr<Node> parseNumber() {
skipSpaces();
size_t start = pos;
bool neg = false;
if (pos < input.size() && input[pos] == '-') { neg = true; ++pos; }
if (pos >= input.size() || !std::isdigit((unsigned char)input[pos]))
throw std::runtime_error("expected number");
long long val = 0;
while (pos < input.size() && std::isdigit((unsigned char)input[pos])) {
val = val * 10 + (input[pos] - '0');
++pos;
}
return std::make_unique<NumberNode>(neg ? -val : val);
}
std::unique_ptr<Node> parseFactor() {
skipSpaces();
if (pos < input.size() && input[pos] == '(') {
++pos; // consume '('
auto node = parseExpression();
skipSpaces();
if (pos >= input.size() || input[pos] != ')') throw std::runtime_error("missing )");
++pos; // consume ')'
return node;
}
return parseNumber();
}
std::unique_ptr<Node> parseTerm() {
auto node = parseFactor();
while (true) {
skipSpaces();
if (pos < input.size() && (input[pos] == '*' || input[pos] == '/')) {
char op = input[pos++];
auto rhs = parseFactor();
node = std::make_unique<BinaryNode>(op, std::move(node), std::move(rhs));
} else break;
}
return node;
}
std::unique_ptr<Node> parseExpression() {
auto node = parseTerm();
while (true) {
skipSpaces();
if (pos < input.size() && (input[pos] == '+' || input[pos] == '-')) {
char op = input[pos++];
auto rhs = parseTerm();
node = std::make_unique<BinaryNode>(op, std::move(node), std::move(rhs));
} else break;
}
return node;
}
};
int main() {
std::vector<std::string> tests = {
"1+2*3",
"(1+2)*3",
"10 - 4 / 2",
"(2+3)*(4-1)"
};
for (auto &t : tests) {
try {
Parser p(t);
auto ast = p.parse();
std::cout << t << " = " << ast->evaluate() << "\n";
} catch (const std::exception &e) {
std::cerr << "parse error: " << e.what() << "\n";
}
}
}
这套代码做了以下事情:先把字符串解析成 AST(NumberNode
、BinaryNode
),再通过 evaluate()
做递归求值。解析器遵循优先级规则:先解析 factor
(数字或括号),再处理乘除(term
),最后处理加减(expression
)。
解释器模式的常见变种与扩展
解释器模式在工程实践中并非一刀切的模板,它有多种变体,常见的有以下几类:
一种比较直接的变体是AST + 解释器的组合:先把文本解析成 AST,后续可以对 AST 做多种操作(求值、优化、序列化、转成字节码等)。这种做法把语法分析与语义执行分离,有利于后续扩展。
另一个变体是直接解释(single-pass/interpreter-on-the-fly),即解析器在解析过程中即时执行,不生成持久 AST。它的优点在于少内存开销与实现简洁,但不利于做多次评估或优化。
词法/语法分层(lexer + parser) 是更工程化的做法:把输入先分解成 token,再用解析器处理 token。这在语法变复杂时非常必要,便于错误定位与扩展。
在并发/性能场景下,解释器还会有JIT 编译/字节码 风格的演化:把语句编译成中间字节码或直接机器指令,然后多次执行以获得更高性能。这已经超出"轻量解释器"的范畴,但在需要重复求值的 DSL 中非常有意义。
组合模式下,解释器也可以和其他行为型模式结合:例如用访问者(Visitor) 在 AST 上做不同的操作(打印、优化、评估),或者用策略(Strategy) 注入不同的语义评估策略(例如浮点或整数语义)。
总结
解释器模式解决了什么问题?
解释器模式要解决的问题是:当应用需要解析并执行某种文本化规则或小型语言时,如何把语言的语法和执行语义以模块化、面向对象的方式表达出来,使得语法扩展、规则演进和复用变得更简单。
如何解决?
它通过把语法单位映射为类(终结符和非终结符),每个类实现 interpret
/evaluate
接口,从而把解析与执行流程以组合对象的方式组织起来。调用方只与解释器接口交互,而不关心内部的具体语法类。
各种变种的优劣对比(便于在工程中选型)
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
直接 AST + 解释器 | 清晰、可调试、方便复用和多次评估;容易加入优化/遍历(Visitor) | 初始实现开销较大,需要内存保存 AST | 需要多次执行同一脚本或要做优化场景 |
直接解释(单遍即时执行) | 实现简单、内存占用低 | 不利于调试、不能复用 AST、难以做优化 | 简单脚本、一次性命令解析 |
lexer + parser(分层) | 结构清晰、容错好、扩展性强 | 实现复杂度提高,需要写词法器 | 语法复杂或需良好错误提示时 |
字节码 / JIT | 高性能、适合热路径多次执行 | 实现复杂,调试难 | 高频重复执行、性能敏感场景 |
结合访问者/策略等模式 | 职责分离,便于增加语义操作 | 设计复杂度上升 | 需要多种对 AST 的不同操作(打印、类型检查、求值) |