C++ 高阶技巧:在同一对象中存储左值或右值的两种实现

发布时间:2026/9/24 23:35:52
C++ 高阶技巧:在同一对象中存储左值或右值的两种实现 写 C 写了几年我越来越觉得“左值”“右值”不是教科书里用来考试的概念而是真实影响你接口设计的东西。之前有个朋友问我能不能写一个包装类既能存左值变量又能存右值临时对象而且左值传入时只保留引用、不触发拷贝右值传入时直接把资源接管过来翻译成标题里的说法就是“在同一对象中存储左值或右值”。这类需求在回调捕获、事件参数传递、表达式模板、任务队列里非常常见如果你只会写死版本就很容易被迫多写几套重载或者被迫在性能上妥协。这篇文章我会从值类别讲起先说明为什么同一个容器需要区分左值右值然后给出两类可落地方案一类是基于std::variant的安全实现一类是手写union的底层实现。最后会重点聊生命周期、悬垂引用和编译期匪夷所思的报错。无论你是已经把std::move用得飞起还是刚看完一篇左值右值入门还没完全消化都能照着这篇文章把代码写出来并且知道每个分支为什么要这么写。1. 先别急着写代码左值和右值在存储时到底差在哪1.1 左值有“身份”右值有“东西”C11 之后值类别被分成了左值、纯右值、亡值等几类但实际写代码时我们只需要抓住两个特征左值可以取地址、有名字、生命周期通常由程序员控制右值是临时的、马上要销毁的东西它的资源可以被“偷”走。比如int x 3;里的x是左值直接写3是右值。再比如函数返回一个结构体return createObj();中间的临时对象是右值但如果你用一个const T绑定它C 会帮它延长生命周期到引用死亡。存储这个动作之所以要区分它们是因为对待方式完全不一样。左值应该尽量用引用或指针去“观察”它不打扰原来对象的生活右值则适合“连锅端”把里面的资源移动进新容器因为它自己反正都要销毁移动之后留下的“空壳”没人关心。这就是为什么我们不希望一个通用存储类总是按值拷贝如果传入一个 100MB 的std::vector左值你只想保存它的引用结果拷贝了一份内存瞬间翻倍如果传入的是临时std::vector你又希望它移动进来而不是再做一次无意义的深拷贝。1.2 什么业务场景会逼着你在“存左值”和“存右值”之间二选一最典型的场景是事件分发。假设你有一个事件总线注册回调时需要把某个参数对象暂存起来等事件真正触发时再取出。这个参数可能是调用者长期持有的一块数据也可能是一个刚从工厂函数返回的临时对象。前者必须存引用否则修改不生效后者必须拥有所有权否则临时对象立刻销毁后面取到的全是悬垂引用。第二个场景是延迟求值或惰性绑定。比如一个日志系统你现在收到一个字符串左值希望在条件满足时才去格式化输出过了几行代码你又收到一个std::string(临时内容)右值。如果存储容器只能存引用临时内容就处于悬垂边缘如果只能存值左值参数就会被平白无故拷贝一份。要兼顾两种诉求就得让同一个存储对象根据传入参数的值类别自动决定自己“指”还是“有”。第三个场景是表达式模板和代理对象。a b c这种表达式会生成一系列临时对象中间结果属于右值而用户可能又会拿一个具名变量a直接参与构造链。为了不造成无谓的拷贝内层包装往往既要能引用外部持久变量也要能“吞下”临时子表达式结果。1.3 常见做法的天花板很多人的第一反应是写两个构造函数重载一个接收const T存拷贝一个接收T存移动。但这么做的问题在于如果只想要左值引用而不是拷贝就没辙了。还有人是把成员类型设计成std::unique_ptrT无论左值右值都一律拷贝或移动进堆区。这个方案简单但左值永远无法获得“非拷贝”待遇而且多一次堆分配。真正进阶的做法是让存储对象本身成为一个小小的“策略分发器”检测到左值成员区域变成指针检测到右值成员区域原地构造一个T。这里的核心难点在于 C 的引用折叠、完美转发和生命周期管理必须配合好否则不是你编译不过就是你运行起来直接崩溃。2. 方案设计一个既能“引用”又能“持有”的存储类2.1 总体思路用两个分支去应对两种人生我们把存储对象看成一个带标签的容器内部有两个可选状态状态一我存了一个指针或引用包装器指向外部某个左值对象。状态二我直接持有了一份移动构造得到的T对象这个对象的数据来自某个右值。对外表现统一都提供一个get()返回T。对内则要根据状态决定返回方式状态一解引用指针状态二直接返回内部成员。问题是怎么设计这个“带标签的容器”。最简单的是把T*和T同时放进一个类里再用一个bool标记当前是哪种状态。但直接写成T* ptr_; T value_; bool is_owned_;会有一个致命问题无论当前是哪种状态两个成员都会同时存在T被默认构造浪费空间不说如果T没有默认构造函数整个类直接编译失败。真正的解决思路有两个一是用std::variant让类型系统帮我们管理活跃成员和析构二是手写union把空间重叠起来自己负责生命周期。两种方案各有取舍下面逐一展开。2.2 用std::variant实现干净版本C17 的std::variant本身就是一种受检 union它可以容纳多个特定类型但某个时刻只有一个类型是活跃的。我们可以定义template typename T class storage { using ref_t std::reference_wrapperT; using val_t T; std::variantstd::monostate, ref_t, val_t holder; public: template typename U storage(U input) { if constexpr (std::is_lvalue_reference_vU) { holder.emplaceref_t(input); } else { holder.emplaceval_t(std::forwardU(input)); } } T get() { if (auto* ptr std::get_ifref_t(holder)) { return ptr-get(); } return std::getval_t(holder); } const T get() const { if (const auto* ptr std::get_ifref_t(holder)) { return ptr-get(); } return std::getval_t(holder); } };先看构造函数。U是完美转发的模板参数传左值时U推导为T传右值时U推导为T。所以在if constexpr里判断std::is_lvalue_reference_vU就能在编译期决定该把输入放进ref_t分支还是val_t分支。std::reference_wrapperT是一个轻量的引用包装器内部其实就是一个T*但它支持拷贝和赋值可以安全放进std::variant。直接存原始引用不行因为std::variant的模板参数不能是引用类型这是语言层面限制。通过reference_wrapper就把“引用语义”包装成了值类型完美适配容器。注意我在variant第一个位置放了std::monostate这是一个占位类型。这么做的好处是默认构造storage也不会触发T的任何构造只是处于“空状态”。如果你想防止用户默认构造也可以把storage()声明为删除。在构造函数里不管走哪个分支都会立即emplace一个活跃类型所以monostate只存在于“还没初始化”或“被移动后的某个自定义状态”中。get()的实现用了std::get_ifref_t如果当前活跃类型确实是ref_t就返回内部的.get()否则就觉得活跃类型必然是val_t直接std::getval_t(holder)。如果你担心调用者在非活跃状态访问可以先检查std::holds_alternativestd::monostate(holder)并抛出异常或者提供bool valid()。但在我们的设计里只要构造成功active 状态就一定已经设置所以get()是安全的。2.3 用union手写实现把底层机制摊开看std::variant很方便但它的实现里藏了很多细节比如对齐、析构、索引管理。为了真正理解“同一个对象里同时存在指针和值”是怎么回事我们可以手写一个基于union的版本。核心思路是用一个union把T* ptr_和T value_放到同一块内存地址上再用bool记录当前是哪个成员活着。template typename T class raw_storage { bool owns_value_ false; union { T* ptr_; T value_; }; void clear() { if (owns_value_) { value_.~T(); owns_value_ false; } else { ptr_ nullptr; } } void copy_from(const raw_storage other) { if (other.owns_value_) { ::new (value_) T(other.value_); owns_value_ true; } else { ptr_ other.ptr_; owns_value_ false; } } void move_from(raw_storage other) { if (other.owns_value_) { ::new (value_) T(std::move(other.value_)); owns_value_ true; } else { ptr_ other.ptr_; owns_value_ false; } } public: raw_storage(T obj) : ptr_(obj), owns_value_(false) {} raw_storage(T obj) : value_(std::move(obj)), owns_value_(true) {} ~raw_storage() { clear(); } raw_storage(const raw_storage other) { copy_from(other); } raw_storage operator(const raw_storage other) { if (this ! other) { clear(); copy_from(other); } return *this; } raw_storage(raw_storage other) noexcept { move_from(std::move(other)); } raw_storage operator(raw_storage other) noexcept { if (this ! other) { clear(); move_from(std::move(other)); } return *this; } T get() { return owns_value_ ? value_ : *ptr_; } const T get() const { return owns_value_ ? value_ : *ptr_; } };这个版本最要紧的两个细节第一个是union的初始化。raw_storage(T obj)里初始化的是ptr_raw_storage(T obj)里初始化的是value_。因为union同一时刻只有一个活动成员所以两个构造函数各自负责自己的那条路互不干扰。第二个是析构。当owns_value_为true时必须显式调用value_.~T()如果为false则ptr_只是一个普通指针不需要释放。拷贝构造和移动构造里使用了 placement new 来构造value_原因是构造函数初始化列表无法在运行时根据某个条件决定激活 union 的哪个成员。我们只能在函数体内部用::new (value_) T(...)显式构造。这个写法很容易被忽视但它是 union 持有非平凡类型时的标准姿势。手写 union 版的好处是内存布局完全透明没有额外的监控索引或对齐开销而且你可以精确控制哪些分支该有什么行为。坏处是容易踩坑忘记析构、拷贝赋值没有清理旧值、移动操作没有处理残留状态任何一个失误都会变成隐蔽的内存错误。如果你是在写一个底层自研容器或者明确禁止使用异常和标准库手写 union 是不得不走的路否则我建议优先用std::variant。3. 实操细节完整代码、扩展用法和关键机制3.1 如何让storage支持更多类型转换和const场景上面的版本已经能处理非 const 左值和右值但有几个边界情况需要补全。首先是const T。如果传入一个 const 左值原始模板方式中U会被推导为const T此时std::is_lvalue_reference_vU为 true会尝试holder.emplaceref_t(input)但ref_t是std::reference_wrapperT不能绑定 const 对象于是编译报错。如果你希望 const 左值也能被接收但又不想因此丢失语义准确性可以这样做把ref_t改成std::reference_wrapperconst T同时把val_t保持为T用来接收右值。但这样会带来另一个问题T版本无法区分传入的是const T还是T因为std::is_lvalue_reference_vU只看左值引用不看顶层 const。这会让实现变得更复杂。我的建议是如果场景需要支持 const 左值不要试图在同一个storageT里塞下所有情况。一个更干净的方向是把类型模板参数拆成两个template typename T class storage { using ref_t std::reference_wrapperconst T; using val_t std::remove_const_tT; std::variantstd::monostate, ref_t, val_t holder; public: template typename U storage(U input) { if constexpr (std::is_lvalue_reference_vU) { holder.emplaceref_t(std::forwardU(input)); } else { holder.emplaceval_t(std::forwardU(input)); } } const T get() const { ... } T get() requires (!std::is_const_vT) { ... } };但这里还有细节要打磨比如val_t是std::remove_const_tT如果传入的右值原本是const int类型的临时对象移动构造一个非 constint是可行的但语义上可能会丢失 const。实际项目中如果你真的需要同时处理const T和T很多时候更好的做法是把这个storage设计成只读访问或者直接让用户传入storageconst T。第二种常见扩展是“显式指定存储策略”。有时候调用方虽然传了一个右值但他知道这个右值其实也不会立刻销毁比如一个用std::move显式转成的右值引用。他希望存储类不要移动而是直接引用这个局部对象。这时候模板默认把右值当成“要持有”是不合适的。你可以提供第二个模板参数或者一个 tagclass reference_tag {}; class value_tag {}; template typename T storage(T obj, reference_tag) : holder(std::in_place_typeref_t, obj) {} template typename T storage(T obj, value_tag) : holder(std::in_place_typeval_t, std::move(obj)) {}这属于接口设计层面的取舍不是必须的但能让你在未来面对“我不想动这个对象但调用处写成了右值”的别扭情况时有回旋余地。3.2 完美转发和引用折叠在这里扮演什么角色很多人写模板构造函数时会困惑为什么我明明写了U input传左值时U却是T这就是引用折叠。C11 之后有了四行折叠规则T 折叠为TT 折叠为TT 折叠为TT 折叠为T。在函数模板storage(U input)里U是模板参数不是右值引用而是“转发引用”。传入左值时实参类型和U结合最终折叠出T传入右值时得到T。完美转发std::forwardU(input)做的事就是根据U来恢复实参原本的值类别U是左值引用下发回左值U是非引用时下发回右值。如果没有这一步std::forward换成一个强制转换或直接传input右值临时对象会被当成左值会触发拷贝构造而不是移动构造存储类的性能优势就没了。在if constexpr里判断std::is_lvalue_reference_vU而不是判断std::is_rvalue_reference_vU是有原因的。因为转发引用在传入左值时U本身是T而在传入右值时U是T而不是T。如果你写成std::is_rvalue_reference_vU对右值输入会得到 false分支就走错了。这是个很容易犯的错我见过不止一次有人把条件写反然后代码在 memset 了很久之后才暴露问题。3.3 关于std::reference_wrapper、std::visit和分支访问std::reference_wrapperT是一个很实用的类型它本质上是个可以拷贝、可以放在容器里的指针但用起来像引用。一个常见坑是在std::visit里直接访问 variant你会得到一个std::reference_wrapperT而不是T需要手动调用.get()。std::visit([](auto arg) { using U std::decay_tdecltype(arg); if constexpr (std::is_same_vU, std::reference_wrapperT) { T ref arg.get(); // ... } else { T ref arg; // 此时 arg 是 T 类型 // ... } }, holder);如果你觉得手动分支很繁琐可以给storage提供一个成员函数get()像前面一样用内部逻辑把两种状态统一成T。这样对调用方来说他不需要关心这个 storage 内部到底引用了一个外部对象还是自己拥有一份拷贝他只需要get()拿引用就行。但这种“统一”也是有代价的调用方无法从get()的返回类型判断自己修改的是原对象还是内部副本。如果业务要求知道这一点最好额外暴露一个is_owner()方法bool is_owner() const { return std::holds_alternativeval_t(holder); }这在调试和写测试时非常有用。4. 生命周期边界悬垂引用是最大的坑4.1 左值分支的宿命外部对象必须活得比 storage 久最危险的问题就是悬垂引用。当storage处于左值引用分支时它并不拥有对象外部变量的生命周期一旦结束get()返回的引用就是悬垂的。比如storagestd::string make_fake() { std::string local temp; return storagestd::string(local); } // local 销毁storage 里的 reference_wrapper 悬垂 storagestd::string s make_fake(); std::cout s.get(); // UB这个问题的根源不在storage而在于调用者用错了场景。想要避免唯一可靠的办法是约定左值对象的生命周期必须覆盖 storage 的使用期。你可以在文档里写清楚也可以在调试版中加一个指针地址登记表析构时检查是否还有 storage 引用它。后者成本很高不建议常规代码里做但嵌入式或长时间运行的服务里这种调试检查可能救你一命。如果实在无法保证生命周期那就不要用左值引用分支强制让 storage 按值拷贝或移动。一个折中是提供两个构造 tag用户自己拍板。另一条路是放弃裸引用改存std::shared_ptrT但这样 T 必须是可共享的所有权对象语义变了性能也变了。4.2 右值分支移动之后存储对象自身安全但源对象不可假设当传入右值storage里通过移动构造持有了一个T对象此时真正的临时对象生命周期已经由 storage 接管不会被销毁。存储本身是安全的哪怕你在函数里创建 storage 再返回它移动语义也能保证安全。但要注意作为源的右值对象如果是具名变量用std::move传进来的它在移动后的状态是“合法但未指定”的。比如std::vector移动后很可能变成空但其他类型未必。千万不要在移动后对源对象的内容做任何假设只把它当“可以安全析构”的资源。在storage(T obj)的实现中value_(std::move(obj))调用的是 T 的移动构造函数。如果 T 没有移动构造但支持拷贝构造这里会自动退化为拷贝构造。这通常会让人一喜一忧喜的是代码还能编译忧的是性能可能没有达到预期。如果你特别依赖移动语义可以用static_assert或者 concept 要求 T 是 move constructible 的。4.3 如何让悬垂引用在运行时更容易暴露悬垂引用属于未定义行为编译器不一定会报错。为了让调试期更友好可以在storage里记录分支状态并在get()中做一次检查T get() { #ifdef STORAGE_DEBUG if (std::holds_alternativeref_t(holder)) { auto* wrapper std::get_ifref_t(holder); assert(wrapper wrapper-get() ! nullptr); } #endif ... }这个检查只能验证指针非空不能验证对象是否已经销毁。真正有效的做法是给外部对象套一层壳使用单向链表登记地址。太复杂的话我通常建议换一种策略宁可多存一份拷贝也不要冒险存悬垂引用。性能瓶颈往往没那么严重内存错误才是真噩梦。4.4 C20/23 之后有没有更优雅的替代品如果你想避免悬垂引用又不想放弃区分左右值现代 C 里有一个思路是使用std::shared_ptr管理所有权再用std::weak_ptr观察。shared_ptr本质上存储的是“可共享的值”不是引用但可以解决生命周期问题。对于本来就是shared_ptr的对象用std::weak_ptr做观察者是最自然的。但这种方案有一个前提对象必须已经用共享所有权管理不像我们现在的 storage 能直接包住任意T或T。C23 的std::expected不是为存储左右值设计的std::optional也不能存引用。我目前看到最主流的做法仍然是variant或union二选一。如果你想要更工程化的接口可以考虑在 storage 外面再加一层“生命周期管理员”比如记录指针是否来自对象池、是否在某个栈帧中但这些都是应用层约束语言本身没有开箱即用的方案。5. 常见问题与排查技巧实录5.1std::variant访问时抛bad_variant_access怎么办std::getval_t(holder)在活跃类型不是val_t时会抛出std::bad_variant_access。最常见的场景是用户在构造函数之前或某个重置操作之后马上调用了get()。我们前面的代码用std::monostate作为初始态但构造函数总会 emplace 一个活跃类型所以通常不会出问题。如果你自己写了移动赋值或重置方法需要确保每个操作都把 variant 状态置成合法分支。排查方法是先打印holder.index()再确认当前活跃类型。如果想避免异常也可以统一用std::visit或get_if做提前判断不要裸用std::get。5.2 编译报错use of deleted function std::variant...::variant()这个报错通常出现在std::variantstd::reference_wrapperT, T这种没有std::monostate的设计里。因为std::variant默认构造时需要默认构造第一个 alternative而std::reference_wrapperT虽然可以默认构造但如果 T 有非平凡的析构函数或拷贝约束可能会让 variant 的默认构造被删除。解决办法有两个要么把第一个 alternative 改成std::monostate要么显式删除默认构造storage() delete;。推荐前者因为monostate还能让你拥有一个“空状态”配合移动操作更灵活。5.3 const 左值传进来直接编译失败如果你按原始版本定义了storage(T)和storage(T)然后调用storageconst std::string s(someConstString)编译器会找不到匹配。原因是T无法绑定 const 对象T也无法绑定左值。这个问题的解法前面已经提过要么把 ref_t 换成std::reference_wrapperconst T要么增加一个非模板的storage(const T)重载让它走拷贝。我会更倾向于明确拒绝 const 左值逼迫调用方在“拷贝”和“使用storageconst T”之间做出选择而不是让他们误以为左值引用版本能正常工作。5.4 手写 union 版反复崩溃多半是析构和派生的组合问题手写 union 版最常见的问题是忘记在移动赋值时先清理旧值。如果raw_storage原来持有的是内部value_后来赋值一个左值引用对象你没有先调用value_.~T()就直接给ptr_赋值那么原来value_体内的资源就会泄漏。反过来如果原来持有的是指针赋值一个右值对象你没有清理旧状态就直接 placement new 到value_那新构造的值会覆盖掉还在“活跃”的指针导致很奇怪的行为。所以我在operator里的第一步一定是clear()清理当前状态第二步再根据源对象的owns_value_分支去构造或取指针。这个顺序不能反。另外union 里含有带析构函数的类型时编译器不会自动生成析构所有分支必须在析构函数中手动清理。我建议把clear()抽成独立函数并在复制赋值、移动赋值、析构里统一调用避免三处逻辑不一致。5.5 调试技巧让storage告诉你它现在是哪一路别等到崩溃才去猜直接在get()或debug_info()里输出当前分支std::string state() const { if (std::holds_alternativestd::monostate(holder)) return empty; if (std::holds_alternativeref_t(holder)) return lvalue-reference; return rvalue-owned; }这个函数在写单元测试时特别好用。你可以断言storage(someVar).state() lvalue-reference、storage(makeTmp()).state() rvalue-owned。一旦回归测试覆盖了这两种构造路径至少你不用担心模板特化选错了。如果你用的是手写 union 版没有 variant 的holds_alternative那就直接返回owns_value_ ? rvalue-owned : lvalue-reference效果一样。5.6 关于编译期概念约束的补充在 C20 里我还会给构造函数加一个requires约束让错误信息好看一点template typename U requires std::constructible_fromref_t, U || std::constructible_fromval_t, U storage(U input) { ... }不过要注意这个约束不能替代if constexpr的分支选择它只是用来让编译器在错误环境下更早地停止。如果你不擅长 concept可以直接用static_assert说明“传入的类型必须可以拷贝或移动构造为 T”。报错会从变魔术一样的长模板错误变成一个你能读懂的提示。6. 更进一步从存储类到通用参数包当你把单个参数的 storage 吃透之后很容易把它推广到多个参数。比如事件处理器需要保存任意类型的参数包同时区分左右值这个时候可以直接做tuplestorageT1, storageT2, ...。在 C17 及以上用折叠表达式或std::apply很容易处理。核心难点仍然是一个参数一个参数地判断值类别而这一层判断我们已经封装在storage内部了外层只管调用。我曾经在一个小型任务调度器里就是这么用的。调度器接收任意签名的回调把参数包存进一个std::tuple等线程池有空闲时再取出并调用。参数里有大量左值对象需要以引用方式传入也有临时字符串、临时 vector 直接移动进来。我用storage包住每个参数之后调度器本身完全不关心左值右值只要生命周期约定正确整个系统非常清爽。推广时还要注意一个点如果你直接写storageT s(param)并且 param 是一个右值s里确实持有了一份移动后的对象但如果你把这个storage放进std::tuple再把这个 tuple 放进一个 lambda 捕获列表千万别忘了 lambda 捕获时也可能再次发生拷贝。你需要在 lambda 里用std::move把它转移走否则就可能白白丢失 storage 的移动语义。这套代码写完之后我自己最深的体会是C 并没有提供一个开箱即用的“能存左右值”的容器但通过variant reference_wrapper 转发引用我们可以用很小的成本把它组装出来。难点从来不是语法而是你要时刻想清楚这一行的对象生命周期到哪结束下一次get()发生时它还在不在。想明白这点几乎所有坑都不会再坑到你了。