在现代 C++ 开发中,std::variant 和 std::visit 成为了处理多态数据类型的一种极简且类型安全的方案。相比传统的继承+多态,std::variant 不需要虚函数表,也不会引入运行时多态带来的缓存不友好问题。下面我们通过一个实际案例,演示如何利用 std::variant 与 std::visit 解决“多种不同消息类型”这一常见需求,并讨论一些高级技巧。
1. 需求场景
假设我们在开发一个网络协议栈,需要处理三种不同的消息类型:
- 文本消息:包含一个字符串内容。
- 二进制消息:包含一个字节数组和长度。
- 控制消息:包含一个枚举值(例如
PING,PONG,CLOSE)。
我们想用一个统一的数据结构来表示这三种消息,并在不同地方对其进行处理。
2. 定义消息结构
#include <variant>
#include <string>
#include <vector>
#include <iostream>
#include <chrono>
#include <iomanip>
// ① 文本消息
struct TextMsg {
std::string content;
};
// ② 二进制消息
struct BinaryMsg {
std::vector <uint8_t> data;
};
// ③ 控制消息
enum class ControlType { PING, PONG, CLOSE };
struct ControlMsg {
ControlType type;
};
using Message = std::variant<TextMsg, BinaryMsg, ControlMsg>;
这样,Message 就是一个可以保存上述三种消息之一的类型。
3. 消息生成与序列化
Message createText(const std::string& txt) {
return TextMsg{txt};
}
Message createBinary(const std::vector <uint8_t>& bytes) {
return BinaryMsg{bytes};
}
Message createControl(ControlType t) {
return ControlMsg{t};
}
// 简单序列化示例(仅演示)
std::string serialize(const Message& msg) {
return std::visit([](auto&& m) -> std::string {
using T = std::decay_t<decltype(m)>;
if constexpr (std::is_same_v<T, TextMsg>) {
return "Text: " + m.content;
} else if constexpr (std::is_same_v<T, BinaryMsg>) {
return "Binary: " + std::to_string(m.data.size()) + " bytes";
} else if constexpr (std::is_same_v<T, ControlMsg>) {
switch (m.type) {
case ControlType::PING: return "Control: PING";
case ControlType::PONG: return "Control: PONG";
case ControlType::CLOSE: return "Control: CLOSE";
}
}
return "Unknown";
}, msg);
}
这里 std::visit 的 lambda 使用了 C++17 的 if constexpr 进行类型判断,保证编译期确定每个分支。
4. 消息处理
4.1 传统方式:多重 if-else
void handleOld(const Message& msg) {
if (std::holds_alternative <TextMsg>(msg)) {
const auto& t = std::get <TextMsg>(msg);
std::cout << "Handle text: " << t.content << '\n';
} else if (std::holds_alternative <BinaryMsg>(msg)) {
const auto& b = std::get <BinaryMsg>(msg);
std::cout << "Handle binary, size=" << b.data.size() << '\n';
} else if (std::holds_alternative <ControlMsg>(msg)) {
const auto& c = std::get <ControlMsg>(msg);
std::cout << "Handle control\n";
}
}
4.2 现代方式:std::visit + overload
std::visit 需要一个可调用对象来处理每种类型。我们可以借助一个 overload 工具(C++20 的 std::overload 在 C++17 里没有,但可以自定义):
template<class... Ts> struct overload : Ts... { using Ts::operator()...; };
template<class... Ts> overload(Ts...) -> overload<Ts...>;
void handle(const Message& msg) {
std::visit(overload{
[](const TextMsg& t) {
std::cout << "[Text] " << t.content << '\n';
},
[](const BinaryMsg& b) {
std::cout << "[Binary] " << b.data.size() << " bytes\n";
},
[](const ControlMsg& c) {
std::cout << "[Control] ";
switch (c.type) {
case ControlType::PING: std::cout << "PING\n"; break;
case ControlType::PONG: std::cout << "PONG\n"; break;
case ControlType::CLOSE: std::cout << "CLOSE\n"; break;
}
}
}, msg);
}
overload 让 lambda 组合成一个多重重载的可调用对象,std::visit 会根据当前 Message 的实际类型调用对应的 lambda。
5. 高级技巧
5.1 std::variant 与 std::monostate
如果某种消息类型可选(例如二进制数据可能为空),可以使用 std::monostate 作为默认值,表示“无效”或“空”。
using OptionalBinaryMsg = std::variant<std::monostate, BinaryMsg>;
5.2 std::variant 的可变性
在需要修改消息内容时,std::variant 本身是可变的,但需要注意:
std::visit默认是const可变的,需要使用std::visit的非 const 版本(C++20 提供std::visit对variant的非 const 访问)。- 如果想在 visit 中修改内部成员,可以使用
std::get_if或std::apply(C++23)等工具。
5.3 与模板元编程结合
因为 std::variant 是编译期已知的类型列表,配合模板元编程可以生成针对每种消息类型的专门代码(例如生成不同的序列化/反序列化函数)。
template<class... Ts>
constexpr auto makeSerializer(const std::variant<Ts...>&) {
return overload{
[](const TextMsg& t){ return "T:" + t.content; },
[](const BinaryMsg& b){ return "B:" + std::to_string(b.data.size()); },
// 其它类型
};
}
6. 性能与内存对比
- 内存占用:
std::variant的大小等于最大子类型大小 + 一个小的标识符(std::size_t或unsigned char,取决于实现)。相比继承+多态,后者会在每个对象上多一个虚函数表指针(8~16 字节)。 - 访问速度:
std::variant的访问在编译期已知类型,直接跳转到对应分支,通常比虚函数表更快,尤其在缓存友好性方面更优。
7. 小结
std::variant与std::visit为 C++ 开发者提供了一种类型安全、无 RTTI、无虚函数表的多态实现方式。- 通过
overload工具,可以写出简洁、可读性高的多分支处理逻辑。 - 在需要动态选择不同处理逻辑的场景(如网络协议、事件系统、UI 事件等)中,
std::variant是极佳的解决方案。
掌握了上述技巧后,你可以在项目中大胆使用 std::variant,减少复杂的继承体系,提升代码的可维护性和性能。