• 忘掉天地
  • 仿佛也想不起自己
bingliaolongBingliaolong  2026-08-10 17:00 Aet 隐藏边栏 |   抢沙发  1 
文章评分 1 次,平均分 5.0

std::variantstd::visit

概述

  1. std::variantC++17 标准库提供的类型安全联合体

variant 解决什么问题

  1. 假设服务器收到的消息可能是
    1. 任意时刻,一条消息只会是其中一种

  1. 传统做法是手工组合枚举和 union

  1. 问题在于:

    1. 必须手动记录当前有效成员
    2. 必须手动调用非平凡成员的构造和析构
    3. type 与真正活动的成员可能不一致
    4. 复制、移动和异常安全很难实现
    5. 访问错误的非活动成员可能产生未定义行为
  2. std::variant 把这套逻辑封装起来

  1. 核心模型是
    1. variant<Ts...> 拥有一块足以存放最大候选类型的存储空间,并用一个索引记录当前存放的是哪一种类型

最小示例

  1. variant 在不处于异常状态时,始终有且只有一个活动候选类型

tuple 的区别

类型 实际保存的对象
tuple<int, string, double> 同时保存三个对象
variant<int, string, double> 任意时刻通常只保存其中一个
optional<int> 保存一个 int 或不保存
any 保存一个运行时确定的任意可复制类型

默认构造规则

  1. variant 默认构造第一个候选类型

  1. 如果第一个类型不能默认构造
    1. 可以把 std::monostate 放在第一位

构造指定候选类型

  1. 根据值选择
    1. 编译器通过重载决议选择候选类型

  1. in_place_type

  1. in_place_index

读取当前值

  1. index()索引按照模板参数顺序从零开始

  1. holds_alternative
    1. 要求该类型在候选列表中恰好出现一次

  1. std::get
    1. 如果请求的类型不是当前活动类型

  1. std::get_if
    1. 不想使用异常时,可以使用 get_if
    2. 需要注意,get_if 接收的是 variant 指针

  1. 总结建议
需求 工具
已经确定当前类型 std::get
想进行非异常分支判断 std::get_if
只判断某类型是否活动 holds_alternative
对所有类型统一分发 std::visit

std::visit:对活动类型进行分发

  1. 假设

  1. 虽然运行时只执行其中一个,但编译器要求访问器能够处理所有可能类型

overloaded模式

  1. 不同消息通常需要不同处理逻辑。可以用重载函数对象:

  1. 这里使用了:
    1. 可变参数模板
    2. 参数包继承;
    3. using 包展开
    4. C++17 CTAD

网络消息分发示例

  1. 这种设计有一个很重要的性质
    1. 那么原来的 visit 会因为不能处理 ChatMessage 而编译失败
    2. 这提供了一种编译期的“分支完整性检查”

  1. 但如果访问器包含兜底分支:
    1. 新增类型便不会触发错误

访问器的返回类型

  1. std::visit 可以返回值

  1. C++17 不带显式返回类型的 visit 要求所有可能调用产生相同的返回类型和相同的值类别

  1. 需要显式统一

  1. C++20 增加了显式返回类型形式

值类别会继续传播

  1. 如果传递左值
    1. 访问器接收 std::string&

  1. 如果传递 const 左值

  1. 如果传递右值
    1. 访问器可以收到 std::string&&

  1. 看到下面的:
    1. 要意识到访问器可能把消息中的资源移动走
    2. 之后 message 依然是一个合法的 variant,但内部对象可能处于已移动状态

valueless_by_exception

  1. 通常我们认为 variant 总是保存一个候选对象,但存在一个特殊状态:

  1. 例如,类型切换时通常需要:
    1. 销毁原活动对象
    2. 构造新类型对象

  1. 具体操作是否会进入无值状态,还受候选类型的复制、移动以及 noexcept 属性影响,不能认为每次异常都会如此

简化的底层实现模型

  1. 它还需要完成:
    1. 根据索引销毁正确类型
    2. 复制或移动当前活动对象
    3. 根据类型切换构造新对象
    4. 实现 get<I>
    5. 实现异常安全
    6. 实现比较操作
    7. 实现访问器分发

如何根据索引析构

  1. 可以为每一种候选类型生成一个析构函数,再组成函数指针表

  1. visit 也可以采用类似的函数指针表或 switch 实现

variant访问

  1. std::visit 可以同时访问多个 variant

  1. 编译器需要为所有组合验证并可能生成调用
left right
int int
int double
string int
string double

  1. 这可能导致:
    1. 编译时间增加
    2. 生成代码体积增加
    3. 错误信息变长
    4. 访问器必须处理大量组合

variant 与虚函数多态

  1. 两者都能表示“多种类型之一”,但模型不同
std::variant 虚函数多态
候选类型集合封闭 派生类型集合开放
通常按值保存 通常通过指针或引用操作
调用逻辑放在 visitor 调用逻辑放在虚函数中
新增类型会影响 visitor 新增派生类通常不影响调用者
新增一种操作较容易 新增虚操作需要修改类层次
可存放互不相关的类型 类型必须属于继承体系
可能造成模板实例化膨胀 存在虚调用和对象所有权设计
  1. 适合 variant
    1. 网络协议消息
    2. 编译器 AST 节点
    3. JSON value
    4. 状态机状态
    5. 解析器 token
    6. 返回值的几种确定结果
  2. 适合虚函数
    1. 插件体系
    2. 类型集合未来持续扩展
    3. 需要稳定 ABI
    4. 对象本来就具有明确继承关系
    5. 不希望所有调用方知道全部具体类型

网络工程中的注意点

  1. 不要把网络字节直接解释成 variant

  1. 原因是:
    1. variant 有内部活动索引
    2. 内存布局未标准化
    3. 候选类型可能包含指针和动态资源
    4. 网络数据不存在合法的 C++ 对象生命周期
    5. 还存在字节序、对齐和边界检查问题
  2. 正确流程是

常见错误

  1. 错误一:错误的候选类型
    1. 不确定时使用 get_ifvisit

  1. 错误二:类型出现多次
    1. 改用索引

  1. 错误三:保存引用

  1. 错误四:访问器返回类型不一致
    1. 显式统一返回类型

  1. 错误五:万能兜底隐藏遗漏
    1. 新增消息类型后,编译器不会提醒你补充专用处理

表达式模板

概述

  1. 表达式模板(Expression Templates)不是某一版 C++ 新增的语法,而是一种模板库设计技术
    1. 它在 EigenBlazextensor 等数值计算库中很常见
  2. 核心思想是
    1. 运算符先不执行计算,而是返回一个描述表达式的轻量对象
    2. 直到赋值或显式求值时,才遍历数据并完成计算
  3. 它综合使用了:
    1. 运算符重载
    2. 模板类型推导
    3. 完美转发
    4. 参数包或嵌套模板
    5. 静态多态
    6. 延迟求值
    7. 引用与对象生命周期管理

表达式模板解决什么问题

  1. 假设有三个大向量

  1. 编译器有时能消除部分复制,但动态内存分配和多次计算通常不能仅依赖复制消除解决

表达式树模型

  1. 根据左结合规则相当于:

  1. 可以编码成类型

  1. 这个对象不会立即生成结果向量

  1. 所以:
    1. 表达式树保存在类型和轻量对象中,实际数值计算发生在最终访问元素时

一个可运行的简化实现

  1. 实现一个支持下面计算的动态向量:

编译器处理了什么

  1. 考虑

  1. 第一步:计算 a + b

  1. 第二步:计算 (a + b) + c

  1. 第三步:赋值

为什么要区分左值和右值

  1. 最简单的表达式模板实现经常统一保存引用

  1. 因此我们的存储策略是
操作数 存储方式
左值对象 保存引用
右值临时表达式 移动并按值保存

  1. 真实库中的闭包存储规则可能更加复杂,还需要考虑
    1. const
    2. 引用包装器
    3. 标量
    4. view
    5. 映射对象
    6. 自定义 allocator
    7. 对齐与 SIMD 类型

延迟求值不是快照

  1. 表达式保存的通常是原对象引用

  1. 这是延迟求值的本质
    1. 结果取决于求值时的操作数状态,而不一定是表达式创建时的状态

操作类型也可以成为模板参数

  1. 刚才为加法单独写了

  1. 大型库通常抽象为:

auto 可能保存的不是结果

  1. 在使用表达式模板的库中

  1. result 很可能不是一个真正的向量,而是

  1. 它可能:
    1. 不拥有 ab
    2. 不包含计算结果
    3. 依赖 ab 继续存活
    4. 在每次访问时重新计算
    5. 在操作数变化后得到不同结果

别名问题

  1. 考虑逐元素加法

  1. 表达式模板库必须处理别名
    1. 检测源和目标是否共享内存
    2. 必要时创建临时对象
    3. 提供 .eval() 强制求值
    4. 提供 noalias() 让用户承诺没有别名

表达式模板不一定更快

  1. 它可能减少:
    1. 临时对象
    2. 动态分配
    3. 内存遍历次数
    4. 中间结果读写
  2. 但也可能造成:
    1. 编译时间增加
    2. 模板错误信息增长
    3. 生成代码膨胀
    4. 指令缓存压力
    5. 表达式重复计算
    6. 难以控制向量化
    7. 调试体验变差
    8. 复杂表达式寄存器压力增大

  1. 成熟库会使用
    1. 代价模型
    2. 子表达式缓存
    3. 强制物化
    4. SIMD packet
    5. 循环融合
    6. 特定表达式重写
    7. 编译期维度与形状分析

网络代码中的相似问题

  1. 假设设计一个延迟拼接缓冲区

  1. 如果 packet 是表达式对象,只保存两个 view,那么:
    1. 并不代表数据已经被复制

  1. 当异步线程执行时:
    1. header 可能已销毁
    2. 接收缓冲区可能已经 erase
    3. vector 扩容可能让指针失效
    4. payload 可能已被下一次接收覆盖

  1. 同步调用中可以使用延迟组合
    1. 前提是发送函数在返回前完成数据消费

  1. 异步调用通常要
    1. 物化成拥有数据的缓冲区
    2. 或让每个缓冲区由 shared_ptr 等对象管理
    3. 或使用 writev/scatter-gather,同时明确所有 buffer 的生命周期

大型项目中的识别模式

  1. 通常意味着表达式模板

  1. 通常是 CRTP 静态多态

  1. 看到:
    1. 通常分别表示:
    2. 读取某个元素
    3. 获取可写元素
    4. 物化完整结果
    5. 把表达式计算到指定目标

表达式模板和普通虚函数多态

表达式模板 虚函数对象
表达式类型编译期确定 具体类型运行时确定
通常可内联 虚调用可能妨碍部分内联
表达式结构进入类型系统 结构存在于运行时对象中
容易进行循环融合 通常逐对象调用
编译时间和代码量可能较大 编译依赖相对较小
很难提供稳定 ABI 更适合跨模块接口
生命周期规则复杂 通常由智能指针管理对象

其他

std::monostate

  1. std::monostate 是一个没有业务数据的占位类型
  2. 常用来表达:
    1. 尚未初始化
    2. 空状态
    3. 状态机的起始状态
    4. 没有结果

声明:本文为原创文章,版权归所有,欢迎分享本文,转载请保留出处!

bingliaolong
Bingliaolong 关注:0    粉丝:0
Everything will be better.

发表评论

表情 格式 链接 私密 签到
扫一扫二维码分享