《EffectiveModernC++》第三章:移步现代C++
第三章 移步现代C++
item7:区别使用()
和{}
创建对象
1 |
|
在很多情况下,你可以使用”=”和花括号的组合:
1 |
|
在这个条款的剩下部分,我通常会忽略”=”和花括号组合初始化的语法,因为C++通常把它视作和只有花括号一样。
区别赋值运算符和初始化就非常重要了,因为这可能包含不同的函数调用:
1 |
|
括号初始化让你可以表达以前表达不出的东西。使用花括号,指定一个容器的元素变得很容易:
1 |
|
只要不包含std::initializer_list
形参,那么花括号初始化和小括号初始化都会产生一样的结果:
1 |
|
然而,如果有一个或者多个构造函数的声明一个std::initializer_list
形参,使用括号初始化语法的调用更倾向于适用std::initializer_list
重载函数。而且只要某个使用括号表达式的调用能适用接受std::initializer_list
的构造函数,编译器就会使用它。如果上面的Widget
类有一个std::initializer_list<long double>
构造函数并被传入实参,就像这样:
1 |
|
w2
和w4
将会使用新添加的构造函数构造,即使另一个非std::initializer_list
构造函数对于实参是更好的选择:
1 |
|
甚至普通的构造函数和移动构造函数都会被std::initializer_list
构造函数劫持:
1 |
|
编译器热衷于把括号初始化与使std::initializer_list
构造函数匹配了,尽管最佳匹配std::initializer_list
构造函数不能被调用也会凑上去。比如:
1 |
|
这里,编译器会直接忽略前面两个构造函数(其中第二个提供了所有实参类型的最佳匹配),然后尝试调用std::initializer_list<bool>
构造函数。调用这个函数将会把int(10)
和double(5.0)
转换为bool
,由于会产生变窄转换(bool
不能准确表示其中任何一个值),括号初始化拒绝变窄转换,所以这个调用无效,代码无法通过编译。
只有当没办法把括号初始化中实参的类型转化为std::initializer_list
时,编译器才会回到正常的函数决议流程中。比如我们在构造函数中用std::initializer_list<std::string>
代替std::initializer_list<bool>
,这时非std::initializer_list
构造函数将再次成为函数决议的候选者,因为没有办法把int
和bool
转换为std::string
:
1 |
|
还要记住一个有趣的edge case:
假如你使用的花括号初始化是空集,并且你欲构建的对象有默认构造函数,也有std::initializer_list
构造函数。你的空的花括号意味着什么?如果它们意味着没有实参,就该使用默认构造函数,但如果它意味着一个空的std::initializer_list
,就该调用std::initializer_list
构造函数。所以该调用哪儿个呢?
最终会调用默认构造函数。空的花括号意味着没有实参,不是一个空的std::initializer_list
:
1 |
|
如果你想用空std::initializer
来调用std::initializer_list
构造函数,你就得创建一个空花括号作为函数实参——通过把空花括号放在小括号或者另一花括号内来界定你想传递的东西。这个简直太过于trick反人类了,建议不要写这样的代码。
1 |
|
vector利用了这个{},()
区分了一些内容:
1 |
|
如果你是一个模板的作者,花括号和小括号创建对象就更麻烦了。通常不能知晓哪个会被使用。举个例子,假如你想创建一个接受任意数量的参数,然后用它们创建一个对象。使用可变参数模板(variadic template)可以非常简单的解决:
1 |
|
在现实中我们有两种方式实现这个伪代码(关于std::forward
请参见Item25):
1 |
|
考虑这样的调用代码:
1 |
|
如果doSomeWork
创建localObject
时使用的是小括号,std::vector
就会包含10个元素。如果doSomeWork
创建localObject
时使用的是花括号,std::vector
就会包含2个元素。哪个是正确的?doSomeWork
的作者不知道,只有调用者知道。
请记住:
- 括号初始化是最广泛使用的初始化语法,它防止变窄转换,并且对于C++最令人头疼的解析有天生的免疫性
- 在构造函数重载决议中,括号初始化尽最大可能与
std::initializer_list
参数匹配,即便其他构造函数看起来是更好的选择 - 对于数值类型的
std::vector
来说使用花括号初始化和小括号初始化会造成巨大的不同 - 在模板类选择使用小括号初始化或使用花括号初始化创建对象是一个挑战。
item8:优先考虑nullptr
而非0
和NULL
在C++98中,对指针类型和整型进行重载意味着可能导致奇怪的事情。如果给下面的重载函数传递0
或NULL
,它们绝不会调用指针版本的重载函数:
1 |
|
而f(NULL)
的不确定行为是由NULL
的实现不同造成的。如果NULL
被定义为0L
(指的是0
为long
类型),这个调用就具有二义性,因为从long
到int
的转换或从long
到bool
的转换或0L
到void*
的转换都同样好。
有趣的是源代码表现出的意思(“我使用空指针NULL
调用f
”)和实际表达出的意思(“我是用整型数据而不是空指针调用f
”)是相矛盾的。
nullptr
的优点是它不是整型。老实说它也不是一个指针类型,但是你可以把它认为是所有类型的指针。nullptr
的真正类型是std::nullptr_t
,在一个完美的循环定义以后,std::nullptr_t
又被定义为nullptr
。std::nullptr_t
可以隐式转换为指向任何内置类型的指针,这也是为什么nullptr
表现得像所有类型的指针。
使用nullptr
调用f
将会调用void*
版本的重载函数,因为nullptr
不能被视作任何整型:
1 |
|
使用nullptr
代替0
和NULL
可以避开了那些令人奇怪的函数重载决议。
1 |
|
可以写这样的代码调用lockAndCall
模板:
1 |
|
前两个情况不能通过编译。在第一个调用中存在的问题是当0
被传递给lockAndCall
模板,模板类型推导会尝试去推导实参类型,0
的类型总是int
,所以这就是这次调用lockAndCall
实例化出的ptr
的类型。不幸的是,这意味着lockAndCall
中func
会被int
类型的实参调用,这与f1
期待的std::shared_ptr<Widget>
形参不符。传递0
给lockAndCall
本来想表示空指针,结果f1
得到的是和它相差十万八千里的int
。把int
类型看做std::shared_ptr<Widget>
类型给f1
自然是一个类型错误。在模板lockAndCall
中使用0
之所以失败是因为在模板中,传给的是int
但实际上函数期待的是一个std::shared_ptr<Widget>
。
第二个使用NULL
调用的分析也是一样的。当NULL
被传递给lockAndCall
,形参ptr
被推导为整型(译注:由于依赖于具体实现所以不一定是整数类型,所以用整型泛指int
,long
等类型),然后当ptr
——一个int
或者类似int
的类型——传递给f2
的时候就会出现类型错误,f2
期待的是std::unique_ptr<Widget>
。
然而,使用nullptr
是调用没什么问题。当nullptr
传给lockAndCall
时,ptr
被推导为std::nullptr_t
。当ptr
被传递给f3
的时候,隐式转换使std::nullptr_t
转换为Widget
,因为std::nullptr_t
可以隐式转换为任何指针类型。
记住
- 优先考虑
nullptr
而非0
和NULL
- 避免重载指针和整型
item9:优先考虑别名声明而非typedef
1 |
|
但typedef
是C++98的东西。虽然它可以在C++11中工作,但是C++11也提供了一个别名声明(alias declaration):
1 |
|
由于这里给出的typedef
和别名声明做的都是完全一样的事情,所以using有什么更好的地方吗?
当声明一个函数指针时别名声明更容易理解:
1 |
|
当然,两个结构都不是非常让人满意,没有人喜欢花大量的时间处理函数指针类型的别名(译注:指FP
)。
别名声明可以被模板化(这种情况下称为别名模板alias templates)但是typedef
不能。
1 |
|
使用typedef
,你就只能从头开始:
1 |
|
更糟糕的是如果你想使用在一个模板内使用typedef
声明一个链表对象,而这个对象又使用了模板形参,你就不得不在typedef
前面加上typename
:
1 |
|
这里MyAllocList<T>::type
使用了一个类型,这个类型依赖于模板参数T
。因此MyAllocList<T>::type
是一个依赖类型(dependent type),在C++很多讨人喜欢的规则中的一个提到必须要在依赖类型名前加上typename
。你可能会疑惑MyAllocList<T>::type
有可能不是类型吗?是的,比如下面,type不是一个类型而是一个数据成员,再或者一个static成员变量也是可以::
调用到的:
1 |
|
如果使用别名声明定义一个MyAllocList
,就不需要使用typename
(同时省略麻烦的“::type
”后缀):
1 |
|
对你来说,MyAllocList<T>
(使用了模板别名声明的版本)可能看起来和MyAllocList<T>::type
(使用typedef
的版本)一样都应该依赖模板参数T
,但是你不是编译器。当编译器处理Widget
模板时遇到MyAllocList<T>
(使用模板别名声明的版本),它们知道MyAllocList<T>
是一个类型名,因为MyAllocList
是一个别名模板:它一定是一个类型名。因此MyAllocList<T>
就是一个非依赖类型(non-dependent type),就不需要也不允许使用typename
修饰符。
C++11在type traits(类型特性)中给了你一系列工具去实现类型转换,如果要使用这些模板请包含头文件<type_traits>
。里面有许许多多type traits,也不全是类型转换的工具,也包含一些可预测接口的工具。给一个你想施加转换的类型T
,结果类型就是std::
transformation<T>::type
,比如:
1 |
|
注意类型转换尾部的::type
。如果你在一个模板内部将他们施加到类型形参上(实际代码中你也总是这么用),你也需要在它们前面加上typename
。至于为什么要这么做是因为这些C++11的type traits是通过在struct
内嵌套typedef
来实现的。是的,它们使用类型同义词(译注:根据上下文指的是使用typedef
的做法)技术实现,而正如我之前所说这比别名声明要差。因为标准委员会没有及时认识到别名声明是更好的选择,所以直到C++14它们才提供了使用别名声明的版本。这些别名声明有一个通用形式:对于C++11的类型转换std::
transformation<T>::type
在C++14中变成了std::
transformation_t
。举个例子或许更容易理解:
1 |
|
C++11的的形式在C++14中也有效,但是我不能理解为什么你要去用它们。就算你没办法使用C++14,使用别名模板也是小儿科。只需要C++11的语言特性,甚至每个小孩都能仿写,对吧?如果你有一份C++14标准,就更简单了,只需要复制粘贴:
1 |
|
看见了吧?不能再简单了。
请记住:
typedef
不支持模板化,但是别名声明支持。- 别名模板避免了使用“
::type
”后缀,而且在模板中使用typedef
还需要在前面加上typename
- C++14提供了C++11所有type traits转换的别名声明版本。
item10:优先考虑限域enum
而非未限域enum
通常来说,在花括号中声明一个名字会限制它的作用域在花括号之内。但这对于C++98风格的enum
中声明的枚举名(译注:enumerator,连同下文“枚举名”都指enumerator)是不成立的。这些枚举名的名字(译注:enumerator names,连同下文“名字”都指names)属于包含这个enum
的作用域,这意味着作用域内不能含有相同名字的其他东西:
1 |
|
这些枚举名的名字泄漏进它们所被定义的enum
在的那个作用域,这个事实有一个官方的术语:未限域枚举(unscoped enum
)。在C++11中它们有一个相似物,限域枚举(scoped enum
),它不会导致枚举名泄漏:
1 |
|
因此使用限域enum
的第一个好处就是:减少命名空间污染。
除此外还有一个更好的理由:在限域enum
中,枚举名是强类型。未限域enum
中的枚举名会隐式转换为整型(现在,也可以转换为浮点类型), 这很容易造成歪曲语义的情况出现:
1 |
|
如上 color被隐式转换为了float
和size_t
在enum
后面写一个class
就可以将非限域enum
转换为限域enum
,接下来就是完全不同的故事展开了。现在不存在任何隐式转换可以将限域enum
中的枚举名转化为任何其他类型:
1 |
|
如上隐式转换被禁止了,如果你真的想用限域enum
,但是却像做到类型转换,可以这么做,虽然这很奇怪,使用正确的类型转换运算符扭曲类型系统:
1 |
|
似乎比起非限域enum
而言,限域enum
有第三个好处,因为限域enum
可以被前置声明。也就是说,它们可以不指定枚举名直接声明:
1 |
|
其实这是一个误导。在C++11中,非限域enum
也可以被前置声明,但是只有在做一些其他工作后才能实现。这些工作来源于一个事实:在C++中所有的enum
都有一个由编译器决定的整型的底层类型。对于非限域enum
比如Color
,
1 |
|
编译器可能选择char
作为底层类型,因为这里只需要表示三个值。
然而,有些enum
中的枚举值范围可能会大些,比如:
1 |
|
这里值的范围从0
到0xFFFFFFFF
。除了在不寻常的机器上(比如一个char
至少有32bits的那种),编译器都会选择一个比char
大的整型类型来表示Status
。
为了高效使用内存,编译器通常在确保能包含所有枚举值的前提下为enum
选择一个最小的底层类型。在一些情况下,编译器将会优化速度,舍弃大小,这种情况下它可能不会选择最小的底层类型,而是选择对优化大小有帮助的类型。为此,C++98只支持enum
定义(所有枚举名全部列出来);enum
声明是不被允许的。这使得编译器能在使用之前为每一个enum
选择一个底层类型。
不能前置声明enum
也是有缺点的。最大的缺点莫过于它可能增加编译依赖。再次考虑Status
enum
:
1 |
|
这种enum
很有可能用于整个系统,因此系统中每个包含这个头文件的组件都会依赖它。如果引入一个新状态值,
1 |
|
那么可能整个系统都得重新编译,即使只有一个子系统——或者只有一个函数——使用了新添加的枚举名。这是大家都不希望看到的。C++11中的前置声明enum
s可以解决这个问题。比如这里有一个完全有效的限域enum
声明和一个以该限域enum
作为形参的函数声明:
1 |
|
即使Status
的定义发生改变,包含这些声明的头文件也不需要重新编译。而且如果Status
有改动(比如添加一个audited
枚举名),continueProcessing
的行为不受影响(比如因为continueProcessing
没有使用这个新添加的audited
),continueProcessing
也不需要重新编译。
所以说了上面一堆,我希望大家可以理解的就是:
前向声明(也叫前置声明,forward declaration)都是需要知道该种类型的大小的,那么如果编译器在使用它之前需要知晓该enum
的大小,该怎么声明才能让C++11做到C++98不能做到的事情呢?
答案很简单:
限域
enum
的底层类型总是已知的,默认是int
,当然如果你的枚举名中有大于int
的,那么编译器出现变窄转换报错,所以你需要改变下你指定的底层类型。而对于非限域
enum
,也是同理,既然你选择了前置声明,那么编译器无法帮你推导了这个枚举类底层类型,那么你可以直接指定它的底层类型即可。
1 |
|
1 |
|
这个规则看起来冗余复杂,但其实只要理解本质并不难理解:不管怎样,编译器都要知道enum
中的枚举名占用多少字节。
限域enum
避免命名空间污染而且不接受荒谬的隐式类型转换,但它并非万事皆宜,你可能会很惊讶听到至少有一种情况下非限域enum
是很有用的。那就是牵扯到C++11的std::tuple
的时候。比如在社交网站中,假设我们有一个tuple保存了用户的名字,email地址,声望值:
1 |
|
虽然注释说明了tuple各个字段对应的意思,但当你在另一文件遇到下面的代码那之前的注释就不是那么有用了:
1 |
|
作为一个程序员,你有很多工作要持续跟进。你应该记住第一个字段代表用户的email地址吗?我认为不。可以使用非限域enum
将名字和字段编号关联起来以避免上述需求:
1 |
|
之所以它能正常工作是因为UserInfoFields
中的枚举名隐式转换成std::size_t
了,其中std::size_t
是std::get
模板实参所需的。
对应的限域enum
版本就很啰嗦了:
1 |
|
为避免这种冗长的表示,我们可以写一个函数传入枚举名并返回对应的std::size_t
值,但这有一点技巧性。为了更好的性能,枚举名变换为std::size_t
值的函数必须在编译期产生这个结果,因此这个转换至少是一个constexpr
函数
但是较之于返回std::size_t
,我们更应该返回枚举的底层类型。这可以通过std::underlying_type
这个type trait获得。
1 |
|
还可以再用C++14 auto
(参见Item3)打磨一下代码:
1 |
|
不管它怎么写,toUType
现在允许这样访问tuple的字段了:
1 |
|
这仍然比使用非限域enum
要写更多的代码,但同时它也避免命名空间污染,防止不经意间使用隐式转换。
记住
- C++98的
enum
即非限域enum
。 - 限域
enum
的枚举名仅在enum
内可见。要转换为其它类型只能使用cast。 - 非限域/限域
enum
都支持底层类型说明语法,限域enum
底层类型默认是int
。非限域enum
没有默认底层类型。 - 限域
enum
总是可以前置声明。非限域enum
仅当指定它们的底层类型时才能前置。 - 在某些时候,非限域的enum可能会比限域的enum在拿tuple属性时更直观,但是为了不抛弃限域enum的优势,我们常会写一个constexpr的转换函数来等价地做到这件事情。
item11:优先考虑使用deleted函数而非使用未定义的私有声明
这里基础的delete方法不再讨论,比如可以delete普通函数和成员函数。
在远古时期,如果你想实现delete等效的功能,常用的方法是定义在private中并不实现。现在有了delete我们可以更直观的做这件事情了。
当然private+不实现在一些情况是无法替代delete的:
首先就是非成员函数,也就是对于一个最简单的普通函数,由于不是在类里没有private的说法,所以delete在此时是无法替代的。
另一种就是模板实例,即实例化后的函数。
如果的类里面有一个函数模板,你可能想用private
(经典的C++98惯例)来禁止这些函数模板实例化,但是不能这样做,因为不能给特化的成员模板函数指定一个不同于主函数模板的访问级别。如果processPointer
是类Widget
里面的模板函数, 你想禁止它接受void*
参数,那么通过下面这样C++98的方法就不能通过编译:
1 |
|
问题是模板特例化必须位于一个命名空间作用域,而不是类作用域。
deleted
函数不会出现这个问题,因为它不需要一个不同的访问级别,且他们可以在类外被删除(因此位于命名空间作用域):
1 |
|
事实上C++98的最佳实践即声明函数为private
但不定义是在做C++11 deleted函数要做的事情。作为模仿者,C++98的方法不是十全十美。它不能在类外正常工作,不能总是在类中正常工作,它的罢工可能直到链接时才会表现出来。所以请坚定不移的使用deleted函数。
item12:使用override声明重写函数
虽然“重写(overriding)”听起来像“重载(overloading)”,然而两者完全不相关,所以让我澄清一下,正是虚函数重写机制的存在,才使我们可以通过基类的接口调用派生类的成员函数:
1 |
|
要想重写一个函数,必须满足下列要求:
- 基类函数必须是
virtual
,也就是父类里要有个虚函数 - 同名:基类和派生类函数名必须完全一样(除非是析构函数)
- 同参数:基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性
const
ness必须完全一样 - 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
简单来说就是同签名+virtual
除了这些C++98就存在的约束外,C++11又添加了一个:
- 函数的引用限定符(reference qualifiers)必须完全一样。成员函数的引用限定符是C++11很少抛头露脸的特性,所以如果你从没听过它无需惊讶。它可以限定成员函数只能用于左值或者右值。成员函数不需要
virtual
也能使用它们:
1 |
|
这么多的重写需求意味着哪怕一个小小的错误也会造成巨大的不同。代码中包含重写错误通常是有效的,但它的意图不是你想要的。因此你不能指望当你犯错时编译器能通知你。比如,下面的代码是完全合法的,咋一看,还很有道理,但是它没有任何虚函数重写——没有一个派生类函数联系到基类函数。
1 |
|
为了让这种编译可以通过但是并没有传达出正确的运行方式的代码不要出现,加入了override/final关键字就很好的减少了这种低级错误的出现。
用法不赘述,以前博客写到过:https://chillstepp.github.io/2022/05/27/C-11%E6%96%B0%E7%89%B9%E6%80%A7/#Override-%E4%B8%8E-final
item12(增补):成员函数引用限定(reference qualifiers)
item12里有一个很有意思的新知识,叫做成员函数引用限定(reference qualifiers),我们这里再来谈谈这个东西:
1 |
|
注意data
重载的返回类型是不同的,左值引用重载版本返回一个左值引用(即一个左值),右值引用重载返回一个临时对象(即一个右值)。这意味着现在客户端的行为和我们的期望相符了:
1 |
|
请记住:
- 为重写函数加上
override
- 成员函数引用限定让我们可以区别对待左值对象和右值对象(即
*this
)
item13:优先考虑const_iterator
而非iterator
STL const_iterator
等价于指向常量的指针(pointer-to-const
)。它们都指向不能被修改的值。标准实践是能加上const
就加上,这也指示我们需要一个迭代器时只要没必要修改迭代器指向的值,就应当使用const_iterator
。
上面的说法对C++11和C++98都是正确的,但是在C++98中,标准库对const_iterator
的支持不是很完整。首先不容易创建它们,其次就算你有了它,它的使用也是受限的。假如你想在std::vector<int>
中查找第一次出现1983(C++代替C with classes的那一年)的位置,然后插入1998(第一个ISO C++标准被接纳的那一年)。如果vector中没有1983,那么就在vector尾部插入。在C++98中使用iterator
可以很容易做到:
1 |
|
但是这里iterator
真的不是一个好的选择,因为这段代码不修改iterator
指向的内容。用const_iterator
重写这段代码是很平常的,但是在C++98中就不是了。下面是一种概念上可行但是不正确的方法:
1 |
|
之所以std::find
的调用会出现类型转换是因为在C++98中values
是non-const
容器,没办法简简单单的从non-const
容器中获取const_iterator
。
严格来说类型转换不是必须的,因为用其他方法获取const_iterator
也是可以的(比如你可以把values
绑定到reference-to-const
变量上,然后再用这个变量代替values
),但不管怎么说,从non-const
容器中获取const_iterator
的做法都有点别扭。
当你费劲地获得了const_iterator
,事情可能会变得更糟,因为C++98中,插入操作(以及删除操作)的位置只能由iterator
指定,const_iterator
是不被接受的。这也是我在上面的代码中,将const_iterator
(我那么小心地从std::find
搞出来的东西)转换为iterator
的原因,因为向insert
传入const_iterator
不能通过编译。 因为没有一个可移植的从const_iterator
到iterator
的方法,即使使用static_cast
也不行。甚至传说中的牛刀reinterpret_cast
也杀不了这条鸡。
所有的这些都在C++11中改变了,现在const_iterator
既容易获取又容易使用。容器的成员函数cbegin
和cend
产出const_iterator
,甚至对于non-const
容器也可用,那些之前使用iterator指示位置(如insert
和erase
)的STL成员函数也可以使用const_iterator
了。使用C++11 const_iterator
重写C++98使用iterator
的代码也稀松平常:
1 |
|
现在使用const_iterator
的代码就很实用了!
我们可以泛化下面的findAndInsert
:
1 |
|
它可以在C++14工作良好,但是很遗憾,C++11不在良好之列。由于标准化的疏漏,C++11只添加了非成员函数begin
和end
,但是没有添加cbegin
,cend
,rbegin
,rend
,crbegin
,crend
。C++14修订了这个疏漏。
当然在c++11你也可以自己写一个cbegin的非成员函数,下面就是非成员函数cbegin
的实现:
1 |
|
你可能很惊讶非成员函数cbegin
没有调用成员函数cbegin
吧?这个cbegin
模板接受任何代表类似容器的数据结构的实参类型C
,并且通过reference-to-const
形参container
访问这个实参。
如果C
是一个普通的容器类型(如std::vector<int>
),container
将会引用一个const
版本的容器(如const std::vector<int>&
)。对const
容器调用非成员函数begin
(由C++11提供)将产出const_iterator
,这个迭代器也是模板要返回的。
用这种方法实现的好处是就算容器只提供begin
成员函数(对于容器来说,C++11的非成员函数begin
调用这些成员函数)不提供cbegin
成员函数也没问题。那么现在你可以将这个非成员函数cbegin
施于只直接支持begin
的容器。
如果C
是原生数组,这个模板也能工作。这时,container
成为一个const
数组的引用。C++11为数组提供特化版本的非成员函数begin
,它返回指向数组第一个元素的指针。一个const
数组的元素也是const
,所以对于const
数组,非成员函数begin
返回指向const
的指针(pointer-to-const
)。在数组的上下文中,所谓指向const
的指针(pointer-to-const
),也就是const_iterator
了。
请记住:
- 优先考虑
const_iterator
而非iterator
- 在最大程度通用的代码中,优先考虑非成员函数版本的
begin
,end
,rbegin
等,而非同名成员函数。这是因为非成员函数版本可以支持原生指针也可以支持迭代器。
item14:如果函数不抛出异常请使用noexcept
在C++11标准化过程中,大家一致认为异常说明真正有用的信息是一个函数是否会抛出异常。非黑即白,一个函数可能抛异常,或者不会。这种”可能-绝不”的二元论构成了C++11异常说的基础,从根本上改变了C++98的异常说明。(C++98风格的异常说明也有效,但是已经标记为deprecated(废弃))。在C++11中,无条件的noexcept
保证函数不会抛出任何异常。
函数是否声明为noexcept
,这个可以影响到调用代码的异常安全性(exception safety)和效率。不抛异常的函数加上noexcept
允许编译器生成更好的目标代码
考虑一个函数f
,它保证调用者永远不会收到一个异常。两种表达方式如下:
1 |
|
如果在运行时,f
出现一个异常,那么就和f
的异常说明冲突了。在C++98的异常说明中,调用栈(the call stack)会展开至f
的调用者,在一些与这地方不相关的动作后,程序被终止。C++11异常说明的运行时行为有些不同:调用栈只是可能在程序终止前展开。
展开调用栈和可能展开调用栈两者对于代码生成(code generation)有非常大的影响:
在一个noexcept
函数中,当异常可能传播到函数外时,优化器不需要保证运行时栈(the runtime stack)处于可展开状态;也不需要保证当异常离开noexcept
函数时,noexcept
函数中的对象按照构造的反序析构。 而标注“throw()
”异常声明的函数缺少这样的优化灵活性,没加异常声明的函数也一样。可以总结一下:
1 |
|
这是一个充分的理由使得你当知道它不抛异常时加上noexcept
。
下面考虑这样一段C++98的代码:Widget
通过push_back
一次又一次的添加进std::vector
:
1 |
|
假设这个代码能正常工作,你也无意修改为C++11风格。但是你确实想要C++11移动语义带来的性能优势,毕竟这里的类型是可以移动的(move-enabled types)。因此你需要确保Widget
有移动操作,可以手写代码也可以让编译器自动生成,当然前提是能满足自动生成的条件(item17)。
std::vector
的大小(size)等于它的容量(capacity)时:std::vector
会分配一个新的更大块的内存用于存放其中元素,然后将元素从老内存区移动到新内存区,然后析构老内存区里的对象。
- 在C++98中,移动是通过复制老内存区的每一个元素到新内存区完成的,然后老内存区的每个元素发生析构。这种方法使得
push_back
可以提供很强的异常安全保证:如果在复制元素期间抛出异常,std::vector
状态保持不变,因为老内存元素析构必须建立在它们已经成功复制到新内存的前提下。 - 在C++11中,一个很自然的优化就是将上述复制操作替换为移动操作。但是这会破坏
push_back
的异常安全保证。如果n个元素已经从老内存移动到了新内存区,但异常在移动第n+1个元素时抛出,那么push_back
操作就不能完成。但是原始的std::vector
已经被修改:有n个元素已经移动走了。恢复std::vector
至原始状态也不太可能,因为从新内存移动到老内存本身又可能引发异常。
因为老代码可能依赖于push_back
提供的强烈的异常安全保证。因此,C++11版本的实现不能简单的将push_back
里面的复制操作替换为移动操作,除非知晓移动操作绝不抛异常,这时复制替换为移动就是安全的,作用就是性能得到提升。
std::vector::push_back
受益于“如果可以就移动,如果必要则复制”策略,并且它不是标准库中唯一采取该策略的函数。C++98中还有一些函数(如std::vector::reverse
,std::deque::insert
等)也受益于这种强异常保证。对于这个函数只有在知晓移动不抛异常的情况下用C++11的移动操作替换C++98的复制操作才是安全的。
但是如何知道一个函数中的移动操作是否产生异常?答案很明显:它检查这个操作是否被声明为noexcept
。(这个检查非常弯弯绕。像是std::vector::push_back
之类的函数调用std::move_if_noexcept
,这是个std::move
的变体,根据其中类型的移动构造函数是否为noexcept
的,视情况转换为右值或保持左值(参见Item23)。反过来,std::move_if_noexcept
查阅std::is_nothrow_move_constructible
这个type trait,基于移动构造函数是否有noexcept
(或者throw()
)的设计,编译器设置这个type trait的值。)
swap
函数是noexcept
的另一个绝佳用地。swap
是STL算法实现的一个关键组件,它也常用于拷贝运算符重载中,它的广泛使用意味着对其施加不抛异常的优化是非常有价值的。
有趣的是,标准库的swap
是否noexcept
有时依赖于用户定义的swap
是否noexcept
。比如,数组和std::pair
的swap
声明如下:
1 |
|
这些函数视情况noexcept
:它们是否noexcept
依赖于noexcept
声明中的表达式是否noexcept
。
比如上面的swap pair代码,如果pair的first和second元素的swap都是noexcept的,那么这个函数就会称为noexcept的。
再比如上面代码第一段,假设有两个Widget
数组,交换数组操作为noexcept
的前提是数组中的元素交换是noexcept
的,即Widget
的swap
是noexcept
。因此Widget
的swap
的作者决定了交换widget
的数组是否noexcept
。
这些例子都在说明的是: c++11提供了一种依赖的noexcept,高层数据结构是否noexcept取决于底层数据结构的noexcept,这是十分合理好用的一个性质。
实际上大多数函数都是异常中立(exception-neutral)的。这些函数自己不抛异常,但是它们内部的调用可能抛出。此时,异常中立函数允许那些抛出异常的函数在调用链上更进一步直到遇到异常处理程序,而不是就地终止。异常中立函数决不应该声明为noexcept
,也就是说在当前这个函数内不处理异常,但是又不立即终止程序,而是让调用这个函数的函数处理异常。因此大多数函数缺少noexcept
设计。
当然一部分函数也是应该保证不要抛出异常的,这是为了更好的提高程序的性能,比如我们上面提到的swap和移动操作。
对于一些函数,使之称为noexcept十分重要,在C++98,允许内存释放(memory deallocation)函数(即operator delete
和operator delete[]
)和析构函数抛出异常是糟糕的代码设计,C++11将这种作风升级为语言规则。默认情况下,内存释放函数和析构函数——不管是用户定义的还是编译器生成的——都是隐式noexcept
。因此它们不需要声明noexcept
。
请记住:
noexcept
是函数接口的一部分,这意味着调用者可能会依赖它noexcept
函数较之于non-noexcept
函数更容易优化noexcept
对于移动语义,swap
,内存释放函数和析构函数非常有用- 大多数函数是异常中立的(译注:可能抛也可能不抛异常)而不是
noexcept
item15:尽可能使用constexpr
注释:constexpr在如今时代已经拥有了大量新增特性,这本书当时最多涉及到C++14, C++17和C++20的诸多特性不会涉及到,如果感兴趣,可以看
看这两篇文章:
- 吴咏炜老师的pdf笔记+现代Cpp30讲的constexpr内容:https://chillstepp.github.io/2022/07/24/%E7%8E%B0%E4%BB%A3C-%E4%B8%AD%E7%9A%84constexpr-%E5%86%85%E6%95%9B%E5%8F%98%E9%87%8F/#constexpr
- netcan的一个关于constexpr的talk:https://netcan.github.io/presentation/constexpr_from_11_20/#/
constexpr来表明一个值的时候被认作是一个常量或是编译期可知的值,这是有失偏颇的,在constexpr应用于函数的时候,你不能假设函数的返回值要是一个const的,也不一定是需要编译期可知的。
编译期可知的值“享有特权”,它们可能被存放到只读存储空间中。对于那些嵌入式系统的开发者,这个特性是相当重要的。
广泛的应用是“其值编译期可知”的常量整数会出现在需要“整型常量表达式(integral constant expression)的上下文中,这类上下文包括数组大小,整数模板参数(包括std::array
对象的长度),枚举名的值,对齐修饰符(译注:alignas(val)
),等等。如果你想在这些上下文中使用变量,你一定会希望将它们声明为constexpr
,因为编译器会确保它们是编译期可知的:
1 |
|
注意const
不提供constexpr
所能保证之事,因为const
对象不需要在编译期初始化它的值。
1 |
|
简而言之,所有constexpr
对象都是const
,但不是所有const
对象都是constexpr
。
如果你想编译器保证一个变量有一个值,这个值可以放到那些需要编译期常量(compile-time constants)的上下文的地方,你需要的工具是constexpr
而不是const
。
下面再来说一说constexpr应用于函数的情况:
如果实参是编译期常量,这些函数将产出编译期常量;如果实参是运行时才能知道的值,它们就将产出运行时值。这听起来就像你不知道它们要做什么一样,那么想是错误的,请这么看:
constexpr
函数可以用于需求编译期常量的上下文。如果你传给constexpr
函数的实参在编译期可知,那么结果将在编译期计算。如果实参的值在编译期不知道,你的代码就会被拒绝。- 当一个
constexpr
函数被一个或者多个编译期不可知值调用时,它就像普通函数一样,运行时计算它的结果。这意味着你不需要两个函数,一个用于编译期计算,一个用于运行时计算。constexpr
全做了。
1 |
|
回忆下pow
前面的constexpr
不表明pow
返回一个const
值,它只说了如果base
和exp
是编译期常量,pow
的值可以被当成编译期常量使用。如果base
和/或exp
不是编译期常量,pow
结果将会在运行时计算。这意味着pow
不止可以用于像std::array
的大小这种需要编译期常量的地方,它也可以用于运行时环境。
我们再来讨论下上面pow函数的实现:
C++11中,constexpr
函数的代码不超过一行语句:一个return
。听起来很受限,但实际上有两个技巧可以扩展constexpr
函数的表达能力。第一,使用三元运算符“?:
”来代替if
-else
语句,第二,使用递归代替循环。因此pow
可以像这样实现:
1 |
|
这样没问题,但是很难想象除了使用函数式语言的程序员外会觉得这样硬核的编程方式更好。在C++14中,constexpr
函数的限制变得非常宽松了,所以下面的函数实现成为了可能:
1 |
|
在C++11中,除了void
外的所有内置类型,以及一些用户定义类型都可以是字面值类型,因为构造函数和其他成员函数可能是constexpr
:
1 |
|
Point
的构造函数可被声明为constexpr
,因为如果传入的参数在编译期可知,Point
的数据成员也能在编译器可知。因此这样初始化的Point
就能为constexpr
:
1 |
|
类似的,xValue
和yValue
的getter(取值器)函数也能是constexpr
,因为如果对一个编译期已知的Point
对象(如一个constexpr
Point
对象)调用getter,数据成员x
和y
的值也能在编译期知道。这使得我们可以写一个constexpr
函数,里面调用Point
的getter并初始化constexpr
的对象:
1 |
|
这太令人激动了。
在C++11中,有两个限制使得Point
的成员函数setX
和setY
不能声明为constexpr
。第一,它们修改它们操作的对象的状态, 并且在C++11中,constexpr
成员函数是隐式的const
。第二,它们有void
返回类型,void
类型不是C++11中的字面值类型。这两个限制在C++14中放开了,所以C++14中Point
的setter(赋值器)也能声明为constexpr
:
1 |
|
现在也能写这样的函数:
1 |
|
请记住:
constexpr
对象是const
,它被在编译期可知的值初始化- 当传递编译期可知的值时,
constexpr
函数可以产出编译期可知的结果 constexpr
对象和函数可以使用的范围比non-constexpr
对象和函数要大constexpr
是对象和函数接口的一部分
item16:让const成员函数线程安全
考虑这样一段代码:
一个多项式类,有一个求根的成员方法roots,为了使得求根更高效,对于以前查询过的根缓存起来,下次遇到相同的查询直接输出即可。
1 |
|
这里面roots成员函数
被声明为const,你可能会疑惑这个成员函数中不是修改了rootsAreValid和rootVals的内容吗,怎么还可以让这个方法是const的呢?如下图报错
是的,所以rootsAreValid和rootVals都被标记为了mutable也就是可变的,这样一个const的方法也可以允许被标有mutabe的变量被修改,其他没标的则不允许。 为什么要搞出这样一个关键字呢?其实有些时候类里的某些内容是不影响我们类的实际内容的,比如一个计数器,你想看看这个类的某个方法调用了多少次,但是这个计数器是不会对类的使用有任何影响的。因此他在const函数中出现变化也是无所谓的。同理,在多项式中,缓存答案这个功能并不会影响多项式的计算结果和多项式本身的任何数据,所以这两个变量都可以设置成mutable,使其在const成员函数下也可以被修改
对于mutable关键字,在cppreference中可以看到其实就两个usage:
https://en.cppreference.com/w/cpp/keyword/mutable
下面我们来讨论第二个问题,如果没有mutable关键字,那么对于一个const的成员函数,是不会产生线程安全的,因为都是简单的读而已,不会存在写竞争,可有了mutable关键字的出现,就要注意下mutable变量是可以写的而且可以存在const成员函数中,此时的const成员函数就不再是线程安全的了。
解决这个问题最普遍简单的方法就是——使用mutex
(互斥量):
1 |
|
注意到std::mutex m
被声明为mutable
,因为锁定和解锁它的都是non-const
成员函数。在roots
(const
成员函数)中,m
却被视为const
对象,所以需要mutable修饰m。
值得注意的是,因为std::mutex
是一种只可移动类型(move-only type,一种可以移动但不能复制的类型),所以将m
添加进Polynomial
中的副作用是使Polynomial
失去了被复制的能力。不过,它仍然可以移动。
在某些情况下,互斥量的副作用显会得过大。例如,如果你所做的只是计算成员函数被调用了多少次,使用std::atomic
修饰的计数器(保证其他线程视它的操作为不可分割的整体,参见item40)通常会是一个开销更小的方法。(然而它是否轻量取决于你使用的硬件和标准库中互斥量的实现。)
以下是如何使用std::atomic
来统计调用次数。
1 |
|
与std::mutex
一样,std::atomic
是只可移动类型,所以在Point
中存在callCount
就意味着Point
也是只可移动的。
因为对std::atomic
变量的操作通常比互斥量的获取和释放的消耗更小,所以你可能会过度倾向与依赖std::atomic
。
例如,在一个类中,缓存一个开销昂贵的int
,你就会尝试使用一对std::atomic
变量而不是互斥量。
1 |
|
这当然是可行的,但难以避免有时出现重复计算的情况。考虑:
- 一个线程调用
Widget::magicValue
,将cacheValid
视为false
,执行这两个昂贵的计算,并将它们的和分配给cachedValue
。 - 此时,第二个线程调用
Widget::magicValue
,也将cacheValid
视为false
,因此执行刚才完成的第一个线程相同的计算。(这里的“第二个线程”实际上可能是其他几个线程。)
也就是说还是有一定概率两个线程重复计算相同的东西。
你可能会认为:是因为cachevalue先计算出了结果,但是没及时的修改cachevalid的状态导致这样的,你可能会修改下 cachedValue和cacheValid的赋值顺序,结果会更糟:
1 |
|
假设cacheValid
是false,那么:
- 一个线程调用
Widget::magicValue
,刚执行完将cacheValid
设置true
的语句。 - 在这时,第二个线程调用
Widget::magicValue
,检查cacheValid
。看到它是true
,就返回cacheValue
,即使第一个线程还没有给它赋值。因此返回的值是不正确的。
是的,这甚至会导致cachedValue没赋值但是cacheValid先有效了,导致计算结果的错误。
所以在Effective Modern CPP中提到:
对于需要同步的是单个的变量或者内存位置,使用
std::atomic
就足够了。不过,一旦你需要对两个以上的变量或内存位置作为一个单元来操作的话,就应该使用互斥量。
所以对于Widget::magicValue
应该是这样实现。
1 |
|
- 为独占单线程使用而设计的类的成员函数是否线程安全并不重要。在这种情况下,你可以避免因使用互斥量和
std::atomics
所消耗的资源 - 然而,这种线程无关的情况越来越少见,而且很可能会越来越少。可以肯定的是,
const
成员函数应支持并发执行,这就是为什么你应该确保const
成员函数是线程安全的。
请记住:
- 确保
const
成员函数线程安全,除非你确定它们永远不会在并发上下文(concurrent context)中使用。 - 使用
std::atomic
变量可能比互斥量提供更好的性能,但是它只适合操作单个变量或内存位置。
item17:理解特殊成员函数的生成
先从C++98时代谈起,那个时候还没有移动操作,因此没有移动构造和移动赋值函数。在那个时代可以自动生成的特殊成员函数是 默认构造,拷贝构造,拷贝赋值,析构。这些函数当然不是在任何情况下都会自动生成:默认构造函数仅在类没有任何构造函数的情况下才会生成。
1 |
|
一般来说,默认生成的特殊成员函数都是非虚,public,inline的。当然这里有一个edge case,就是除了析构函数,如果继承的父类析构函数是virtual的,那么自动生成的也会是virtual的。
我们再来讨论移动构造和移动赋值这两个C++11的新产物,在C++11这俩也会被自动生成。
当我对一个数据成员或者基类使用移动构造或者移动赋值时,没有任何保证移动一定会真的发生。逐成员移动,实际上,更像是逐成员移动请求,因为对不可移动类型(即对移动操作没有特殊支持的类型,比如大部分C++98传统类)使用“移动”操作实际上执行的是拷贝操作。
- 对于拷贝操作的特殊函数自动生成,两者是独立的:即当你声明了拷贝构造函数,不会影响拷贝赋值函数的自动生成,反之同理。但是对于移动操作却不是的,你声明了其中之一另一个就不会自动生成了。
- 甚至,如果一个类显式声明了析构/拷贝操作,编译器就不会生成移动操作。
他们背后的理由是:如果声明拷贝操作(构造或者赋值)就暗示着平常拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。析构函数同理,你需要很特殊的析构成员,也不能自动生成移动操作。
综上,仅当下面条件成立时才会生成移动操作(当需要时):
- 类中没有拷贝操作
- 类中没有移动操作
- 类中没有用户定义的析构
假设这个类没有声明拷贝操作,没有移动操作,也没有析构,如果它们被用到编译器会自动生成。没错,很方便。
后来需要在对象构造和析构中打日志,增加这种功能很简单:
1 |
|
看起来合情合理,但是声明析构有潜在的副作用:它阻止了移动操作的生成。但如果我们还是希望可以用默认的移动构造移动赋值可以让这俩函数=default
即可。
最后总结下在C++11自动生成的特殊函数的条件:
C++11对于特殊成员函数处理的规则如下:
- 默认构造函数:和C++98规则相同。仅当类不存在用户声明的构造函数时才自动生成。
- 析构函数:基本上和C++98相同;稍微不同的是现在析构默认
noexcept
(参见Item14)。和C++98一样,仅当基类析构为虚函数时该类析构才为虚函数。 - 拷贝构造函数:和C++98运行时行为一样:逐成员拷贝non-static数据。仅当类没有用户定义的拷贝构造时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝赋值或者析构,该函数自动生成已被废弃。
- 拷贝赋值运算符:和C++98运行时行为一样:逐成员拷贝赋值non-static数据。仅当类没有用户定义的拷贝赋值时才生成。如果类声明了移动操作它就是delete的。当用户声明了拷贝构造或者析构,该函数自动生成已被废弃。
- 移动构造函数和移动赋值运算符:都对非static数据执行逐成员移动。仅当类没有用户定义的拷贝操作,移动操作或析构时才自动生成。
还要知道一个edge case:
注意没有“成员函数模版阻止编译器生成特殊成员函数”的规则。这意味着如果Widget
是这样:
1 |
|
编译器仍会生成移动和拷贝操作(假设正常生成它们的条件满足),即使可以模板实例化产出拷贝构造和拷贝赋值运算符的函数签名。(当T
为Widget
时。)很可能你会觉得这是一个不值得承认的边缘情况,但是我提到它是有道理的,Item26将会详细讨论它可能带来的后果。
当然我还是建议你如果想自动生成,不放显示的写出来+=default
,让阅读者和写代码的人都可以一目了然,毕竟不是所有人你都会了解特殊函数的生成条件。
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!