各位同仁,各位编程爱好者,大家好!
今天,我们将深入探讨 C++ 中一个强大而优雅的设计模式——访问者模式(Visitor Pattern),并将其与 C++17 引入的现代语言特性 std::variant 和 std::visit 结合,实现一种类型安全、高效且更具现代 C++ 风格的双分派机制。
1. 访问者模式的本质与传统实现回顾
1.1 什么是双分派?
在深入访问者模式之前,我们首先要理解“分派”(Dispatch)的概念。在面向对象编程中,当我们调用一个虚函数时,具体的函数实现是在运行时根据对象的实际类型来确定的,这被称为“单分派”(Single Dispatch)。例如:
class Base {
public:
virtual void foo() { std::cout << "Base::foo()n"; }
};
class Derived : public Base {
public:
void foo() override { std::cout << "Derived::foo()n"; }
};
int main() {
Base* b = new Derived();
b->foo(); // 运行时决定调用 Derived::foo()
delete b;
return 0;
}
然而,有时我们需要根据两个对象的运行时类型来决定调用哪个函数。这种根据两个(或更多)对象的动态类型来选择执行哪个操作的机制,被称为“双分派”(Double Dispatch)。C++ 本身并没有原生支持双分派,但访问者模式正是解决这个问题的经典方案。
1.2 访问者模式解决的核心问题
访问者模式旨在解决在不修改现有对象结构的前提下,为对象结构中的元素添加新操作的问题。它将一个操作从它所作用的对象结构中分离出来,使得操作可以独立于对象结构而变化。
想象一个图形绘制系统,我们有 Circle、Square、Triangle 等多种图形。我们可能需要对这些图形执行多种操作,例如绘制(draw)、序列化(serialize)、计算面积(calculate_area)等。
如果我们将所有操作都作为虚函数放入 Shape 及其派生类中:
// 缺点:每次添加新操作,都需要修改所有Shape派生类
class Shape {
public:
virtual void draw() = 0;
virtual void serialize() = 0;
virtual double calculate_area() = 0;
// ... 更多操作
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
void draw() override { /* 绘制圆形 */ }
void serialize() override { /* 序列化圆形 */ }
double calculate_area() override { /* 计算圆形面积 */ }
};
// ... Square, Triangle 类似
这种做法的缺点是显而易见的:每当需要添加一个新的操作时(例如,rotate),我们都必须修改 Shape 接口以及所有具体的图形类,这违反了开放/封闭原则(Open/Closed Principle)——对扩展开放,对修改封闭。
1.3 传统访问者模式的实现
传统的访问者模式通过引入两个主要角色来解决这个问题:
- 元素(Element):代表对象结构中的各个元素(例如
Shape及其派生类)。它们定义一个accept方法,该方法接收一个访问者对象作为参数。 - 访问者(Visitor):定义一个接口,声明一组
visit方法,每个方法对应一种元素类型。具体访问者类实现这些visit方法,以执行针对特定元素的操作。
传统实现示例:
#include <iostream>
#include <vector>
#include <memory> // For std::unique_ptr
// 前向声明 Visitor
class ShapeVisitor;
// 1. 元素接口 (Element)
class Shape {
public:
virtual void accept(ShapeVisitor& visitor) = 0;
virtual ~Shape() = default;
};
// 2. 具体元素 (Concrete Element)
class Circle : public Shape {
public:
double radius;
Circle(double r) : radius(r) {}
void accept(ShapeVisitor& visitor) override; // 实现在下方
};
class Square : public Shape {
public:
double side;
Square(double s) : side(s) {}
void accept(ShapeVisitor& visitor) override; // 实现在下方
};
// 3. 访问者接口 (Visitor)
class ShapeVisitor {
public:
virtual void visit(Circle& circle) = 0;
virtual void visit(Square& square) = 0;
virtual ~ShapeVisitor() = default;
};
// 实现 Element 的 accept 方法
void Circle::accept(ShapeVisitor& visitor) {
visitor.visit(*this);
}
void Square::accept(ShapeVisitor& visitor) {
visitor.visit(*this);
}
// 4. 具体访问者 (Concrete Visitor)
class DrawVisitor : public ShapeVisitor {
public:
void visit(Circle& circle) override {
std::cout << "Drawing Circle with radius " << circle.radius << std::endl;
}
void visit(Square& square) override {
std::cout << "Drawing Square with side " << square.side << std::endl;
}
};
class SerializeVisitor : public ShapeVisitor {
public:
void visit(Circle& circle) override {
std::cout << "Serializing Circle: { type: "Circle", radius: " << circle.radius << " }" << std::endl;
}
void visit(Square& square) override {
std::cout << "Serializing Square: { type: "Square", side: " << square.side << " }" << std::endl;
}
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Square>(10.0));
DrawVisitor draw_visitor;
SerializeVisitor serialize_visitor;
std::cout << "--- Drawing Shapes ---n";
for (const auto& shape : shapes) {
shape->accept(draw_visitor);
}
std::cout << "n--- Serializing Shapes ---n";
for (const auto& shape : shapes) {
shape->accept(serialize_visitor);
}
return 0;
}
运行结果:
--- Drawing Shapes ---
Drawing Circle with radius 5
Drawing Square with side 10
--- Serializing Shapes ---
Serializing Circle: { type: "Circle", radius: 5 }
Serializing Square: { type: "Square", side: 10 }
传统访问者模式的工作原理是双分派:
- 第一次分派:
shape->accept(visitor)。这里accept是Shape接口的一个虚函数,它根据shape的运行时类型(Circle或Square)决定调用哪个accept实现。 - 第二次分派:在
Circle::accept或Square::accept内部,调用visitor.visit(*this)。这里的visit是ShapeVisitor接口的一个虚函数,它根据visitor的运行时类型(DrawVisitor或SerializeVisitor)以及传入的*this(其静态类型已知为Circle或Square)决定调用哪个visit重载。
通过这两次分派,我们成功地根据元素类型和访问者类型这两个运行时信息,决定了最终执行的特定操作。
1.4 传统访问者模式的优缺点
优点:
- 易于添加新操作: 如果需要添加一个新的操作(例如,计算周长),只需创建新的具体访问者类,而无需修改现有的元素类。这符合开放/封闭原则。
- 将操作与对象结构分离: 使得元素类专注于其数据和基本行为,而操作逻辑则集中在访问者类中。
- 跨越多个异构类进行操作: 访问者模式特别适合对一个复杂对象结构(如 AST、图结构)中的不同类型节点执行统一的操作。
缺点:
- 添加新元素类型困难: 如果要添加一个新的具体元素类型(例如
Triangle),则必须修改Shape接口,并修改所有现有的具体访问者类,为其添加一个新的visit方法。这违反了开放/封闭原则。 - 引入额外的复杂性: 需要定义访问者接口、具体访问者类,以及元素接口中的
accept方法,增加了代码的复杂性和样板代码。 - 元素类需要暴露内部状态: 为了让访问者能够执行操作,元素类有时需要通过公共方法暴露其内部状态,可能破坏封装性。
- 依赖动态多态和指针: 通常需要使用基类指针或引用,涉及虚函数调用和可能的堆内存分配,可能对性能和缓存局部性有一定影响。
这些缺点,尤其是在添加新元素类型时的困难,使得传统访问者模式在某些场景下显得不够灵活。幸运的是,C++17 提供的 std::variant 和 std::visit 提供了一种现代的、更类型安全且通常更高效的替代方案。
2. std::variant:类型安全的联合体
在 C++17 之前,如果我们需要一个变量能够存储多种不同类型的值,最常见的选择是 union。然而,union 是不安全的,因为它不记录当前存储的是哪种类型,需要程序员手动管理类型标签。
std::variant 是 C++17 引入的一个类型安全的、判别式联合体(discriminated union)。它可以在任何给定时间点存储其模板参数列表中指定类型中的一个值。
2.1 std::variant 的基本使用
#include <variant>
#include <string>
#include <iostream>
int main() {
// 定义一个可以存储 int, double 或 std::string 的 variant
std::variant<int, double, std::string> v;
// 存储一个 int
v = 42;
std::cout << "v holds int: " << std::get<int>(v) << std::endl; // 通过类型访问
std::cout << "v holds index: " << v.index() << std::endl; // 0 (第一个类型)
// 存储一个 double
v = 3.14;
std::cout << "v holds double: " << std::get<double>(v) << std::endl;
std::cout << "v holds index: " << v.index() << std::endl; // 1 (第二个类型)
// 存储一个 std::string
v = "Hello, Variant!";
std::cout << "v holds string: " << std::get<std::string>(v) << std::endl;
std::cout << "v holds index: " << v.index() << std::endl; // 2 (第三个类型)
// 尝试获取错误的类型会抛出 std::bad_variant_access 异常
try {
std::get<int>(v);
} catch (const std::bad_variant_access& e) {
std::cerr << "Error: " << e.what() << std::endl;
}
// 检查当前存储的类型
if (std::holds_alternative<std::string>(v)) {
std::cout << "v currently holds a string.n";
}
// 通过索引访问 (需要明确知道类型)
std::cout << "v at index 2 (string): " << std::get<2>(v) << std::endl;
// std::get_if 用于安全地获取指针
int* p_int = std::get_if<int>(&v);
if (p_int) {
std::cout << "Got int pointer: " << *p_int << std::endl;
} else {
std::cout << "v does not hold an int.n";
}
return 0;
}
运行结果:
v holds int: 42
v holds index: 0
v holds double: 3.14
v holds index: 1
v holds string: Hello, Variant!
v holds index: 2
Error: std::get: wrong index for variant
v currently holds a string.
v at index 2 (string): Hello, Variant!
v does not hold an int.
std::variant 的特点:
- 类型安全: 它知道当前存储的是哪种类型,并提供安全的访问机制(如
std::get、std::holds_alternative、std::get_if)。 - 值语义:
std::variant存储的是实际的值,而不是指针或引用。这意味着它通常分配在栈上或嵌入在其他对象中,具有更好的缓存局部性。 - 编译时已知类型集合:
std::variant的类型列表在编译时是固定的。一旦定义,就不能在运行时添加新的类型。 - 没有继承关系:
std::variant内部的类型之间不需要有任何继承关系。这使得它非常灵活,可以组合完全不相关的类型。
3. std::visit:对 std::variant 进行操作
有了 std::variant 存储异构类型,我们如何以类型安全的方式,对其中存储的当前活跃类型执行操作呢?std::visit 应运而生。
std::visit 是一个函数模板,它接受一个可调用对象(通常是一个函数对象、lambda 表达式或函数指针)和一到多个 std::variant 对象。它会根据 std::variant 中当前存储的类型,调用可调用对象中与之匹配的重载函数。
3.1 std::visit 的基本使用与 overloaded 辅助结构
std::visit 的核心在于重载解析。它会遍历 std::variant 中的所有类型,然后尝试在可调用对象中找到一个能够接受当前活跃类型的 operator() 或函数重载。
最常见的用法是结合 Lambda 表达式:
#include <variant>
#include <string>
#include <iostream>
int main() {
std::variant<int, double, std::string> v = "Hello";
// 使用 lambda 表达式作为访问者
std::visit([](auto&& arg) {
// 这是通用的 lambda,可以处理任何类型
std::cout << "Value: " << arg << std::endl;
}, v);
v = 123;
std::visit([](auto&& arg) {
// 这是通用的 lambda,可以处理任何类型
std::cout << "Value: " << arg << std::endl;
}, v);
// 如果需要针对特定类型执行不同逻辑,可以组合多个 lambda 或使用函数对象
// C++17 的一个常见模式是使用一个辅助结构来合并多个 lambda
struct Overloaded {
void operator()(int i) const { std::cout << "It's an int: " << i << std::endl; }
void operator()(double d) const { std::cout << "It's a double: " << d << std::endl; }
void operator()(const std::string& s) const { std::cout << "It's a string: " << s << std::endl; }
};
v = 42;
std::visit(Overloaded{}, v);
v = 3.14159;
std::visit(Overloaded{}, v);
v = "Variant magic!";
std::visit(Overloaded{}, v);
return 0;
}
运行结果:
Value: Hello
Value: 123
It's an int: 42
It's a double: 3.14159
It's a string: Variant magic!
overloaded 辅助结构:
为了方便地组合多个 lambda 表达式作为 std::visit 的参数,C++ 社区常使用一个 overloaded 辅助结构(或 match 函数),利用 C++17 的折叠表达式(fold expressions)或简单的继承:
// overloaded.h (或直接放在 .cpp 文件中)
#include <variant> // 包含 variant 头文件
// C++17 generic lambda version with fold expressions
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>; // Deduction guide (C++17)
// Usage:
// std::visit(overloaded {
// [](int i){ std::cout << "int: " << i << std::endl; },
// [](double d){ std::cout << "double: " << d << std::endl; },
// [](const std::string& s){ std::cout << "string: " << s << std::endl; }
// }, my_variant);
有了 overloaded 辅助结构,我们可以更简洁地定义多重 lambda 访问器:
#include <variant>
#include <string>
#include <iostream>
// overloaded 辅助结构
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
std::variant<int, double, std::string> v = "Hello";
std::visit(overloaded {
[](int i){ std::cout << "Visited int: " << i << std::endl; },
[](double d){ std::cout << "Visited double: " << d << std::endl; },
[](const std::string& s){ std::cout << "Visited string: " << s << std::endl; }
}, v); // v 此时是 string
v = 123;
std::visit(overloaded {
[](int i){ std::cout << "Visited int: " << i << std::endl; },
[](double d){ std::cout << "Visited double: " << d << std::endl; },
[](const std::string& s){ std::cout << "Visited string: " << s << std::endl; }
}, v); // v 此时是 int
return 0;
}
运行结果:
Visited string: Hello
Visited int: 123
3.2 std::visit 访问多个 std::variant
std::visit 不仅可以访问单个 std::variant,还可以同时访问多个 std::variant。这在处理不同类型组合时非常有用,它会根据所有 variant 的当前活跃类型进行组合分派。
#include <variant>
#include <string>
#include <iostream>
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
int main() {
std::variant<int, std::string> v1 = 10;
std::variant<double, bool> v2 = true;
std::visit(overloaded {
[](int i, double d){ std::cout << "int (" << i << ") and double (" << d << ")n"; },
[](int i, bool b){ std::cout << "int (" << i << ") and bool (" << std::boolalpha << b << ")n"; },
[](const std::string& s, double d){ std::cout << "string ("" << s << "") and double (" << d << ")n"; },
[](const std::string& s, bool b){ std::cout << "string ("" << s << "") and bool (" << std::boolalpha << b << ")n"; }
}, v1, v2); // 当前是 (int, bool)
v1 = "test";
v2 = 99.9;
std::visit(overloaded {
[](int i, double d){ std::cout << "int (" << i << ") and double (" << d << ")n"; },
[](int i, bool b){ std::cout << "int (" << i << ") and bool (" << std::boolalpha << b << ")n"; },
[](const std::string& s, double d){ std::cout << "string ("" << s << "") and double (" << d << ")n"; },
[](const std::string& s, bool b){ std::cout << "string ("" << s << "") and bool (" << std::boolalpha << b << ")n"; }
}, v1, v2); // 当前是 (string, double)
return 0;
}
运行结果:
int (10) and bool (true)
string ("test") and double (99.9)
这展示了 std::visit 强大的多参数分派能力,它在编译时生成所有可能的类型组合的函数调用,并在运行时选择正确的那个。
4. std::variant 与 std::visit 实现访问者模式
现在,我们将 std::variant 和 std::visit 结合起来,实现一种现代 C++ 风格的访问者模式。这种方法不再需要虚函数、accept 方法和继承层次结构,而是将异构类型存储在 std::variant 中,并使用 std::visit 对其执行操作。
4.1 核心思想
- 元素类型定义为独立结构体/类: 不再需要共同的基类和虚函数。它们可以是简单的结构体,只包含数据。
std::variant封装所有元素类型: 定义一个std::variant类型,它能够存储所有可能存在的元素类型。- 操作定义为
std::visit的可调用对象: 每一个操作(如绘制、序列化)都定义为一个std::visit的访问者(通常是overloaded结构体或独立的函数对象),其中包含针对std::variant中每种元素类型的重载。
4.2 示例:图形绘制与序列化
让我们重写之前的图形示例:
#include <iostream>
#include <variant>
#include <vector>
#include <string>
#include <memory> // For std::unique_ptr, though not strictly needed for variant elements
// 1. 定义元素类型 - 简单的结构体,无需基类或虚函数
struct Circle {
double radius;
// 构造函数可以提供,也可以省略使用聚合初始化
Circle(double r) : radius(r) {}
};
struct Square {
double side;
Square(double s) : side(s) {}
};
struct Triangle {
double base;
double height;
Triangle(double b, double h) : base(b), height(h) {}
};
// 2. 使用 std::variant 封装所有元素类型
// 这就是我们的“Shape”概念,一个可以持有任何一种具体图形的类型
using Shape = std::variant<Circle, Square, Triangle>;
// 3. 定义 overloaded 辅助结构 (如果还没定义)
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
// 4. 定义操作 - 使用 std::visit 和 overloaded
// 操作 1: Draw
void draw_shape(const Shape& shape) {
std::visit(overloaded {
[](const Circle& c) {
std::cout << "Drawing Circle with radius " << c.radius << std::endl;
},
[](const Square& s) {
std::cout << "Drawing Square with side " << s.side << std::endl;
},
[](const Triangle& t) {
std::cout << "Drawing Triangle with base " << t.base
<< " and height " << t.height << std::endl;
}
}, shape);
}
// 操作 2: Serialize
void serialize_shape(const Shape& shape) {
std::visit(overloaded {
[](const Circle& c) {
std::cout << "Serializing Circle: { type: "Circle", radius: " << c.radius << " }" << std::endl;
},
[](const Square& s) {
std::cout << "Serializing Square: { type: "Square", side: " << s.side << " }" << std::endl;
},
[](const Triangle& t) {
std::cout << "Serializing Triangle: { type: "Triangle", base: " << t.base
<< ", height: " << t.height << " }" << std::endl;
}
}, shape);
}
// 操作 3: Calculate Area (新操作)
double calculate_area(const Shape& shape) {
return std::visit(overloaded {
[](const Circle& c) {
return 3.14159 * c.radius * c.radius;
},
[](const Square& s) {
return s.side * s.side;
},
[](const Triangle& t) {
return 0.5 * t.base * t.height;
}
}, shape);
}
int main() {
std::vector<Shape> shapes; // 存储 Shapes 的 vector
shapes.push_back(Circle(5.0));
shapes.push_back(Square(10.0));
shapes.push_back(Triangle(4.0, 6.0)); // 添加新的 Triangle 类型
std::cout << "--- Drawing Shapes ---n";
for (const auto& shape : shapes) {
draw_shape(shape);
}
std::cout << "n--- Serializing Shapes ---n";
for (const auto& shape : shapes) {
serialize_shape(shape);
}
std::cout << "n--- Calculating Areas ---n";
for (const auto& shape : shapes) {
std::cout << "Area: " << calculate_area(shape) << std::endl;
}
return 0;
}
运行结果:
--- Drawing Shapes ---
Drawing Circle with radius 5
Drawing Square with side 10
Drawing Triangle with base 4 and height 6
--- Serializing Shapes ---
Serializing Circle: { type: "Circle", radius: 5 }
Serializing Square: { type: "Square", side: 10 }
Serializing Triangle: { type: "Triangle", base: 4, height: 6 }
--- Calculating Areas ---
Area: 78.53975
Area: 100
Area: 12
与传统访问者模式的对比:
- 添加新操作: 极其方便。只需创建一个新的函数(如
calculate_area),在其中使用std::visit和overloaded定义针对每种Shape类型的逻辑即可,无需修改任何现有代码。这完美符合开放/封闭原则中“对扩展开放”的部分。 - 添加新元素类型: 相对困难。如果我们要添加
Rectangle类型,需要:- 定义
struct Rectangle { ... }; - 修改
using Shape = std::variant<Circle, Square, Triangle, Rectangle>;,将Rectangle加入variant的类型列表中。 - 修改所有使用
std::visit操作Shape的函数(如draw_shape,serialize_shape,calculate_area),为Rectangle添加相应的 lambda 重载。
这与传统模式添加新元素时需要修改所有Visitor类的visit方法类似,同样违反了开放/封闭原则中“对修改封闭”的部分。
- 定义
这表明 std::variant + std::visit 访问者模式与传统访问者模式在“添加新操作容易”和“添加新元素困难”的权衡上是相似的。它们都是在编译时已知所有元素类型时表现最佳,如果元素类型经常变化,则需要考虑其他模式。
4.3 std::variant + std::visit 访问者模式的优势
- 类型安全: 所有的类型检查都在编译时完成,避免了运行时的类型转换错误(如
dynamic_cast失败)。 - 无继承层次: 元素类型之间无需共享一个基类,它们可以是完全独立的结构体。这减少了设计上的约束和潜在的耦合。
- 无虚函数开销: 不涉及虚表查询,
std::visit的分派是在编译时通过模板元编程和重载解析实现的,通常会生成更高效的代码,甚至可能内联。 - 值语义和缓存局部性:
std::variant存储的是值,而不是指针。这意味着对象通常存储在连续的内存区域,可能带来更好的缓存局部性,尤其是在处理大量异构对象时。 - 更少的样板代码: 相较于传统的访问者模式,它消除了
accept虚函数、Visitor接口以及每个Element类中的accept实现。 - 表达力强: 使用 lambda 表达式和
overloaded辅助结构,代码更加简洁、直观,易于阅读和维护。 - 防止遗漏处理: 如果
std::visit的访问者没有为std::variant中的所有类型提供重载,编译器会报错,强制你处理所有可能的类型,这有助于防止逻辑遗漏。
4.4 递归 std::variant 与表达式树示例
有时,我们希望 std::variant 的某个类型可以是它自己。例如,在构建抽象语法树(AST)或表达式树时,一个节点可能包含其他节点。这被称为递归 std::variant。
直接声明 std::variant<A, std::vector<std::variant<A, B>>> 这种嵌套是没问题的,但如果 A 内部直接包含 std::variant<A, B>,就会出现循环定义的问题。
例如,一个二元表达式节点需要包含两个子表达式,而子表达式本身也可能是表达式。
std::variant 默认是值语义,为了处理递归类型,我们需要使用 std::recursive_wrapper(C++17 引入)来打破循环依赖,或者使用智能指针(如 std::unique_ptr 或 std::shared_ptr)。std::recursive_wrapper 的好处是保持了值语义,而智能指针则引入了堆分配。
示例:简单的表达式树求值器
#include <iostream>
#include <variant>
#include <string>
#include <vector>
#include <functional> // For std::recursive_wrapper
// 1. 定义元素类型
struct Literal {
int value;
};
// 前向声明 Expression,因为 Add/Subtract 会用到它
struct Add;
struct Subtract;
struct Multiply;
struct Divide;
// 2. 使用 std::variant 封装所有元素类型,处理递归
// std::recursive_wrapper 用于包裹递归类型,使其能够被 std::variant 包含
using Expression = std::variant<
Literal,
std::recursive_wrapper<Add>,
std::recursive_wrapper<Subtract>,
std::recursive_wrapper<Multiply>,
std::recursive_wrapper<Divide>
>;
// 结构体定义,现在可以引用 Expression
struct Add {
Expression left;
Expression right;
};
struct Subtract {
Expression left;
Expression right;
};
struct Multiply {
Expression left;
Expression right;
};
struct Divide {
Expression left;
Expression right;
};
// 3. 定义 overloaded 辅助结构
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
// 4. 定义操作 - 求值 (Evaluate)
int evaluate_expression(const Expression& expr) {
return std::visit(overloaded {
[](const Literal& l) {
return l.value;
},
// 递归调用 evaluate_expression
[](const Add& a) {
return evaluate_expression(a.left) + evaluate_expression(a.right);
},
[](const Subtract& s) {
return evaluate_expression(s.left) - evaluate_expression(s.right);
},
[](const Multiply& m) {
return evaluate_expression(m.left) * evaluate_expression(m.right);
},
[](const Divide& d) {
int right_val = evaluate_expression(d.right);
if (right_val == 0) {
std::cerr << "Error: Division by zero!n";
// 抛出异常或返回特定值处理错误
return 0; // 简单处理,实际应用中应更严谨
}
return evaluate_expression(d.left) / right_val;
}
}, expr);
}
// 5. 定义操作 - 打印 (Print)
void print_expression(const Expression& expr) {
std::visit(overloaded {
[](const Literal& l) {
std::cout << l.value;
},
[](const Add& a) {
std::cout << "(";
print_expression(a.left);
std::cout << " + ";
print_expression(a.right);
std::cout << ")";
},
[](const Subtract& s) {
std::cout << "(";
print_expression(s.left);
std::cout << " - ";
print_expression(s.right);
std::cout << ")";
},
[](const Multiply& m) {
std::cout << "(";
print_expression(m.left);
std::cout << " * ";
print_expression(m.right);
std::cout << ")";
},
[](const Divide& d) {
std::cout << "(";
print_expression(d.left);
std::cout << " / ";
print_expression(d.right);
std::cout << ")";
}
}, expr);
}
int main() {
// 构建表达式: (10 + 5) * (20 - (3 / 1))
Expression expr = Multiply{
Add{Literal{10}, Literal{5}},
Subtract{Literal{20}, Divide{Literal{3}, Literal{1}}}
};
std::cout << "Expression: ";
print_expression(expr);
std::cout << std::endl;
int result = evaluate_expression(expr);
std::cout << "Result: " << result << std::endl;
// 另一个表达式: 10 + 2 * 3 - 4
// 对应 (10 + (2 * 3)) - 4
Expression expr2 = Subtract{
Add{Literal{10}, Multiply{Literal{2}, Literal{3}}},
Literal{4}
};
std::cout << "nExpression 2: ";
print_expression(expr2);
std::cout << std::endl;
int result2 = evaluate_expression(expr2);
std::cout << "Result 2: " << result2 << std::endl;
// 尝试除零
Expression expr_div_zero = Divide{Literal{10}, Literal{0}};
std::cout << "nExpression with division by zero: ";
print_expression(expr_div_zero);
std::cout << std::endl;
int result_div_zero = evaluate_expression(expr_div_zero);
std::cout << "Result (div by zero): " << result_div_zero << std::endl;
return 0;
}
运行结果:
Expression: ((10 + 5) * (20 - (3 / 1)))
Result: 255
Expression 2: ((10 + (2 * 3)) - 4)
Result 2: 12
Expression with division by zero: (10 / 0)
Error: Division by zero!
Result (div by zero): 0
这个示例完美展示了 std::variant 和 std::visit 如何优雅地处理递归数据结构,并对其执行复杂的、递归的操作。
5. std::variant 访问者模式的考量与局限性
尽管 std::variant 和 std::visit 提供了许多优势,但它们并非银弹。在使用时,需要考虑以下几点:
5.1 优点总结
- 编译时类型安全: 强大的编译时检查,避免运行时错误。
- 性能优势: 无虚函数开销,通常更好的缓存局部性。
- 值语义: 默认存储值,避免堆分配和指针管理。
- 简洁性: 减少样板代码,使用 lambda 表达式增强可读性。
- 强制完全处理: 编译器会确保
std::visit的访问者处理了variant的所有可能类型。
5.2 缺点与局限性
- 添加新元素类型困难: 这是其与传统访问者模式共享的主要缺点。每次向
std::variant添加新类型,都需要修改所有使用std::visit对其进行操作的代码,并重新编译。这在元素类型经常变化且操作相对固定的场景下不适用。 std::visit调用站点可能变得庞大: 如果std::variant包含的类型很多,或者操作逻辑复杂,std::visit的 lambda 列表会变得很长,降低可读性。- 不适合“外部扩展”: 如果你希望第三方库能够扩展你的类型系统(例如,添加他们自己的图形类型),而你又不能修改
std::variant的定义,那么这种模式就不适用。传统的虚函数多态允许通过继承实现外部扩展。 - 无法动态添加类型:
std::variant的类型列表在编译时是固定的。 - 递归类型: 虽然
std::recursive_wrapper解决了递归问题,但它依然增加了复杂性。
5.3 何时选择 std::variant 访问者模式?
这种模式最适合以下场景:
- 异构类型集合相对固定且已知: 你知道所有可能在
std::variant中出现的类型,并且这些类型不经常改变。 - 需要对这些类型执行多种操作,且操作经常增加: 这是访问者模式的核心优势。
- 需要高性能和类型安全: 编译时分派和值语义通常带来更好的性能。
- 避免继承和虚函数: 当设计上不希望引入继承层次,或希望避免虚函数开销时。
- C++17 或更高版本项目: 依赖于
std::variant和std::visit特性。
6. 传统访问者模式与 std::variant 访问者模式对比
为了更直观地理解两种方法的权衡,我们通过表格进行比较:
| 特性/考量 | 传统访问者模式 (基于继承和虚函数) | std::variant + std::visit 访问者模式 |
|---|---|---|
| 基础机制 | 运行时虚函数分派 (双分派),继承 | 编译时模板元编程和重载解析,值语义,判别式联合体 |
| 添加新操作 | 容易:创建新的具体访问者类,无需修改元素类。 | 容易:创建新的 std::visit 函数,无需修改 variant 定义。 |
| 添加新元素类型 | 困难:需要修改元素基类(添加 accept 声明)和所有具体访问者类(添加 visit 实现)。 |
困难:需要修改 std::variant 的类型列表,并修改所有使用 std::visit 的操作函数。 |
| 类型安全 | 依赖 dynamic_cast 或知道类型层次,可能存在运行时错误。 |
高:编译时检查,几乎所有类型不匹配都会导致编译错误。 |
| 性能 | 运行时虚表查找开销,可能涉及堆分配(unique_ptr/shared_ptr)。 |
高:编译时分派,无虚函数开销,通常栈上分配,缓存局部性好。 |
| 样板代码 | Visitor 接口、Element 接口、accept 方法、多个 visit 方法。 |
overloaded 辅助结构,每个操作可能有一组 lambda。整体相对较少。 |
| 封装性 | 元素类可能需要暴露内部状态给访问者。 | 元素类可以是简单的 struct,内部状态由 std::visit 直接处理。 |
| 代码可读性 | 结构清晰,但 accept 和 visit 的跳转可能略显间接。 |
简洁直观,Lambda 表达式使得操作逻辑集中。 |
| 运行时扩展性 | 允许通过继承在运行时扩展类型(例如插件系统)。 | 低:std::variant 的类型列表在编译时固定,无法动态添加。 |
| 内存管理 | 通常需要智能指针管理堆上的元素对象。 | std::variant 通常值语义,栈上或嵌入式存储,自动管理内存。 |
| C++ 版本要求 | C++98/03 及更高版本。 | C++17 及更高版本。 |
7. 结论
std::variant 与 std::visit 的组合为 C++ 带来了现代且强大的双分派能力。它以编译时类型安全、高性能和更少的样板代码,改进了传统访问者模式在特定场景下的应用。当您的异构类型集合相对稳定且操作频繁变化时,这种基于值语义的访问者模式无疑是一个卓越的选择。它将类型安全和表达力提升到了一个新的水平,让 C++ 代码更加健壮和优雅。
理解这两种访问者模式的优缺点和适用场景,能帮助我们根据具体需求做出明智的设计决策,从而编写出更高质量、更具维护性的 C++ 应用程序。