本文灵感来源于知乎专栏文章《最后的绅士》
一、一条刻在肌肉记忆里的铁律
作为一个 C++ 工程师,我相信很多人和我一样,脑子里都刻着一条铁律:
"传参用 const&,拷贝贵。"
这条规则在很多场景下确实是正确的。当函数只是观察参数,不作任何修改时,使用 const& 可以避免不必要的拷贝:
1 2 3 4 5 6 7
| double norm(std::vector<double> const& xs) { double sum = 0.0; for (double x : xs) { sum += x * x; } return std::sqrt(sum); }
|
这个函数只是读取向量的元素,不会修改它。使用 const& 是完全合理的选择。
但是,这条铁律在一种场景下会完全失效——当函数的工作是转发参数的时候。
二、const& 毁掉一切的场景
让我们来看一个看似人畜无害的包装类:
1 2 3 4 5 6 7 8 9 10
| template <class T> class bag { public: template <class... Args> T& emplace(Args const&... args) { return xs_.emplace_back(args...); } private: std::vector<T> xs_; };
|
这个 bag 类包装了 std::vector 的 emplace_back 方法。看起来很简单,对吧?
但问题来了。当调用者这样写的时候:
1 2
| bag<std::string> b; b.emplace(std::string(1024, 'x'));
|
在 emplace 内部,这个临时的 std::string 已经变成了 std::string const&。vector 不能从 const 左值移动——它只能拷贝。
你传了一个右值进来,本可以零开销移动,结果硬生生被拷贝了一份。
const& 把调用者的意图丢了。
三、完美转发:让参数保持原意
现代 C++ 的写法是这样的:
1 2 3 4 5 6 7 8 9 10
| template <class T> class bag { public: template <class... Args> T& emplace(Args&&... args) { return xs_.emplace_back(std::forward<Args>(args)...); } private: std::vector<T> xs_; };
|
这里的 Args&& 就是转发引用(forwarding reference),配合 std::forward 使用,可以让:
重载决议看到的就是调用者真正写的东西。
让我用一个更直观的例子来解释:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| void process(std::string const& s) { std::cout << "const&: " << s.size() << std::endl; }
void process(std::string&& s) { std::cout << "&&: " << s.size() << std::endl; }
template <class T> void forwarder(T&& t) { process(std::forward<T>(t)); }
int main() { std::string s = "hello"; forwarder(s); forwarder(std::move(s)); forwarder(std::string(5, 'x')); }
|
运行结果:
可以看到,std::forward 完美地保留了参数的值类别。
四、三种参数传递方式的对比
现代 C++ 给了我们三种主要的参数传递方式,每种都有其适用场景:
| 场景 |
推荐方式 |
说明 |
| 观察已有对象 |
const& |
只读访问,不修改、不转移所有权 |
| 消费/存储 |
值传递 + std::move |
需要修改或获得所有权 |
| 转发构造 |
转发引用 + std::forward |
需要保持参数的值类别 |
4.1 观察已有对象:const&
当函数只是读取参数,不修改也不需要获得所有权时:
1 2 3
| void print(const std::string& s) { std::cout << s << std::endl; }
|
4.2 消费/存储:值传递 + move
当函数需要修改参数或获得其所有权时:
1 2 3 4 5 6 7
| void consume(std::string s) { s += " modified"; std::cout << s << std::endl; }
std::string s = "hello"; consume(std::move(s));
|
或者在构造函数中存储参数:
1 2 3 4 5
| class holder { std::string name_; public: holder(std::string name) : name_(std::move(name)) {} };
|
4.3 转发构造:forwarding reference
当函数需要将参数转发给另一个函数时:
1 2 3 4
| template <class... Args> auto make_unique(Args&&... args) { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); }
|
五、性能对比:基准测试
让我们用实际数据来说话。下面是一个基准测试,比较 const& 和完美转发在 emplace 操作中的性能差异:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| #include <vector> #include <string> #include <chrono> #include <iostream>
template <class T> class bag_const_ref { public: template <class... Args> T& emplace(Args const&... args) { return xs_.emplace_back(args...); } private: std::vector<T> xs_; };
template <class T> class bag_perfect_forward { public: template <class... Args> T& emplace(Args&&... args) { return xs_.emplace_back(std::forward<Args>(args)...); } private: std::vector<T> xs_; };
template <class Bag> void benchmark(size_t n, size_t iterations) { Bag b; auto start = std::chrono::high_resolution_clock::now(); for (size_t i = 0; i < iterations; ++i) { b.emplace(std::string(n, 'x')); } auto end = std::chrono::high_resolution_clock::now(); auto ns = std::chrono::duration_cast<std::chrono::nanoseconds>(end - start).count(); std::cout << "n=" << n << ": " << ns / iterations << " ns/op" << std::endl; }
int main() { std::cout << "使用 const&:" << std::endl; benchmark<bag_const_ref<std::string>>(64, 100000); benchmark<bag_const_ref<std::string>>(1024, 100000); benchmark<bag_const_ref<std::string>>(4096, 100000); std::cout << "\n使用完美转发:" << std::endl; benchmark<bag_perfect_forward<std::string>>(64, 100000); benchmark<bag_perfect_forward<std::string>>(1024, 100000); benchmark<bag_perfect_forward<std::string>>(4096, 100000); }
|
运行结果:
1 2 3 4 5 6 7 8 9
| 使用 const&: n=64: 86.1 ns/op n=1024: 124 ns/op n=4096: 180 ns/op
使用完美转发: n=64: 53.3 ns/op n=1024: 58.8 ns/op n=4096: 86.3 ns/op
|
性能提升非常明显:
- n=64:提升约 38%
- n=1024:提升约 53%
- n=4096:提升约 52%
原因就在于省掉了一次拷贝——当把 string 塞进内部 vector 时,可以直接移动而不是拷贝。
六、为什么 const& 会丢失信息
让我用一个比喻来解释这个问题:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| ┌─────────────────────────────────────────────────────────────┐ │ const& 的问题 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 调用者: 我有一个临时对象,你可以随便拿(右值) │ │ │ │ │ ▼ │ │ const&: 好的,我收到了一个 const 引用 │ │ │ │ │ ▼ │ │ 内部函数: 我只能读,不能移动(因为是 const) │ │ │ │ │ ▼ │ │ 结果: 被迫拷贝一份 │ │ │ └─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐ │ 完美转发的解决方案 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 调用者: 我有一个临时对象,你可以随便拿(右值) │ │ │ │ │ ▼ │ │ Args&&: 我收到了一个转发引用,保留值类别 │ │ │ │ │ ▼ │ │ std::forward: 我把它原样转发给内部函数 │ │ │ │ │ ▼ │ │ 内部函数: 我收到了右值,可以直接移动 │ │ │ │ │ ▼ │ │ 结果: 零开销移动 │ │ │ └─────────────────────────────────────────────────────────────┘
|
七、实际应用案例
7.1 包装第三方库
假设你要包装一个第三方库的 API,需要转发参数:
1 2 3 4
| template <class... Args> void my_api(Args&&... args) { third_party_api(std::forward<Args>(args)...); }
|
7.2 工厂函数
工厂函数需要将参数转发给构造函数:
1 2 3 4
| template <class T, class... Args> std::unique_ptr<T> make_unique(Args&&... args) { return std::unique_ptr<T>(new T(std::forward<Args>(args)...)); }
|
7.3 回调包装
包装回调函数时,需要保持参数的值类别:
1 2 3 4 5 6 7
| template <class Func, class... Args> auto wrap_callback(Func&& func, Args&&... args) { return [func = std::forward<Func>(func), ... args = std::forward<Args>(args)]() mutable { std::invoke(std::move(func), std::move(args)...); }; }
|
八、常见误区
8.1 滥用转发引用
转发引用虽然强大,但不是万能的。当你不需要转发时,不要滥用:
1 2 3 4 5 6 7 8 9
| template <class T> void print(T&& t) { std::cout << t << std::endl; }
template <class T> void print(const T& t) { std::cout << t << std::endl; }
|
上面的例子中,第二个版本更清晰,因为它只是观察参数。
8.2 忘记 std::forward
转发引用必须配合 std::forward 使用,否则会变成普通的右值引用:
1 2 3 4 5 6 7 8 9
| template <class T> void forwarder(T&& t) { process(t); }
template <class T> void forwarder(T&& t) { process(std::forward<T>(t)); }
|
上面的例子中,第二个版本是正确的。第一个版本中,t 作为命名变量是左值,会丢失原始的值类别信息。
8.3 在非模板函数中使用转发引用
转发引用只能在模板函数中使用:
1 2 3 4
| void func(int&& x) {}
template <class T> void func(T&& x) {}
|
上面的例子中,第二个版本才是真正的转发引用。第一个版本只是普通的右值引用。
九、总结
现代 C++ 给了我们更丰富的参数传递"词汇",不再只有 const& 这一种"万能"写法:
| 场景 |
推荐方式 |
| 观察已有对象 |
const& |
| 消费/存储 |
值传递 + std::move |
| 转发构造 |
转发引用 + std::forward |
关键要点:
const& 适合只读访问,但会丢失调用者的意图(左值/右值)
- 转发引用 +
std::forward 是转发参数的不二之选,可以保持参数的值类别
- 性能差异显著,完美转发可以避免不必要的拷贝,特别是对于大对象
- 不要滥用,根据实际场景选择合适的传递方式
老 C++ 只有 const& 这一种"万能"写法,所以大家都用它。现在你有更精确的工具了,用类型系统保留意图。
记住:现代 C++ 不是关于写更少的代码,而是关于写更精确的代码。
参考文章:最后的绅士 - const& 万能引用——「永远用 const& 传参」的陷阱