第 7 章 按值传递还是按引用传递
从一开始,C++就提供了按值传递(call-by-value)和按引用传递(call-by-reference)两种参数传递方式,但是具体该怎么选择,有时并不容易确定:通常对复杂类型用按引用传递的成本更低,但是也更复杂。C++11 又引入了移动语义(move semantics),也就是说又多了一种按引用传递的方式:
1. X const &(const 左值引用)
参数引用了被传递的对象,并且参数不能被更改。
2. X &(非 const 左值引用)
参数引用了被传递的对象,但是参数可以被更改。
3. X &&(右值引用)
参数通过移动语义引用了被传递的对象,并且参数值可以被更改或者被“窃取”。仅仅对已知的具体类型,决定参数的方式就已经很复杂了。在参数类型未知的模板中,就更难选择合适的传递方式了。
不过在 1.6.1 节中,我们曾经建议在函数模板中应该优先使用按值传递,除非遇到以下情况: 对象不允许被 copy。
- 参数被用于返回数据。
- 参数以及其所有属性需要被模板转发到别的地方。
- 可以获得明显的性能提升。
本章将讨论模板中传递参数的几种方式,并将证明为何应该优先使用按值传递,也列举了不该使用按值传递的情况。同时讨论了在处理字符串常量和裸指针时遇到的问题。在阅读本章的过程中,最好先够熟悉下附录 B 中和数值分类有关的一些术语(lvalue,rvalue,prvalue,xvalue)。
7.1 按值传递
当按值传递参数时,原则上所有的参数都会被拷贝。因此每一个参数都会是被传递实参的一份拷贝。对于 class 的对象,参数会通过 class 的拷贝构造函数来做初始化。调用拷贝构造函数的成本可能很高。
但是有多种方法可以避免按值传递的高昂成本:事实上编译器可以通过移动语义(move semantics)来优化掉对象的拷贝,这样即使是对复杂类型的拷贝,其成本也不会很高。
比如下面这个简单的按值传递参数的函数模板:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void printV(T arg) {}int main() {std::string returnString();std::string s = "hi";printV(s); //copy constructorprintV(std::string("hi")); //copying usually optimized away (if not,move constructor)printV(returnString()); // copying usually optimized away (if not, moveconstructor)printV(std::move(s)); // move constructorreturn 0;
}
在第一次调用中,被传递的参数是左值(lvalue),因此拷贝构造函数会被调用。
但是在第二和第三次调用中,被传递的参数是纯右值(prvalue,pure right value,临时对象或者某个函数的返回值,参见附录 B),此时编译器会优化参数传递,使得拷贝构造函数不会被调用。从 C++17 开始,C++标准要求这一优化方案必须被实现。在 C++17 之前,如果编译器没有优化掉这一类拷贝,它至少应该先尝试使用移动语义,这通常也会使拷贝成本变得比较低廉。
在最后一次调用中,被传递参数是 xvalue(一个使用了 std::move()的已经存在的非const 对象),这会通过告知编译器我们不在需要 s 的值来强制调用移动构造函数(move constructor)。
综上所述,在调用 printV()(参数是按值传递的)的时候,只有在被传递的参数是lvalue(对象在函数调用之前创建,并且通常在之后还会被用到,而且没有对其使用std::move())时, 调用成本才会比较高。不幸的是,这唯一的情况也是最常见的情况,因为我们几乎总是先创建一个对象,然后在将其传递给其它函数
按值传递会导致类型退化(decay)
关于按值传递,还有一个必须被讲到的特性:当按值传递参数时,参数类型会退化(decay)。也就是说,裸数组会退化成指针,const 和 volatile 等限制符会被删除(就像用一个值去初始化一个用 auto
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void printV(T arg) {}int main() {std::string const c = "hi";printV(c); // c decays so that arg has type std::stringprintV("hi"); //decays to pointer so that arg has type char const*int arr[4];int arr[4];printV(arr); // decays to pointer so that arg has type int *return 0;
}
当传递字符串常量“hi”的时候,其类型 char const[3]退化成 char const *,这也就是模板参数 T 被推断出来的类型。此时模板会被实例化成:
void printV (char const* arg)
{ …
}
这一行为继承自 C 语言,既有优点也有缺点。通常它会简化对被传递字符串常量的处理,但是缺点是在 printV()内部无法区分被传递的是一个对象的指针还是一个存储一组对象的数组。在 7.4 节将专门讨论如何应对字符串常量和裸数组的问题。
7.2 按引用传递
现在来讨论按引用传递。按引用传递不会拷贝对象(因为形参将引用被传递的实参)。而且,按引用传递时参数类型也不会退化(decay)。不过,并不是在所有情况下都能使用按引用传递,即使在能使用的地方,有时候被推断出来的模板参数类型也会带来不少问题。
7.2.1 按 const 引用传递
为了避免(不必要的)拷贝,在传递非临时对象作为参数时,可以使用const 引用传递。
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void printR(T const& arg) {
}int main() {std::string returnString();std::string s = "hi";printR(s); // no copyprintR(std::string("hi")); // no copyprintR(std::move(s)); // no copyint i = 42;printR(i); // passes reference instead of just copying ireturn 0;
}
这个模板永远不会拷贝被传递对象(不管拷贝成本是高还是低)
即使是按引用传递一个 int 类型的变量,虽然这样可能会事与愿违(不会提高性能,见下段中的解释),也依然不会拷贝。
这样做之所以不能提高性能,是因为在底层实现上,按引用传递还是通过传递参数的地址实现的。地址会被简单编码,这样可以提高从调用者向被调用者传递地址的效率。不过按地址传递可能会使编译器在编译调用者的代码时有一些困惑:被调用者会怎么处理这个地址?理论上被调用者可以随意更改该地址指向的内容。这样编译器就要假设在这次调用之后,所有缓存在寄存器中的值可能都会变为无效。而重新载入这些变量的值可能会很耗时(可能比拷贝对象的成本高很多)。你或许会问在按 const 引用传递参数时:为什么编译器不能推断出被调用者不会改变参数的值?不幸的是,确实不能,因为调用者可能会通过它自己的非const 引用修改被引用对象的值(这个解释太好,另一种情况是被调用者可以通过const_cast 移除参数中的 const)。
不过对可以 inline 的函数,情况可能会好一些:如果编译器可以展开inline 函数,那么它就可以基于调用者和被调用者的信息,推断出被传递地址中的值是否会被更改。函数模板通常总是很短,因此很可能会被做 inline 展开。但是如果模板中有复杂的算法逻辑,那么它大概率就不会被做 inline 展开了。
按引用传递不会做类型退化(decay)
按引用传递参数时,其类型不会退化(decay)。也就是说不会把裸数组转换为指针,也不会移除 const 和 volatile 等限制符。而且由于调用参数被声明为 T const &,被推断出来的模板参数 T 的类型将不包含 const。比如:
std::string const c = "hi";
printR(c); // T deduced as std::string, arg is std::string const&printR("hi"); // T deduced as char[3], arg is char const(&)[3]
int arr[4];
printR(arr); // T deduced as int[4], arg is int const(&)[4]
因此对于在 printR()中用 T 声明的变量,它们的类型中也不会包含 const。
7.2.2 按非 const 引用传递
如果想通过调用参数来返回变量值(比如修改被传递变量的值),就需要使用非const 引用(要么就使用指针)。同样这时候也不会拷贝被传递的参数。被调用的函数模板可以直接访问被传递的参数。 考虑如下情况:
template<typename T>
void outR(T& arg) {
}
注意对于 outR(),通常不允许将临时变量(prvalue)或者通过 std::move()处理过的已存在的变量(xvalue)用作其参数:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void outR(T& arg) {
}int main() {std::string returnString();std::string s = "hi";outR(s); //OK: T deduced as std::string, arg is std::string&outR(std::string("hi")); //ERROR: not allowed to pass a temporary(prvalue)outR(returnString()); // ERROR: not allowed to pass a temporary(prvalue)outR(std::move(s)); // ERROR: not allowed to pass an xvaluereturn 0;
}
同样可以传递非 const 类型的裸数组,其类型也不会 decay:
int arr[4];
outR(arr); // OK: T deduced as int[4], arg is int(&)[4]
这样就可以修改数组中元素的值,也可以处理数组的长度。比如:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void outR(T& arg) {if (std::is_array<T>::value) {std::cout << "got array of " << std::extent<T>::value << " elems\n";}
}int main() {int arr[4];outR(arr); // OK: T deduced as int[4], arg is int(&)[4]return 0;
}
但是在这里情况有一些复杂。此时如果传递的参数是 const 的,arg 的类型就有可能被推断为 const 引用,也就是说这时可以传递一个右值(rvalue)作为参数,但是模板所期望的参数类型却是左值(lvalue):
std::string const c = "hi";
outR(c); // OK: T deduced as std::string const
outR(returnConstString()); // OK: same if returnConstString() returnsconst string
outR(std::move(c)); // OK: T deduced as std::string const6
outR("hi"); // OK: T deduced as char const[3]
在这种情况下,在函数模板内部,任何试图更改被传递参数的值的行为都是错误的。在调用表达式中也可以传递一个 const 对象,但是当函数被充分实例化之后(可能发生在接接下来的编译过程中),任何试图更改参数值的行为都会触发错误(但是这有可能发生在被调用模板的很深层次逻辑中,具体细节请参见 9.4 节)。
如果想禁止想非 const 应用传递 const 对象,有如下选择:
可以将任意类型的参数传递给转发引用,而且和往常的按引用传递一样,都不会创建被传递参数的备份:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void passR(T&& arg) {if (std::is_array<T>::value) {std::cout << "got array of " << std::extent<T>::value << " elems\n";}
}int main() {std::string s = "hi";passR(s); // OK: T deduced as std::string& (also the type of arg)passR(std::string("hi")); // OK: T deduced as std::string, arg is std::string&&passR(std::string()); // OK: T deduced as std::string, arg is std::string&&passR(std::move(s)); // OK: T deduced as std::string, arg is std::string&&int arr[4];passR(arr); // OK: T deduced as int(&)[4] (alsoreturn 0;
}
但是,这种情况下类型推断的特殊规则可能会导致意想不到的结果:
看上去将一个参数声明为转发引用总是完美的。但是,没有免费的午餐。比如,由于转发引用是唯一一种可以将模板参数 T 隐式推断为引用的情况,此时如果在模板内部直接用 T 声明一个未初始化的局部变量,就会触发一个错误(引用对象在创建的时候必须被初始化):
template<typename T>
void passR(T&& arg) {T x;
}
int main() {passR(42); // OK: T deduced as intint i;passR(i); // ERROR: T deduced as int&, which makes the declaration ofxin passR() invalidreturn 0;
}
??? 没看懂问题
7.3 使用 std::ref()和 std::cref() (限于模板)
从 C++11 开始,可以让调用者自行决定向函数模板传递参数的方式。如果模板参数被声明成按值传递的,调用者可以使用定义在头文件中的 std::ref()和std::cref()将参数按引用传递给函数模板。比如
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>template<typename T>
void printT(T arg) {
}int main() {std::string s = "hello";printT(s); //pass s By valueprintT(std::cref(s)); // pass s “as if by reference”return 0;
}
7.4 处理字符串常量和裸数组
到目前为止,我们看到了将字符串常量和裸数组用作模板参数时的不同效果:
- 按值传递时参数类型会 decay,参数类型会退化成指向其元素类型的指针。
- 按引用传递是参数类型不会 decay,参数类型是指向数组的引用。
两种情况各有其优缺点。将数组退化成指针,就不能区分它是指向对象的指针还是一个被传递进来的数组。另一方面,如果传递进来的是字符串常量,那么类型不退化的话就会带来问题,因为不同长度的字符串的类型是不同的。比如:
这里 foo(“hi”, “guy”)不能通过编译,因为”hi”的类型是 char const [3],而”guy”的类型是char const [4],但是函数模板要求两个参数的类型必须相同。
这种 code 只有在两个字符串常量的长度相同时才能通过编译。因此,强烈建议在测试代码中使用长度不同的字符串。
如果将 foo()声明成按值传递的,这种调用可能可以正常运行:
但是这样并不能解决所有的问题。反而可能会更糟,编译期间的问题可能会变为运行期间的问题
7.4.1 关于字符串常量和裸数组的特殊实现
有时候可能必须要对数组参数和指针参数做不同的实现。此时当然不能退化数组的类型。
为了区分这两种情况,必须要检测到被传递进来的参数是不是数组。
通常有两种方法:
可以将模板定义成只能接受数组作为参数:
template<typename T, std::size_t L1, std::size_t L2>
void foo(T (&arg1)[L1], T (&arg2)[L2])
{
T* pa = arg1; // decay arg1
T* pb = arg2; // decay arg2
if (compareArrays(pa, L1, pb, L2)) { …
}
}
参数 arg1 和 arg2 必须是元素类型相同、长度可以不同的两个数组。但是为了支持多种不同类型的裸数组,可能需要更多实现方式(参见 5.4 节)。
可以使用类型萃取来检测参数是不是一个数组:
template<typename T, typename =
std::enable_if_t<std::is_array_v<T>>>
void foo (T&& arg1, T&& arg2)
{ …
}
由于这些特殊的处理方式过于复杂,最好还是使用一个不同的函数名来专门处理数组参数。或者更近一步,让模板调用者使用 std::vector 或者 std::array 作为参数。但是只要字符串还是裸数组,就必须对它们进行单独考虑
7.5 处理返回值
将返回类型声明为 auto,从而让编译器去推断返回类型,这是因为auto 也会导致类型退化
template<typename T>
auto retV(T p) // by-value return type deduced by compiler
{
return T{…}; // always returns by value
}
7.6 关于模板参数声明的推荐方法
正如前几节介绍的那样,函数模板有多种传递参数的方式:
将参数声明成按值传递:
这一方法很简单,它会对字符串常量和裸数组的类型进行退化,但是对比较大的对象可能会受影响性能。在这种情况下,调用者仍然可以通过 std::cref()和 std::ref()按引用传递参数,但是要确保这一用法是有效的。
将参数声明成按引用传递:
对于比较大的对象这一方法能够提供比较好的性能。尤其是在下面几种情况下:
- 将已经存在的对象(lvalue)按照左值引用传递,
- 将临时对象(prvalue)或者被 std::move()转换为可移动的对象(xvalue)按右值引用传递,
- 或者是将以上几种类型的对象按照转发引用传递。
由于这几种情况下参数类型都不会退化,因此在传递字符串常量和裸数组时要格外小心。对于转发引用,需要意识到模板参数可能会被隐式推断为引用类型(引用折叠)。
一般性建议
基于以上介绍,对于函数模板有如下建议: 1. 默认情况下,将参数声明为按值传递。这样做比较简单,即使对字符串常量也可以正常工作。对于比较小的对象、临时对象以及可移动对象,其性能也还不错。对于比较大的对象,为了避免成本高昂的拷贝,可以使用 std::ref()和 std::cref()
2. 如果有充分的理由,也可以不这么做:
- 如果需要一个参数用于输出,或者即用于输入也用于输出,那么就将这个参数按非const 引用传递。但是需要按照 7.2.2 节介绍的方法禁止其接受 const 对象。
- 如果使用模板是为了转发它的参数,那么就使用完美转发(perfect forwarding)。也就是将参数声明为转发引用并在合适的地方使用 std::forward<>()。考虑使用std::decay<>或者 std::common_type<>来处理不同的字符串常量类型以及裸数组类型的情况。
- 如果重点考虑程序性能,而参数拷贝的成本又很高,那么就使用const 引用。不过如果最终还是要对对象进行局部拷贝的话,这一条建议不适用。
3. 如果你更了解程序的情况,可以不遵循这些建议。但是请不要仅凭直觉对性能做评估。在这方面即使是程序专家也会犯错。真正可靠的是:测试结果
不要过分泛型化
值得注意的是,在实际应用中,函数模板通常并不是为了所有可能的类型定义的。而是有一定的限制。比如你可能已经知道函数模板的参数只会是某些类型的 vector。这时候最好不要将该函数模板定义的过于泛型化,否则,可能会有一些令人意外的副作用。针对这种情况应该使用如下的方式定义模板:
template<typename T>
void printVector (std::vector<T> const& v)
{ …
}
这里通过的参数 v,可以确保 T 不会是引用类型,因为 vector 不能用引用作为其元素类型。而且将 vector 类型的参数声明为按值传递不会有什么好处,因为按值传递一个vector 的成本明显会比较高昂(vector 的拷贝构造函数会拷贝 vector 中的所有元素)。此处如果直接将参数 v 的类型声明为 T,就不容易从函数模板的声明上看出该使用那种传递方式了。
以 std::make_pair<>为例
std::make_pair<>()是一个很好的介绍参数传递机制相关陷阱的例子。使用它可以很方便的通过类型推断创建 std::pair<>对象。它的定义在各个版本的 C++中都不一样:
在第一版 C++标准 C++98 中,std::make_pair<>被定义在 std 命名空间中,并且使用按引用传递来避免不必要的拷贝:
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 const& a, T2 const& b)
{
return pair<T1,T2>(a,b);
}
但是当使用 std::pair<>存储不同长度的字符串常量或者裸数组时,这样做会导致严重的问题。
因此在 C++03 中,该函数模板被定义成按值传递参数
template<typename T1, typename T2>
pair<T1,T2> make_pair (T1 a, T2 b)
{
return pair<T1,T2>(a,b);
}
不过在 C++11 中,由于 make_pair<>()需要支持移动语义,就必须使用转发引用。因此,其定义大体上是这样:
template<typename T1, typename T2>
constexpr pair<typename decay<T1>::type, typename
decay<T2>::type>
make_pair (T1&& a, T2&& b)
{
return pair<typename decay<T1>::type, typename
decay<T2>::type>(forward<T1>(a), forward<T2>(b));
}
完 整 的 实 现 还 要 复 杂 的 多 : 为 了 支 持 std::ref() 和 std::cref() ,该函数会将std::reference_wrapper 展开成真正的引用。 目前 C++标准库在很多地方都使用了类似的方法对参数进行完美转发,而且通常都会结合std::decay<>使用
7.7 总结
最好使用不同长度的字符串常量对模板进行测试。
模板参数的类型在按值传递时会退化,按引用传递则不会。
可以使用 std::decay<>对按引用传递的模板参数的类型进行退化。 在某些情况下,对被声明成按值传递的函数模板,可以使用 std::cref()和std::ref()将参数按引用进行传递。
按值传递模板参数的优点是简单,但是可能不会带来最好的性能。 除非有更好的理由,否则就将模板参数按值传递。
对于返回值,请确保按值返回(这也意味着某些情况下不能直接将模板参数直接用于返回类型)。
在比较关注性能时,做决定之前最好进行实际测试。不要相信直觉,它通常都不准确
第 8 章 编译期编程
8.1 模板元编程
模板的实例化发生在编译期间(而动态语言的泛型是在程序运行期间决定的)。事实证明C++模板的某些特性可以和实例化过程相结合,这样就产生了一种 C++自己内部的原始递归的“编程语言”。因此模板可以用来“计算一个程序的结果”。第 23 章会对这些特性进行全面介绍,这里通过一个简单的例子来展示它们的用处。
判断一个数是不是质数
下面的代码在编译期间就能判断一个数是不是质数:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>// p: number to check, d: current divisor
template<unsigned p, unsigned d>
struct DoIsPrime {static constexpr bool value = (p % d != 0) && DoIsPrime < p, d - 1 >::value;
};// end recursion if divisor is 2
template<unsigned p>
struct DoIsPrime<p, 2> {static constexpr bool value = (p % 2 != 0);
};// primary template
template<unsigned n>
struct IsPrime {// start recursion with divisor from p/2:static constexpr bool value = DoIsPrime < n, n / 2 >::value;
};// special cases (to avoid endless recursion with template instantiation):
template<>
struct IsPrime<0> { static constexpr bool value = false; };
template<>
struct IsPrime<1> { static constexpr bool value = false; };
template<>
struct IsPrime<2> { static constexpr bool value = true; };
template<>
struct IsPrime<3> { static constexpr bool value = true; };int main() {std::cout << IsPrime<9>::value;return 0;
}
IsPrime<>模板将结果存储在其成员 value 中。为了计算出模板参数是不是质数,它实例化了DoIsPrime<>模板,这个模板会被递归展开,以计算 p 除以 p/2 和 2 之间的数之后是否会有余数。
正如以上实例化过程展现的那样:
- 我们通过递归地展开 DoIsPrime<>来遍历所有介于 p/2 和 2 之间的数,以检查是否有某个数可以被 p 整除。
- 用 d 等于 2 偏特例化出来的 DoIsPrime<>被用于终止递归调用。但是以上过程都是在编译期间进行的。
8.2 通过 constexpr 进行计算
在 C++14 中,constexpr 函数可以使用常规 C++代码中大部分的控制结构。因此为了判断一个数是不是质数,可以不再使用笨拙的模板方式(C++11 之前)以及略显神秘的单行代码方式
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>constexpr bool IsPrime(unsigned int n) {for (unsigned int d = 2; d <= n / 2; ++d) {if (n % d == 0) {return false;}}return n > 1;
}int main() {constexpr bool b1 = IsPrime(9);return 0;
}
但是上面所说的“可以”在编译期执行,并不是一定会在编译期执行。在需要编译期数值的上下文中(比如数组的长度和非类型模板参数),编译器会尝试在编译期对被调用的 constexpr 函数进行计算,此时如果无法在编译期进行计算,就会报错(因为此处必须要产生一个常量)。
在其他上下文中,编译期可能会也可能不会尝试进行编译期计算,如果在编译期尝试了,但是现有条件不满足编译期计算的要求,那么也不会报错,相应的函数调用被推迟到运行期间执行。 比如:
constexpr bool b1 = isPrime(9); // evaluated at compile time
会在编译期进行计算(因为 b1 被 constexpr 修饰)。而对
const bool b2 = isPrime(9); // evaluated at compile time if in namespacescope
如果 b2 被定义于全局作用域或者 namespace 作用域,也会在编译期进行计算。如果b2 被定义于块作用域({}内),那么将由编译器决定是否在编译期间进行计算。下面这个例子就属于这种情况:
bool fiftySevenIsPrime() {
return isPrime(57); // evaluated at compile or running time
}
此时是否进行编译期计算将由编译期决定。
另一方面,在如下调用中:
int x;
…
std::cout << isPrime(x); // evaluated at run time
不管 x 是不是质数,调用都只会在运行期间执行
8.3 通过部分特例化进行路径选择
诸如 isPrime()这种在编译期进行相关测试的功能,有一个有意思的应用场景:可以在编译期间通过部分特例化在不同的实现方案之间做选择。
比如,可以以一个非类型模板参数是不是质数为条件,在不同的模板之间做选择:
#include <utility>
#include <string>
#include <iostream>
#include <type_traits>constexpr bool IsPrime(unsigned int n) {for (unsigned int d = 2; d <= n / 2; ++d) {if (n % d == 0) {return false;}}return n > 1;
}// primary helper template:
template<int SZ, bool = IsPrime(SZ)>
struct Helper;
// implementation if SZ is not a prime number:
template<int SZ>
struct Helper<SZ, false> {};
// implementation if SZ is a prime number:
template<int SZ>
struct Helper<SZ, true> {};int main() {Helper<9> h;return 0;
}