Effective Modern C++:右值引用、移动语义和完美转发
在学习 C++ 时,当接触到右值引用和移动语义等概念时,就会劝退很多人。一方面,是因为这些概念本身已经不再是入门知识了,即使不懂,也可以写出一些程序了;另一方面,大多数教材中,并没有以最简洁易懂的方式完成这些知识的传授。
本章节会把这部分知识最底层最细节的内容,用另一个视角展示出来。但前提是,你需要已经理解什么是左值和右值,什么是引用这些基本的概念。
条款 23:理解移动语义和完美转发
移动语义
当我们写下一条赋值语句时,很多时候,实际上是完成了一次拷贝操作,也就是将数据复制一份,放入新声明的对象内存中。当数据量比较大时,并且赋值之后,原始的数据将不会再使用时,这种复制的代价会很大也很没必要。
直觉的理解,就是直接把地址复用一下,就能避免复制一份的开销,这种行为看起来像把数据 “移动” 到新的对象内存中,从而提高了性能。
在 Modern C++ 中,我们使用 std::move 这个操作,来支持移动语义。
然而,C++ 烦人的地方就在于,很多你看似显而易见的概念,实际上却暗藏玄机。比如,std::move 并没有移动任何东西,后边提到的 std::forward 也并没有转发任何东西。事实上,他们在运行期什么也不做,一个字节都不会生成。
std::move 也不一定总是能保证会产生移动行为,它实际上只是一个强制类型转换,无条件将实参转换为右值引用。
我认为,如果你敢于说出自己 “熟练掌握” C++,那么,一定可以盲写出 std::move 的实现函数,因为它真的很简单:
1 | template <typename T> |
代码中,核心是 static_cast,它将形参 param 转换为另一个类型。形参是一个万能引用,而不是一个右值引用(虽然有两个 &&),它即可以绑定左值引用,也可以绑定右值引用。函数返回的类型,首先使用 remove_reference 去除所有引用属性,然后取其类型后,再转变为右值引用,所以,返回类型一定是原始类型去掉引用之后的右值引用类型。
C++ 14 中的实现更加简洁:
1 | template <typename T> |
从源码中可以确定,std::move 确实是一个类型转换。
那为什么它和 “移动” 扯上关系呢?
我们的编译器,会在明确一个类型是一个右值时,调用这个类型的移动语义(移动构造函数和移动赋值运算符),而移动语义通常是更轻量级的实现,比如拷贝地址而不是拷贝值。std::move 帮我们确保将类型变成右值引用,从而 “尝试” 触发移动语义。
注意,我这里使用了 “尝试” 这个词,是因为 std::move 不保证一定会触发移动,因为它只能保证返回的是一个右值引用,两者之间还隔着一个编译器行为。书中一个典型的反例是:
1 | class Obj { |
这段代码中,构造函数的实参,到对象中的数据成员 value,就发生了拷贝而不是移动。原因是,构造函数形参是 const std::string,如果将一个 const 属性的数据,移动到一个非 const 属性的数据,那就会出现本不希望修改的东西可能被修改,编译器不接受这种行为,所以还是会拷贝而不是移动。
更专业的解释是,std::string 中只提供 const string& 作为形参类型的拷贝构造函数和以 string&& 作为形参类型的移动构造函数,所以 value 的构造中,被 std::move 处理后的实参 std::move(text),其类型虽然是 const std::string&&,但在实例化 value 时,选择了拷贝构造函数(常量右值引用退化为常量左值引用)。
谬误:
std::move只保证返回的是一个右值引用,而不保证一定发生移动语义。
陷阱:针对常量对象的移动操作,一定会悄无声息的退化为拷贝操作。
理论上来说,std::move 这个命名是有歧义的,更合适的命名是 rvalue_cast 之类,然而,当时 C++ 标准委员会考虑的可能是尽可能不让用户感知更下层的细节,避免陷入语法漩涡中。我个人的意见认为,既然 C++ 无法在语法层面掩藏 “右值引用” 的概念,那就不应该在 std::move 这里多虑,选择暴露细节并让用户权衡,或者选择隐藏细节和提供封装,二者择一即可,而不是既要又要,却引入了尴尬的歧义。
同样的,std::move 看似和 “移动” 千丝万缕,但即使真的发生了移动行为,移动之后的原对象,其依然是有效的,程序员需要手动关注移动后的原对象状态,比如在移动构造中将类型成员指针赋值 nullptr。否则,状态是未定义的。
另外,一个和 Rust 对比的有趣细节,C++ 中传递非引用类型参数时,默认的行为是拷贝,若类型实现了移动语义,且实参是右值(如临时对象或手动使用 std::move 的结果),传参行为变成移动;Rust 中默认的传参行为是移动(转移所有权),当类型实现 Copy trait 或手动调用 .clone() 方法时,传参行为变成拷贝。可见,Rust 通过默认移动语义保证了安全性和性能,而 C++ 的默认拷贝行为则需要开发者主动留意安全性和性能。
比如,在 C++ 代码中:
1 | Widget w(42); |
在 Rust 代码中:
1 | let w = Widget::new(42); |
完美转发
之前提到过,函数形参不可能是一个右值类型,所以,如果函数调用时实参是一个右值引用,那函数形参就丢失了右值引用属性,对于 void func(Widget&& w) 来说,当 func 内部操作 w 时,就会当作一个左值引用来处理。为了能让 func 内部的 w 也是和实参一样的右值引用,使用完美转发便可以做到。
在 Modern C++ 中,我们使用 std::forward 这个操作,来支持完美转发。
std::forward 是一个 “有条件” 的强制类型转换。只有当函数形参是万能引用类型,实参是一个右值时,会把形参强制转换为一个右值类型。
它的常用场景是,如果我们希望把调用函数时的右值实参的右值性,保留在函数内部(而不是转化为左值),就去使用它。
1 | void process(const Widget& arg); // 接受左值作为参数 |
使用时,对于 a 情况,w 作为左值传入 func,万能引用形参的模板被实例化为左值(T 的类型是 Widget&,依据引用折叠规则,param 的类型是 Widget&),所以 std::forward 什么也不会做,最终调用的是 void process(const Widget& arg) 重载版本;对于 b 情况,w 作为右值传入 func,形参被实例化为右值(T 的类型是 Widget,param 的类型是 Widget&&,注意它此时是左值),所以 std::forward 会将其转换为右值,传入 process 中,最终调用的是void process(Widget&& arg)。
如果对这块类型推导不清楚,可以回到第一章,再看看模板类型推导的规则,并结合后边提到的引用折叠来理解。
如果读完该条款,还是没有理解万能引用,那么本章后续的内容对你可能太深奥了,万能引用只是后续模板化编程的门槛石,还是先从其他地方搞明白这些概念为好。
最后做一个总结,如果你想要将一个左值尝试通过移动的方式交给函数内部,就使用 std::move;如果你在函数内,希望将外边传入的右值(函数内部变成左值),保持其右值性质,就使用 std::forward。但要记住,两者本质都是类型转换。
条款 24:区分万能引用和右值引用
当我们在代码中看到一个类型的声明中带有 && 的时候,按最初的学习内容,会认为这是一个右值引用,因为左值引用是 & 来标记的。然而,还有一种叫万能引用的东西。
万能引用本身的概念很简单,它既可以绑定左值,也可以绑定右值,所以它既可能是一个左值引用,也可能是一个右值引用,具体是哪种,取决于实例化时的入参。
读懂代码,首先要能分得清万能引用:
1 | void f(Object&& param); // 右值引用 |
区分万能引用的依据是:
- 类型都涉及类型推导;
- 不能带有 cv 属性;
- 必须是类型本身的推导,而不是二次推导的结果(比如
std::vector<T>); - 模板类中的函数,只有自身是一个模板函数;
有关于最后一点的简单解释,书中的示例为:
1 | template <typename T, class Allocator = allocator<T>> |
这是标准库容器 vector 的实现,其中 push_back 中的 T&& 虽然满足万能引用的语法形式,实际上却是右值引用;而 emplace_back 参数才是万能引用,Args&& 是属于成员函数自己的模板类型。
谬误:符合
T&&形式的模板参数类型,不一定是万能引用。
记住这些规则没有意义,编译器也不会去用这种类型区分什么是万能引用。事实上万能引用只是一个抽象。它的本质是引用折叠,编译器使用引用折叠规则来推导这个引用类型是左值引用还是右值引用。
引用折叠的本质是第一章中提到的模板类型推导,包括 auto 类型推导,在后边会继续介绍。
虽然万能引用是一种抽象,但依然有必要了解什么是万能引用,一方面,是可以更方便的和其他开发成员沟通,另一方面,也能更好的读懂代码,写出高质量的代码。
条款 25:灵活使用 std::move 和 std::forward
这一章节我看地很费劲,感觉一个很简单的知识点,翻译出来的内容非常晦涩难懂。
只要已经掌握本章节前边几个条款的介绍,这个条款的内容基本可以忽略了,都是重复的内容。
简单来说,当需要拿到一个右值引用时,使用 std::move,当需要拿到一个万能引用(也就是想要在入参的实参是右值时,才获得右值)时,使用 std::forward。
书中给出了一种错误地把 std::forward 替换为 std::move 导致的问题。
1 | class Widget { |
这个例子中,str 是一个左值,但在 setName 函数中,通过 std::move 将其转换为右值。编译器会认为,外边的 str 已经是一个无效的值,在内部调用了 std::string 的移动赋值将内容放到了成员 name 中。
将 std::move 替换为 std::forward 就可以保证不出现意外。
另外一个值得聊的话题,是返回值优化(RVO)。考虑一下示例代码:
1 | Widget makeWidget1() { |
粗略地看,我们会认为第一版函数中,返回时会发生复制构造,而第二版函数中,会发生移动构造(假设 Widget 实现了移动语义);然而,实际上第一版中,发生了移动构造,而第二版中,发生了复制构造。
究其原因,是因为第一版代码中,满足编译器的返回值优化,编译器会在函数外边的栈上分配 Widget 的内存,并将构造直接放在外边,从而避免复制操作;而在第二版中,由于手动使用了 std::move 操作,它返回的是右值引用,编译器不得不为引用对象生成一个原始对象,从而阻碍了编译器去做返回值优化。
有关于返回值优化不是特别难懂的一个知识点,可以从其他地方了解更多细节。
条款 26:避免在重载函数中使用到万能引用类型的形参
重载是很常见的 C++ 应用技巧,万能引用作为一种函数形参的类型,自然也可以当作重载的一种实现,然而,当你这么做时,就开始掉入一个有点危险的坑里边。
考虑这样一个场景:
1 | std::vector<std::string> names; |
我们可能最初希望以上述这种代码来实现功能,事实上工作地很好,第一个调用是以左值传入,会执行一次复制构造;第二个调用是以右值传入,在万能引用的加持下,实际执行了 string 的移动构造;第三个是字面量,编译器事实上会利用 emplace 的移动构造,直接在 names 的内存位置构造一个 string,省去了在 func 中的一次 string 形参实例化。
然而,当我们有一个新的需求,需要添加一个重载版本的 func,问题就出现了:
1 | // 另一个同名的重载函数,形参为 int |
讨论为什么第二个调用,没有按预期调用到 func(int) 的重载版本,而是报错,便是本条款要引出的问题。
实际很容易理解,第一个调用,常量 1 会被编译器当作 int 类型,直接匹配到 func(int),而传入 short idx,在决策使用哪个重载版本时,却使用了万能引用所在的模板函数的实例化,即:
1 | // 万能引用的模板函数,以 short 实例化后的结果 |
当编译器发现,以万能引用作为参数的模板函数,实例化后的版本,能够完美匹配时,相比于另一个 func(int) 版本还需要类型转换,自然使用万能引用模板函数的实例化更合理。而我们也发现,names 的类型,是无法接收 short 作为构造函数参数的(string 类型不能通过 short 构造),所以编译器就报错了。
实际上的编译器报错可能会输出一大串似乎没有什么用的信息,这是 C++ 模板出现问题时,不人性化的体现。于是,当编译报错出现时,很可能不太容易发现是这里的问题。
依据 C++ 规范,编译器会认为,模板实例化后的版本,没有任何的额外成本,所以这么选择是合理的,自然不能去怪编译器。所以,也就是本条款要提的主题,避免为万能引用作为形参的函数,提供重载版本。形参为万能引用的函数,是重载版本中最贪婪的。
之所以第一个版本:func(1) 没有问题,是因为 C++ 也规定了,如果模板实例化后的版本和另一个重载的普通函数是一样的,那么优先使用普通函数的版本。
陷阱:盲目使用万能引用而不顾及其他,可能会带来潜在问题。
当发现一个问题时,最好能举一反三。书中便进一步继续这个话题。当万能引用的版本作为类的构造函数,又会发生什么现象。
1 | class Person { |
这里第一眼看上去,报错很匪夷所思,我们希望调用到 Person 的复制构造函数,为什么就出错呢?
原因还是在于万能引用,我们提供的万能引用版本的构造函数,实际上可以实例化出来一个类似复制构造函数的版本:
1 | // 实例化为 |
万万没想到,本来只是打算通过万能引用的构造函数,来接收一个 name 作为参数,结果却把 Person 对象也实例化出来了。
而我们也知道,编译器生成的默认复制构造函数,其参数是带有 const 属性的:
1 | // 编译器隐式生成的复制构造函数 |
编译器在实例化万能引用的模板构造函数后,经过权衡,发现使用模板实例化的版本,不需要额外添加 const 属性,所以选择了这个模板实例化的版本。然后,我们便看到了将 name 这个 string 类型的对象,使用 Person 对象(形参)来构造,自然无法实施,导致报错。
事实上,如果调用代码中,采用以下实现:
1 | const Person p3("name"); |
问题便不存在了,原因和之前非类内的阐述是一样的。
当这个话题出现在继承结构中,也会出现类似的问题。假设有个类继承自 Person:
1 | class SpecialPerson : public Person { |
代码中,对 SpecialPerson 的复制构造函数和移动构造函数中,对 Person 的构造,实际上都调用了基类 Person 的完美转发构造函数,并都会编译失败。
在这一条款中得到的经验就是,尽可能不要去把万能引用参数的函数去做重载。在下一条款,我们会深入介绍几个技术,来迎着困难而上。
陷阱:小心不要把万能引用参数的函数去做函数重载,很可能在调用时,会得到错误的重载版本。
条款 27:重载中出现万能引用时的替代方案
针对 条款 26,本条款提供了一些可以解决前述问题的替代方案。
方案 1:放弃重载
当出现万能引用时,不要使用重载,而是用不同的函数名称来实现功能。
这是一种逃避策略,但逃避不一定意味着不好。事实上当你看完整个条款后,再回过头,会发现这种方案反而是最直接也是最推荐的做法,至少在大多数项目中,与其设计复杂的、高端的编程技巧,远不如设计简单的、易维护的代码。
方案 2:将万能引用改为左值引用
既然两个特性冲突时容易有问题,除了放弃重载,自然也可以放弃万能引用。将万能引用类型全部改为常量左值引用。
这种做法失去了万能引用的性能优势,但如果放弃性能能带来代码的简洁性和安全性,也不失为一种可斟酌的方案。
方案 3:将引用改为传值
虽然我们使用引用是为了改进性能,但有些时候,即便使用传值,也并不是带来额外的性能开销。在 条款 41 中会详细阐述这个问题。
实践经验中,当知道什么情况下引用类型可以改进性能时,也就有能力去判断什么情况下,传值也能带来类似的效果,反而还规避了之前的问题。
方案 4:标签分派
前边 3 个方案都可以认为是逃避式的方案,不过确实有一些直接解决问题的方案。
重载决议时,编译器会考察所有重载版本的形参和传入的实参,匹配全局的最优函数。既然万能引用很强大,总是能优先吸引编译器选择自己所在的重载版本,那么我们可以给重载函数多加一个参数,用额外的这个参数来制约万能引用形参的吸引力。
我们重新修改 条款 26 中的重载函数,为了保证对外接口不变,将添加额外参数的函数设置为其内部的子函数,也将重载这个内部子函数:
1 | std::vector<std::string> names; |
这个额外的形参 std::false_type 和 std::true_type 被称为 tag,基于这种方式的重载决议也被叫做 标签分派(tag dispatch)。
代码中 std::is_integral<T> 和 std::remove_reference<T> 是类型特征(type traits),之前条款中已经出现过,C++ 的类型特征很多,它们用于对类型做处理或者判断。本例子中,首先注意到第二个 tag 参数返回的是一个 “是否是整形类型” 的判断。因为类型特征是一个编译期行为,所以判断的结果,也应该是一个静态的状态,所以 C++ 标准库中提供了名为 std::false_type 和 std::true_type 这样的静态类型。注意到,这些 tag 并没有形参的名字,所以它们在运行期不会起作用,编译器会在生成程序时,去除掉这种形参(即使不去掉也不影响我们理解代码)。
继续观察 std::is_integral<T> 中的 T,为了保证无论传入的类型是 T 还是 T& 或 T&&,都应该去处理,使用了 std::remove_reference<T> 来移除引用属性。
最终,在重载决议时,如果类型 T 是一个整形,那么 tag 实参的判断就是 true_type,会选中第二个(第一个形参为 int)重载版本;否则,tag 判断是 false_type,会选中第一个(第一个形参为 T&&)重载版本。将 tag 作为重载函数参数,掩盖了万能引用影响重载决议的吸引力,最终达到我们的目的。
说句题外话,C++ 的很多复杂性的演化动力,就来自于这种不断对现有技术中的缺陷进行更多设计和改善的需求中。换句话说,不断地用一个更复杂的补丁,弥补之前的不足。
方案 5:处理类构造函数中存在万能引用的问题
在 条款 26 中,还提到了如果类构造函数中使用了万能引用,调用构造函数时,本欲调用复制构造函数,却实际调用到了万能引用版本的构造函数。方案 4 无法解决这个问题,因为复制构造函数有可能是编译器自己生成的,所以没办法用方案 4 中,实现一个子函数来做重载。
万能的 C++ 当然考虑到了这一点,实际上,标准库中很多代码都面临这个问题,学习这块知识有助于我们去阅读学习标准库中的代码,也有助于理解和读懂编译报错时的信息。这部分会比较复杂,但也依然是类型特征的范畴。
由于没办法自己生成带有标签分派的重构函数,所以只能另寻他法,这里引出一个新的东西:std::enable_if。
如果你看过一些标准库或复杂的 C++ 项目,就应该已经熟悉它,它可以指定编译器处理一个模板实例化时的条件。默认的模板总是使能的,但如果 enable_if 中的条件不满足,那么模板将会被禁用,有关于完整的原理这里不展开(可以在网上查一下 SFINAE)。针对我们的问题,实际上,我希望在带有万能引用形参的模板中,加入这个功能,控制仅当满足特定条件时才启用该模板。下边,我们将讨论指定什么条件可以达成我们的目的。
1 | class Person { |
这段代码中的条件很复杂,如果之前没看过这个代码,也没关系,现在一起来看一下。代码按关键位置做了换行,以方便阅读。
简单来说,模板参数的第二项:typename = typename std::enable_if<...>::type 是对 enable_if 的使用,当满足 ... 中的条件时,该模板被启用;
展开里边的结构:!std::is_same<...>::value 是启用条件,也是一个类型特征,它的语义为,当 ... 中指定的两个类型不同时,模板被启用;
再继续展开:Person, typename std::decay<T>::type,指定了两个要比较的类型,第一个是 Person 类,第二个是另一个类型特征,std::decay<T>::type 的语义是,将类型 T 的引用属性、CV 特性(const 和 volatile)、数组和函数等类型,简化为最简单的形式。换句话说,比如 T& 或 T&&,const T& 或 const T&& 等等类型,经过该类型特征,输出都是 T。
总结起来,这个代码设定的就是,当某个类型 T,将其简化后(经过 decay),和 Person 不同时,该万能引用作为形参的构造函数,将被启用;否则,不会被启用。那么,针对上一个条款中的问题,当遇到:
1 | Person p1("name"); |
便可正常编译,因为 p1 被推断为 Person 类型,万能引用模板构造函数被禁用了,编译器只能找到复制构造函数。
以上我们已经开始逐步地接触模板元编程(TMP),感兴趣可以到其他地方学习。类型特征是模板元编程中一块重要的内容,可以理解为是类型的类型,在 Rust 等编程语言中,被称为特征 trait。要想熟练模板元编程,对类型特征的集合要有一定的熟悉度,这在大多数 C++ 教材中,都不会大篇幅介绍。
方案 6:处理继承结构中遇到的问题
在继承结构中,比如 条款 26 中最后的 SpecialPerson 示例,目前使用方案 5 还是无法解决。因为 decay 无法将一个派生类型简化为其基类类型,导致 enable_if 通过。
C++ 标准当然为我们考虑到了这一点,使用 std::is_base_of 便可以处理派生类和基类之间比较的问题:
1 | class Person { |
使用 is_base_of 取代 is_same 即可。当两个类型相同的类型使用 is_base_of,如 std::is_base_of<Person, Person>,结果依然是成立的,所以可以放心大胆地取代 is_same。
对了,以上代码使用 C++14 均可以更简洁一些,结果是一样的:
1 | class Person { |
总结
被绕了很久,已经逐渐忘记最初的目标是什么了,做一个简单的回顾。
在本章节开始时,我们介绍了万能引用的作用,而在 条款 26 中,却提出了当万能引用函数和重载、或者和构造函数一起出现时,会遇到的棘手问题。在本条款中,提出了几种简单的和复杂的方案,去解决这些问题。
最后的结论依然是,如果可以用前边几个简单的方案来解决这些问题,那么就去用便好了,在工程实践中,追求技术的专业和高效并不是最优的,代码的稳定性和可维护性才是更重要的事情。如果你是在编写库代码、做技术研究,或者单纯地就是想追寻极致的代码之美,试试模板元编程也并不为过。
但需要注意,C++ 一个极其让人诟病的地方就是它的报错很可能非常复杂,问题点夹杂在冗长的编译报错消息中,难以检查,即使你不去自己使用这些高深的技术,只要使用了标准库,也很可能会体会到,或已经深受其害。如果去使用这些技术,那么就要做好充分的心理准备,去面对恐怖的报错清单(C++20 的 Concept 一定程度上缓解了这里的烦恼)。
唯一值得安慰的是,当你掌握这些技术之后,你就已经超过了绝大多数 C++ 开发者,面对相同问题时,就更可能快速定位问题。
条款 28:理解引用折叠
到这里,我们终于可以展开说一下什么是万能引用。在这之前,需要先引入一个概念叫 引用折叠(reference collapse)。
C++ 中,不允许出现引用的引用,如果你这么写了,编译器会报错。但编译器自己却允许在内部推导时,出现引用的引用,它会将其合并起来,这就是引用折叠。引用折叠的规则是:
- 如果其中存在左值引用,那么折叠后是左值引用。比如
T& & => T&,T& && => T&以及T&& & => T&; - 如果都是右值引用,那么折叠后是右值引用。比如
T&& && => T&&;
注意,这些都是编译器内的行为,不能写出这种代码。
C++ 中使用引用折叠的场合有 4 个:
- 模板实例化(万能引用就是其中的一种实践);
- auto 类型推导(本质上和模板实例化一样);
- typedef 类型定义;
- decltype 表达式类型推导;
接下来我们要使用引用折叠规则了。回到我们的万能引用中,对于一个典型示例:
1 | template <typename T> |
其中 arg 是一个万能引用,所以我们知道,传入 f 的实参是左值还是右值,会被编码到形参类型 T 中。万能引用能做到的是,当实参是一个左值时,T 的结果是一个左值引用,当实参是一个右值时,T 的结果是一个非引用类型(注意和引用折叠做区分,这里并不是一个右值引用,这是模板推导规则之一,可见条款 1)。
我们再将 std::forward 的定义写出来:
1 | template <typename T> |
依次考虑一下。首先,当函数 f 的实参是一个左值时,由于万能引用的推导,f 的模板类型 T 是一个左值引用:
1 | // 推导后: |
对 forward 的实例化结果为:
1 | // 推导后: |
forward 函数实际上什么也没做,返回了一个左值引用。
整体上来看,当给万能引用模板函数 f 传入一个左值时,传递给函数 g 的实参也是一个左值引用,符合完美转发的设定。
考虑第二种情况,当函数 f 的实参是一个右值时,由于万能引用的推导,f 模板类型 T 是一个右值(非引用类型):
1 | // 推导后: |
对 forward 的实例化结果为:
1 | Widget&& forward(Widget& param) { |
可以看到,forward 将一个左值引用作为输入,返回了一个类型转换后的右值引用。
整体上来看,当给万能引用模板函数 f 传入一个右值时,传递给函数 g 的实参,也是一个右值引用,同样符合完美转发的设定。
最后,对万能引用做一个定义。万能引用并非一种新的引用类型,它其实就是满足以下语境的右值引用:
- 类型推导过程会区分左值和右值;
- 会发生引用折叠;
条款 29:假定移动操作不会发生或成本更高
如果你已经很顺利地看完了前边的内容,那么这一条很容易理解。
C++ 的移动操作并不保证一定会发生移动操作,他仅仅只是把一个类型转换为右值,所以虽然用到了 std::move,移动也不一定总会发生;或者虽然没有显式使用移动语义,但编译器可能会调用移动构造和移动运算符的场景,移动也不总是会发生。
可能的一些原因有:
- 对象没有提供移动操作,或者编译器无法自动为对象添加移动操作;
- 如果对象有移动操作,但移动操作可能并不会比复制操作更快;
- 如果移动操作本可以发生,但如果对象要求强异常安全保证,而移动没有添加
noexcept,那么移动操作也不会调用;
对于其中的第 2 点,展开说一下。移动操作本质上没有什么神奇的,移动比复制快的原因,是因为移动是浅拷贝,也就是只复制了指向数据的指针,而没有复制数据本身。如果一些类型,它的数据本身就是其自身的一部分,比如 std::array,或者小数据量下的 std::vector 和 std::string,当对它们做移动操作时,和复制操作没有效率上的优势。
条款 30:完美转发失败的情况
最后,我们来看一下,完美转发不适用的场景。事实上,大多数使用完美转发的情形都是符合规范的,本条款中提到的一些情况,都是很罕见的用法。
首先来定义一个问题,我们要讨论的示例代码:
1 | template <typename T> |
如果以下两种用法,产生的效果是等价的,那么就可以认为完美转发成功了,否则,完美转发失败了:
1 | g( expression ); // 直接调用内部函数 |
我们来看一下哪些场景不适用完美转发。
情况 1:大括号初始化
在第一章 条款 1 的讨论中,我们将大括号初始化来做模板类型推导时,就发现这种推导是无法执行的,所以,很显然,如果函数 f 传入的是一个大括号初始化,那么就不可能通过编译。
比如,函数 g 的定义为:
1 | void g(const std::vector<int>& v); |
以下代码:
1 | g({1, 2, 3}); // 可以通过编译,编译器隐式转换大括号初始化为 std::vector |
有意思的是,在 条款 2 的讨论中,我们知道,auto 类型是可以接受大括号初始化类型推导的,所以一种变通的修改方案是:
1 | auto temp = {1, 2, 3}; // 先通过 auto 将大括号初始化转换为 std::initializer_list<int> |
情况 2:0 和 NULL 用作空指针
读到这里的读者,应该不再会愿意使用 0 和 NULL 来指定空指针了。
1 | f(0); // 预期想推导为 void*,结果推导为 int |
由于函数 f 的形参需要一个万能引用,而常量因为没有地址,无法被引用,所以编译会失败。
情况 3:声明为 static const 的成员变量
在 C++ 中有这样一个规定,如果类的成员函数中,声明了 static const 的成员,由于这样一个成员实际上可以被编译器当作常量来对待,所以编译器不会要求必须给这个成员做定义(只需要做声明)。
而如果这样一个成员变量,没有定义而只有声明,编译器编译不会报错,但对于将他作为 f 的参数绑定到万能引用类型形参,就同样遇到了无法被引用的错误。它会在链接时报错,找不到名称的定义。
修改的方案就是,为它提供定义。
1 | class Widget { |
情况 4:重载函数和模板函数
对于重载函数和模板函数,都存在着没有地方指导万能引用确定引用哪个版本的重载函数或模板实例化的问题,从而导致编译失败。
看下重载函数的例子:
1 | int processVal(int val); |
然后再来看模板函数:
1 | template <typename T> |
调整方案都是一样的,使用一个额外的对象,先为输入的实参做好类型签名,再传递给 f:
1 | int (*)(int) processValPtr = processVal; |
情况 5:位域
从前边几个情况,可以发现一个普适的规律,万能引用是一种引用,所以它需要绑定的对象,一定是要能找到地址的(可引用的)。位域是另一种无法被直接引用的类型。
1 | struct IP { |
由于位域是由机器字上的若干任意部分组成的,所以无法为其取地址,从而就意味着无法引用。
如果你看到这里,还比较自在,说明你对 C++ 的掌握能力,已经很不错了。有关于万能引用和模板实例化的高级技术,上文中都没有展开,但那些东西是通往高级 C++ 工程师的必经之路,谦虚地说,我还没有完全入门。
如果你到此依然充满热情,那就继续准备开下一章节吧,后半部书中,会讨论一些有不同风味的知识。
本系列的其他文章:
- Effective Modern C++ 读书笔记:类型推导
- Effective Modern C++ 读书笔记:auto
- Effective Modern C++ 读书笔记:转向现代C++
- Effective Modern C++ 读书笔记:智能指针
- Effective Modern C++:右值引用、移动语义和完美转发
- Effective Modern C++:lambda 表达式
- Effective Modern C++:并发 API
- Effective Modern C++:微调
本文同步发布在知乎账号下:https://zhuanlan.zhihu.com/p/1949164417171306465













