那些年:只有拷贝,没有“搬家”
在 C++11 之前。
写 C++ 很像写 C。
只不过多了个 class。
那会儿没有 std::move。
也没有右值引用这种东西。
你想把一个对象传给别人。
通常就一个动作。
拷贝。
对只写过 C 的人来说。
拷贝大概就是 memcpy。
但 std::string 这种对象不一样。
它背后可能有一块堆内存。
拷贝一次。
就可能要重新分配。
再把字符搬过去。
所以当年大家的直觉很朴素。
能不拷贝就别拷贝。
能用引用就用引用。
于是接口经常长这样。
void f(const std::string& s);
看起来挺对。
也确实挺对。
直到你开始写“通用包装器”。
日志。
计时。
统一异常。
你只是想把参数转交下去。
结果线上啪一下。
慢了。
还选错了函数。
你回头一看。
罪魁祸首往往很短。
短到你第一眼只想骂一句。
“这也能出事?”
当年的人怎么写包装器
在 C++11 之前。
包装器要想“通用”。
通常就两条路。
要么值传递。
要么 const T&。
值传递的坏处很直白。
你会无条件拷贝一次。
template <class F, class T>
void call(F f, T t) {
f(t);
}
t 是一份副本。
你本来只是“中转”。
却顺手多拷贝了一次。
于是很多人会改成引用。
template <class F, class T>
void call(F f, const T& t) {
f(t);
}
这样不拷贝了。
但它也很爱“自作主张”。
它把 t 变成了 const。
如果下游函数想改参数。
或者它有两个重载(同名不同参数)。
那你这层包装器可能就开始干扰它选路。
当年大家也就忍了。
因为那会儿没有更好的工具。
拷贝为什么贵:它拷的不是字节,是资源
这句话对刚学 C 的人有点抽象。
所以我用一个很土的类把它做实。
struct Buffer {
char* p;
size_t n;
explicit Buffer(size_t n) : p(new char[n]), n(n) {}
~Buffer() {
delete[] p;
}
};
这就叫资源。
p 指向的那块内存,是要有人负责释放的。
C++ 里通常让析构函数(~Buffer())来做这件事。
这套习惯有个名字。
RAII。
你可以暂时把它理解成一句话。
“构造函数拿资源,析构函数还资源。”
这里先插一句新人常见的坑。
如果你啥都不写。
编译器会给你一个“按成员拷贝”的版本。
Buffer a(8);
Buffer b = a;
这时候 a.p 和 b.p 指向同一块内存。
两个对象析构。
就释放两次。
所以你才会看到很多老代码。
一上来就把拷贝禁掉。
或是老老实实手写拷贝。
那拷贝意味着什么。
意味着你得再申请一块新内存。
再把老数据搬过去。
struct Buffer {
char* p;
size_t n;
explicit Buffer(size_t n) : p(new char[n]), n(n) {}
~Buffer() {
delete[] p;
}
Buffer(const Buffer& other) : p(new char[other.n]), n(other.n) {
std::memcpy(p, other.p, n);
}
};
std::memcpy 来自 <cstring>。
你现在不用纠结细节。
你只要知道:深拷贝会分配。
还会搬数据。
所以它贵。
那 move 呢。
move 的典型写法反而很短。
struct Buffer {
char* p;
size_t n;
Buffer(Buffer&& other) : p(other.p), n(other.n) {
other.p = nullptr;
other.n = 0;
}
};
它不拷数据。
它只是接管指针。
然后把旧对象清空。
所以当年大家能不用拷贝就不用。
但又绕不开。
因为你得保证资源最后有人还。
C++03 时代的“省拷贝”:swap、auto_ptr、以及一点点运气
当年为了少拷贝。
大家会用 swap。
用“先拷一份,再交换”的写法把异常安全也顺手解决。
这套写法叫 copy-and-swap。
你可以先不记名字。
你只要记它的气质。
很绕。
但能跑。
还有个更有名的过渡产物。
std::auto_ptr。
它想表达“独占所有权”。
但它只能用拷贝语义去模拟。
结果就是拷贝一个 auto_ptr。
原来的那个会被清空。
这玩意儿在容器里简直灾难。
你看它的“拷贝”。
std::auto_ptr<int> p(new int(1));
std::auto_ptr<int> q = p;
这行执行完。
p 就空了。
它用“拷贝的语法”做了“转移所有权的事”。
所以它后来被 std::unique_ptr 取代。
绕了一大圈。
你会发现当年的日子其实就两个关键词。
少拷贝。
别翻车。
过渡期:Boost 先试水,标准再收编
这里其实有一段“过渡史”。
C++ 社区不是等到标准定稿才开始折腾的。
很多新点子,会先在 Boost 这种库里跑一圈。
跑顺了。
再被标准库吸收。
move 语义就是典型例子。
当年 Boost.Move 先把“能搬就别拷贝”这条路子跑通。
后来 C++11 把它收编成 std::move 和右值引用。
完美转发也是同一批问题的延伸。
标准库要写 emplace、bind 这种“通用胶水”。
它必须把你的参数原样传下去。
于是就有了 std::forward。
顺便还有一个关键配套。
可变参数模板(class... Args)和参数包展开(args...)。
没有它们。
你想写“能接任意个参数”的包装器。
就得手写一堆重载。
后来:C++11 带来了“搬家”,坑也跟着来了
C++11 上线之后。
“少拷贝”终于不只是口号了。
右值引用(T&&)和移动语义来了。
你可以为“临时对象”单独写一条更快的路。
比如这样。
void sink(const std::string&) {
std::cout << "lvalue\n";
}
void sink(std::string&&) {
std::cout << "rvalue\n";
}
同一个名字。
两套参数。
这就是重载。
你传“能复用的老对象”。
走 const std::string&。
你传“临时用一下就扔的对象”。
走 std::string&&。
思路很美。
然后我写了个小项目。
一个小小的工具库。
专门把用户的函数包一层。
顺手加日志。
顺手计时。
顺手统一异常。
看起来像这样。
template <class T>
void wrap(T&& x) {
sink(x);
}
就这一行 sink(x)。
线上啪一下。
本来该走右值那条路。
它偏偏走了左值那条路。
性能掉了。
还选错了重载。
事故复现:有名字的参数,会“装成”左值
你把一个临时对象塞进来。
wrap(std::string("hi"));
按直觉应该打印 rvalue,但它会打印 lvalue。
原因很朴素:x 在函数体里有名字,有名字的表达式在大多数场景下都会被当成左值。
你可以先不背定义。
记一句手感就够了。
在函数体里。
只要你给它起了名字。
它就更像左值。
这句话听起来像段子。
但它解释了很多“模板包装器的灵异事件”。
先把“左值/右值”说成人话
你可以把它先理解成两类东西:一种“有身份的”,比如变量;另一种“临时的”,比如表达式算出来的结果。
它们其实就是 C++ 里常说的“值类别”。
很多优化都围绕一件事:临时对象用完就扔,那我能不能把它的资源直接搬走,别再傻乎乎拷贝一份。
右值引用和 std::move,大体就是在解决这个问题。
顺嘴澄清一句。
std::move 不是“执行搬家”。
它更像一句声明。
“你可以把它当右值用了。”
真正搬不搬。
取决于这个类型有没有 move 构造/赋值。
你明明写的是 T&&,为什么还会走左值重载
这点很反直觉。
关键在于:类型是一回事,表达式又是另一回事。
template <class T>
void wrap(T&& x) {
sink(x);
}
x 的类型也许是 std::string&&。
但 x 这个表达式有名字。
所以它按规则更像左值。
你可以把它理解成一句土话。
“我手里拿着一个右值引用。
但我拿着它的方式,是个变量。”
万能引用:T&& 在模板里会变身
这里有个新手最容易皱眉的点:为什么我写了 T&&,它还能接住左值?
因为在模板参数推导里,T&& 不是“纯右值引用”。它更像一个“什么都能接”的捕手。
你可以把“模板参数推导”理解成:你写了 take(T&&),编译器会看你传了什么,然后替你把 T 猜出来。
template <class T>
void take(T&& x);
你传左值进去,T 会被推导成 U&,于是 T&& 会折叠成 U&。
你传右值进去,T 推导成 U,于是 T&& 就是 U&&。
这里的“折叠”也别怕。
它就是在说:当 & 和 && 混到一起,最后会按规则“收敛”成一个引用。
这套规则的目的很单纯:接住一切。
但接住只是第一步。
你还得把它“原样交出去”。
std::forward:把“当初的样子”还回去
std::forward 做的事不是把东西都变成右值。
它更像一句承诺:“你给我的是什么,我就还你什么。”
你可以先记一个对比。
std::move(x) 看的是 x 这个表达式。
std::forward<T>(x) 看的是模板参数 T。
所以 forward 才会要求你把 T 也写出来。
如果你不想背实现。
可以先粗暴理解成这句。
std::forward<T>(x) ≈ static_cast<T&&>(x)
读到这里你只要明白一件事就行。
forward 不是魔法。
它只是按你当初推导出来的 T。
把“右值该走右值路”这件事补回来。
包装器应该写成这样。
template <class T>
void wrap(T&& x) {
sink(std::forward<T>(x));
}
这次如果你传进来的是临时对象,forward 会把它继续当临时对象交下去,于是就能走到 sink(std::string&&)。
如果你传进来的是变量,它也不会擅自把变量当成“可搬走的临时对象”。
多来几个小实验:你会在这些地方遇到转发
先来一个最常见的。
转发不只转一个参数。
你经常是一串。
template <class F, class... Args>
decltype(auto) call(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
}
Args&&... 是“接住一切参数”。
std::forward<Args>(args)... 是“把每个参数原样交出去”。
再来一个更贴近标准库。
emplace_back 为什么能什么都塞。
std::vector<std::string> v;
template <class... Args>
void add(Args&&... args) {
v.emplace_back(std::forward<Args>(args)...);
}
你传变量进去。
它就拷贝。
你传临时对象进去。
它就移动。
还有一种你迟早会写到的。
你自己手写一个 make_unique。
template <class T, class... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
这里的转发很关键。
不然你这层工厂会“悄悄改变”调用者挑中的构造函数重载。
一个更隐蔽的坑:别把同一个参数 forward 两次
这坑不新手也会踩。
因为它看起来完全合法。
template <class T>
void bad(T&& x) {
sink(std::forward<T>(x));
sink(std::forward<T>(x));
}
如果第一次调用把 x 当成右值用掉了。
第二次你还拿它当右值。
你其实是在用一个“可能已经被搬空”的对象。
这不是 forward 的锅。
这是你自己把同一份资源交出去两次。
这个词怎么来的:universal reference / forwarding reference
你可能会在网上看到两个名字。
universal reference。
forwarding reference。
前者是 Scott Meyers 在书里带火的叫法。
后者更接近标准里的表述。
说到底就一件事:只有当 T&& 出现在“会推导的模板参数”里。
它才有这种“既能接左值又能接右值”的神奇。
一旦没有推导。
你就别指望它了。
那种场景该 std::move 就 std::move。
横向对比:别的语言怎么处理“把参数原样传下去”
C 的世界里。
你想清楚就行。
谁 malloc 谁 free。
你传指针。
你心里得有数:这玩意儿到底归谁。
Java 和 C# 这种语言。
大对象通常是引用。
你“传来传去”传的是同一个对象的地址。
至于什么时候释放。
交给 GC。
Rust 更硬。
它把“能不能搬走”写进类型系统里。
move 是默认。
borrow 需要你显式标出来。
C++ 走的是另一条路。
它不强迫你选边。
它给你一套工具。
你自己写规则。
std::forward 就是这套工具里很关键的一把。
再往前一步:把“函数”也一起转发
很多真实场景里,你不只是转发参数。
你连可调用对象本身也要转发,比如统一的 invoke。
template <class F, class... Args>
decltype(auto) invoke(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...);
}
这里的 decltype(auto) 不是摆设。
有些函数返回引用,你如果写成 auto,引用会在包装器里悄悄“变成值”。
然后你又多拷贝了一次。
一个很容易混的点:std::forward 不是“万能 move”
很多人学完会手痒,看到 && 就想 forward 一下。
比如这样。
void g(std::string&& x) {
sink(std::forward<std::string>(x));
}
看起来很像,其实不是。
因为这里没有模板推导。x 的类型虽然是 std::string&&,但 x 这个表达式依然有名字,所以它依然更像左值。
你在这里如果就是想“把它当右值交出去”,该用的是 std::move(x)。
完美转发解决的场景更克制。
你写的是模板包装器,你不知道调用者给你的是左值还是右值。你也不该替他做决定。
你只负责保留原样。
小洞见
我后来越来越觉得,std::forward 不是一个“技巧”。
它更像一种礼貌:你写库代码,就别在无意间替用户做选择。
有名字的东西都像左值。
你得主动把它“还回去”。