昨天在写《深入探讨C++的高级反射机制(2):写个能用的反射库》的时候,正好遇到动态反射需要的类型擦除技术。所谓的类型擦除,就是在两个模块之间的接口层没有任何类型信息,实现两个模块之间安全的通信。可以理解为:
为了实现这个功能,于是用到了std::any这个工具。考虑到许多开发者分不清std::variant和std::any之间的区别,于是萌发了写一篇文章系统性介绍一下他们的想法。
传统上,C++17标准为C++的类型系统和容器库带来了重要的补充和改进,std::optional, std::any, 和 std::variant这三种类型被统称为“C++17容器三剑客”,本文将在C++11引入在C++17获得的增强的std::tuple也纳入介绍,系统性地揭示C++体系中这类容器的完整面貌。
1. std::optional —— 语义明确的可选值
std::optional是一个模板类型,它提供了一种表示“可能没有值”的方式。在C++17之前,程序员通常会使用指针、特殊值或者布尔标记来表达这种“可选”语义,这些方法都有其局限性和缺陷。std::optional的出现,让这种表达方式变得更加安全和直观。
1.1 std::optional的基本概念
std::optional可以看作是一个可能包含类型T的值的容器。它提供了一种检查是否存储了值的安全方式,并且可以用简洁的API访问该值或者处理值不存在的情况。
1.2 使用场景
- 函数可能无法返回有效值时
- 配置项可能未设置时
- 缓存结果可能不存在时
1.3 基本用法
#include <optional>
#include <iostream>
std::optional<int> maybeGetInt(bool flag) {
if (flag) {
return 123; // 返回有效的int
}
return {}; // 返回一个空的optional
}
int main() {
auto val = maybeGetInt(true);
if (val) { // 检查是否含有值
std::cout << "Value: " << *val << std::endl; // 解引用访问值
}
auto noVal = maybeGetInt(false);
std::cout << "No value: " << noVal.value_or(-1) << std::endl; // 使用value_or提供默认值
return 0;
}
2. std::any —— 类型安全的void*
对于需要存储任意类型的值,C++17提供了std::any。这个容器可以存储任何类型的单个值,并且能够在运行时安全地访问存储的值。这对于编写泛型代码或者需要类型擦除的场景非常有用。非常类似C语义中的void*,不过差别是void*本身不能直接存储对象,而std::any本身提供了存储对象的能力。当然,也可以用std::any存储指针类型。
2.1 std::any的基本概念
std::any可以存储任意类型的值,只要该类型是可复制的。使用std::any_cast可以试图取回原始类型的值,如果类型不匹配,会抛出std::bad_any_cast异常。
2.2 使用场景
- 动态类型的API设计
- 类型安全的容器
- 简化类型擦除实现
2.3 基本用法
#include <any>
#include <iostream>
int main() {
std::any a = 10;
std::cout << std::any_cast<int>(a) << std::endl; // 正确类型转换
a = std::string("Hello, std::any!");
std::cout << std::any_cast<std::string>(a) << std::endl; // 正确类型转换
try {
std::cout << std::any_cast<float>(a) << std::endl; // 错误类型转换,将抛出异常
} catch (const std::bad_any_cast& e) {
std::cout << e.what() << std::endl;
}
// 你可以使用不抛异常的指针版本的转换:
auto casted_a = std::any_cast<float*>(a); // 错误类型转换,但不会抛异常,返回空指针
return 0;
}
2.4 实现原理
std::any 的本质就是一段内存,内存承载的对象是“CopyConstructible”。在MSVC的STL中,如果是平凡对象,则直接存储,否则如果对象地址小于48字节,那么就会直接存储在std::any成员数组中,如果都不满足,就会通过malloc申请对于内存进行存储。
std::any在还原类型时,核心逻辑如下:
这段代码笔者依次注释如下:
template <class _Decayed>
_NODISCARD const _Decayed* _Cast() const noexcept {
const type_info* const _Info = _TypeInfo();
// 首先获取 std::any 对象当前存储值的类型信息。如果没有存储任何值(_Info 为空),或者存储的值的类型不是 _Decayed 类型,那么函数会返回 nullptr。
if (!_Info || *_Info != typeid(_Decayed)) {
return nullptr;
}
// 判断 _Decayed 类型是否为简单类型(POD 类型,可以直接复制内存)
if constexpr (_Any_is_trivial<_Decayed>) {
// 获取指向存储的 _Decayed 类型的平凡(trivial)值的指针
return reinterpret_cast<const _Decayed*>(&_Storage._TrivialData);
}
// 判断 _Decayed 类型是否足够小,可以存储在 std::any 的小对象优化缓冲区中
else if constexpr (_Any_is_small<_Decayed>) {
// 获取指向存储的 _Decayed 类型的小对象(small object)值的指针
return reinterpret_cast<const _Decayed*>(&_Storage._SmallStorage._Data);
}
// 大对象情况,即 _Decayed 类型的对象无法放入小对象优化缓冲区中,需要动态分配内存
else {
// 获取指向存储的 _Decayed 类型的大对象(big object)值的指针
return static_cast<const _Decayed*>(_Storage._BigStorage._Ptr);
}
}
3. std::variant —— 安全的联合体
std::variant是一个类型安全的联合体。它可以存储定义在它的模板参数列表中的任意类型的值。与C联合体不同的是,std::variant总是知道它当前存储的是哪种类型的值。
3.1 std::variant的基本概念
std::variant<…>可以被理解为一个可以存储多种类型中的一种的容器。使用std::get或std::get_if可以安全地访问存储的值。如果访问的类型不是当前存储的类型,会抛出std::bad_variant_access异常。
3.2 使用场景
- 需要在同一位置存储不同类型值的情况
- 替代传统的union或void*指针
- 类型安全的状态机实现
3.3 基本用法
#include <variant>
#include <iostream>
#include <string>
int main() {
std::variant<int, std::string> v = 20;
std::cout << std::get<int>(v) << std::endl; // 正确类型访问
v = "Variant can hold a string now!";
std::cout << std::get<std::string>(v) << std::endl; // 正确类型访问
try {
std::cout << std::get<double>(v) << std::endl; // 错误类型访问,将抛出异常
} catch (const std::bad_variant_access& e) {
std::cout << e.what() << std::endl;
}
return 0;
}
4. std::tuple—— 异构元素的组合器
虽然std::tuple并非C++17的新特性,它自C++11起就已经存在,但它在C++17获得更好的完善和加强,并且与std::variant有着互补的特性,在处理类型异构的数据结构时非常有用。
std::tuple
允许我们将任意数量和类型的元素组合成单一对象。与std::variant
相比,std::tuple
可以存储多个不同类型的值,同时保持每个值的类型信息。这使得std::tuple
成为了执行多任务返回值、聚合不同类型数据以及实现类型相关算法的理想选择。
4.1 std::tuple的基本概念
std::tuple<T1, T2, ..., TN>
可以看作是一个异构的固定大小容器,它可以包含任意数目(N)的不同类型(T1, T2, …, TN)的元素。std::tuple
对于打包数据和从函数返回多个值非常有用。
4.2 使用场景
- 函数需要返回多个值时
- 将一组不同类型的数据作为单个单位处理时
- 用于实现编译时计算和元编程技术
4.3 基本用法
#include <tuple>
#include <string>
#include <iostream>
std::tuple<int, std::string, float> createComplexObject() {
return std::make_tuple(42, "Test", 3.14f);
}
int main() {
auto [id, name, value] = createComplexObject(); // 结构化绑定(C++17特性)
std::cout << "ID: " << id << std::endl;
std::cout << "Name: " << name << std::endl;
std::cout << "Value: " << value << std::endl;
// 也可以使用std::get访问tuple中的元素
std::tuple<int, double, std::string> t = std::make_tuple(1, 2.0, "tuple");
std::cout << "First element: " << std::get<0>(t) << std::endl;
std::cout << "Second element: " << std::get<1>(t) << std::endl;
return 0;
}
总结:
- std::optional:常常用于代替nullptr实现空安全(用来包装非指针类型)或其他可能为空的场景。
- std::any:STL中少有的不需要指定容器内容类型的模板类(但是在使用时需要传入容器内容类型以获取内容),常用于类型擦除。相当于C++版本的void*。
- std::variant:C++版本的“联合体”。提供了类型安全的联合功能。
- std::tuple:std::pair 的增强版,支持任意数量的异构元素存储。
如果你的编译器不支持C++17,那么可以了解Boost 库提供的 Boost.Variant 和 Boost.Any 类型。它们提供了类似的功能,在旧代码或不支持 C++17 的环境中,可以考虑采用作为代替。