std::variant 与 std::visit
概述
std::variant是C++17标准库提供的类型安全联合体
|
1 |
#include <variant> |
variant 解决什么问题
- 假设服务器收到的消息可能是
- 任意时刻,一条消息只会是其中一种
|
1 2 3 |
struct Login {}; struct Logout {}; struct Heartbeat {}; |
- 传统做法是手工组合枚举和
union
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
enum class MessageType { login, logout, heartbeat }; union MessageData { Login login; Logout logout; Heartbeat heartbeat; }; struct Message { MessageType type; MessageData data; }; |
-
问题在于:
- 必须手动记录当前有效成员
- 必须手动调用非平凡成员的构造和析构
type与真正活动的成员可能不一致- 复制、移动和异常安全很难实现
- 访问错误的非活动成员可能产生未定义行为
-
std::variant把这套逻辑封装起来
|
1 2 |
using Message = std::variant<Login, Logout, Heartbeat>; |
- 核心模型是
variant<Ts...>拥有一块足以存放最大候选类型的存储空间,并用一个索引记录当前存放的是哪一种类型
最小示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> value; // 默认构造第 0 个候选类型 int std::cout << value.index() << '\n'; // 0 value = 42; std::cout << std::get<int>(value) << '\n'; value = std::string{"hello"}; std::cout << value.index() << '\n'; // 1 std::cout << std::get<1>(value) << '\n'; } |
variant在不处于异常状态时,始终有且只有一个活动候选类型
|
1 2 3 4 5 6 7 8 9 10 11 12 |
std::variant<int, std::string> value; // 初始时 index = 0 活动对象 = int{0} // 执行 value = std::string{"hello"}; // 大致发生 // 销毁原来的 int; // 在同一块存储中构造 std::string; // 把活动索引更新为 1 |
和 tuple 的区别
| 类型 | 实际保存的对象 |
tuple<int, string, double> |
同时保存三个对象 |
variant<int, string, double> |
任意时刻通常只保存其中一个 |
optional<int> |
保存一个 int 或不保存 |
any |
保存一个运行时确定的任意可复制类型 |
|
1 2 3 4 |
tuple<Ts...> // “这些对象我全都要” variant<Ts...> // “这些类型中选一个” optional<T> // “一个 T 或者没有” any // “类型集合事先不确定” |
默认构造规则
variant默认构造第一个候选类型
|
1 2 3 4 5 6 |
std::variant<int, std::string> value; // 等价效果近似于 std::variant<int, std::string> value{ std::in_place_index<0> }; |
|
1 2 |
value.index() == 0; std::get<0>(value) == 0; |
- 如果第一个类型不能默认构造
- 可以把
std::monostate放在第一位
- 可以把
|
1 2 3 4 5 |
struct Connection { Connection(int fd) {} }; std::variant<Connection, int> value; // 错误 |
|
1 2 3 4 |
std::variant<std::monostate, Connection, int> value; // 此时默认状态为: value.index() == 0; |
构造指定候选类型
- 根据值选择
- 编译器通过重载决议选择候选类型
|
1 2 |
std::variant<int, std::string> value = 42; std::variant<int, std::string> text = std::string{"hello"}; |
in_place_type
|
1 2 3 4 5 6 7 |
std::variant<int, std::string> value{ std::in_place_type<std::string>, 5, 'A' }; // 直接在 variant 内部构造 std::string(5, 'A') // "AAAAA" |
in_place_index
|
1 2 3 4 5 6 |
std::variant<int, std::string> value{ std::in_place_index<1>, 5, 'A' }; // 这同样选择第 1 个候选类型 |
|
1 2 3 4 5 6 7 8 |
// 当候选类型重复时,必须使用索引 std::variant<int, int> value{ std::in_place_index<1>, 42 }; std::variant<int, int> value{42}; // 错误 |
读取当前值
index()索引按照模板参数顺序从零开始
|
1 2 3 4 5 |
std::variant<int, std::string> value{"hello"}; if (value.index() == 1) { // 当前是 string } |
holds_alternative- 要求该类型在候选列表中恰好出现一次
|
1 2 3 |
if (std::holds_alternative<std::string>(value)) { // 当前保存 string } |
|
1 2 3 |
std::variant<int, int> value; std::holds_alternative<int>(value); // 编译错误:int 不唯一 |
std::get- 如果请求的类型不是当前活动类型
|
1 2 3 4 |
std::variant<int, std::string> value{"hello"}; std::string& text = std::get<std::string>(value); std::string& text2 = std::get<1>(value); |
|
1 2 |
std::get<int>(value); // 会抛出`std::bad_variant_access` |
std::get_if- 不想使用异常时,可以使用
get_if - 需要注意,
get_if接收的是variant指针
- 不想使用异常时,可以使用
|
1 2 3 |
if (auto* text = std::get_if<std::string>(&value)) { std::cout << *text << '\n'; } |
|
1 2 3 4 5 6 7 |
// 类型不匹配时返回空指针 if (auto* number = std::get_if<int>(&value)) { // 当前是 int } else { // 当前不是 int } |
- 总结建议
| 需求 | 工具 |
| 已经确定当前类型 | std::get |
| 想进行非异常分支判断 | std::get_if |
| 只判断某类型是否活动 | holds_alternative |
| 对所有类型统一分发 | std::visit |
std::visit:对活动类型进行分发
- 假设
|
1 |
std::variant<int, std::string> value{"hello"}; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 可以使用泛型 lambda std::visit( [](const auto& item) { std::cout << item << '\n'; }, value ); // 运行时 variant 检查活动索引,调用对应版本: lambda(const int&) // 或者 lambda(const std::string&) |
- 虽然运行时只执行其中一个,但编译器要求访问器能够处理所有可能类型
|
1 2 3 4 5 6 7 8 |
std::visit( [](const std::string& text) { std::cout << text; }, value ); // 因为 value 也可能保存 int,而这个 lambda 不能接收 int |
overloaded模式
- 不同消息通常需要不同处理逻辑。可以用重载函数对象:
|
1 2 3 4 5 6 7 8 |
template<class... Functions> struct overloaded : Functions... { using Functions::operator()...; }; template<class... Functions> overloaded(Functions...) -> overloaded<Functions...>; |
- 这里使用了:
- 可变参数模板
- 参数包继承;
using包展开C++17 CTAD
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
#include <iostream> #include <string> #include <variant> template<class... Functions> struct overloaded : Functions... { using Functions::operator()...; }; template<class... Functions> overloaded(Functions...) -> overloaded<Functions...>; int main() { std::variant<int, std::string> value{"hello"}; std::visit( overloaded{ [](int number) { std::cout << "integer: " << number << '\n'; }, [](const std::string& text) { std::cout << "string: " << text << '\n'; } }, value ); } |
|
1 2 3 4 5 6 7 8 9 10 11 |
// overloaded 展开后近似于 struct Generated : LambdaForInt, LambdaForString { using LambdaForInt::operator(); using LambdaForString::operator(); }; // 因此,它把多个 lambda 的 operator() 合并成一个重载集合 |
网络消息分发示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include <cstdint> #include <iostream> #include <string> #include <variant> #include <vector> struct Login { std::string username; }; struct Logout { std::uint32_t user_id; }; struct Heartbeat { std::uint64_t timestamp; }; using Message = std::variant<Login, Logout, Heartbeat>; template<class... Functions> struct overloaded : Functions... { using Functions::operator()...; }; template<class... Functions> overloaded(Functions...) -> overloaded<Functions...>; void dispatch(const Message& message) { std::visit( overloaded{ [](const Login& login) { std::cout << "login: " << login.username << '\n'; }, [](const Logout& logout) { std::cout << "logout: " << logout.user_id << '\n'; }, [](const Heartbeat& heartbeat) { std::cout << "heartbeat: " << heartbeat.timestamp << '\n'; } }, message ); } int main() { std::vector<Message> messages; messages.emplace_back(Login{"Aet"}); messages.emplace_back(Heartbeat{1000}); messages.emplace_back(Logout{42}); for (const auto& message : messages) { dispatch(message); } } |
- 这种设计有一个很重要的性质
- 那么原来的
visit会因为不能处理ChatMessage而编译失败 - 这提供了一种编译期的“分支完整性检查”
- 那么原来的
|
1 2 3 4 5 6 |
// 如果以后增加 struct ChatMessage {}; // 并修改 using Message = std::variant<Login, Logout, Heartbeat, ChatMessage>; |
- 但如果访问器包含兜底分支:
- 新增类型便不会触发错误
|
1 2 3 |
[](const auto&) { // 通用处理 } |
访问器的返回类型
std::visit可以返回值
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
using Value = std::variant<int, double>; Value value = 3.5; double result = std::visit( overloaded{ [](int number) -> double { return number * 2.0; }, [](double number) -> double { return number * 2.0; } }, value ); |
C++17不带显式返回类型的visit要求所有可能调用产生相同的返回类型和相同的值类别
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 下面通常不能编译 // 虽然 int 能转换成 double,但 visit 不会自动寻找公共返回类型 auto result = std::visit( overloaded{ [](int number) { return number; // int }, [](double number) { return number; // double } }, value ); |
- 需要显式统一
|
1 2 3 |
[](int number) -> double { return number; } |
C++20增加了显式返回类型形式
|
1 |
std::visit<double>(visitor, value); |
值类别会继续传播
|
1 |
std::variant<std::string> value{"payload"}; |
- 如果传递左值
- 访问器接收
std::string&
- 访问器接收
|
1 2 3 4 5 6 |
std::visit( [](std::string& text) { text += "_processed"; }, value ); |
- 如果传递
const左值
|
1 2 3 4 5 6 |
const auto& const_value = value; std::visit( [](const std::string& text) {}, const_value ); |
- 如果传递右值
- 访问器可以收到
std::string&&
- 访问器可以收到
|
1 2 3 4 5 6 |
std::visit( [](std::string&& text) { std::string owned = std::move(text); }, std::move(value) ); |
- 看到下面的:
- 要意识到访问器可能把消息中的资源移动走
- 之后
message依然是一个合法的variant,但内部对象可能处于已移动状态
|
1 |
std::visit(visitor, std::move(message)); |
valueless_by_exception
- 通常我们认为
variant总是保存一个候选对象,但存在一个特殊状态:
|
1 |
value.valueless_by_exception() |
- 例如,类型切换时通常需要:
- 销毁原活动对象
- 构造新类型对象
|
1 2 |
// 如果第 2 步构造抛出异常,那么旧对象已经被销毁,新对象又没有构造成功 // variant 可能变成没有活动值的状态 |
- 具体操作是否会进入无值状态,还受候选类型的复制、移动以及
noexcept属性影响,不能认为每次异常都会如此
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct Throwing { Throwing(int) { throw 42; } }; std::variant<std::string, Throwing> value{"hello"}; try { value.emplace<Throwing>(1); } catch (...) { if (value.valueless_by_exception()) { // value 当前没有活动候选对象 } } |
|
1 2 3 4 5 6 |
// 无值时 value.index() == std::variant_npos // 调用会抛出异常 std::bad_variant_access std::visit(visitor, value); std::get<0>(value); |
简化的底层实现模型
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
template<class... Types> class SimpleVariant { static constexpr std::size_t storage_size = std::max({sizeof(Types)...}); static constexpr std::size_t storage_align = std::max({alignof(Types)...}); alignas(storage_align) std::byte storage_[storage_size]; std::size_t index_; }; |
|
1 2 3 4 |
// 对于 std::variant<char, int, std::string> // 存储空间只需要容纳最大的候选对象,而不是三个对象大小之和 |
- 它还需要完成:
- 根据索引销毁正确类型
- 复制或移动当前活动对象
- 根据类型切换构造新对象
- 实现
get<I> - 实现异常安全
- 实现比较操作
- 实现访问器分发
如何根据索引析构
- 可以为每一种候选类型生成一个析构函数,再组成函数指针表
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
template<class T> void destroy_one(void* address) { static_cast<T*>(address)->~T(); } using DestroyFunction = void(*)(void*); template<class... Types> struct DestroyTable { static constexpr DestroyFunction functions[] = { &destroy_one<Types>... }; }; |
|
1 2 3 4 5 6 7 8 9 10 11 |
// 当活动索引为 2 时 // 便会调用第 2 种类型的析构函数 function[2](storage); // 这里的 &destroy_one<Types>... // 展开成 &destroy_one<T0>, &destroy_one<T1>, &destroy_one<T2> |
visit也可以采用类似的函数指针表或switch实现
多variant访问
std::visit可以同时访问多个variant
|
1 2 3 4 5 6 7 8 9 10 |
std::variant<int, std::string> left = 42; std::variant<int, double> right = 3.5; std::visit( [](const auto& a, const auto& b) { std::cout << a << ", " << b << '\n'; }, left, right ); |
- 编译器需要为所有组合验证并可能生成调用
left |
right |
int |
int |
int |
double |
string |
int |
string |
double |
|
1 2 3 4 5 |
// 如果第一个 variant 有 N 种类型,第二个有 M 种类型,组合数是 N x M // 三个variant是 N x M x K |
- 这可能导致:
- 编译时间增加
- 生成代码体积增加
- 错误信息变长
- 访问器必须处理大量组合
variant 与虚函数多态
- 两者都能表示“多种类型之一”,但模型不同
std::variant |
虚函数多态 |
| 候选类型集合封闭 | 派生类型集合开放 |
| 通常按值保存 | 通常通过指针或引用操作 |
调用逻辑放在 visitor |
调用逻辑放在虚函数中 |
新增类型会影响 visitor |
新增派生类通常不影响调用者 |
| 新增一种操作较容易 | 新增虚操作需要修改类层次 |
| 可存放互不相关的类型 | 类型必须属于继承体系 |
| 可能造成模板实例化膨胀 | 存在虚调用和对象所有权设计 |
- 适合
variant- 网络协议消息
- 编译器
AST节点 JSON value- 状态机状态
- 解析器
token - 返回值的几种确定结果
- 适合虚函数
- 插件体系
- 类型集合未来持续扩展
- 需要稳定
ABI - 对象本来就具有明确继承关系
- 不希望所有调用方知道全部具体类型
网络工程中的注意点
- 不要把网络字节直接解释成
variant
|
1 2 |
auto* message = reinterpret_cast<Message*>(recv_buffer.data()); // 错误 |
- 原因是:
variant有内部活动索引- 内存布局未标准化
- 候选类型可能包含指针和动态资源
- 网络数据不存在合法的
C++对象生命周期 - 还存在字节序、对齐和边界检查问题
- 正确流程是
|
1 2 3 4 5 6 7 |
接收字节 → 检查完整包长度 → 读取并验证 cmd → 按协议解析字段 → 构造 Login/Logout/Heartbeat → 放入 Message variant → std::visit 分发 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 异步队列中也尽量让消息拥有数据 struct Login { std::string username; }; // 谨慎使用 struct LoginView { std::string_view username; }; // 如果接收缓冲区在任务执行前被清空、扩容或销毁,string_view 就会悬空 // variant 不会自动解决候选对象内部的生命周期问题 |
常见错误
- 错误一:错误的候选类型
- 不确定时使用
get_if或visit
- 不确定时使用
|
1 2 3 |
std::variant<int, std::string> value{"hello"}; std::get<int>(value); // 抛 bad_variant_access |
- 错误二:类型出现多次
- 改用索引
|
1 2 3 |
std::variant<int, int> value; std::get<int>(value); // 编译错误 |
- 错误三:保存引用
|
1 2 3 4 5 6 7 8 9 |
// variant 不能直接以引用作为候选类型 std::variant<int&, std::string&> value; // 错误 // 必要时使用 // 但仍然必须保证被引用对象存活 std::variant< std::reference_wrapper<int>, std::reference_wrapper<std::string> > |
- 错误四:访问器返回类型不一致
- 显式统一返回类型
|
1 2 |
[](int) { return 1; } // int [](double) { return 1.0; } // double |
- 错误五:万能兜底隐藏遗漏
- 新增消息类型后,编译器不会提醒你补充专用处理
|
1 2 3 |
[](const auto&) { // 什么都不做 } |
表达式模板
概述
- 表达式模板(
Expression Templates)不是某一版C++新增的语法,而是一种模板库设计技术- 它在
Eigen、Blaze、xtensor等数值计算库中很常见
- 它在
- 核心思想是
- 运算符先不执行计算,而是返回一个描述表达式的轻量对象
- 直到赋值或显式求值时,才遍历数据并完成计算
- 它综合使用了:
- 运算符重载
- 模板类型推导
- 完美转发
- 参数包或嵌套模板
- 静态多态
- 延迟求值
- 引用与对象生命周期管理
表达式模板解决什么问题
- 假设有三个大向量
|
1 2 3 4 5 6 |
Vector a; Vector b; Vector c; Vector result; result = a + b + c; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// 普通运算符重载可能这样实现 Vector operator+(const Vector& lhs, const Vector& rhs) { Vector result(lhs.size()); for (std::size_t i = 0; i < lhs.size(); ++i) { result[i] = lhs[i] + rhs[i]; } return result; } // 那么 result = a + b + c; // 概念上可能产生 Vector temporary1 = a + b; Vector temporary2 = temporary1 + c; result = temporary2; // 这可能意味着: // 创建两个临时向量; // 进行临时内存分配; // 多次遍历数据; // 写入临时对象后再读出; // 增加内存带宽消耗 |
- 编译器有时能消除部分复制,但动态内存分配和多次计算通常不能仅依赖复制消除解决
|
1 2 3 4 5 6 7 8 9 |
// 理想情况是一次完成 for (std::size_t i = 0; i < result.size(); ++i) { result[i] = a[i] + b[i] + c[i]; } // 表达式模板就是把: a + b + c // 先构造成表达式树,然后在赋值时进行一次循环 |
表达式树模型
- 根据左结合规则相当于:
|
1 2 3 4 |
a + b + c // (a + b) + c |
- 可以编码成类型
|
1 2 3 4 |
AddExpr< AddExpr<VectorRef, VectorRef>, VectorRef > |
- 这个对象不会立即生成结果向量
|
1 2 3 4 |
// 当请求第 i 个元素时 expr[i] // 递归展开为: (a[i] + b[i]) + c[i] |
- 所以:
- 表达式树保存在类型和轻量对象中,实际数值计算发生在最终访问元素时
一个可运行的简化实现
- 实现一个支持下面计算的动态向量:
|
1 |
result = a + b + c; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
#include <cassert> #include <cstddef> #include <functional> #include <initializer_list> #include <iostream> #include <type_traits> #include <utility> #include <vector> struct ExpressionTag { }; template<class T> inline constexpr bool is_expression_v = std::is_base_of_v<ExpressionTag, std::decay_t<T>>; // 如果操作数是左值,保存引用; // 如果操作数是右值,保存对象。 template<class T> using StoredOperand = std::conditional_t< std::is_lvalue_reference_v<T>, std::reference_wrapper< const std::remove_reference_t<T> >, std::decay_t<T> >; template<class T> auto capture_operand(T&& value) -> StoredOperand<T&&> { if constexpr (std::is_lvalue_reference_v<T&&>) { return std::cref(value); } else { return std::forward<T>(value); } } // 普通存储对象 template<class T> const T& unwrap_operand(const T& value) { return value; } // reference_wrapper 存储的引用 template<class T> const T& unwrap_operand( const std::reference_wrapper<const T>& value) { return value.get(); } class Vector : public ExpressionTag { private: std::vector<double> data_; public: explicit Vector(std::size_t size) : data_(size) { } Vector(std::initializer_list<double> values) : data_(values) { } template< class Expression, std::enable_if_t< is_expression_v<Expression>, int > = 0 > Vector(const Expression& expression) : data_(expression.size()) { *this = expression; } std::size_t size() const noexcept { return data_.size(); } double& operator[](std::size_t index) { return data_[index]; } double operator[](std::size_t index) const { return data_[index]; } template< class Expression, std::enable_if_t< is_expression_v<Expression>, int > = 0 > Vector& operator=(const Expression& expression) { data_.resize(expression.size()); // 真正的计算发生在这里 for (std::size_t i = 0; i < size(); ++i) { data_[i] = expression[i]; } return *this; } void print() const { for (double value : data_) { std::cout << value << ' '; } std::cout << '\n'; } }; template<class LeftStorage, class RightStorage> class AddExpression : public ExpressionTag { private: LeftStorage left_; RightStorage right_; public: AddExpression( LeftStorage left, RightStorage right) : left_(std::move(left)), right_(std::move(right)) { } std::size_t size() const { return unwrap_operand(left_).size(); } double operator[](std::size_t index) const { return unwrap_operand(left_)[index] + unwrap_operand(right_)[index]; } }; template< class Left, class Right, std::enable_if_t< is_expression_v<Left> && is_expression_v<Right>, int > = 0 > auto operator+(Left&& left, Right&& right) { assert(left.size() == right.size()); auto left_storage = capture_operand(std::forward<Left>(left)); auto right_storage = capture_operand(std::forward<Right>(right)); return AddExpression< decltype(left_storage), decltype(right_storage) >{ std::move(left_storage), std::move(right_storage) }; } int main() { Vector a{1, 2, 3}; Vector b{10, 20, 30}; Vector c{100, 200, 300}; Vector result(3); result = a + b + c; result.print(); } |
编译器处理了什么
- 考虑
|
1 |
result = a + b + c |
- 第一步:计算
a + b
|
1 2 3 4 5 6 7 8 9 10 11 |
// 不会计算三个元素,而是返回 AddExpression< reference_wrapper<const Vector>, reference_wrapper<const Vector> > // 内部只保留了: left_ -> 引用 a right_ -> 引用 b // 没有创建结果向量,也没有遍历数据 |
- 第二步:计算
(a + b) + c
|
1 2 3 4 5 6 7 8 9 10 11 |
// 产生外层表达式 AddExpression< AddExpression< reference_wrapper<const Vector>, reference_wrapper<const Vector> >, reference_wrapper<const Vector> > // 内层表达式是临时右值,因此我们的实现将它按值保存;c 是左值,因此按引用保存 |
- 第三步:赋值
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 进入 Vector::operator=(const Expression&) // 执行 for (std::size_t i = 0; i < size(); ++i) { data_[i] = expression[i]; } // expression[i] 递归调用 outer.operator[](i) // 相当于 inner[i] + c[i] // 继续展开 (a[i] + b[i]) + c[i] 最终只进行一次循环 |
为什么要区分左值和右值
- 最简单的表达式模板实现经常统一保存引用
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
template<class Left, class Right> class AddExpression { const Left& left_; const Right& right_; }; // 对于 result = a + b // 通常没有问题,因为a、b在求值期间都存活 // 但是 auto expression = a + b + c; // 存在危险 // a + b 产生一个临时 AddExpression,外层表达式如果保存它的引用 outer.left_ -> 临时的 a + b // 当前语句结束后,内层临时对象销毁: // auto expression = a + b + c; //内层临时表达式已经销毁 // 之后 result = expression; // 会访问悬空引用 |
- 因此我们的存储策略是
| 操作数 | 存储方式 |
| 左值对象 | 保存引用 |
| 右值临时表达式 | 移动并按值保存 |
|
1 2 3 4 5 6 7 8 9 10 11 |
template<class T> using StoredOperand = std::conditional_t< std::is_lvalue_reference_v<T>, std::reference_wrapper< const std::remove_reference_t<T> >, std::decay_t<T> >; // 这和 ranges/view、lambda 捕获以及异步任务参数保存面临的是同一个问题 // 借用长期存在的左值,拥有生命周期较短的右值 |
- 真实库中的闭包存储规则可能更加复杂,还需要考虑
const- 引用包装器
- 标量
view- 映射对象
- 自定义
allocator - 对齐与
SIMD类型
延迟求值不是快照
- 表达式保存的通常是原对象引用
|
1 2 3 4 5 |
Vector a{1, 2}; Vector b{10, 20}; auto expression = a + b; // 此时没有计算 |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 如果随后修改 a[0] = 100; // 再进行求值 Vector result = expression; // 结果是 110 22 // 不是 11 22 // 因为expression描述的是 a[i] + b[i] // 它不是创建表达式时的数据快照 |
- 这是延迟求值的本质
- 结果取决于求值时的操作数状态,而不一定是表达式创建时的状态
|
1 2 3 |
// 如果需要快照,必须显式求值 Vector snapshot = a + b; |
操作类型也可以成为模板参数
- 刚才为加法单独写了
|
1 |
AddExpression |
- 大型库通常抽象为:
|
1 2 |
template<class Operation, class Left, class Right> class BinaryExpression; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// 操作由空类型表示 struct AddOperation { template<class L, class R> constexpr auto operator()(L&& left, R&& right) const { return std::forward<L>(left) + std::forward<R>(right); } }; struct MultiplyOperation { template<class L, class R> constexpr auto operator()(L&& left, R&& right) const { return std::forward<L>(left) * std::forward<R>(right); } }; |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// 统一表达式节点 template<class Operation, class Left, class Right> class BinaryExpression { [[no_unique_address]] Operation operation_; Left left_; Right right_; public: auto operator[](std::size_t i) const { return operation_(left_[i], right_[i]); } }; |
auto 可能保存的不是结果
- 在使用表达式模板的库中
|
1 |
auto result = a + b; |
result很可能不是一个真正的向量,而是
|
1 |
BinaryExpression<AddOperation, Ref<A>, Ref<B>> |
- 它可能:
- 不拥有
a和b - 不包含计算结果
- 依赖
a和b继续存活 - 在每次访问时重新计算
- 在操作数变化后得到不同结果
- 不拥有
|
1 2 |
// 如果需要拥有结果,应该显式指定目标类型或强制求值 Vector result = a + b; |
别名问题
- 考虑逐元素加法
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
a = a + b; // 我们的求值过程是 a[i] = a[i] + b[i]; // 这种操作通常没有问题,因为写入 a[i] 不会影响其他元素的计算 // 对于矩阵转置 matrix = transpose(matrix); // 如果逐元素边读边写,前面写入的值可能破坏后面尚未读取的源数据 // 矩阵乘法也有类似风险 A = A * B; // 表达式右侧引用了 A,而左侧正在覆盖它 |
- 表达式模板库必须处理别名
- 检测源和目标是否共享内存
- 必要时创建临时对象
- 提供
.eval()强制求值 - 提供
noalias()让用户承诺没有别名
表达式模板不一定更快
- 它可能减少:
- 临时对象
- 动态分配
- 内存遍历次数
- 中间结果读写
- 但也可能造成:
- 编译时间增加
- 模板错误信息增长
- 生成代码膨胀
- 指令缓存压力
- 表达式重复计算
- 难以控制向量化
- 调试体验变差
- 复杂表达式寄存器压力增大
|
1 2 3 4 5 6 7 |
// 如果每次索引访问都会重新执行昂贵计算,延迟求值反而可能更慢 auto expression = expensive(a) + expensive(a); for (...) { use(expression[i]); } |
- 成熟库会使用
- 代价模型
- 子表达式缓存
- 强制物化
SIMD packet- 循环融合
- 特定表达式重写
- 编译期维度与形状分析
网络代码中的相似问题
- 假设设计一个延迟拼接缓冲区
|
1 |
auto packet = header_view + payload_view; |
- 如果
packet是表达式对象,只保存两个view,那么:- 并不代表数据已经被复制
|
1 |
task_queue.push(packet); |
- 当异步线程执行时:
- 原
header可能已销毁 - 接收缓冲区可能已经
erase vector扩容可能让指针失效payload可能已被下一次接收覆盖
- 原
|
1 2 3 |
// 与 std::forward_as_tuple(...) // 的生命周期陷阱非常相似 |
- 同步调用中可以使用延迟组合
- 前提是发送函数在返回前完成数据消费
|
1 |
socket.send(header_view + payload_view); |
- 异步调用通常要
- 物化成拥有数据的缓冲区
- 或让每个缓冲区由
shared_ptr等对象管理 - 或使用
writev/scatter-gather,同时明确所有buffer的生命周期
大型项目中的识别模式
- 通常意味着表达式模板
|
1 2 3 4 5 6 7 8 9 |
BinaryExpr<Operation, Left, Right> UnaryExpr<Operation, Operand> ScalarExpr<T> CwiseBinaryOp<Operation, Left, Right> Transpose<Expression> Block<Expression> Map<T> Ref<T> Evaluator<Expression> |
- 通常是
CRTP静态多态
|
1 |
expression.derived() |
|
1 2 3 4 5 6 7 8 9 |
template<class Derived> class ExpressionBase { public: const Derived& derived() const { return static_cast<const Derived&>(*this); } }; |
- 看到:
- 通常分别表示:
- 读取某个元素
- 获取可写元素
- 物化完整结果
- 把表达式计算到指定目标
|
1 2 3 4 |
coeff(i) coeffRef(i) eval() evalTo(destination) |
表达式模板和普通虚函数多态
| 表达式模板 | 虚函数对象 |
| 表达式类型编译期确定 | 具体类型运行时确定 |
| 通常可内联 | 虚调用可能妨碍部分内联 |
| 表达式结构进入类型系统 | 结构存在于运行时对象中 |
| 容易进行循环融合 | 通常逐对象调用 |
| 编译时间和代码量可能较大 | 编译依赖相对较小 |
很难提供稳定 ABI |
更适合跨模块接口 |
| 生命周期规则复杂 | 通常由智能指针管理对象 |
其他
std::monostate
std::monostate是一个没有业务数据的占位类型- 常用来表达:
- 尚未初始化
- 空状态
- 状态机的起始状态
- 没有结果
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ C++20_第二篇03/21
- ♥ SOUI源码:log4z06/24
- ♥ 数据结构模板03/09
- ♥ COM组件_303/07
- ♥ C++并发编程 _ 共享数据05/16
- ♥ 51CTO:Linux C++网络编程一08/13
