恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
C++左值与右值存储:拷贝、借用与托管三种方案
首页
资讯中心
/
C++左值与右值存储:拷贝、借用与托管三种方案
C++左值与右值存储:拷贝、借用与托管三种方案
发布时间:2026/10/12 13:34:44
我前阵子在一个模拟项目里要写一个通用的延迟执行器调用方可以往队列里塞一个任务任务里附带一个参数之后在另一个线程里取出执行。参数可能是某个状态对象自己的左值引用也可能是临时算出来的右值。最开始我写的是const T结果上线测了没几天就出现奇怪的随机崩溃——临时对象析构之后队列里的引用还悬在那。后来我把成员改成按值存储用万能引用配合std::forward做构造才把问题解决。这件事让我把在一个对象里既能存左值又能存右值这件事彻底想透了。它听起来很简单实际里面涉及引用折叠、生命周期、拷贝/移动语义、类型擦除好几层东西。这篇文章就是把我的思路和踩坑过程完整梳理一遍适合已经写过不少 C、但想进一步理解左右值存储设计的开发者。看完你不仅能拿代码还能真正懂得为什么这么写、什么时候该用哪种方案。1. 先把问题说清楚左值、右值和存储之间的根本矛盾很多人在初学阶段被左右值搞晕过但做存储设计时光会区分左右值还不够。这里的关键是生命周期。左值通常有稳定的内存位置一个局部变量、一个类的成员、一个通过引用传进来的东西它在你操作期间保持有效。右值是临时的字面量、函数返回的临时对象、std::move出来的将亡值。右值可以绑定到T但你如果把它保存下来它随时可能消失。假设我们有这样一个类template typename T class ValueBox { public: explicit ValueBox(const T value) : ref_(value) {} const T get() const { return ref_; } private: const T ref_; };用它存左值没问题int x 42; ValueBoxint box(x); // ref_ 指向 xx 活着 box 就活着但是传入右值就麻烦了ValueBoxint box(42); // 编译能过但 42 这个临时量在完整表达式结束后就没了box.get()以后拿到的就是一个悬垂引用。更隐蔽的情况是传给函数后临时对象在函数返回前已经析构。这是引用存储的天然缺陷存引用不拥有对象而右值恰恰需要一个拥有者才能活下去。所以在同一对象中存储左值或右值这句话真正的含义不是让一个引用既能绑定左值又能绑定右值——那用const T就能做到但没有价值。它的含义是同一个类根据传入的实参是左值还是右值自动采用不同的存储策略或者自动拷贝、自动移动最终让这个对象内部持有安全的数据。要么你复制/移动一份数据进来自己拥有它要么你明确保存一个引用并约定好生命周期由外部负责。两条路都行但必须二选一不能含糊。下面我分享三套可落地的方案它们代表三种不同的存储策略。2. 先试最直接的思路用T当构造参数但是内部存一份值第二种方案其实是最常见的做法也是我第一次修正时采用的对象内部直接存T值构造时用一个万能引用forwarding reference接受参数再用std::forward原样转发给成员变量做初始化。template typename T class ValueBox { public: template typename U explicit ValueBox(U value) : stored_(std::forwardU(value)) {} const T get() const { return stored_; } // 如果需要修改获取 T get() { return stored_; } private: T stored_; };这里发生了什么关键在于模板参数推导和引用折叠。当你传入一个左值时int x 42; ValueBoxint box(x);U被推导为intU折叠后就是int参数value是一个左值引用。std::forwardU(value)在这个情况下不强制转换value仍然是左值于是stored_(value)触发拷贝构造stored_是x的一份副本。当你传入右值时ValueBoxint box(make_value()); // 返回临时 intU推导为intU就是int参数value绑定到右值。std::forwardU(value)把它转回右值stored_(std::move(...))触发移动构造。stored_拥有临时对象的内存资源临时对象析构不影响stored_。所以这个方案并没有真正把左值引用存下来它只是针对左值做拷贝、针对右值做移动。最终对象内部永远拥有一个独立的值。从左值初始化和右值初始化的角度看同一个对象的设计应对了两者。我把这套逻辑展开给项目里一个刚接触模板的同事看他问为什么不直接写两个构造函数重载一个T const一个T我的回答是重载可以但写起来啰嗦而且转换会多一层。比如ValueBox(const T v) : stored_(v) {} ValueBox(T v) : stored_(std::move(v)) {}对一个具体类型来说这样写完全没问题。成本是当T是一个复杂类型比如std::string传入一个可以隐式转换的类型比如const char*时两个重载都要参与决议有时候会引发二义性。而万能引用模板只有一个函数配合转发构造类型转换在成员初始化阶段自然完成。不过万能引用也有它的问题它会贪婪地匹配所有类型包括别人本来想调用的拷贝构造。如果ValueBox还需要拷贝/移动你得注意让拷贝构造不被模板遮蔽。通常的解决方法是给模板构造函数加std::enable_if约束或者直接让这个类不可拷贝只做所有权转移。这个方案的取舍空间其实很大适合那些你本来就希望把数据快照下来的场景。比如事件记录器、任务参数打包、异步消息发送它拷贝一份值后续原变量怎么变都不影响。它不是零开销但如果你本来就打算长期持有数据这份拷贝/移动就是必需的。3. 改一种视角用std::variantstd::reference_wrapperT, T做到能引用就引用不能引用就拥有有时候我嫌上面那个方案浪费。某个对象非常大比如一张百万级顶点的网格或者一个包含大量句柄的配置器。调用方明确说了左值传给你你只准看不许复制右值临时传给你你帮我接管。这时候按值拷贝一份左值成本高得离谱。我需要的是一个真正能左值存引用、右值存储值的类。听起来容易写起来有点绕。C 标准库里的std::variant不允许直接放引用类型因为variant的存储要满足任一可能类型都能被原地构造引用不是对象。但我们可以用std::reference_wrapperT包装引用它本身是一个可拷贝的对象内部保存指针行为像引用。于是存储类型就是template typename T class RefOrValue { public: RefOrValue(T lvalue) : storage_(std::ref(lvalue)) {} RefOrValue(T rvalue) : storage_(std::move(rvalue)) {} const T get() const { if (auto* ref std::get_ifstd::reference_wrapperT(storage_)) { return ref-get(); } return std::getT(storage_); } private: std::variantstd::reference_wrapperT, T storage_; };左值版本把std::ref(lvalue)放进variant保存的是引用语义。右值版本把std::move(rvalue)放进variant因为variant的第二个替代类型是T会用移动构造初始化。使用的时候通过get_if判断当前到底存的是引用还是值。这个设计真正做到了标题里说的同一对象左值或右值不同存储方式。我写这个类时踩过一个很重要的坑右值重载函数是T左值重载是T。但传一个const T进来左值重载无法绑定右值重载无法绑定因为T不能绑定const T编译直接报错。解决方案是给右值重载也接受const T或者在类模板里加一个约束。我在实际项目里通常是只处理非 const 左值、非 const 右值两种主要情况遇到 const 左值干脆直接拷贝RefOrValue(const T lvalue) : storage_(lvalue) {}这样行为是明确的const 左值按值拷贝非 const 左值按引用保存临时右值按移动持有。但这样variant里会有三种替代类型reference_wrapperT、reference_wrapperconst T、T。也没问题只是get里分支会变多。这个方案最大的坑是生命周期责任。当存储的是reference_wrapperT时类本身不拥有对象。如果调用方传进来的左值在RefOrValue之前析构了那么get()就会返回悬垂引用。所以在设计要求明确的地方必须和调用方约定非 const 左值引用调用方保证活过容器不能保证的请传右值或 const 左值会被拷贝。我在某跨平台系统的命令缓冲区里用过这个方案。命令对象持有参数而不实际拷贝大型资源。当时有个隐藏 bug缓冲区在某个线程里持有某个参数的引用但参数属于另一个线程的一个栈对象那个线程退出后参数就没了。后来我加了一个调试接口每次get()时检查引用对象是否已经析构通过标记虽然无法真正防护但能提前暴露问题。4. 更彻底的所有权方案用std::unique_ptrT配合移动语义存储对象继续往下延伸第三种方案适合传进来的右值本身就无法轻易移动、或者“我想统一所有权、让所有存储内容都在堆上”。这就是所有对象都变成unique_ptr持有的堆对象。左值传入时拷贝一份到堆上右值传入时把对象搬运到堆上。template typename T class PointerBox { public: template typename U explicit PointerBox(U value) : ptr_(std::make_uniqueT(std::forwardU(value))) {} const T get() const { return *ptr_; } T get() { return *ptr_; } private: std::unique_ptrT ptr_; };为什么我说它更彻底因为它把存储语义统一成了指针指向堆上的一个对象这样无论左值还是右值类最终都拥有一个稳定的、生命周期由unique_ptr管理的对象。左值会被拷贝到堆上因为没有转移所有权的道理右值会被移动构造到堆上。PointerBox对象本身可以很小就一个指针大小适合放进容器、移动、甚至做类型擦除的基类。这个方案的另一个好处是T即使不可拷贝只要可移动也能支持右值传入。比如某个对象内部有std::unique_ptr成员它是 move-only 的你传左值进不来但传右值可以。ValueBox按值存储也支持 move-only 类型但你把ValueBox对象本身放在堆上还是栈上都有额外大小PointerBox始终只有指针那么大多一次堆分配。堆分配的代价是明显的。左值拷贝和右值移动都多了一次make_unique的堆内存申请。所以在高性能低延迟的场景里第三种方案通常不是首选。它更适用于你想在容器中稳定地存对象地址防止对象在 vector 扩容时被移动/拷贝导致地址失效或者说你想让对象的具体存储在sink构造函数里完成所有权转移。注意这里也有一个隐蔽的地方make_uniqueT(std::forwardU(value))。如果U是T它会调用T的拷贝构造。如果T没有拷贝构造但左值不能绑定到T编译失败。这和第二方案的语义一致。但比起ValueBoxPointerBox的T可以是不完整的类型吗不行make_unique必须知道T的完整定义才能调用析构。这是又一个取舍。5. 三个方案放一起怎么选关键看你是要快照还是借用还是托管我把自己实际项目里用过这三种方案的决策过程复盘成一张表格方便你对照维度ValueBox按值RefOrValue引用或值PointerBox堆指针左值传入拷贝一份保存引用不拷贝拷贝到堆右值传入移动一份移动进 own 分支移动到堆对象大小等于 T 大小大致等于较大分支大小一个指针大小堆分配无仅在右值移动时可能无堆分配T中如果有堆再分配每次构造一次堆分配生命周期责任完全自持安全存引用时依赖外部完全自持安全适用场景快照/事件/序列化大型对象的只读借用所有权转移/类型擦除/稳定地址回到开头的延迟执行器当时我选择的是ValueBox按值存储。原因是任务队列中的参数必须独立存活我不能依赖调用方的生命周期同时这些参数体积不大拷贝/移动开销可接受。用RefOrValue当然更省拷贝但延迟队列会跨线程外部变量的生命周期极难保证我不想在并发环境下埋雷。某次处理存储大型图像数据的任务时我改用了RefOrValue。因为我们明确约定图像对象由图像缓存中心持有全程序生命周期内不会销毁任务对象只是在短时间内把引用拿来访问不需要拥有数据。如果传进来的是一张新生成的临时图像那么任务对象接管它结束时销毁。至于PointerBox我用得最多的是在实现非多态类型擦除时。比如一个小型function对象我只想存一个能被调用的值内部用指向堆的指针把任何 callable 存下来。这种模式下移动function对象只是移动一个指针开销小。而且堆对象可以让 callable 在自身被移动后仍然稳定存在。选型时还有一个容易忽视的问题通用引用构造函数会拦截很多隐式转换。比如你写template typename U ValueBox(U value) : stored_(std::forwardU(value)) {}调用ValueBoxstd::string box(hello)U推导为const char()[6]stored_(std::forwardU(value))会先构造一个临时std::string再移动工作正常。但如果你还定义了ValueBox(std::string_view)想匹配字符串视图模板会优先拦截导致你的字符串视图构造函数永远调不到。解决方法是给模板加requires std::constructible_fromT, UC20或enable_if。这个坑我遇到不止一次。6. 代码之外的细节重载决议、悬垂检查、约束措施最后这部分不是额外知识而是真正让方案落地时容易出问题的细节。我按踩坑的先后顺序整理。第一当你想禁止左值传引用或右值传拷贝时别用delete重载而是用概念约束。比如只允许右值构造的移动专用盒子template typename U requires (!std::is_lvalue_reference_vU) explicit MoveOnlyBox(U value) : val_(std::forwardU(value)) {}这样做的好处是错误信息更友好如果传入左值编译器会告诉你constraints not satisfied而不是列举一堆重载歧义。我在项目里大量使用 C20 concept 之后同事调试效率提升了很多。第二RefOrValue的悬垂问题是硬伤要么文档约定要么增加一个显式的生命周期标志。我试过一个办法给存储的引用多加一个std::weak_ptr的哨兵但如果引用对象本身不是shared_ptr管理的就没法搞。最终我的经验是如果类被设计成跨线程传递尽量不要用引用存储如果只在单线程小范围使用严格约定“源对象必须活得比盒子久”然后加注释放到头文件里。靠编译器检测这种悬垂不现实只能靠代码评审的自觉。第三PointerBox的make_unique如果抛异常不会导致传入对象被破坏。因为make_unique先分配内存再在内存上构造。如果构造抛出分配的内存会被释放ptr_仍然为空——这点很安全。反而是直接写ptr_(new T(std::forwardU(value)))如果new之后的构造抛出ptr_可能处于未初始化的风险如果用初始化列表所以一律用make_unique。第四测试时多覆盖“自移动”的边界。比如ValueBoxint box(123); box std::move(box);如果你定义了移动赋值你可能会写出悬垂的this访问。标准库容器大多要求自移动安全但你自己写的存储类不保证。我习惯在移动构造、移动赋值里显式判断if (this ! other)或者干脆把成员设成临时再swap。做完这三个方案对比并形成自己的代码之后我最大的感受是同一对象存储左值或右值不是某一个魔法而是一组围绕生命周期与所有权做出的取舍。代码只占一半另一半是对调用方使用场景的深刻理解。如果让我给一个简单的初选规则我会说你不确定谁会活多久就按值存储你确定来源活得够久就引用存储你想统一所有权并且不介意堆分配就unique_ptr存储。这套思维也适用于其他 C 组件设计——先把生命周期和所有权讲清楚再谈怎么写代码方向就不会跑偏。