C++17 std::make_any用法详解:类型安全容器与高效对象构造 1. 项目概述为什么我们需要std::make_any在 C17 之前处理类型不确定的对象是个麻烦事。你可能得用void*指针配合类型标签或者自己写一套复杂的类型擦除系统代码又长又容易出错。std::any的出现就是为了解决“安全地存储任意类型”这个需求。它就像一个类型安全的万能容器你可以在运行时把任何可拷贝构造的类型塞进去需要时再安全地取出来。而std::make_any则是创建这个万能容器的“快捷工厂”。很多刚接触std::any的朋友会直接用它的构造函数比如std::any a 42;或者std::any a(std::in_place_typeT, args...)。这当然可以但make_any提供了更清晰、更符合现代 C 习惯的创建方式。它的核心价值在于两点一是表达意图更明确看到make_any就知道这是在构造一个any对象二是完美转发参数能高效地将构造参数传递给内部存储的对象避免不必要的拷贝。对于复杂的对象构造比如需要多个参数或初始化列表make_any的语法比直接构造any要直观和简洁得多。简单来说如果你用过std::make_shared或std::make_unique那么std::make_any就是同一个设计理念下的产物让对象的创建更安全、更高效、更现代。这篇文章我就结合自己这些年踩过的坑和积累的经验带你彻底搞懂make_any的用法、原理以及那些官方文档里不会写的细节。2.std::make_any的核心语法与工作原理2.1 函数签名与重载解析我们先从最根本的语法看起。根据 C 标准库定义std::make_any有两个重载版本都定义在any头文件中。// 版本 (1): 最通用的形式 template class T, class... Args std::any make_any( Args... args ); // 版本 (2): 处理需要初始化列表的情况 template class T, class U, class... Args std::any make_any( std::initializer_listU il, Args... args );版本 (1) 是主力。它接受一个模板参数T你希望存储在any中的类型以及一个可变参数包Args...。这些Args会被完美转发给类型T的构造函数。这意味着make_anystd::string(hello, 3)实际上会调用std::string(hello, 3)来构造一个内容为hel的字符串对象然后将其封装进any。版本 (2) 专门用于处理需要std::initializer_list进行构造的类型比如std::vector。这里的U是初始化列表中元素的类型。这个重载的存在解决了直接使用版本 (1) 时编译器可能无法正确推导出你要用初始化列表构造T的尴尬。举个例子你想构造一个std::vectorint用make_anystd::vectorint({1, 2, 3})编译器可能会懵。而用版本 (2)你可以清晰地写成make_anystd::vectorint(std::initializer_listint{1, 2, 3})虽然有点啰嗦但意图绝对明确。在实际编码中我更推荐使用auto配合列表初始化来规避这个问题后面会详细说。这两个函数都返回一个构造好的std::any对象。根据标准它们的实现等价于版本 (1):return std::any(std::in_place_typeT, std::forwardArgs(args)...);版本 (2):return std::any(std::in_place_typeT, il, std::forwardArgs(args)...);这里的std::in_place_typeT是一个标签用于告诉any的构造函数“请原地in-place构造一个T类型的对象就用我后面给的这些参数”。make_any本质上就是这个过程的语法糖。2.2 底层机制类型擦除与 Small Buffer Optimization理解make_any干了什么还得稍微了解一下std::any的底层。any的核心是类型擦除。它内部持有一个某种“可调用”的接口通常是一个虚基类指针这个接口知道如何执行拷贝、移动、销毁以及查询类型等操作。具体存储的对象则通过继承这个接口来实现多态。一个关键的性能优化是SBOSmall Buffer Optimization。类似于std::string或std::functionstd::any也会在对象内部预留一小块缓冲区具体大小是实现定义的通常能容纳两个或三个指针大小的对象。如果要存储的对象尺寸小于等于这个缓冲区就直接在栈上的这块缓冲区里构造这就是“原地构造”的由来避免了堆内存分配的开销。如果对象太大则会在堆上分配内存。make_any的调用最终会触发这个决策过程。编译器根据T的大小和对齐要求决定使用 SBO 还是堆分配。这对我们来说是透明的但知道这一点有助于理解为什么any在存储小对象时效率很高。注意SBO 的实现是编译器相关的。这意味着用make_any创建一个any在 MSVC、GCC 和 Clang 下的内存布局和性能特征可能略有不同。对于性能临界代码如果需要确定性最好进行测试。3.std::make_any的实战用法详解光说不练假把式我们直接上代码看看make_any在各种场景下怎么用。3.1 基础类型与自定义类型的存储对于基础类型和简单的自定义结构体make_any用起来非常直观。#include any #include iostream #include string struct Point { int x, y; Point(int a, int b) : x(a), y(b) { std::cout Constructing Point( x , y )\n; } ~Point() { std::cout Destroying Point\n; } }; int main() { // 存储基础类型 auto any_int std::make_anyint(42); auto any_double std::make_anydouble(3.14159); auto any_string std::make_anystd::string(Hello, make_any!); // 存储自定义类型传递构造参数 auto any_point std::make_anyPoint(10, 20); // 调用 Point(10, 20) // 通过 any_cast 获取值 (注意返回的是副本) int i std::any_castint(any_int); std::string s std::any_caststd::string(any_string); Point p std::any_castPoint(any_point); // 这里会调用 Point 的拷贝构造函数 std::cout Int: i \n; std::cout String: s \n; std::cout Point: ( p.x , p.y )\n; return 0; } // 输出可能类似于 // Constructing Point(10, 20) // Constructing Point(10, 20) // 注意这是 any_cast 时拷贝构造的 // Int: 42 // String: Hello, make_any! // Point: (10, 20) // Destroying Point // 拷贝的 p 被销毁 // Destroying Point // any 中的 Point 被销毁这里有个非常重要的细节std::any_castT(any_object)返回的是T类型的一个副本。如果T是像Point这样有自定义构造/析构的类型就会发生拷贝。对于昂贵的对象这可能是性能瓶颈。正确的做法是使用引用形式的any_cast。3.2 使用引用避免拷贝处理多态与大型对象为了避免不必要的拷贝any_cast支持返回引用或指针。// 接上例 // 错误尝试修改副本原 any 中的值不变 Point p_copy std::any_castPoint(any_point); p_copy.x 100; std::cout After modifying copy: std::any_castPoint(any_point).x \n; // 输出 10 // 正确获取引用 Point p_ref std::any_castPoint(any_point); // 注意模板参数是 Point p_ref.x 100; std::cout After modifying ref: std::any_castPoint(any_point).x \n; // 输出 100 // 正确获取常量引用如果 any 存储的是 const 对象 const Point p_cref std::any_castconst Point(any_point); // 正确获取指针失败时返回 nullptr Point* p_ptr std::any_castPoint(any_point); // 参数是 any 的地址 if (p_ptr) { p_ptr-y 200; }对于大型对象比如一个大向量或多态对象基类指针指向派生类使用引用或指针是必须的。考虑以下场景#include any #include vector #include memory class Base { public: virtual void foo() { std::cout Base\n; } virtual ~Base() default; }; class Derived : public Base { public: void foo() override { std::cout Derived\n; } }; int main() { // 存储大型对象使用移动语义 std::vectorint huge_vec(1000000, 42); // 错误做法make_any 会拷贝 huge_vec // auto any_vec_bad std::make_anystd::vectorint(huge_vec); // 正确做法移动进去 auto any_vec_good std::make_anystd::vectorint(std::move(huge_vec)); // 此时 huge_vec 为空 // 获取引用避免 any_cast 时再次拷贝 std::vectorint vec_ref std::any_caststd::vectorint(any_vec_good); std::cout Vector size: vec_ref.size() \n; // 输出 1000000 // 存储多态对象存储 std::unique_ptrBase auto any_poly std::make_anystd::unique_ptrBase(std::make_uniqueDerived()); // 通过 any_cast 获取 unique_ptr 的引用然后调用虚函数 std::unique_ptrBase ptr_ref std::any_caststd::unique_ptrBase(any_poly); ptr_ref-foo(); // 输出 Derived return 0; }实操心得我强烈建议只要存储的不是int、double这种微不足道的类型在any_cast时都优先考虑使用引用any_castT。这能明确你的意图——你是想访问any内部的那个对象而不是要一个它的拷贝。这既是性能优化也能避免一些意想不到的逻辑错误。3.3 处理需要初始化列表的容器类型这是make_any第二个重载大显身手的地方也是最容易让人困惑的地方。假设我们想存储一个std::vectorstd::string。#include any #include vector #include string int main() { // 方法1使用 auto 推导这是最简洁的方式推荐 auto any_vec1 std::make_anystd::vectorstd::string(std::vectorstd::string{apple, banana, cherry}); // 等价于先构造一个 vector再移动给 make_any // 方法2使用第二个重载显式传递 initializer_list auto any_vec2 std::make_anystd::vectorstd::string( std::initializer_liststd::string{apple, banana, cherry} ); // 方法3容易出错直接传递初始化列表。这在某些编译器/标准库版本下可能无法编译或产生非预期行为。 // auto any_vec3 std::make_anystd::vectorstd::string({apple, banana, cherry}); // 编译器可能无法推导出 Args... 包因为初始化列表不是表达式没有明确的类型。 // 验证 auto vec std::any_caststd::vectorstd::string(any_vec1); for (const auto s : vec) { std::cout s ; } std::cout \n; return 0; }为什么直接传{apple, ...}不行因为{apple, banana, cherry}作为一个花括号初始化列表它本身没有类型在模板参数推导阶段是个“非推导上下文”。编译器很难确定它应该被转换成std::initializer_liststd::string还是其他什么东西。因此最安全、最通用的做法就是方法1显式地构造一个std::vector临时对象这里会触发移动语义效率很高然后交给make_any。对于std::map,std::set等同样适用auto any_map std::make_anystd::mapint, std::string( std::mapint, std::string{{1, one}, {2, two}} );3.4 与std::any_cast的配合与错误处理创建了any对象最终目的是把值取出来用。std::any_cast是唯一的“钥匙”但它也可能抛异常。#include any #include iostream int main() { auto a std::make_anyint(100); try { // 尝试转换成错误的类型 double d std::any_castdouble(a); // 抛出 std::bad_any_cast } catch (const std::bad_any_cast e) { std::cout Caught exception: e.what() \n; // 通常输出 bad any_cast } // 安全转换使用指针版本 if (auto* ptr std::any_castdouble(a)) { // 传入 any 的地址 std::cout Its a double: *ptr \n; } else { std::cout Its NOT a double.\n; } // 检查 any 中是否存储了值及其类型 if (a.has_value()) { std::cout a has value, type is: a.type().name() \n; // type().name() 返回的类型名是实现定义的可能不可读如 i 代表 int } // 重置 any 对象 a.reset(); if (!a.has_value()) { std::cout a is now empty.\n; } return 0; }关键点异常安全如果类型不匹配any_castT(any)会抛出std::bad_any_cast。务必在不确定类型时使用try-catch或者使用指针版本。指针版本any_castT(any)在失败时返回nullptr不会抛出异常。这是进行“安全试探”的首选方法。type()成员函数它返回std::type_info的引用可以用来比较类型。但type_info::name()的字符串格式是编译器相关的比如 GCC 返回的可能是iMSVC 返回的可能是int不适合用于逻辑判断。比较类型应该用a.type() typeid(int)。reset()清空any对象使其不包含任何值。析构时内部存储的对象也会被正确销毁。4. 高级主题make_any的陷阱、技巧与性能考量掌握了基本用法我们来看看那些容易踩坑的地方和一些进阶技巧。4.1 陷阱一Lambda 表达式的类型处理这是官方示例里就提到的一个经典陷阱。Lambda 表达式每个都是独一无二的类型闭包类型。#include any #include functional #include iostream int main() { // 陷阱直接存储 lambda std::any a1 []() { std::cout Lambda 1\n; }; std::cout a1 type: a1.type().name() \n; // 输出一个编译器生成的独特类型名 // 尝试将其转换为 std::functionvoid() 会失败 try { std::any_caststd::functionvoid()(a1)(); // 抛出 bad_any_cast } catch (...) { std::cout Cast failed! Lambda type is unique.\n; } // 正确做法使用 make_any 明确指定存储类型为 std::functionvoid() auto a2 std::make_anystd::functionvoid()( []() { std::cout Lambda 2\n; } ); std::cout a2 type: a2.type().name() \n; // 输出与 std::function 相关的类型名 std::any_caststd::functionvoid()(a2)(); // 成功调用输出 Lambda 2 return 0; }原因分析a1存储的是 lambda 表达式本身的闭包类型比如main::lambda()而std::functionvoid()是一个完全不同的、可调用的包装器类型。any_cast要求类型完全匹配所以转换失败。结论如果你想在any中存储一个可调用对象并希望后续能以一种统一的接口如std::function调用它那么必须在创建时就通过make_any将其转换为目标类型。直接赋值 lambda 进去它的类型就被“锁定”为那个独特的闭包类型了。4.2 陷阱二生命周期管理与std::any的拷贝语义std::any管理着内部对象的生命周期。当any被拷贝、移动或销毁时内部对象也会相应地被拷贝、移动或销毁。这要求存储的类型必须是可拷贝构造的对于拷贝操作或可移动构造的对于移动操作。#include any #include iostream struct NonCopyable { NonCopyable(int v) : value(v) {} NonCopyable(const NonCopyable) delete; // 禁止拷贝 NonCopyable operator(const NonCopyable) delete; int value; }; int main() { NonCopyable nc(42); // 错误无法拷贝构造因为 make_any 内部需要构造一个 T 的实例。 // auto a1 std::make_anyNonCopyable(nc); // 正确移动构造是允许的如果 NonCopyable 定义了移动构造函数或编译器生成了默认的。 // 假设 NonCopyable 有移动语义编译器通常为只有简单成员的结构生成默认移动操作 auto a2 std::make_anyNonCopyable(std::move(nc)); // 调用移动构造函数 // 或者直接传递构造参数 auto a3 std::make_anyNonCopyable(100); // 直接调用 NonCopyable(100) // 但是拷贝 any 对象本身会失败因为内部对象不可拷贝。 // auto a4 a3; // 编译错误std::any 的拷贝构造函数要求内部 T 可拷贝。 // 移动 any 对象是可行的 auto a5 std::move(a3); // a3 被移空a5 获得所有权 if (!a3.has_value()) { std::cout a3 is empty after move.\n; } return 0; }重要规则std::any要求其模板参数T满足CopyConstructible条件因为any本身需要支持拷贝。如果你存储了一个不可拷贝的类型如std::unique_ptr或自定义的只移动类型那么这个any对象将不能被拷贝只能被移动。使用make_any时如果传递一个左值它会尝试拷贝如果希望移动请使用std::move。4.3 技巧结合std::any实现简单的类型安全异构容器std::any的一个典型应用场景是构建异构容器比如一个可以存放多种类型元素的向量。结合std::make_any我们可以优雅地初始化它。#include any #include vector #include string #include iostream #include typeinfo class HeterogeneousContainer { std::vectorstd::any data_; public: templatetypename T void push(T value) { // 使用完美转发支持拷贝和移动 data_.push_back(std::make_anystd::decay_tT(std::forwardT(value))); } templatetypename T bool try_get(size_t index, T out_value) const { if (index data_.size()) return false; const std::any elem data_[index]; if (elem.type() ! typeid(T)) return false; try { out_value std::any_castT(elem); return true; } catch (const std::bad_any_cast) { return false; } } void print_types() const { for (size_t i 0; i data_.size(); i) { std::cout [ i ]: data_[i].type().name() \n; } } }; int main() { HeterogeneousContainer container; container.push(42); // int container.push(3.14); // double container.push(std::string(hello)); // std::string container.push(std::vectorint{1,2,3});// std::vectorint container.print_types(); int i; if (container.try_get(0, i)) { std::cout Got int at index 0: i \n; } std::string s; if (container.try_get(2, s)) { std::cout Got string at index 2: s \n; } // 尝试获取错误类型 double d; if (!container.try_get(1, d)) { // 索引1存储的是 double但这里我们故意用 int 去取不这里演示的是正确获取。 std::cout Failed to get value.\n; } else { std::cout Got double at index 1: d \n; } return 0; }在这个例子中push方法利用make_any和完美转发可以高效地存储任何类型的值。try_get提供了类型安全的访问。这是一种比使用union或void*安全得多的实现异构容器的方式。4.4 性能考量与最佳实践类型查询开销每次调用any_cast或type()都涉及到一次类型比较typeid比较或内部类型信息比较。虽然这个开销很小但在极高频的循环中仍需注意。拷贝开销any的拷贝需要拷贝内部对象。如果内部对象很大如大容器拷贝成本会很高。在需要传递any时优先考虑使用移动std::move或常量引用。堆分配开销当存储的对象太大无法放入 SBO 缓冲区时any会在堆上分配内存。频繁创建和销毁大型对象的any可能导致堆内存碎片。对于性能敏感的场景如果类型范围是已知的使用std::variantC17可能是更好的选择因为它是在栈上分配固定大小的存储没有动态内存分配开销。何时用any何时用variant用std::any当需要存储的类型集合在编译期完全未知或非常庞大时。例如插件系统、脚本引擎绑定、序列化框架中存储任意用户数据。用std::variant当需要存储的类型集合在编译期已知且数量有限时通常不超过十几个。variant提供类型安全的访问std::visit性能通常优于any并且能利用栈存储。make_anyvs 直接构造在大多数情况下make_any和直接使用std::any的in_place_type构造函数在性能上没有区别。make_any的优势在于代码可读性和一致性与现代 C 的make_*工厂函数家族保持一致。我个人的习惯是只要不是最简单的any a value;这种形式一律使用make_any让“构造”这个意图更明显。5. 常见问题排查与调试技巧在实际项目中用any和make_any难免会遇到一些奇怪的问题。这里记录几个我踩过的坑和解决方法。5.1 问题any_cast抛出bad_any_cast但类型看起来是对的场景你确信any里存的是int但any_castint却失败了。可能原因及排查类型不严格匹配any_cast要求精确匹配。int和const int、int、volatile int都被视为不同的类型。std::any a 42; // auto x std::any_castconst int(a); // 错误类型是 int不是 const int auto x std::any_castint(a); // 正确 auto x_ref std::any_castint(a); // 正确获取引用解决方法是使用type()成员函数检查实际类型或者使用any_cast的指针版本进行安全尝试。跨动态链接库边界如果你在一个 DLL/so 中创建any比如存储了一个自定义类对象在另一个 DLL/so 中尝试any_cast可能会失败。这是因为typeid的比较可能依赖于运行时类型信息RTTI的实现细节跨模块时可能不匹配。这是一个非常棘手的问题通常的解决方案是避免跨模块传递具体类型的any或者使用类型名type().name()进行字符串比较虽然不可靠或者设计一套不依赖typeid的类型标识系统。any对象已被移动或重置移动一个any对象后源对象变为空。对空的any进行any_cast会抛出bad_any_cast。auto a1 std::make_anyint(1); auto a2 std::move(a1); // a1 被移空 // int x std::any_castint(a1); // 抛出异常a1 现在没有值。在any_cast前先用has_value()检查一下是个好习惯。5.2 问题使用make_any时编译错误 “no matching function”场景auto a std::make_anyMyClass(arg1, arg2);编译失败。可能原因及排查参数无法转换为目标构造函数的参数检查MyClass是否存在接受(arg1类型, arg2类型)的构造函数。make_any的本质是完美转发参数给T的构造函数。遇到了 Most Vexing Parse如果你的参数看起来像是一个函数声明编译器可能会误解。// 假设有类 Timer 可以用一个 int 初始化 auto a std::make_anyTimer(10); // 正确构造一个延迟10的Timer // 但如果 Timer 有一个构造函数接受一个函数指针 // 某些情况下复杂的参数列表可能导致解析歧义。解决方法是确保参数列表清晰或者使用花括号初始化但要注意前面提到的初始化列表问题。缺少必要的头文件或命名空间确保MyClass的定义在作用域内可见。C 版本过低std::make_any是 C17 特性。确认你的编译器开启了 C17 或更高标准-stdc17//std:c17。5.3 调试技巧打印any中存储的内容由于any的类型是运行时确定的直接打印其内容很困难。一个实用的调试方法是写一个辅助函数针对几种已知的常用类型进行尝试性转换和打印。#include any #include iostream #include string #include typeinfo void debug_print_any(const std::any a) { if (!a.has_value()) { std::cout (empty); return; } const std::type_info ti a.type(); // 尝试几种常见类型 if (ti typeid(int)) { std::cout std::any_castint(a); } else if (ti typeid(double)) { std::cout std::any_castdouble(a); } else if (ti typeid(std::string)) { std::cout \ std::any_caststd::string(a) \; } else if (ti typeid(const char*)) { std::cout \ std::any_castconst char*(a) \; } else { // 对于未知类型至少打印类型名 std::cout [object of type: ti.name() ]; } } int main() { std::any arr[] { std::make_anyint(42), std::make_anydouble(3.14), std::make_anystd::string(test), std::any() // empty }; for (const auto a : arr) { debug_print_any(a); std::cout , ; } std::cout \n; // 输出可能类似42, 3.14, test, (empty), }当然对于生产代码可能需要更完善的类型注册和访问机制。但这样一个简单的调试函数在开发阶段能解决大部分问题。std::make_any作为std::any的伴侣函数将类型安全的动态类型存储这一强大功能以符合现代 C 习惯的方式呈现给我们。从简单的值存储到复杂的异构容器再到与多态对象的配合它提供了一种灵活且相对安全的机制。核心在于理解其“完美转发参数进行原地构造”的原理并时刻牢记any_cast的精确类型匹配和引用/指针用法以避免性能陷阱。在实际项目中权衡any的灵活性与variant的性能和类型安全选择最适合的工具才是用好这些现代 C 特性的关键。