本文灵感来源于知乎专栏文章《最后的绅士》

一、一条刻在肌肉记忆里的铁律

作为一个 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::vectoremplace_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'));
}

运行结果:

1
2
3
const&: 5
&&: 5
&&: 5

可以看到,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

关键要点:

  1. const& 适合只读访问,但会丢失调用者的意图(左值/右值)
  2. 转发引用 + std::forward 是转发参数的不二之选,可以保持参数的值类别
  3. 性能差异显著,完美转发可以避免不必要的拷贝,特别是对于大对象
  4. 不要滥用,根据实际场景选择合适的传递方式

老 C++ 只有 const& 这一种"万能"写法,所以大家都用它。现在你有更精确的工具了,用类型系统保留意图。

记住:现代 C++ 不是关于写更少的代码,而是关于写更精确的代码。


参考文章:最后的绅士 - const& 万能引用——「永远用 const& 传参」的陷阱