2026-09-09 · C++17 / Qt 6 · 全文约 5200 字,阅读约 21 分钟
解释器模式(Interpreter Pattern)是一种行为型设计模式:给定一门语言,定义其文法(语法规则)的一种对象化表示,并定义一个解释器——解释器拿着这个表示,就能解释该语言中的句子。
核心记忆:把"语法规则"变成"类",把"句子"变成"对象树",把"解释"变成"在树上递归求值"。每一条语法规则对应一个表达式类;一句话经解析后成为一棵由这些对象拼成的树(AST);调用树根的 interpret(),每个节点各自解释自己,答案就递归地算出来了。
它是 GoF 23 种模式里最接近"编译器/脚本引擎"的一个:正则表达式引擎、SQL 解析器、配置文件规则引擎、甚至 QML/JavaScript 引擎,骨子里都是它的工程化放大版。学完它,你就摸到了"如何给程序造一门小语言"的完整套路——这也是 23 种 GoF 模式中收官的最后一员。
场景:你要给一个报表/工控上位机软件加一个"公式计算"功能——用户在输入框里敲 3 + 5 - 2,程序算出结果。第一版只支持个位数加减,新手程序员用"逐字符扫描"很快写出来了:
// 坏代码:字符扫描式求值器,只支持个位数 + / -
double badEval(const std::string& text) {
double result = 0.0;
char op = '+'; // 记录"上一个运算符"
for (char c : text) {
if (c == ' ') continue; // 跳过空格
if (c >= '0' && c <= '9') { // 遇到数字:按上一个运算符累加
if (op == '+') result += (c - '0');
else if (op == '-') result -= (c - '0');
} else if (c == '+' || c == '-') {
op = c; // 记住运算符,等下一个数字
} else {
throw std::runtime_error("不支持的字符");
}
}
return result;
}
跑起来一切正常。可需求立刻升级:
第 2 天:要支持 * /——"先乘除后加减"的优先级怎么扫?你得预读后面的字符,扫描循环开始长出各种 if 补丁。第 3 天:要支持括号 (3 + 5) * 2——"括号"意味着嵌套,扫描状态机迅速失控:inParen、depth、pendingOp……一堆临时变量互相纠缠。第 4 天:要支持变量 x、函数 max(a, b)——到这个阶段,badEval 已经变成一段没人敢碰的 300 行意大利面条,加一个运算符就可能弄坏三个旧功能。
这段坏代码犯了哪些错?
3 * 4 的正确性,只能跑整个字符串。根源在于:"语言"是一种有结构的东西,而你用没有结构的线性扫描去处理它。正确的思路是:先把"语言的结构"显式建模出来——每一种语法规则(数字、加法、乘法、括号)都是一个类,一个表达式就是这些类的对象拼成的树;然后再让这棵树自己会"算"。这就是解释器模式。
先看一个生活类比。CPU 怎么执行程序?它不"看懂"源代码,而是拿到一串指令字节码,一条一条机械地解释执行:这条是加法、那条是跳转。程序之所以能表现出复杂行为,是因为指令的排列组合,而不是 CPU 认识你的业务。解释器模式是同一思想的软件版:我们给程序定义一组"指令"(语法节点类),把用户的输入(一句话)翻译成这些指令拼成的树,然后让每个节点自己执行自己。语言千变万化,但"树 + 递归"的执行骨架永远不变。
再打个比方:看菜谱做菜。"西红柿炒蛋"这句菜名你没法直接执行,但菜谱把做法组织成树形结构:①准备食材(蛋、番茄)→ ②热油 → ③下蛋翻炒 → ④加番茄 → ⑤调味出锅。每一步是一个"叶子动作","步骤序列"是非终结结构。解释器模式就是:把一句"话"拆解成结构化步骤,再按结构一步步执行。
回到软件工程,抓住三句话:
工程上它带来一个意外收获:解析(文本 → 树)与解释(树 → 结果)彻底分离。同一个表达式树,你可以解释执行它,也可以遍历它做"语法检查"、"优化"、"生成别的语言代码"——树的消费者可以有无数个,而它们都不需要碰语法定义。
解释器模式有四个经典角色,外加一个 Client:
interpret(Context) 接口,所有语法节点的统一入口。本文中即 Expr。interpret() 直接返回自身代表的值。本文中即 NumberExpr、VariableExpr。interpret() 的语义是"先递归解释所有子节点,再按本规则组合结果"。本文中即 BinaryExpr。Context。interpret()。结构图(注意:这是一棵"组合树",树枝持有子树的引用,整体呈递归结构):
Client / Parser(按文法把句子变成树)
│ 构建
▼
┌────── Expr(抽象:interpret())──────┐
│ ▲ │
┌──────────┴──────────┐ │ ┌───────────┴───────────┐
│ TerminalExpression │ │ │ NonterminalExpression │
│ NumberExpr 等叶子 │ │ │ BinaryExpr(树枝) │
│ 直接返回自身值 │ │ │ 持有 left/right 子树 │
└─────────────────────┘ │ │ 先递归解释子节点 │
│ │ 再组合出本节点结果 │
│ └───────────▲───────────┘
Context(变量表)◄────interpret(ctx) 递归读取──────┘
文法(BNF,三层嵌套同时决定了运算优先级):
expr := term (('+' | '-') term)*
term := factor (('*' | '/') factor)*
factor := 数字 | 变量 | '(' expr ')'
依赖方向:非终结符依赖抽象 Expr 而不是具体子类(对子表达式只认接口,组合优于继承);终结符与解释器都只通过 Context 读写环境(上下文与语法解耦);Client 只依赖 Expr 与文法。哪部分可以扩展?新增语法规则 = 新增一个 Expr 子类 + 在 Parser 里加一条分支,既有类零改动。
下面实现一个完整可编译的迷你计算器:支持四则运算、括号与变量(如 2 * x + 1,x 由 Context 提供)。它麻雀虽小五脏俱全——词法分析(Lexer)、语法分析(Parser,递归下降)、语法树(AST)、解释执行(interpret)全都有,解释器模式的四个角色一个不缺:
// interpreter_min.cpp — 解释器模式最小示例(C++17,可直接编译运行)
// 文法:expr → term → factor 三层递归下降,天然实现"先乘除后加减、括号优先"
#include <cctype>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <utility>
#include <vector>
// ---------- 角色1:Context(上下文)——保存变量值,解释时被叶子节点读取 ----------
class Context {
public:
void set(const std::string& name, double value) { vars_[name] = value; }
double get(const std::string& name) const {
auto it = vars_.find(name);
if (it == vars_.end())
throw std::runtime_error("未定义的变量: " + name);
return it->second;
}
private:
std::unordered_map<std::string, double> vars_;
};
// ---------- 角色2:AbstractExpression(抽象表达式)——所有语法节点的接口 ----------
class Expr {
public:
virtual ~Expr() = default; // 基类必须有虚析构(多态删除)
virtual double interpret(const Context& ctx) const = 0; // 解释自己
};
// ---------- 角色3a:TerminalExpression——数字字面量(叶子) ----------
class NumberExpr : public Expr {
public:
explicit NumberExpr(double value) : value_(value) {}
double interpret(const Context&) const override { return value_; } // 叶子:直接返回值
private:
double value_;
};
// ---------- 角色3b:TerminalExpression——变量引用(叶子,需查 Context) ----------
class VariableExpr : public Expr {
public:
explicit VariableExpr(std::string name) : name_(std::move(name)) {}
double interpret(const Context& ctx) const override { return ctx.get(name_); }
private:
std::string name_;
};
// ---------- 角色3c:NonterminalExpression——二元运算(树枝,组合两个子表达式) ----------
enum class Op { Add, Sub, Mul, Div };
class BinaryExpr : public Expr {
public:
BinaryExpr(Op op, std::unique_ptr<Expr> left, std::unique_ptr<Expr> right)
: op_(op), left_(std::move(left)), right_(std::move(right)) {}
double interpret(const Context& ctx) const override {
const double l = left_->interpret(ctx); // ① 递归解释左子树
const double r = right_->interpret(ctx); // ② 递归解释右子树
switch (op_) { // ③ 按本节点规则组合结果
case Op::Add: return l + r;
case Op::Sub: return l - r;
case Op::Mul: return l * r;
case Op::Div:
if (r == 0.0)
throw std::runtime_error("除数为 0");
return l / r;
}
return 0.0; // 所有枚举分支均已 return,此行仅为消除编译器告警
}
private:
Op op_;
std::unique_ptr<Expr> left_; // unique_ptr:独占所有权;树析构时子节点被自动释放
std::unique_ptr<Expr> right_; // 无裸 new/delete,无内存泄漏
};
// ---------- 词法单元与词法分析器:把字符串切成 Token 流 ----------
enum class TokKind { Number, Ident, Plus, Minus, Star, Slash, LParen, RParen, End };
struct Token {
TokKind kind = TokKind::End;
double number = 0.0; // kind == Number 时的数值
std::string ident; // kind == Ident 时的变量名
};
class Lexer {
public:
explicit Lexer(std::string text) : text_(std::move(text)) {}
std::vector<Token> tokenize() {
std::vector<Token> tokens;
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (std::isspace(static_cast<unsigned char>(c))) { ++pos_; continue; }
if (std::isdigit(static_cast<unsigned char>(c))) { // 数字(含小数点)
std::size_t end = pos_;
while (end < text_.size() &&
(std::isdigit(static_cast<unsigned char>(text_[end]))
|| text_[end] == '.'))
++end;
tokens.push_back(Token{TokKind::Number,
std::stod(text_.substr(pos_, end - pos_)), {}});
pos_ = end;
continue;
}
if (std::isalpha(static_cast<unsigned char>(c))) { // 变量名
std::size_t end = pos_;
while (end < text_.size() &&
std::isalnum(static_cast<unsigned char>(text_[end])))
++end;
tokens.push_back(Token{TokKind::Ident, 0.0,
text_.substr(pos_, end - pos_)});
pos_ = end;
continue;
}
switch (c) { // 单字符运算符与括号
case '+': tokens.push_back(Token{TokKind::Plus, 0.0, {}}); break;
case '-': tokens.push_back(Token{TokKind::Minus, 0.0, {}}); break;
case '*': tokens.push_back(Token{TokKind::Star, 0.0, {}}); break;
case '/': tokens.push_back(Token{TokKind::Slash, 0.0, {}}); break;
case '(': tokens.push_back(Token{TokKind::LParen, 0.0, {}}); break;
case ')': tokens.push_back(Token{TokKind::RParen, 0.0, {}}); break;
default:
throw std::runtime_error(std::string("无法识别的字符: ") + c);
}
++pos_;
}
tokens.push_back(Token{TokKind::End, 0.0, {}}); // 末尾哨兵,防止越界
return tokens;
}
private:
std::string text_;
std::size_t pos_ = 0;
};
// ---------- 角色4:Client/语法分析器——递归下降,把 Token 流建成语法树 ----------
class Parser {
public:
explicit Parser(std::vector<Token> tokens) : tokens_(std::move(tokens)) {}
std::unique_ptr<Expr> parse() { // 入口:解析一整条表达式
auto ast = parseExpr();
if (peek().kind != TokKind::End)
throw std::runtime_error("表达式末尾有多余内容");
return ast;
}
private:
const Token& peek() const { return tokens_[index_]; } // 只看不取
Token next() { return tokens_[index_++]; } // 取出并前进
// expr := term (('+'|'-') term)* —— 加减法,左结合
std::unique_ptr<Expr> parseExpr() {
auto node = parseTerm();
while (peek().kind == TokKind::Plus || peek().kind == TokKind::Minus) {
const Op op = (next().kind == TokKind::Plus) ? Op::Add : Op::Sub;
node = std::make_unique<BinaryExpr>(op, std::move(node), parseTerm());
}
return node;
}
// term := factor (('*'|'/') factor)* —— 乘除法,比 expr 低一层所以优先计算
std::unique_ptr<Expr> parseTerm() {
auto node = parseFactor();
while (peek().kind == TokKind::Star || peek().kind == TokKind::Slash) {
const Op op = (next().kind == TokKind::Star) ? Op::Mul : Op::Div;
node = std::make_unique<BinaryExpr>(op, std::move(node), parseFactor());
}
return node;
}
// factor := 数字 | 变量 | '(' expr ')' —— 最底层:原子或括号
std::unique_ptr<Expr> parseFactor() {
const Token token = next();
switch (token.kind) {
case TokKind::Number:
return std::make_unique<NumberExpr>(token.number);
case TokKind::Ident:
return std::make_unique<VariableExpr>(token.ident);
case TokKind::LParen: { // 括号内又是一个完整表达式(递归!)
auto inner = parseExpr();
if (next().kind != TokKind::RParen)
throw std::runtime_error("缺少右括号 ')'");
return inner;
}
default:
throw std::runtime_error("表达式位置出现意外的符号");
}
}
std::vector<Token> tokens_;
std::size_t index_ = 0;
};
int main() {
Context ctx;
ctx.set("x", 10.0); // 预置变量 x = 10
const std::vector<std::string> tests = {
"3 + 5 * 2", // 期望 13:先乘后加
"(3 + 5) * 2", // 期望 16:括号优先
"2 * x + 1" // 期望 21:读取变量 x
};
for (const auto& text : tests) {
try {
Lexer lexer(text); // ① 词法分析:字符串 → Token 流
Parser parser(lexer.tokenize()); // ② 语法分析:Token 流 → 语法树
auto ast = parser.parse(); // (树根是一个 unique_ptr<Expr>)
std::cout << text << " = "
<< ast->interpret(ctx) // ③ 解释执行:从树根递归求值
<< std::endl;
} catch (const std::exception& e) {
std::cout << text << " => 错误: " << e.what() << std::endl;
}
}
return 0;
}
程序输出:
3 + 5 * 2 = 13
(3 + 5) * 2 = 16
2 * x + 1 = 21
看 "3 + 5 * 2" 的语法树长什么样(这就是"句子对象化"的结果):
BinaryExpr(+) ← 树根,interpret() 从这里开始
/ \
NumberExpr(3) BinaryExpr(*) ← 因为 * 在文法里比 + 低一层,乘先算
/ \
NumberExpr(5) NumberExpr(2)
几个工程要点:① 树节点用 std::unique_ptr 持有子节点,树析构时所有节点被自动释放——无裸 new/delete、无内存泄漏、无悬空指针;这也顺带让 Expr 不可拷贝(只能移动),符合"树是独占所有权链"的语义。② interpret() 全部为 const,语法树一旦建好就是只读的,可被多线程安全共享。③ 优先级不是用 if 判断出来的,而是文法分层(expr→term→factor)天然编码的——这正是不用扫描式坏代码的原因。④ 出错(除零、未定义变量、缺右括号)全部以异常抛出,调用方统一捕获,错误信息可读。
先说实话:Qt 并没有把解释器模式当作对外主推的核心模式(它不像信号槽之于观察者那样有明显对应)。但 Qt 家族里到处是解释器思想的工程化实现:QRegularExpression 把正则文本"编译"成内部字节码再执行匹配;QJSEngine / QQmlEngine 是完整的 JavaScript/QML 语言解释器(词法→语法树→执行);QUiLoader 把 .ui 的 XML"解释"成 QWidget 对象树。第 ⑫ 节会展开。
下面做一个真正能跑在 Qt Creator 里的实战:一个"表达式计算器"小工具——输入框敲 3 + 5 * 2,点按钮立刻出结果。教学重点是:第 ⑤ 节那个与 UI 毫无关系的表达式引擎,可以被"原封不动"地搬进 Qt 工程——引擎不知道也不关心输入来自 QLineEdit 还是文件还是网络报文,这就是解释器模式带来的解耦红利。
expr.h(表达式引擎头文件):
// expr.h — 表达式引擎:纯 C++17,与 Qt 无关,可独立单元测试
#ifndef EXPR_H
#define EXPR_H
#include <cstddef>
#include <memory>
#include <string>
#include <utility>
#include <vector>
// 抽象表达式:所有语法节点的统一接口(AbstractExpression)
class Expr {
public:
virtual ~Expr() = default;
virtual double interpret() const = 0; // 本引擎无需变量,故不带 Context
};
// 终结符表达式:数字字面量
class NumberExpr : public Expr {
public:
explicit NumberExpr(double value) : value_(value) {}
double interpret() const override { return value_; }
private:
double value_;
};
// 非终结符表达式:二元运算(组合两个子表达式)
enum class Op { Add, Sub, Mul, Div };
class BinaryExpr : public Expr {
public:
BinaryExpr(Op op, std::unique_ptr<Expr> left, std::unique_ptr<Expr> right);
double interpret() const override;
private:
Op op_;
std::unique_ptr<Expr> left_;
std::unique_ptr<Expr> right_;
};
// 词法单元与词法分析器
enum class TokKind { Number, Plus, Minus, Star, Slash, LParen, RParen, End };
struct Token {
TokKind kind = TokKind::End;
double number = 0.0;
};
class Lexer {
public:
explicit Lexer(std::string text) : text_(std::move(text)) {}
std::vector<Token> tokenize();
private:
std::string text_;
std::size_t pos_ = 0;
};
// 递归下降语法分析器:Token 流 → 语法树(Client 角色)
class Parser {
public:
explicit Parser(std::vector<Token> tokens) : tokens_(std::move(tokens)) {}
std::unique_ptr<Expr> parse();
private:
const Token& peek() const;
Token next();
std::unique_ptr<Expr> parseExpr();
std::unique_ptr<Expr> parseTerm();
std::unique_ptr<Expr> parseFactor();
std::vector<Token> tokens_;
std::size_t index_ = 0;
};
#endif // EXPR_H
expr.cpp(引擎实现,逻辑与第 ⑤ 节完全一致,仅去掉变量支持):
// expr.cpp — 表达式引擎实现
#include "expr.h"
#include <cctype>
#include <stdexcept>
double BinaryExpr::interpret() const {
const double l = left_->interpret(); // 递归解释左子树
const double r = right_->interpret(); // 递归解释右子树
switch (op_) {
case Op::Add: return l + r;
case Op::Sub: return l - r;
case Op::Mul: return l * r;
case Op::Div:
if (r == 0.0) throw std::runtime_error("除数不能为 0");
return l / r;
}
return 0.0;
}
std::vector<Token> Lexer::tokenize() {
std::vector<Token> tokens;
while (pos_ < text_.size()) {
const char c = text_[pos_];
if (std::isspace(static_cast<unsigned char>(c))) { ++pos_; continue; }
if (std::isdigit(static_cast<unsigned char>(c))) { // 数字
std::size_t end = pos_;
while (end < text_.size() &&
(std::isdigit(static_cast<unsigned char>(text_[end]))
|| text_[end] == '.'))
++end;
tokens.push_back(Token{TokKind::Number,
std::stod(text_.substr(pos_, end - pos_))});
pos_ = end;
continue;
}
switch (c) { // 运算符与括号
case '+': tokens.push_back(Token{TokKind::Plus, 0.0}); break;
case '-': tokens.push_back(Token{TokKind::Minus, 0.0}); break;
case '*': tokens.push_back(Token{TokKind::Star, 0.0}); break;
case '/': tokens.push_back(Token{TokKind::Slash, 0.0}); break;
case '(': tokens.push_back(Token{TokKind::LParen, 0.0}); break;
case ')': tokens.push_back(Token{TokKind::RParen, 0.0}); break;
default:
throw std::runtime_error(std::string("无法识别的字符: ") + c);
}
++pos_;
}
tokens.push_back(Token{TokKind::End, 0.0}); // 末尾哨兵
return tokens;
}
const Token& Parser::peek() const { return tokens_[index_]; }
Token Parser::next() { return tokens_[index_++]; }
std::unique_ptr<Expr> Parser::parse() {
auto ast = parseExpr();
if (peek().kind != TokKind::End)
throw std::runtime_error("表达式末尾有多余内容");
return ast;
}
// expr := term (('+'|'-') term)*
std::unique_ptr<Expr> Parser::parseExpr() {
auto node = parseTerm();
while (peek().kind == TokKind::Plus || peek().kind == TokKind::Minus) {
const Op op = (next().kind == TokKind::Plus) ? Op::Add : Op::Sub;
node = std::make_unique<BinaryExpr>(op, std::move(node), parseTerm());
}
return node;
}
// term := factor (('*'|'/') factor)*
std::unique_ptr<Expr> Parser::parseTerm() {
auto node = parseFactor();
while (peek().kind == TokKind::Star || peek().kind == TokKind::Slash) {
const Op op = (next().kind == TokKind::Star) ? Op::Mul : Op::Div;
node = std::make_unique<BinaryExpr>(op, std::move(node), parseFactor());
}
return node;
}
// factor := 数字 | '(' expr ')'
std::unique_ptr<Expr> Parser::parseFactor() {
const Token token = next();
if (token.kind == TokKind::Number)
return std::make_unique<NumberExpr>(token.number);
if (token.kind == TokKind::LParen) {
auto inner = parseExpr();
if (next().kind != TokKind::RParen)
throw std::runtime_error("缺少右括号 ')'");
return inner;
}
throw std::runtime_error("表达式位置出现意外的符号");
}
main.cpp(GUI:输入框 + 按钮 + 结果显示;注意用 Qt 父子对象机制管理内存,无一处手动 delete):
// main.cpp — 把表达式引擎接进 Qt GUI(复制到 Qt Creator 即可运行)
#include <QApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QWidget>
#include <exception>
#include "expr.h"
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
QWidget window; // 栈上窗口(RAII:作用域结束自动析构)
window.setWindowTitle(QStringLiteral("表达式计算器 — 解释器模式"));
// 子控件都以 &window 为父:Qt 父子链会随 window 析构统一释放,无需手动 delete
auto* input = new QLineEdit(&window);
input->setPlaceholderText(QStringLiteral("输入表达式,例如:3 + 5 * 2"));
auto* calcBtn = new QPushButton(QStringLiteral("= 计算"), &window);
auto* result = new QLabel(QStringLiteral("结果显示在这里"), &window);
auto* layout = new QHBoxLayout(&window); // 布局挂在 window 上,一并释放
layout->addWidget(input, 1);
layout->addWidget(calcBtn);
layout->addWidget(result, 1);
window.resize(560, 90);
// 点击按钮:词法分析 → 语法分析建树 → 递归解释求值(引擎与 UI 完全解耦)
QObject::connect(calcBtn, &QPushButton::clicked, &window, [=]() {
try {
Lexer lexer(input->text().toStdString()); // ① 从 QLineEdit 取字符串
Parser parser(lexer.tokenize()); // ② 建语法树(AST)
auto ast = parser.parse();
result->setText(QString::number(ast->interpret())); // ③ 递归求值
} catch (const std::exception& e) {
result->setText(QStringLiteral("错误:")
+ QString::fromUtf8(e.what())); // 语法/除零错误友好回显
}
});
window.show();
return app.exec(); // 进入事件循环(Qt 6 中也可写作 QApplication::exec())
}
CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(ExprCalculator LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) # 需要 C++17(unique_ptr、结构化思维)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON) # 本项目无 Q_OBJECT 类,此行为保险设置
find_package(Qt6 REQUIRED COMPONENTS Widgets) # Qt 5 改为 find_package(Qt5 ...)
qt_add_executable(ExprCalculator
main.cpp
expr.cpp
expr.h
)
target_link_libraries(ExprCalculator PRIVATE Qt6::Widgets)
Qt 5 迁移提示:仅需把 find_package(Qt6 ...) 与 Qt6::Widgets 换成 Qt5 对应写法,代码本身 Qt 5.15 亦可编译。运行效果:输入 3 + 5 * 2 点"= 计算",右侧显示 13;输入 (3+5)*2 显示 16;输入 3 + 这种非法串会显示中文错误提示而不是崩溃——异常被引擎抛出、被 UI 层统一接住。
以 Qt 例子里用户输入 3 + 5 * 2 并点击按钮为例,完整走一遍:
① 取文本并词法分析(字符串 → Token 流):Lexer::tokenize() 从左到右扫 "3 + 5 * 2":跳过空格,遇 3 读成 Number(3),遇 + 读成 Plus,遇 5 读成 Number(5),遇 * 读成 Star,遇 2 读成 Number(2),最后补一个 End 哨兵。得到 [Number(3), Plus, Number(5), Star, Number(2), End]。
② 语法分析(Token 流 → 语法树):Parser::parse() 调用 parseExpr()。它先 parseTerm() 吃掉 Number(3);看到 Plus,于是把 3 作为左子树,再递归 parseTerm() 吃 Number(5) Star Number(2)——parseTerm 内部看到 Star,把 5 与 2 组成 BinaryExpr(Mul, 5, 2)。回到 parseExpr,把 3 与这个乘法子树组成 BinaryExpr(Add, 3, BinaryExpr(Mul, 5, 2))。语法树诞生,根是 Add 节点(结构见第 ⑤ 节树图)。
③ 递归解释(树 → 结果):调用树根 interpret():Add 节点先问左子树"你等于几?"——NumberExpr(3) 返回 3;再问右子树——Mul 节点又分别问它的两个叶子,得到 5 与 2,算出 10 并返回。Add 节点把 3 与 10 相加,返回 13。注意执行顺序是"深度优先、先子后父",乘法天然比加法先完成,优先级不需要任何特殊判断。
④ 结果上屏与异常处理:13 经 QString::number 显示到 QLabel。若中途任何一步抛异常(如输入 3 +,parseTerm 在行尾找不到 factor 而抛"表达式位置出现意外的符号"),lambda 的 try/catch 统一捕获并把 e.what() 转成 QString 显示为"错误:……",程序不崩溃、界面不卡死。用户再输入新式子,①②③④ 重新走一遍——每次点击都是一次完整的"编译 + 执行"。
解耦发生在两个维度:一是"语言的文本表示"与"语言的对象表示"解耦——Lexer/Parser 只负责把字符串变成树,之后文本是什么样再也不重要;二是"语法树的结构"与"树上的算法"解耦——树上的算法(求值)由每个节点自己提供,但如果你想要"打印这棵树""统计节点数""把表达式翻译成 SQL",你完全可以另写一个遍历器(这正是 Day 22 访问者模式的用武之地),语法定义一行不用改。
扩展点(OCP)在哪里?对照第 ⑤ 节代码:想支持一元负号 -3,新增一个 UnaryExpr 类 + 在 parseFactor() 里加一条分支;想支持幂运算 ^,新增 TokKind::Caret 枚举 + PowerExpr 类 + 文法里加一层。已存在的 NumberExpr、BinaryExpr、Parser 的既有逻辑全部零修改——对扩展开放、对修改关闭。
依赖倒置(DIP):BinaryExpr 依赖的是抽象 Expr(成员是 unique_ptr<Expr>),它永远不需要知道子类具体是谁;Parser::parse() 返回 unique_ptr<Expr>,调用方也只面向抽象。高层策略(求值流程)与低层细节(某个数字怎么存)都稳定地钉在抽象上。
组合优于继承:表达式树是教科书级的组合结构——树枝由子树组成,叶子是终点。如果反过来用继承去表达"3+5*2"这种任意嵌套的语法,你得为每种嵌套深度建一个类,显然荒谬。组合让"无限复杂的句子"由"有限的几类节点"拼出来。
对单元测试的帮助:这是本模式被低估的优点。每个节点类可以独立测试:NumberExpr(5).interpret() == 5;BinaryExpr(Mul, 5, 2).interpret() == 10;BinaryExpr(Div, 1, 0) 抛异常;Parser 对 "1+2"、"(1+2)*3"、"1+"(应当报错)分别断言。引擎是纯函数式输入输出,不碰 UI、不碰 IO,测试又便宜又快——第 ⑥ 节的 expr.h/expr.cpp 可以直接放进测试工程。
RAII 与内存安全:整棵树的生命周期由 unique_ptr 链管理——树根析构 → 递归析构全部子孙,一次释放、绝不泄漏、绝无双重释放;解释过程全部 const,天然线程安全。这正符合现代 C++"资源随对象生命周期走"的准则。
把第 ② 节坏代码的悲剧继续演下去。假设你坚持"扫描 + 分支"路线给计算器加乘除:
// 反例:用"状态机补丁"硬撑出来的乘除支持(示意,已出现明显坏味道)
double result = 0.0, current = 0.0; // current:当前"项"的值
char op = '+', pendingHigh = '\0'; // pendingHigh:记录是否遇到 * /
bool lastWasOp = false;
for (char c : text) {
if (std::isdigit(static_cast<unsigned char>(c))) {
const double v = c - '0';
if (pendingHigh == '*') current *= v, pendingHigh = '\0';
else if (pendingHigh == '/') current /= v, pendingHigh = '\0';
else current = v;
lastWasOp = false;
} else if (c == '*' || c == '/') {
if (lastWasOp) throw ...; // 各种边界补丁开始堆叠
pendingHigh = c; lastWasOp = true;
} else if (c == '+' || c == '-') {
result += (op == '+') ? current : -current; // 只有遇到低优先级运算符
op = c; current = 0.0; lastWasOp = true; // 才"结算"当前项
}
// 还要处理括号:再引入 depth、subResult、subOp……状态变量雪崩
}
问题清单随需求线性恶化:
op、pendingHigh、depth、subResult、inFunction……每个新语法都新增两三个互相纠缠的标志位,组合爆炸式增长——这本质上是在用手工状态机模拟一棵隐形的树,而树已经被压扁成线性变量,必然漏情况。对比之下,解释器模式的树形方案里:状态就是递归的调用栈(编译器替你管理),优先级就是文法的层级,每个语法是一条独立可测的规则。哪个方案能撑到"第 50 个需求"不言自明。
✅ 适合使用(3-5 个场景):
单价 * 数量 * (1 - 折扣))、绩效考核规则、优惠券引擎——语法简单但规则由用户频繁自定义。IF 温度 > 80 THEN 报警、测试用例的步骤描述语言。规则数量可控(几十条以内)时,手写解释器比引第三方库更轻、可控性更强。level=ERROR AND time > 12:00 之类的过滤条件,解析成可执行的判定树。CMD( MOVE(10,20), DRAW ))解析成命令树再逐条执行。❌ 不适合使用(2-4 个场景):
key=value 格式?QRegularExpression 一行搞定,建 AST 是杀鸡用牛刀。3 + 5 - 2 这一种形态,第 ② 节坏代码的前两行就够用——不要为不存在的需求付费。⚠️ 过度设计提醒:解释器模式是"造语言"的重武器。判断标准很简单——你的语法规则会持续增长、且由多变的使用方(用户/业务方)驱动吗?如果只是写给自己用的两次性解析,普通解析函数即可;等第三个运算符需求真的来了,再按今天的方式重构也不迟。另外注意:解释器模式的类数量与文法规则数量成正比,"规则很多"时类会很多,这正是它"优雅但昂贵"的一面——规则超过几十条就认真考虑现成工具。
vs 组合模式(Composite,Day 8):最容易混淆的一对,因为两者都是"树 + 叶子/树枝 + 递归"。区别在目的:Composite 解决"整体-部分"的一致对待(文件和文件夹都能双击打开、都能删),树枝与叶子共享同一套操作;Interpreter 解决"语言句子"的结构化解释——树是文法的产物,每个节点类型对应一条语法规则,递归的语义是"按文法求值"。可以说解释器模式内部天然使用了组合结构(Nonterminal 持有子表达式),但组合模式本身不关心文法。实现上解释器的非终结符通常持有类型各异的子节点并各自分派,比 Composite 更"各司其职"。
vs 访问者模式(Visitor,Day 22):两者互补,常被放在一起用。interpret() 把"算法"塞在每个节点类内部(每加一种算法要改所有节点);Visitor 把算法搬进独立的访问者类(每加一种算法只新增一个 Visitor,节点零改动)。语法树结构稳定、而树上的操作频繁增加时,用 Visitor 遍历 AST 是经典组合——编译器对 AST 的类型检查、优化、代码生成往往就是一批 Visitor。反过来说,如果树的操作基本固定(就是求值),节点自带 interpret() 更简单直接。区别口诀:Interpreter 让节点"会做自己的事",Visitor 让外部"替节点做各种事"。
vs 策略模式(Strategy,Day 20):Strategy 是"算法整体替换"——同一个对象在运行时换一种算法(排序换快排还是归并);Interpreter 是"按结构组合求值"——一棵树上不同位置的同类节点执行同一规则,靠的是树的结构而不是运行时的算法切换。一个表达式 3 + 5 * 2 里的加法和乘法同时存在,这不符合 Strategy"整体替换"的心智模型;而"用加法策略还是乘法策略"如果真用 Strategy 写,会退化成每个运算符一个策略对象的过度设计。
诚实地说:Qt 没有把 GoF 解释器模式列为框架自用的核心模式,我们不为关联而强关联。但 Qt 生态里有三处"解释器思想"的工程化体现,读懂了它们,你就知道真实世界的解释器长什么样:
pcre2_compile 把模式文本编译成内部字节码(对应我们 Parser 建树),之后每次 match() 只是拿字节码去跑文本(对应 interpret)。这就是解释器思想里"先编译、后执行"的工程化——所以 Qt 文档反复强调:同一个 QRegularExpression 对象应被反复使用,避免每次匹配都重新编译。代码习惯:// Qt 正确用法:编译一次(建树),执行多次(解释)
QRegularExpression re(QStringLiteral(R"(^(\d+)\s*[+-]\s*(\d+)$)"));
if (auto m = re.match(line); m.hasMatch()) {
const int a = m.captured(1).toInt();
const int b = m.captured(2).toInt(); // 等价于"解释执行"这条匹配规则
}
QUiLoader::load() 读取设计器保存的 .ui 文件(一种描述界面的 DSL),按标签逐层创建 QWidget 子对象并设置属性,最终得到一棵活的控件树——"XML 句子 → 控件对象树"正是解释/构建思想的体现。小结:Qt 的实践给我们的启示不是"某个类用了解释器模式",而是"凡是要让程序理解一种文本语言,都值得走'解析成结构、再对结构执行'这条路"——这正是你今天学到的核心。
Q1:解释器模式的四个角色是什么?它和 AST(抽象语法树)是什么关系?
A:抽象表达式(定义 interpret 接口)、终结符表达式(叶子,直接返回自身值)、非终结符表达式(树枝,持有子表达式,interpret = 先递归子节点再组合)、上下文 Context(解释过程中的共享环境,如变量表)。AST 就是"句子对象化"的产物——由这些表达式对象拼成的树;解释器模式规定"如何在这棵树上递归求值",而"如何把文本建成树"(Parser)属于 Client 职责。
Q2:用解释器模式实现表达式求值,运算符优先级是怎么处理的?
A:不是用 if 判断"谁优先",而是把文法分层:expr(加减)→ term(乘除)→ factor(数字/括号)。解析时低层规则先被递归调用、在树中处于更深的子树位置;解释时深度优先、子节点先于父节点求值,于是乘除先于加减完成,括号则通过 factor 里递归调用 expr 实现——优先级被"编码在树的结构里",与第 ⑤ 节代码一一对应。
Q3:解释器模式有什么缺点?性能差怎么办?
A:缺点:类数量与文法规则数成正比,规则一多类就爆炸;递归虚函数调用有开销,解释执行比直接计算慢。工程对策:① 解析一次、缓存 AST 反复解释(对应 Qt 建议复用 QRegularExpression 对象);② 把 AST 编译成字节码再执行(去掉虚函数分派);③ 语法太复杂就别手写,上 ANTLR 或内嵌 QJSEngine/Lua 这类现成引擎——模式不是目的,可维护性才是。
Q4:解释器模式和访问者模式、组合模式什么关系?什么时候组合使用?
A:解释器内部天然是组合结构(非终结符持有子表达式),可以认为它"用到了"组合模式的组织方式。访问者与解释器的区别是算法放哪里:解释器把 interpret 放进节点类,节点自带行为;当 AST 结构稳定而树上的操作(类型检查、优化、代码生成)频繁增加时,用 Visitor 遍历 AST 可以做到节点零修改地加操作——编译器领域这是标配组合。
Q5:C++ 实现解释器模式时,AST 节点的内存如何管理才安全?
A:用 std::unique_ptr 表达独占所有权:非终结符以 unique_ptr<Expr> 持有子节点,Parser 用 make_unique 构建、std::move 传递。好处:树析构时自动递归释放全部节点,无泄漏、无双重释放;Expr 不可拷贝只可移动,符合"一棵树只有一个所有者"的语义;解释过程 const 化后树可安全共享。切忌裸 new + 手写递归 delete(极易漏删或双删),也不要无脑 shared_ptr(循环引用与无谓的引用计数开销)。
练习:为表达式语言扩展"函数调用"与"一元负号"(约 20-30 分钟)
基于今天的引擎(第 ⑤ 节代码),实现:
-3 + 5 应得 2,-(2 + 3) 应得 -5;sqrt(16) 得 4,max(3, 7) 得 7,支持至少两个函数;sqrt(max(9, 16)) 得 4。提示 1:文法怎么改?加一层 unary := ('-' unary) | factor,并把 factor := ... | IDENT '(' 实参列表 ')'——记住"每加一条语法规则 = 一个新节点类 + 一条解析分支",函数名本身是终结符,调用是新的非终结符(持有"函数名 + 参数表达式列表")。
提示 2:函数表放哪里?这是"环境",正是 Context 角色的职责——扩展 Context 存一张 函数名 → 函数指针/λ 的表,叶子节点(函数调用节点)通过 Context 查到函数再执行,引擎核心仍然不认识任何具体函数——这才是"语言与业务解耦"的终极形态。
自测用例:1 - -2 = 3;max(3, 2 + 8) = 10;sqrt(-4) 应报错(由函数内部检查,而不是崩溃)。
看到以下代码特征时,可以考虑解释器模式(3-5 个信号):
明日预告:🎉 GoF 23 种设计模式今天全部学完!从明天起进入第二阶段——架构模式。Day 24 将学习 MVC(Model-View-Controller):它是 Qt 的 Model/View 框架、也是几乎所有 GUI 架构(MVP/MVVM)的鼻祖。你会看到 MVC 如何把"数据、展示、交互"三权分立,以及它与今天的关系:一棵"语法树"是 Model,QLabel 是 View,按钮回调是 Controller——架构模式是多个设计模式(观察者、策略、组合)的合奏,我们之前 23 天的积累将第一次全面合体。明天见!