2026-09-07 · C++17 / Qt 6 · 全文约 5000 字,阅读约 20 分钟
访问者模式(Visitor Pattern)是一种行为型设计模式:它把"施加在一组对象结构之上的某种操作/算法"从对象自身中剥离出来,封装进独立的访问者(Visitor)类中。对象结构只需提供一个 accept() 入口"放行"访问者;访问者则针对每一种具体元素类型各提供一个 visit() 重载。最终由双重分派(Double Dispatch)在运行时同时依据"元素的实际类型"与"访问者的实际类型"选定真正执行的算法分支。
核心记忆:结构打开门(accept),访客来做客(visit)——不改元素类,就能为整个对象结构不断挂载新操作。对象结构对"新操作"开放(OCP 的扩展侧),却不必为每一个新操作修改任何既有类。
它是 GoF 23 种模式中结构最精巧、理解门槛最高的几个之一,难点在于"双重分派"这个 C++ 语言并不原生支持的概念。今天我们就从坏代码出发,把它彻底拆开。
场景:一个绘图程序,元素基类 Shape,已有 Circle、Rectangle、Triangle 三个子类。需求一路演化:先要"导出 JSON",又要"导出 XML",还要"计算面积"、"碰撞检测"……请看两种典型的坏味道。
坏味道 A:把操作塞进元素类(类职责膨胀 + 反复修改稳定代码)
// 每新增一种"操作",所有元素类都要打开修改一次
class Shape {
public:
virtual ~Shape() = default;
virtual std::string toJson() const = 0; // 第一天:导出 JSON
virtual std::string toXml() const = 0; // 第二天:又要导出 XML
virtual double area() const = 0; // 第三天:还要算面积
// 第 N 天:序列化、渲染、碰撞盒……Shape 越来越臃肿
};
// Circle / Rectangle / Triangle 每个类都要同步实现所有这些方法
问题:元素类本应只表达"我是谁、我有什么数据",现在被各种操作淹没;每加一个操作就要改动所有稳定且已测试的类,改一次就多一次回归风险——直接违背开闭原则(OCP)(对扩展开放、对修改关闭)与单一职责原则(SRP)。
坏味道 B:集中式类型判断(dynamic_cast 链爆炸)——把操作做成自由函数,用运行时类型判断分派:
// 把"按具体类型分支"写在调用方:分支数量 = 元素数 × 操作数
std::string exportJson(const Shape& s) {
if (const auto* c = dynamic_cast<const Circle*>(&s))
return "{ \"kind\": \"circle\", \"r\": " + std::to_string(c->radius) + " }";
if (const auto* r = dynamic_cast<const Rect*>(&s))
return "{ \"kind\": \"rect\", \"w\": " + std::to_string(r->width) + " }";
if (const auto* t = dynamic_cast<const Triangle*>(&s))
return "{ \"kind\": \"triangle\", ... }";
// 万一漏掉未来新增的 Pentagon?编译器一声不吭,运行时才炸
throw std::runtime_error("unknown shape type");
}
问题:3 种元素 × 3 种操作,这样的函数要写 3 份、每份 3 个分支;10 种元素 × 10 种操作就是 100 个散落分支。漏掉一个分支编译器不报错、只能运行时暴露;而且"同一操作的各类型分支"被切成了一段段 if,可读性极差。这种写法还迫使调用方依赖具体子类(违反依赖倒置原则 DIP 的精神)。
两种坏味道的共同根源:算法(操作)与数据结构(元素类)被硬绑在一起。当"操作"是演化最频繁的维度、而"元素类型"相对稳定时,我们就需要一种机制,把操作从元素类中彻底解放出来——这就是访问者模式的诞生动机。
访问者模式把"操作"拟人化为一位访问者(Visitor)。它不站在门外用 dynamic_cast 偷看屋里是谁,而是按门铃——每个元素类都开着一扇统一的门 accept(Visitor&)。元素自己把门打开、把访问者请进屋,并且主动报出自己的确切类型(把 *this 递给访问者),访问者据此选择专门为这种元素准备的做法(visit(Circle&) / visit(Rectangle&))。
生活类比:体检医生到访各科室。医院组织年度体检,眼科、内科、放射科的接待方式各不相同,但每个科室都遵守同一条规则:"医生来了 → 科室接待(accept)→ 配合检查(visit)"。如果明年新增"牙科检查",需要改各科室吗?完全不用——医院只需再派一位牙科医生(新 Visitor),每个科室照旧开门接待即可。反过来,如果医院新设了一个"核磁共振科"(新元素),那所有体检医生都得学习怎么给它做检查——这正是访问者模式"元素维度封闭、操作维度开放"的代价所在,务必牢记。
技术核心:双重分派(Double Dispatch)。C++ 的虚函数只支持单分派:一次调用只按"接收者对象"的动态类型决定。而这里我们想要的效果是:一次调用同时由两个对象的动态类型决定(元素类型 × 访问者类型)。C++ 没有原生支持,访问者模式用两步拼出双重分派:
第一步:element.accept(visitor) 是虚函数调用,按元素的实际类型分派——进入 Circle::accept 还是 Rectangle::accept;第二步:在 accept 内部执行 visitor.visit(*this),此时 *this 的静态类型已经被编译器精确锁定为 Circle&(而不是基类),重载决议自动选中 visit(Circle&)——注意这里不再需要虚表,是编译期就定死的重载选择。两步合起来,等价于一次"按 (元素动态类型, 访问者动态类型) 二元组"的运行时选择。
一句话类比:元素说"我开门了,你看清楚我是谁";访问者说"好,我知道你是圆,就用对付圆的办法"。门(accept)只有一个,但屋里每个人的待客之道(visit 重载)各不相同。
类关系总览(文字版 UML):
ShapeVisitor(抽象访问者) Shape(抽象元素,被访问的结构)
├─ visit(Circle&) 纯虚 └─ accept(ShapeVisitor&) 纯虚
└─ visit(Rectangle&) 纯虚 ▲ ▲
▲ │ │
┌────┴─────┐ ┌───────────┴──┐ ┌──────┴─────────┐
│ AreaVisitor │ │ Circle │ │ Rectangle │
│ 计算面积 │ │ accept(){ │ │ accept(){ │
└────────────┘ │ v.visit(*this)}│ │ v.visit(*this)}│
└──────────────┘ └────────────────┘
ObjectStructure(对象结构/容器):持有元素集合,遍历并逐个调用 accept()
四个角色 + 一个可选角色:
| 角色 | 名字 | 职责 |
|---|---|---|
| 访问者接口 | Visitor(ShapeVisitor) | 为每一种具体元素声明一个 visit() 重载。这一组重载合起来,就是"某一种操作在所有元素类型上的分支表"。 |
| 具体访问者 | ConcreteVisitor(AreaVisitor 等) | 实现某一种操作在各类元素上的具体算法。新增一种操作 = 新增一个 ConcreteVisitor,其他类一概不动。 |
| 元素接口 | Element(Shape) | 声明 accept(Visitor&),接收访问者。 |
| 具体元素 | ConcreteElement(Circle/Rectangle) | accept 实现只有一行:v.visit(*this)。*this 的静态类型在此刻是确定的,编译器据此选中正确的 visit 重载——这是双重分派的第二跳。 |
| 对象结构(可选) | ObjectStructure | 保存元素集合,提供遍历入口(例如对每个元素调用 accept(v))。可以是容器、链表、树(此时常与组合模式/迭代器模式合租)。 |
协作过程:客户端构造一个具体访问者 → 把它交给对象结构(或自己遍历)→ 对象结构对每个元素调用 accept(visitor) → 元素回调 visitor.visit(*this) → 访问者执行针对该元素类型的算法。整个过程中,元素类完全不知道访问者具体是谁、要做什么,只知道"有人来访,我接待"。
完整可编译(C++17,单文件)。我们用两个访问者演示"不改元素类、各加一种操作":AreaVisitor 累加面积、DescribeVisitor 打印每个元素的描述。
#include <iostream>
#include <memory>
#include <vector>
class Circle; // 前向声明:访问者接口需要按具体类型声明重载
class Rectangle;
// ===== 抽象访问者:一种"操作"在每种元素上的分支表 =====
class ShapeVisitor {
public:
virtual ~ShapeVisitor() = default;
virtual void visit(Circle& c) = 0; // 每种具体元素一个重载
virtual void visit(Rectangle& r) = 0;
};
// ===== 抽象元素:唯一的对外虚函数是 accept() =====
class Shape {
public:
virtual ~Shape() = default;
virtual void accept(ShapeVisitor& v) = 0; // "开门迎客"
};
// ===== 具体元素:accept 的实现永远只有一行 =====
class Circle : public Shape {
public:
explicit Circle(double r) : radius(r) {}
double radius; // 公开以便访问者读取(示例简化;真实工程可用 getter 或 friend)
void accept(ShapeVisitor& v) override {
v.visit(*this); // 第一跳:虚分派进入这里;
// 第二跳:*this 静态类型是 Circle&,编译器选中 visit(Circle&)
}
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width(w), height(h) {}
double width, height;
void accept(ShapeVisitor& v) override { v.visit(*this); }
};
// ===== 具体访问者 1:计算面积(累加到 total)=====
class AreaVisitor : public ShapeVisitor {
public:
double total = 0.0;
void visit(Circle& c) override {
total += 3.141592653589793 * c.radius * c.radius;
}
void visit(Rectangle& r) override {
total += r.width * r.height;
}
};
// ===== 具体访问者 2:打印描述 =====
class DescribeVisitor : public ShapeVisitor {
public:
void visit(Circle& c) override {
std::cout << "Circle(r=" << c.radius << ")" << std::endl;
}
void visit(Rectangle& r) override {
std::cout << "Rectangle(w=" << r.width << ", h=" << r.height << ")" << std::endl;
}
};
int main() {
// ObjectStructure:用智能指针持有异构元素集合,杜绝裸指针与泄漏
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(std::make_unique<Circle>(1.0));
shapes.emplace_back(std::make_unique<Rectangle>(2.0, 3.0));
shapes.emplace_back(std::make_unique<Circle>(2.0));
AreaVisitor area; // 操作 1:算面积
DescribeVisitor desc; // 操作 2:打印描述
// 统一遍历:元素对"谁来访问、做什么"毫不知情
for (const auto& s : shapes) {
s->accept(area); // 双重分派:进入 visit(Circle&) 或 visit(Rectangle&)
s->accept(desc);
}
std::cout << "总面积 = " << area.total << std::endl;
return 0;
}
注意 DescribeVisitor 中 visit 的两个重载签名必须是 Circle& / Rectangle&(引用类型),重载决议靠的就是引用所绑定的静态类型。若写成 Shape& 则双重分派失效。
程序输出:
Circle(r=1)
Rectangle(w=2, h=3)
Circle(r=2)
总面积 = 21.708
面积核对:π×1² + 2×3 + π×2² = π + 6 + 4π = 5π + 6 ≈ 21.708。现在想加第三种操作(比如导出 SVG)?写一个 SvgVisitor : ShapeVisitor 即可,Shape/Circle/Rectangle 一个字节都不用改。
场景:一个小型"矢量场景"。我们把两种图元(圆、矩形)做成元素,用访问者挂载两种操作:DrawVisitor 把图元画进 QImage(模拟渲染后端),StatsVisitor 统计数量与总面积。今后要新增"导出 SVG"只需再写一个 Visitor,图元类零改动——这正是渲染器/导出器架构的常见形态。
说明:这里刻意使用自定义轻量元素层级而不是 QGraphicsItem 家族,是为了让"访问者"结构纤毫毕现(QGraphicsItem 的 type()/qgraphicsitem_cast 是 Qt 官方的类型分派工具,见第 ⑫ 节)。
shapes.h(元素与访问者接口)
#pragma once
#include <QPointF>
#include <QRectF>
class CircleShape; // 前向声明,供访问者接口声明重载
class RectShape;
// 抽象访问者:一种"渲染/统计操作"在每种图元上的分支表
class ShapeVisitor {
public:
virtual ~ShapeVisitor() = default;
virtual void visit(CircleShape& c) = 0;
virtual void visit(RectShape& r) = 0;
};
// 抽象元素
class Shape {
public:
virtual ~Shape() = default;
virtual void accept(ShapeVisitor& v) = 0; // 开门迎客
virtual double area() const = 0; // 内禀属性,不属于任何访问者
};
// 圆图元
class CircleShape : public Shape {
public:
explicit CircleShape(QPointF c, double r) : center(c), radius(r) {}
QPointF center;
double radius;
double area() const override { return 3.141592653589793 * radius * radius; }
void accept(ShapeVisitor& v) override { v.visit(*this); }
};
// 矩形图元
class RectShape : public Shape {
public:
explicit RectShape(QRectF r) : rect(r) {}
QRectF rect;
double area() const override { return rect.width() * rect.height(); }
void accept(ShapeVisitor& v) override { v.visit(*this); }
};
main.cpp(具体访问者 + 主流程)
#include <QGuiApplication>
#include <QImage>
#include <QPainter>
#include <QDebug>
#include <memory>
#include <vector>
#include "shapes.h"
// 具体访问者 1:绘制——把每种图元画到 QPainter 上
class DrawVisitor : public ShapeVisitor {
public:
explicit DrawVisitor(QPainter& p) : painter(p) {}
void visit(CircleShape& c) override {
painter.setBrush(Qt::yellow);
painter.drawEllipse(c.center, c.radius, c.radius); // 画圆
}
void visit(RectShape& r) override {
painter.setBrush(Qt::cyan);
painter.drawRect(r.rect); // 画矩形
}
private:
QPainter& painter; // 引用成员:访问者生命周期内 painter 必须存活
};
// 具体访问者 2:统计——无需触碰图元类即可新增"操作"
class StatsVisitor : public ShapeVisitor {
public:
int circles = 0, rects = 0;
double totalArea = 0.0;
void visit(CircleShape& c) override { ++circles; totalArea += c.area(); }
void visit(RectShape& r) override { ++rects; totalArea += r.area(); }
};
int main(int argc, char* argv[]) {
QGuiApplication app(argc, argv);
// ObjectStructure:异构图元集合
std::vector<std::unique_ptr<Shape>> scene;
scene.emplace_back(std::make_unique<CircleShape>(QPointF(60, 60), 40));
scene.emplace_back(std::make_unique<RectShape>(QRectF(130, 40, 90, 60)));
scene.emplace_back(std::make_unique<CircleShape>(QPointF(250, 90), 25));
// 离屏画布:把"绘制"与"统计"两个操作同时挂到同一组元素上
QImage image(320, 160, QImage::Format_ARGB32_Premultiplied);
image.fill(Qt::white);
QPainter painter(&image);
painter.setRenderHint(QPainter::Antialiasing);
DrawVisitor draw(painter);
StatsVisitor stats;
for (const auto& s : scene) {
s->accept(draw); // 双重分派:元素类型 × 访问者类型 共同决定绘制分支
s->accept(stats);
}
painter.end(); // 显式收尾,之后才能安全使用 image
const bool ok = image.save("/tmp/visitor_demo.png");
qDebug() << "PNG saved:" << ok
<< "| circles:" << stats.circles
<< "rects:" << stats.rects
<< "| totalArea:" << stats.totalArea;
return ok ? 0 : 1;
}
CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(visitor_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Gui)
add_executable(visitor_demo main.cpp shapes.h)
target_link_libraries(visitor_demo PRIVATE Qt6::Gui)
构建与运行(Qt 6 环境):
cmake -S . -B build && cmake --build build -j
./build/visitor_demo
# 期望输出:
# PNG saved: true | circles: 2 rects: 1 | totalArea: 12391.1
# (并生成 /tmp/visitor_demo.png 可供查看)
验证面积:π×40² + 90×60 + π×25² = 5026.55 + 5400 + 1963.50 ≈ 12390.05(qDebug 浮点打印可能与手算末位有微小差异,属正常)。扩展点:想加"导出 SVG"?新建 SvgVisitor,把每种图元输出成 SVG 标签字符串,主循环一行不用改。
以第 ⑤ 节最小示例的主循环为例,逐步拆解一次 accept 调用内部发生了什么(以第一个元素 Circle(1.0) + AreaVisitor 为例):
步骤 1:构造对象结构与访问者。main() 用 unique_ptr 创建三个 Shape 存入 vector,并创建 AreaVisitor area 与 DescribeVisitor desc。此刻没有任何类型判断代码。
步骤 2:遍历开始,调用 accept(第一重分派:按元素动态类型)。循环执行 s->accept(area)。s 的静态类型是 unique_ptr<Shape>,但虚表把调用分派到实际对象 Circle::accept。若元素是 Rectangle,则进入 Rectangle::accept——元素"自己选择接待方式"。
步骤 3:accept 内部回调 visit(第二重分派:按元素的静态类型做重载决议)。Circle::accept 执行 v.visit(*this)。注意此处 *this 的静态类型就是 Circle&(不是 Shape&),编译器在编译期就锁定 visit(Circle&) 这个重载——不查虚表、不靠 RTTI,零运行时开销地完成了"识别出具体类型"。
步骤 4:访问者执行对应分支。进入 AreaVisitor::visit(Circle&),把 πr² 累加进 total。同理,换成 desc 访问时进入 DescribeVisitor::visit(Circle&),打印描述。也就是说,同一个元素先后接待了两位"客人",各自做了不同的事。
步骤 5:循环推进。处理完 Circle 后,迭代器移到 Rectangle(2,3)、Circle(2),重复步骤 2~4。三次 accept 覆盖了两种元素的全部组合。
步骤 6:汇总输出。遍历结束,area.total 携带聚合结果,打印 总面积 = 21.708。整个流程中,元素类只做了"开门"这一件事,操作全部发生在访问者内部。
把 (元素类型 × 访问者类型) 看作一张二维表:行是 Circle/Rectangle,列是 AreaVisitor/DescribeVisitor。虚分派选行(第一跳),重载决议选列(第二跳),四个格子里各放着一个 visit 实现体——这就是双重分派的完整图景。
解耦点:操作与结构彻底分离。访问者模式把变化最频繁的"操作"放进独立的访问者类族,把稳定的"元素结构"留在原处,二者只通过一对窄接口(accept / visit)协作。元素不必知道世界上存在多少种操作,访问者也不必继承元素——耦合度降到最低。
扩展点 1:操作维度开放(OCP 的"对扩展开放")。新增一种操作 = 新增一个 ConcreteVisitor 子类 + 在对象结构上跑一遍,所有元素类、所有既有访问者零改动。这是本模式最大的价值:编译器/AST、渲染导出、报表统计这类"结构万年不变、需求月月翻新"的场景,扩展成本被压到"只写一个新类"。
扩展点 2:相关行为被聚合。同一操作的各类型分支(visit(Circle&) / visit(Rectangle&))集中在一个类里,而不是被类边界切开散落在 Circle::xxx、Rectangle::xxx 中。读代码时"看一个类 = 看懂一种操作的全部",改口径(比如面积统一改用 double 精度)只动一个文件。
职责分配(SRP):元素类只回答"我是谁、我有什么数据"(属性 + 少量内禀方法如 area());"拿这些数据做什么"(导出、绘制、统计、校验)全部外置给访问者。每个类都只因为一个理由而改变。
依赖倒置(DIP):元素依赖抽象的 ShapeVisitor,客户端面向 Shape / ShapeVisitor 抽象编程;具体访问者通过基类指针传递,运行时才绑定——两条继承层级之间没有横跨的强依赖。
组合优于继承:想给结构加操作时,继承路线意味着给每个元素类开新方法、或派生"带导出功能的 Circle"(类爆炸);组合路线只要求元素有 accept 这一个钩子,操作以对象身份"插"进来。访问者本质上是把"按类型分派的算法"做成了一等公民对象,可复用、可组合、可单独测试。
可测试性:每个 ConcreteVisitor 可独立单元测试(喂构造好的元素、断言聚合结果);遍历逻辑全结构只有一份,行为可预测。相比散落各处的 if-else 链,测试面小得多。
诚实的代价:访问者通常要读取元素内部数据(示例里 radius/width 被迫公开),这会撬开封装,要么公开 getter、要么用 friend;同时"元素维度"被锁死——新增元素类型必须改动所有访问者(见第 ⑩ 节)。所以它适合"元素封闭、操作开放"的结构,不适合两边都在变的结构。
反例 1:元素类被操作淹没。绘图程序的 Shape 最终长出 toJson / toXml / toSvg / serialize / render / hitTest / clone……十几个方法,每个方法在 Circle/Rectangle/Triangle 里各实现一遍。类不再是"几何元素的抽象",而成了"所有需求的大杂烩"。每加一个操作就要打开 N 个类,每改一次都可能碰坏已经稳定的绘制逻辑——回归风险随操作数量线性上升,最终没人敢动这些类,只能继续往上堆方法。
反例 2:dynamic_cast / typeid 的 if-else 链爆炸。M 种元素 × N 种操作 = M×N 个手工分支,分散在 N 个函数里。编译器无法检查分支是否齐全:漏掉新元素的分支,编译照过、测试漏网,生产环境里静默返回错误结果或抛异常。而且 dynamic_cast 链是 O(类型数) 的顺序比较,类型一多还有性能损耗。更糟的是每个操作函数都要 import 全部具体类型的头文件,编译依赖图迅速膨胀。
反例 3:同一操作的代码被类边界撕碎。不用访问者时,"计算面积"会以 Circle::area、Rectangle::area、Triangle::area 的形式散落在三个类文件里。想统一修改计费口径(比如面积要四舍五入到两位小数),你得跨三个文件改三处,漏一处就是隐蔽的 bug——"同一件事"的代码失去了内聚性。
本质后果:当"操作"是主变化轴时,不用访问者就只有两条路——改稳定的元素结构(违反 OCP),或在调用方堆类型判断(违反 DIP 且不可维护)。两条路都会让系统在需求迭代中加速腐化。
适合使用的场景(3~5 个):
1. 对象结构类型稳定、操作频繁演化——访问者的"主场"。最经典的例子是编译器/解释器:AST 节点类型(数字、二元运算、变量……)几乎不变,但语义分析、类型检查、代码生成、打印、优化各是一路 Visitor,几十种操作全靠挂新 Visitor 实现。
2. 同一结构需要大量互不相关的操作,且想按"操作"聚合代码而不是按元素分散。例如文档模型上的渲染、拼版、字数统计、导出 PDF。
3. 元素类来自第三方库、不能(或不想)修改,但仍需按具体类型做跨类处理。给元素包一层"可被访问"的门面,或把访问逻辑建立在公开接口之上。
4. 代码里出现失控的 dynamic_cast / typeid 判断链(结构封闭时),用访问者把分支交给编译器与虚表,顺带消灭漏分支隐患。
5. 需要按 (元素类型 × 操作类型) 做运行时二元分派,且希望编译器帮你保证每种元素都有对应处理(visit 是纯虚函数,漏实现直接编译失败)。
不适合的场景(2~4 个):
1. 元素类型频繁新增——每加一种元素,所有 ConcreteVisitor 都必须补一个 visit 重载,否则编译失败。此时访问者把"改动的痛苦"从元素侧转嫁到了访问者侧,反而破坏了 OCP。这种结构请改用普通虚函数或 Acyclic Visitor(用 dynamic_cast 兜底、允许跳过不关心的类型)。
2. 元素内部状态高度封装且不愿破例——访问者要工作就得能读到元素数据;若不想公开 getter、不想加 friend,访问者寸步难行。
3. 操作固定、元素类型很多——直接用虚函数就是最简答案,访问者纯属绕远路。
4. 行为本来就内聚于对象(元素自己知道怎么画自己、怎么算自己)——paint()/area() 这类"元素的本分",不要外置。
过度设计提醒:总共两三种元素、操作也就两三个、几个月才加一次需求——老老实实写虚函数或 if 就够了。另外 C++17 起,类型封闭的场景可以优先考虑 std::variant + std::visit:它把"访问者"变成可调用对象、把"元素"变成值语义的 variant 备选类型,编译器强制穷尽处理所有类型且没有继承与虚表开销;代价是类型集完全封闭、递归结构(如树)需要额外包装。一句话:继承层级开放用 Visitor,类型封闭爱值语义用 std::visit,两三个类型就别用模式。
访问者最容易被拿来和下面几个模式比较:
| 对比模式 | 相似点 | 关键区别 |
|---|---|---|
| 策略模式(Strategy) | 都把算法外置成独立对象,可插拔、可替换 | 策略只有一个维度:同一个对象换算法(运行时换排序方式)。访问者是两个维度:元素类型 × 操作类型都要参与分派——策略的 execute 只收一个参数且不按"被作用对象的类型"重载;访问者的 visit 按元素具体类型重载。可以说访问者 = "策略 + 按元素类型的多态分派"。 |
| 命令模式(Command) | 都是"把行为对象化" | 命令是 1 个命令 ↔ 1 个动作 ↔ 1 个接收者,重点是封装请求以便排队、撤销、记录日志;访问者是 1 个访问者 ↔ 一族动作 ↔ N 种元素,重点是跨类型分派与在不改元素的前提下加操作。 |
| 迭代器模式(Iterator)/ 组合模式(Composite) | 都经常与访问者搭档出现 | 迭代器解决"如何遍历、如何屏蔽内部结构";组合解决"整体与部分一致对待"。访问者解决"遍历到每个元素之后做什么"——三者是流水线关系:组合组织结构 → 迭代器负责游走 → 访问者落地操作。 |
| 模板方法模式(Template Method) | 都定义"算法框架 + 可替换步骤" | 模板方法把骨架写在基类内部,靠继承让子类填空(对类内扩展);访问者在类外部附加操作,靠组合让访问者对象落地(对结构外扩展)。一个是"父类导演、子类演戏",一个是"结构搭台、访客唱戏"。 |
记忆锚点:策略换算法不换对象,命令包动作不包类型,迭代器管走不管做,访问者按类型分派做全套。
先说结论:Qt 核心几乎不使用经典 GoF 访问者模式——它的类层级很深、且高度依赖虚函数 + 信号槽 + 枚举,多数"按类型集中处理"的场合 Qt 选择了"枚举 + 集中 switch"而非"类型层级 + visit 重载"。但读懂访问者,能让你看懂 Qt 三处设计的用意:
1. QStyle 体系——最接近访问者思想的部分。QStyle 把所有控件绘制操作集中到一个抽象类里:
// QStyle 的绘制入口:元素用"枚举"表达,数据装在 QStyleOption 里
void QCommonStyle::drawControl(ControlElement element,
const QStyleOption* option,
QPainter* painter,
const QWidget* widget = nullptr) const;
// 具体元素数据用 qstyleoption_cast 做类型安全下转(类似 qobject_cast)
对照访问者:QStyle 扮演"访问者"(可整体替换成子类换肤),QStyleOption 及其子类扮演"被访问的元素数据",ControlElement 枚举是"元素类型表"。Qt 没有让每种控件都成为一个带 visit 的 Element 层级,而是用 枚举 + 集中实现达到同样的"结构稳定、绘制操作可整体替换"目标——当"类型集"用枚举表达时,集中 switch 往往比 visitor 更轻,这是 Qt 给我们的重要一课。
2. QEvent 的事件类型分派。QObject::event(QEvent*) 内部按 event->type() 做类型分发,把事件送到 mousePressEvent()、keyPressEvent() 等处理函数。这正是"按类型集中分派"的样板,而访问者模式想消灭的正是手工类型分支——对照可见:Qt 的事件类型集是开放但集中管理的枚举,新增事件类型要改 event() 的 switch,这与 visitor "新增元素要改所有访问者"的代价是同构的。想给任意 QObject 追加事件处理而不改其类?installEventFilter() 提供的"外部观察者"通道,思路与访问者"把操作外置"一脉相承。
3. QDomNode 的手工 downcast 工具。Qt 的 DOM 接口没有给每种节点做 accept/visit,而是提供类型询问 + 下行转换:
QDomNode node = doc.firstChild();
if (node.isElement()) { // 运行时类型询问
QDomElement e = node.toElement(); // 手工 downcast
} else if (node.isText()) {
QDomText t = node.toText();
}
如果你写过 QDomNode 遍历,一定体会过这种 isXxx/toXxx 链的笨重与漏分支风险——这正是访问者(或 C++17 的 std::variant)想要替代的代码形态。同理,QGraphicsItem 家族提供 type() + qgraphicsitem_cast<T>() 作为不依赖 RTTI 的类型分派工具,本质都是"元素类型分派"的基础设施。
给你的工程启示:继承层级开放、操作常新 → 上访问者;类型是枚举、逻辑集中可控 → 学 Qt 用 switch;类型封闭又想要值语义与编译期穷尽检查 → std::variant + std::visit。三种武器各管一段,别迷信其中之一。
Q1:什么是双重分派?C++ 中如何实现访问者的双重分派?
A:普通虚函数是单分派——只按接收者的动态类型选实现。双重分派要求"一次调用同时由两个对象的动态类型决定"。C++ 没有原生支持,访问者用两步拼成:第一步 element.accept(visitor) 走虚表,按元素实际类型进入对应 accept(如 Circle::accept);第二步 accept 内执行 visitor.visit(*this),此时 *this 的静态类型已被锁定为 Circle&,编译器通过重载决议直接选中 visit(Circle&)——不依赖虚表和 RTTI。两层合起来等价于按 (元素类型, 访问者类型) 二元组选实现。
Q2:访问者模式如何体现开闭原则?它真的两头都开放吗?
A:只对"操作"维度开放:新增操作 = 新增 ConcreteVisitor,元素结构零改动,符合"对扩展开放、对修改关闭"。但它对"元素"维度是关闭的:新增元素类型必须给所有既有访问者补 visit 重载,否则编译失败。所以使用前先问自己:哪个维度在变?操作常变、类型稳定 → 访问者划算;类型常变 → 别用。
Q3:访问者模式的主要缺点有哪些?
A:① 新增元素类型要改动全部访问者(元素维度不开放);② 访问者通常需要读取元素内部数据,倒逼公开 getter 或 friend,破坏封装;③ 访问者接口与元素类之间可能形成循环依赖,头文件管理变复杂;④ 类数量增加(每种操作一个类),小项目显臃肿。缓解手段:Acyclic Visitor(允许访问者只实现关心的类型)、公开只读接口、或改用 std::variant + std::visit。
Q4:访问者模式 vs dynamic_cast + if-else 链,怎么选?
A:if-else 链直白、零额外类,但漏分支编译器不报错(运行时才炸)、分支随类型数线性膨胀、且操作函数要依赖所有具体类型的头文件。访问者把"分支"交给虚表和重载决议:visit 是纯虚函数,漏实现直接编译失败;新增操作不动既有代码。取舍:元素和操作都少 → if 链务实;操作维度会持续演化 → 访问者;类型封闭且想要编译期穷尽检查 → std::visit。
Q5:std::variant + std::visit 和访问者模式是什么关系?
A:std::visit 是访问者思想的"值语义 + 编译期"变体:variant 备选类型即封闭的元素集,可调用对象即访问者,编译器强制 visit 的调用对象能处理全部类型(穷尽性),且无继承、无虚表、无堆分配。代价:类型集完全封闭、不能运行时扩展子类;处理递归结构(表达式树)需要间接层(如 unique_ptr<Node> + 递归 variant 包装),代码比继承版绕。加分回答:C++17 之后面试官通常期待你主动对比这两条路,并说出"类型开放用 GoF Visitor,类型封闭优先 std::visit"的判据。
需求:迷你四则运算表达式引擎。定义两种节点:数字字面量 NumberExpr(double value) 与二元运算 BinaryExpr(char op, 左子表达式, 右子表达式)(op 支持 + - * /),用 unique_ptr<Expr> 持有子树,构成一棵表达式树。请用经典访问者模式实现两个操作:PrintVisitor(输出中缀表达式字符串,例如输入 (1+2)*3 结构输出 "(1+2)*3")与 EvalVisitor(返回 double 求值结果)。
提示 1:BinaryExpr::accept 里要先让左右子树 accept 同一个 visitor,再 v.visit(*this)——递归结构靠"先下后上"完成整树遍历;除法记得处理除零(返回 inf 或抛异常并说明理由)。
提示 2:括号策略可简化——BinaryExpr 打印时总是给左右子表达式加括号,保证输出无歧义;EvalVisitor 维护一个 double 栈或在 visit 中返回累加值(可用成员变量暂存"最近一次求值结果",因为 visit 返回 void)。
进阶题:再用 std::variant + std::visit 重写一遍(需要处理递归 variant 的间接层),对比两种写法的代码量、扩展体验与编译期保证。完成后自问:再加一种操作(比如统计节点数)时,两条路各要改几个文件?
代码特征信号(看到这些,说明访问者正在/应该被使用):
1. 元素类里出现 accept(Visitor&),实现体只有一行 v.visit(*this);
2. 访问者接口上有一组按具体元素类型重载的 visit(ConcreteElement&);
3. 元素类型集合长期封闭,而新需求总是"再导出一种格式 / 再统计一项指标";
4. 新功能以"新增一个 Visitor 子类"落地,而不是打开既有元素类加方法;
5. 结构遍历与操作分离:主循环里只有 accept 调用,看不到任何 dynamic_cast / typeid 判断。
明日预告:Day 23 解释器模式(Interpreter Pattern)——用类表达文法规则、把"语言"变成可执行的对象树。它是 GoF 23 种模式的收官之作,也与今天 AST + 访问者的组合一脉相承。学完 Day 23,GoF 全部 23 种设计模式即告通关,之后将进入架构模式阶段(MVC / MVVM / EventBus / Plugin / QUndo / StateMachine / Model-View 等)。明天见!