解析C++11的std::ref、std::cref源码

发布时间:2026/10/22 8:01:36
解析C++11的std::ref、std::cref源码
前言std::ref与std::cref定义在functional中C11 起成为标准。它们的作用一句话就能说完把某个对象包装成一个「可拷贝的引用」。但这个简单描述背后有两个容易混淆的点。第一std::ref不创建引用类型。C 没有「引用的引用」这种类型函数模板按值传参时引用会被丢弃。std::ref返回的是std::reference_wrapperT——一个类模板的对象内部存指针对外提供到T的隐式转换。它本身是值类型可以拷贝、可以赋值赋值是重新绑定不是修改被引用对象。第二std::ref不延长生命周期。它只是保存地址被引用对象什么时候销毁引用就什么时候悬垂。标准用删除重载的方式阻止你对右值调用std::ref但这只能挡住「明显绑临时量」的情况挡不住「局部变量提前销毁」。本文从接口、典型实现、使用场景三个层面拆解这两个函数重点讲清它们与std::bind、std::thread的配合以及那些编译能过但运行会出问题的边界。文中的实现是示意性的等价代码用于说明语义不代表任何标准库的具体源码真实实现会涉及更多内部细节。一、接口长什么样标准给出的声明大致如下省略了noexcept之外的一些细节// 适用于 C17示意声明namespace std {template class T class reference_wrapper;template class T reference_wrapperT ref(T t) noexcept;template class T reference_wrapperT ref(reference_wrapperT t) noexcept;template class T reference_wrapperconst T cref(const T t) noexcept;template class T reference_wrapperconst T cref(reference_wrapperT t) noexcept;// 阻止绑定右值template class T void ref(const T) delete;template class T void cref(const T) delete;}几个要点。ref的参数是T所以只能接受左值传入 const 左值时T被推导为const X返回reference_wrapperconst X。对已经包装过的对象再ref返回的是同一个 wrapper幂等不会套两层。cref总是加上 const返回reference_wrapperconst T。两个 delete的重载专门接收右值任何std::ref(42)、std::ref(std::string(x))都会在编译期报错。这是标准库用重载决议做静态检查的典型手法。函数都标了noexcept因为它们只做取地址和构造不分配内存、不抛异常。reference_wrapper的公开接口很少成员作用get()返回被引用对象的Toperator T()隐式转换让 wrapper 能传给形参为T的函数operator()当T可调用时转发参数调用它C11 起拷贝构造/拷贝赋值拷贝指针 / 重新绑定reference_wrapper(T)构造函数接受左值引用注意reference_wrapper没有默认构造函数也不能用右值构造。它是平凡可拷贝的trivially copyable大小通常就是一个指针。二、等价实现一个存指针的薄壳理解源码最直接的方式是看它的等价结构。下面这段代码不是标准库源码但语义上与主流实现一致。// 适用于 C17示意实现#include functional#include memory#include type_traits#include utilitytemplate class Tclass ReferenceWrapper {public:explicit ReferenceWrapper(T t) noexcept: ptr_(std::addressof(t)) {}T get() const noexcept { return *ptr_; }operator T() const noexcept { return *ptr_; }template class... Argsauto operator()(Args... args) const- decltype(std::invoke(*ptr_, std::forwardArgs(args)...)) {return std::invoke(*ptr_, std::forwardArgs(args)...);}private:T* ptr_;};template class TReferenceWrapperT ref(T t) noexcept {return ReferenceWrapperT(t);}三处细节值得说明。为什么用std::addressof而不是。如果类型重载了operatort拿到的可能不是对象地址而是一个重载函数的结果。std::addressof绕过重载始终返回真实地址。标准库在需要可靠取址的地方都用它。为什么operator T要 const。wrapper 对象本身是 const 不影响被引用对象的可变性。const ReferenceWrapperint仍然应该能转换成int并修改原对象——引用语义和 const 语义是两回事。为什么operator()需要完美转发。当 T 是函数对象比如 lambda时wrapper 要能代替它被调用。用std::forward保留实参的值类别用std::invoke统一处理成员函数指针、成员数据指针和普通可调用对象。C11 的实现用的是std::result_ofC17 起改用std::invoke_result/decltype(std::invoke(...))因为result_of在 C17 被弃用、C20 移除。真实的reference_wrapper还会对T做remove_cv/remove_reference处理并提供operator()仅在T可调用时参与的约束不同标准库在 SFINAE 的写法上差别很大但语义一致。三、为什么需要它按值传递的世界C 的很多高层设施在传递参数时会按值拷贝引用语义在那一层被抹掉了。这正是reference_wrapper存在的理由。std::bind会 decay-copy 所有绑定的实参。std::bind(f, x)存的是x的副本之后调用的是副本。要让它持有引用必须写std::bind(f, std::ref(x))。// 适用于 C17#include functional#include iostreamvoid addTo(int target, int delta) { target delta; }int main() {int counter 0;// 下面这行若取消注释会编译失败bind 保存的是 int 副本无法绑定 int// auto byValue std::bind(addTo, counter, 5);auto byRef std::bind(addTo, std::ref(counter), std::placeholders::_1);byRef(7); // counter 变为 7std::cout counter \n;auto byCRef std::bind([](const int v) { return v * 2; }, std::cref(counter));std::cout byCRef() \n;return 0;}被注释掉的那一行需要解释std::bind会把counter拷贝进内部存储调用addTo时传入的是一个右值副本而addTo的第一个参数是int右值不能绑定到非 const 左值引用于是编译报错。这恰恰说明std::bind默认按值保存。std::thread的构造函数同样对实参做 decay-copy线程对象内部保存的是参数的副本等线程真正启动时才调用可调用对象。因此std::thread(f, x)修改的是副本主线程看不到。传引用必须显式std::ref。// 适用于 C17#include functional#include thread#include vectorvoid fill(std::vectorint out, int n) {out.assign(n, 42);}int main() {std::vectorint data;std::thread worker(fill, std::ref(data), 10);worker.join(); // 必须 join 或 detach// 此时 data.size() 10return 0;}这里join()是必须的std::thread析构时如果仍处于 joinable 状态会调用std::terminate。而且join同时保证了「线程执行完毕」这一同步点主线程之后读data才是安全的。容器存引用。标准容器不能存引用类型但可以存reference_wrapper。这让「一个 vector元素是其它对象的别名」成为可能。// 适用于 C17#include algorithm#include functional#include vectorint main() {int a 3, b 1, c 2;std::vectorstd::reference_wrapperint refs{a, b, c};std::sort(refs.begin(), refs.end(),[](std::reference_wrapperint lhs,std::reference_wrapperint rhs) {return lhs.get() rhs.get();});for (int v : refs) {v * 10; // 通过 wrapper 的隐式转换修改原对象}return 0;}排序交换的是 wrapper也就是指针原对象跟着「换位置」的错觉不存在——a、b、c的值没变变的是refs里指针的顺序。这个例子也说明了 wrapper 的赋值语义拷贝赋值让 wrapper 指向另一个对象而不是把值写过去。四、生命周期唯一的真正风险std::ref的设计里标准只能挡住一种错误绑定右值。其余的悬垂问题全靠程序员。// 适用于 C17错误示例仅用于说明风险#include functional#include threadstd::thread makeWorker() {int local 0;return std::thread([](int v) { v 1; }, std::ref(local)); // local 先销毁}这段代码能通过编译local是左值但线程可能在local销毁之后才执行访问的是已经结束生命周期的对象属于未定义行为。标准库无法在编译期发现这种跨函数的生命周期问题std::ref也不做任何运行期检查。判断标准很简单被引用对象的生命周期必须严格覆盖 wrapper 被使用的整个区间。在线程场景里这意味着join()必须发生在对象销毁之前在异步场景里意味着要确保回调执行完再释放资源。std::async配合std::ref尤其危险因为std::async返回的 future 如果不等待任务可能跑到对象销毁之后。另一个隐蔽的坑是把std::ref传给按值接收的模板函数。比如template class T void g(T t)传入std::ref(x)时T被推导为reference_wrapperint函数内部拿到的是 wrapper 的副本很便宜但仍是指针只有发生到int的转换时才真正接触原对象。这不是错误但要清楚拷贝的是 wrapper 而非int。常见坑点❌std::ref(42)或std::ref(std::string(tmp))。✅ 右值重载被 delete编译期就会报错先存成具名左值再ref。❌ 把局部变量的std::ref交给线程/异步任务局部变量先销毁。✅ 保证join()或 future 等待发生在对象生命周期内。❌ 认为std::ref会延长对象寿命或认为它做了拷贝。✅ 它只存指针不拥有对象、不延长寿命。❌std::bind(f, x)以为传了引用实际传了副本。✅ 需要引用语义时写std::bind(f, std::ref(x))能用 lambda 时优先 lambda。❌ 对const对象用std::ref之后试图修改。✅ref会推导出reference_wrapperconst T要只读就用cref要可写就别加 const。❌ 容器里存reference_wrapper被引用对象先于容器销毁。✅ 明确所有权容器不拥有元素必要时改用std::reference_wrapper之外的拥有型容器。❌ 把reference_wrapper当成线程安全的共享机制。✅ 它只提供引用语义并发读写仍需互斥量或原子操作。❌ 对重载了operator的类型自己写t取地址再包装。✅ 用std::ref/std::cref内部走std::addressof。总结项目std::refstd::cref返回类型reference_wrapperTreference_wrapperconst T接受右值否删除重载否删除重载是否拷贝对象否只存地址否只存地址是否延长生命周期否否典型用途bind/thread传引用、容器存引用只读传递、避免拷贝std::ref与std::cref的本质是在「按值传递」的设施里塞进一个「按引用访问」的小对象。它的实现只有一个指针和几个转发函数复杂度全部集中在生命周期管理上。用之前先问一句被引用的对象能不能保证活到 wrapper 最后一次被使用答不上来就不要用。