最开始,C++就提供了传值调用传引用调用两种传参方式,但是决定选择哪种并不是那么容易:通常复杂对象传引用的成本更低但也更复杂。C++11新增了移动语义,这意味着我们可能通过不同的方式来传引用。:
1.X const&:常左值引用。

2.X&非常量左值引用。

3.X&&右值引用。通过移动语义传递引用对象。

我们建议在函数模板中使用传值的方式传参,除非有以下情况:

**1.**无法拷贝(对于左值来说,自C++17起,可以按值传递临时实体)。

**2.**参数用于返回数据。

**3.**模板需要保留原始实参的所有属性,只是通过转发调用其他函数。

**4.**性能可获得明显提升。

传值

按值传递实参时,原则上会拷贝每个参数,对于类来说,会调用类的拷贝构造函数,因此每个参数都成为传递实参的副本。

调用拷贝构造函数的成本可能会很高,不过我们有很多种策略可以避免高昂的拷贝成本。此外,编译器也可以根据情况优化掉拷贝操作,变为移动语义等,可以让复杂对象的传递成本变得很低。

例如。让我们看一个函数模板:

1
2
3
4
5
template <typename STRING>
void print(STRING str)
{
std::cout << str << std::endl;
}

如果我们调用

1
2
std::string str = "Hello";
print(str);

那么模板参数将会实例化为std::string,传递参数时将调用共string类的拷贝构造函数并生成副本。但并不是所有传参都隐式地调用拷贝构造函数,看下面的例子:

1
2
3
4
5
6
7
8
print(str); // 调用拷贝构造函数
print("Hello"); // 拷贝构造函数被优化掉,如果没有,会调用移动构造函数
auto return_string = []() -> std::string
{
return "Hello";
};
print(return_string()); // 拷贝构造函数被优化掉,如果没有,会调用移动构造函数
print(std::move(str)); // 调用移动构造函数

在第二次和第三次调用函数的过程中,我们传入了一个纯右值,编译器通常会优化实参传递。自C++17开始,这种优化变为了必需的,在此之前,编译器也会尝试调用移动语义来降低拷贝成本,在最后一次调用中,我们使用了std::move()来强制编译器使用移动构造。

因此,调用声明按值传递的函数的过程中,通常只在传递左值时才会有高额的成本。但这种情况却很常见。

传值导致的类型退化

必须注意的一点是,当按值传递实参时,实参的类型会退化,如移除const或者volatile修饰符,原始数组替换为指针等,常用的就是char const[]退化为char const*,这样做的优点是可以简化传参过程,但一个问题是这样我们就无法区分传入的是数字还是一个普通的指针,我们后边会介绍如何区分这两种情况。

传引用

传常量引用

传递非临时常量时,为了防止过高的拷贝成本,通常使用常引用:

1
2
3
4
5
template <typename STRING>
void print(STRING const &str)
{
std::cout << str << std::endl;
}

通过此声明,传递对象将永远不会调用拷贝构造函数。即使是基础类型,虽然它们的拷贝成本要比传引用更低。

因为引用的实质就是指针,在底层,按引用传递就是通过传递参数的地址来实现的。地址编码紧凑,因此,这对于将地址从调用者传递到被调用者本身来说很有帮助,但是会带来不确定性:调用者会如何处理这段内存?通过这个位置的内存,理论上来说,调用者可以通过该地址修改通过这个地址能访问到的所有位置的数据,这意味着编译器不得不对此做出一些限制,它不得不假设它缓存的所有值(通常存储在设备寄存器中)在调用之后都是无效的,而去重新加载这段内存的数据。遗憾的是,这个问题通过传递常引用也无法改变,因为我们实际上可以修改常引用的数据,编译器为了保险起见,仍会重新加载这段地址的数据。

举一个简单例子,现在有一个变量int x,通过一个func()函数返回x + 1的值,通常对于这样一个简单局部变量,cpu会将它缓存在寄存器中(假设为ax),如果func函数为按值传递,那么发生的只可能是:将ax的值mov到一个另一个寄存器bx,bx寄存器的值加1,返回bx的值;而对于按引用传递函数来说,由于传送的是数据的地址,在函数调用完毕后,编译器很难确定ax是否仍然有效(因为ax代表局部变量的值,而该值很可能已经被修改,使得ax不能够继续使用),因此会将变量x重新加载到ax中,这就产生了不必要的耗费。

不过对于内联函数来说就方便很多,如果编译器可以展开内联调用,那么它可以将调用者和被调用者放在一起推理,并且引用仅作为传递原始数值的作用而不发生缓存数据的操作,但对于内部实现复杂的函数,编译器不会对它进行内联,这种问题依然存在。

传引用不会导致类型退化

使用传引用时,不会移除限定符,也不会将原始数组变为指针。

传递非常量引用

当我们期望通过函数修改传入参数的某些值时,传递非常量引用就是最好的选择(除非你更喜欢用指针),这意味着在创建函数参数时,不会产生任何副本。

1
2
3
4
5
template <typename STRING>
void print(STRING &str)
{
std::cout << str << std::endl;
}

请注意,通常不允许临时对象(纯右值)或者使用std::move()传递到现存对象(将亡值)来使用按值传递。

1
2
print(return_string()); // error
print(std::move(str)); // error

对于普通函数来说,这样的使用方法简洁明了,但是对于函数模板来说,出现了新的问题。考虑如下情况,我们传入了一个const量,函数模板会实例化出一个传入参数为STRING const&的版本,这样,我们就会发现,本来不允许右值传递的模板函数,又可以进行常量的右值传递了。

1
2
3
4
5
6
7
8
std::string const str2 = "hello";
print(str2);
auto return_const_string = []() -> std::string const
{
return "Hello";
};
print(return_const_string());
print(std::move(str2));

编译通过并且运行时不报错。

这种情况下,任何尝试修改函数模板内传递实参的行为都将发生错误。在调用的表达式自身中传递常量对象是可能的,但当编译期结束,模板实例化完成时,任何修改该对象的尝试都会报错。这与C++函数模板调用的底层逻辑有关。

这时我们只能被迫禁止传递常量对象给非常量引用来避免这个让人头疼的问题,可使用的方法有静态断言触发编译期错误或者使用std::enable_if<>彻底禁用常量参数调用模板。

1
2
3
4
5
6
template <typename STRING>
void print(STRING &str)
{
static_assert(!std::is_const_v<STRING>, "STRING must be non-const");
std::cout << str << std::endl;
}

或者

1
2
3
4
5
6
template <typename STRING,
typename = std::enable_if_t<!std::is_const_v<STRING>>>
void print(STRING &str)
{
std::cout << str << std::endl;
}

传递转发引用

使用转发引用的一个原因是我们可以用其完美转发参数,但要注意,当使用转发引用时,其定义为一个模板参数的右值引用,适用于特殊的规则:

1
2
3
4
5
template <typename STRING>
void print(STRING &&str) // 转发引用
{
std::cout << str << std::endl;
}

可以将任意类型的参数传递给转发引用,而不会产生副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main()
{
std::string str = "Hello";
print(str);
print("Hello");
auto return_string = []() -> std::string
{
return "Hello";
};
std::string const str2 = "hello";
print(str2);
auto return_const_string = []() -> std::string const
{
return "Hello";
};
print(return_const_string());
print(std::move(str2));
return 0;
}

我们可以从函数模板内部的STRING类型,获取传入参数到底是右值/左值,常量/非常量,这是唯一一种让我们可以通过只传递一个实参就可以区分以上这么多类型的方法。这样的转发引用几乎是完美的,但万事不绝对,有一个非常特殊的情况:

1
2
int i;
print(i);

对于这样一个未经初始化的局部简单类型变量来说,它的类型是int&而非int,于是,如果我们在模板函数中写下了如定义STRING a;的语句时,就会出现错误。

std::ref()std::cref()的使用

从C++11开始调用者可以自行决定对于函数模板实参是传值还是传引用。当函数模板声明为按值传递时,调用者可以使用在头文件<functional>中声明的std::ref()std::cref()来按引用传递实参。那么它的内部是如何实现的呢?

事实上,这个声明并没有改变函数模板内部的处理方式,它实际上是创建了一个std::reference_wrapper<>类型的对象,该对象引用了原始实参,并将这个对象以值的方式传递给了函数模板。并且这个包装器还支持将隐式类型转换为原始类型,生成原始实参对象(如需要使用到被包装的相应类型作为返回值类型的话,这个操作是必须的)。

由于传递的是一个包装器,只有在必须进行隐式转换为原始类型的情况下才会进行类型转换,因此使用了这两个包装器的传参多多少少会有些限制,如

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
#include <functional>
#include <iostream>
#include <string>
#include <type_traits>

template <typename T>
void print(T str)
{
std::cout << str << std::endl; // error,包装器没有重载<<
}
template <typename T>
void compare(T a, T b)
{
if (a > b) // error,包装器没有重载比较运算符
{
std::cout << "a > b" << std::endl;
}
else if (a < b)
{
std::cout << "a < b" << std::endl;
}
else
{
std::cout << "a == b" << std::endl;
}
}

int main()
{
std::string str = "Hello";
print(std::ref(str));
std::string s1 = "123", s2 = "456";
compare(std::ref(s1), std::ref(s2));
return 0;
}

处理返回值

对于返回值来说,我们也可以使用按值或者按引用返回,由于返回的对象可能已经失效,所以返回引用可能是出现问题的根源,但在一下情况中,我们多用按引用返回:
1.返回容器或者数组中的元素。

2.允许对类成员进行写操作。

3.为链式调用返回对象(如流操作重载operator<<,赋值=等)。

除此之外,如果我们想要按值返回信息,也要注意以下几个常见问题:

1
2
3
4
5
template <typename T>
T foo(T &&p)
{
return T{};
}

使用转发引用时,传入左值,T将会被推导为引用类型,函数将返回一个引用类型的值。

1
2
3
4
5
template <typename T>
T foo(T p)
{
return T{};
}

即使是支持类型退化的按值传递,也可能会由于我们显式指定模板参数为引用类型而发生错误。

安全起见,我们有以下两个方法避免这个问题:
1.使用类型特征std::remove_reference<>将类型T转换为非引用类型。

2.通过仅修改返回类型为auto,让编译器推导类型,因为使用auto总会导致类型退化。

1
2
3
4
5
6
7
8
9
10
11
template <typename T>
std::remove_reference_t<T> foo(T p)
{
return T{};
}

template <typename T>
auto foo(T p)
{
return T{};
}