设计模式 · 行为型 · GoF 23/23 · GoF 收官

Day 23:解释器模式(Interpreter Pattern)

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 行意大利面条,加一个运算符就可能弄坏三个旧功能。

这段坏代码犯了哪些错?

根源在于:"语言"是一种有结构的东西,而你用没有结构的线性扫描去处理它。正确的思路是:先把"语言的结构"显式建模出来——每一种语法规则(数字、加法、乘法、括号)都是一个类,一个表达式就是这些类的对象拼成的树;然后再让这棵树自己会"算"。这就是解释器模式。

③ 核心思想:通俗解释

先看一个生活类比。CPU 怎么执行程序?它不"看懂"源代码,而是拿到一串指令字节码,一条一条机械地解释执行:这条是加法、那条是跳转。程序之所以能表现出复杂行为,是因为指令的排列组合,而不是 CPU 认识你的业务。解释器模式是同一思想的软件版:我们给程序定义一组"指令"(语法节点类),把用户的输入(一句话)翻译成这些指令拼成的树,然后让每个节点自己执行自己。语言千变万化,但"树 + 递归"的执行骨架永远不变。

再打个比方:看菜谱做菜。"西红柿炒蛋"这句菜名你没法直接执行,但菜谱把做法组织成树形结构:①准备食材(蛋、番茄)→ ②热油 → ③下蛋翻炒 → ④加番茄 → ⑤调味出锅。每一步是一个"叶子动作","步骤序列"是非终结结构。解释器模式就是:把一句"话"拆解成结构化步骤,再按结构一步步执行。

回到软件工程,抓住三句话:

工程上它带来一个意外收获:解析(文本 → 树)与解释(树 → 结果)彻底分离。同一个表达式树,你可以解释执行它,也可以遍历它做"语法检查"、"优化"、"生成别的语言代码"——树的消费者可以有无数个,而它们都不需要碰语法定义。

④ UML / 角色关系

解释器模式有四个经典角色,外加一个 Client:

结构图(注意:这是一棵"组合树",树枝持有子树的引用,整体呈递归结构):

                    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 里加一条分支,既有类零改动。

⑤ 最小 C++ 示例(C++17)

下面实现一个完整可编译的迷你计算器:支持四则运算、括号与变量(如 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 6 + C++17)

先说实话: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……状态变量雪崩
}

问题清单随需求线性恶化:

对比之下,解释器模式的树形方案里:状态就是递归的调用栈(编译器替你管理),优先级就是文法的层级,每个语法是一条独立可测的规则。哪个方案能撑到"第 50 个需求"不言自明。

⑩ 什么时候应该使用

✅ 适合使用(3-5 个场景):

❌ 不适合使用(2-4 个场景):

⚠️ 过度设计提醒:解释器模式是"造语言"的重武器。判断标准很简单——你的语法规则会持续增长、且由多变的使用方(用户/业务方)驱动吗?如果只是写给自己用的两次性解析,普通解析函数即可;等第三个运算符需求真的来了,再按今天的方式重构也不迟。另外注意:解释器模式的类数量与文法规则数量成正比,"规则很多"时类会很多,这正是它"优雅但昂贵"的一面——规则超过几十条就认真考虑现成工具。

⑪ 和其他模式的区别

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 源码 / Qt 框架中的体现

诚实地说:Qt 没有把 GoF 解释器模式列为框架自用的核心模式,我们不为关联而强关联。但 Qt 生态里有三处"解释器思想"的工程化体现,读懂了它们,你就知道真实世界的解释器长什么样:

// 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();   // 等价于"解释执行"这条匹配规则
}

小结: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 分钟)

基于今天的引擎(第 ⑤ 节代码),实现:

提示 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 天的积累将第一次全面合体。明天见!