0%

Effective Modern C++读书笔记(二)

本文包含《Effective Modern C++》中第3章内容的个人笔记,讨论现代c++新增语法使用。

第3章 转向现代C++

条款7: 在创建对象时注意区分()和{}

C++11标准中增加了统一初始化,亦即使用大括号进行初始化。这种初始化方式可以防止隐式窄化型别转换,即如果需要执行窄化,则无法通过编译,该方式亦可避免形如

1
Widget w2();

被编译器解释为函数声明的可能。
同时统一初始化在构造函数重载决议时,编译器会尽可能的使用带有std::initializer_list的重载进行匹配,只有在无法转化成std::initializer_list时才会使用不带std::initializer_list的重载。这导致的问题就是形如:

1
2
std::vector<int> v1(10, 20);
std::vector<int> v2{10, 20};

这两种定义方式实际调用了vector两种不同的构造,v1使用了

1
vector(size_type, const value_type&, const allocator_type& = allocator_type());

而v2使用了std::initializer_list的构造。这也提示我们在设计类时最好是避免出现当使用统一初始化可以被解释为两种构造的场景。而在模版设计时,亦有可能出现如上所述因为使用()或者{}的初始化问题。
*Intuitibe interface - Part I - Andrzej 2013-06-05

条款8: 优先选用nullptr,而非0或NULL

这一条还是比较简单明了的,0和NULL都不具备指针的型别,在重载决议时0和NULL都会优先和int的形参进行匹配调用。而nullptr的型别是std::nullptr_t,可以隐式转换到所有的裸指针型别,从而在重载决议时优先与指针形参匹配。同时在代码可阅读性上,nullptr也是一眼可知的优势。不过即使是这样,在新标准里依然还是要避免整形和指针的重载,因为某些程序员依然会使用0和NULL进行调用。

条款9: 优先选用别名声明,而非typedef

C++标准规定,带有依赖的型别声明前面必须加上typename,即

1
2
3
4
5
6
7
8
9
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
template<typename T>
class Widget {
private:
typename MyAllocList<T>::type list;
}

这是由于编译器需要明确知道type是一个型别,而使用using别名声明则无需受该规则限制,因为using所声明的必然是一个型别,如上例可改写为:

1
2
3
4
5
6
7
8
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;

template<typename T>
class Widget {
private:
MyAllocList<T> list;
};

所以using别名声明比起typedef可以少写一个“::type”,对于有依赖的型别还少写一个typename。
c++11标准中的<type_traits>仍然沿用的是typedef的写法,即

1
std::remove_const<T>::type

type是remove_const<T>的型别的typedef,而在14标准中则可使用

1
std::remove_const_t<T>

当然在11标准中也可以手工声明

1
2
template<typename T>
using remove_const_t = typename std::remove_const<T>::type;

条款10: 优先选用限定作用域的枚举型别,而非不限定作用域的枚举型别

c++98风格枚举类型由于不限制范围,导致所有编译范围内的旧风格枚举不能重名,这也是c++标准委员会在11标准中增加enum class的主要原因。
新旧风格的枚举都可以通过如下方式指定底层型别:

1
2
3
enum class Status : std::uint32_t {
...
}

enum class默认使用int作为底层型别,而旧枚举没有默认底层型别。同时,enum class由于有默认底层型别其可以使用前置声明,旧枚举由于没有默认底层型别只有在指定了底层型别后才可以使用前置声明。
旧枚举由于可以使用隐式类型转换为整形,而enum class只有使用强制类型转换才可以,导致某些场景使用旧枚举可能较为方便,如tuple:

1
2
3
4
using UserInfo = std::tuple<std::string, std::string, std::size_t>;
enum UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo;
auto val = std::get<uiEmail>(uInfo);

enum class则较为麻烦

1
2
3
enum class UserInfoFields { uiName, uiEmail, uiReputation };
UserInfo uInfo;
auto Val = std::get<static_cast<std::size_t>(UserInfoFields::uiEmail)>(uInfo);

条款12: 为意在改写的函数添加override声明

11标准中增加了override和final关键字,本节意在推广override关键的使用。override关键字或者类似功能在其他高级语言中也可以看到,显式的说明了子类会改写父类的虚函数,使代码有更好的可阅读性,也避免了因为手误造成的声明错误,并且如书上所说当需要改变父类virtual函数签名时可以知道影响了多少子类。
这里书上还提到了11标准中新增的“override”函数约束,引用饰词(reference qualifier)。即子类需要“override”的函数必须拥有和父类相同的引用饰词。
11标准中增加了函数引用饰词,如

1
2
3
4
5
6
7
8
9
class Widget {
public:
using DataType = std::vector<double>;
...
DataType& data() & // 对于左值Widget型别,返回左值
{ return values; }
DataType data() && // 对于右值Widget型别,返回右值
{ return std::move(values); }
}

这个新增的语法让需要区分对象左右值场景进行不同调用成为可能。

条款13: 优先选用const_iterator,而非iterator

优先选用const这一规则在98标准中就有,例如传递参数时优先使用const形参,而对于iterator来说,由于98标准中stl提供的const_iterator不好用导致在98标准中我们只在一些特殊场合(如容器本身就声明为const)才使用const_iterator。这一缺陷在11标准中被修正,如vector的insert方法,98标准的声明为:

1
iterator insert (iterator position, const value_type& val);

11标准的声明为:

1
iterator insert (const_iterator position, const value_type& val);

这使const_iterator能被更好的使用。
当然在一些特殊场景,如容器类型未知的函数模版里,这时容器对于const_iterator的支持未知,可以优先使用非成员函数版本的begin、end,并通过auto声明迭代器类型完成对const_iterator的可能性支持。

条款14: 只要函数不会发射异常,就为其加上noexcept声明

c++98标准中异常规范在11标准中废弃,理由很简单,异常规范让编码变得复杂,而且编译器也不会针对异常规范提供任何帮助,导致异常规范基本上在旧标准中就没人用。
11标准开始增加的noexcept关键字用于表明函数是否抛异常,而不再关心抛什么异常。这是由于大多时使用接口的客户端方只关心是否抛异常。同时在11标准中,使用noexcept声明的函数由于编译器无须再保持函数可开解状态,可以得到编译器“可能”的优化。
对于移动操作、swap函数,使用noexcept声明可以带来性能优化。push_back使用“能移动则移动,必须复制才复制”原则,而这一原则依赖于不抛异常的移动操作,swap函数类似。
11标准中,默认会给析构函数加上noexcept隐式声明。

条款15: 只要有可能使用constexpr,就使用它

constexpr实际上宣告的是:“但凡任何c++要求在此使用一个常量表达式的语境,皆可以用我。”(包括数组的尺寸规格、整形模版实参、枚举变量的值、对齐规格等)
constexpr表明该值或者函数返回值可以是一个编译期常量,从而在编译期完成运行期工作,提高效率。
constexpr对象总是编译期常量,constexpr函数只在传入实参皆是编译期常量才返回编译器常量,否则和普通函数一致。
综上,constexpr对象或函数拥有相对于非constexpr对象或函数更广阔的使用语境,但同时需注意constexpr是一个接口声明,若后期去除constexpr声明,可能会造成大量的编译错误。

条款16: 保证const成员函数的线程安全性

需要保证const成员函数的线程安全性的前提是该const成员函数会在并发语境中使用。之所以需要保证线程安全性,是因为const成员函数对于调用方来说“看上去”是不会引发并发问题的,因为const的声明“看上去”保证了不会修改成员变量,但是mutable成员会使该保证失效,所以需要保证const成员函数的线程安全性。
当只有单个成员变量需要确保线程安全性时,atomic是更高效的选择,多个时就需要使用mutex来保证。
这一节和其他章节有点不一样,本身不算是在11标准以后才需要注意的事情,可能梅耶斯觉得现代c++编程会是高并发的时代吧。

条款17: 理解特种成员函数的生成机制

在98标准中有四种编译器会自动生成的成员函数,他们是:默认构造函数、析构函数、复制构造函数和复制赋值运算符。而在11标准中增加了两个:移动构造函数和移动赋值运算符。
两个复制操作彼此独立,声明其中一个,并不会阻止编译器生成另一个。而两个移动操作并不彼此独立,声明其中一个,就会阻止编译器生成另一个,该行为的理由为声明其中一个表明默认移动方式可能不适用于当前类型,所以编译器阻止了生成另一个移动操作。从该理论出发,一旦声明了复制操作,编译器则不会在生成移动操作,即既然默认复制操作不适用于当前类,那默认移动操作很可能也不适用于当前类。反之,一旦声明了移动操作,编译器就会废除复制操作。
在98标准中,出现了一条指导原则名为大三律(Rule of three),即如果你声明了复制构造函数、复制赋值操作父、析构函数其中的一个,你就得同时声明所有这三个。这条原则的理论是,定义了其中一个证明该类型具有特殊的资源管理方式,那么必然需要去定义所有这三个。而该理论在98标准中并没有得到充分的重视,所以单独定义了析构函数,编译器仍然会生成复制操作。为了避免破坏遗留代码,11标准中保持了该情况。
大三律的理论推动了11标准的规定:只要用户声明了析构函数,就不会生成移动操作。即如果移动操作需要被生成,需要满足:

  • 该类未声明任何复制操作
  • 该类未声明任何移动操作
  • 该类未声明任何析构函数

并且在11标准中开始,在已经存在复制操作或析构函数函数时,仍然自动生成复制操作已经成为了废弃的行为。
这也就形成了新的大五律(Rule of five),即大三律的基础上新增移动构造函数和移动赋值运算符。
而之所以关注这些自动生成的函数,是因为其可能造成性能问题。比如只定义了析构函数,期待编译器自动生成移动操作函数来完成移动操作,结果编译器没有生成移动操作使用了复制操作来完成移动操作,这就造成性能问题。
如果编译器生成的函数的是需要的行为,则可以使用=default来显式表达。
需要注意的是函数模版并不会阻止编译器生成任何特种成员函数。

1
2
3
4
5
6
7
8
9
10
class Widget {
...
template<typename T>
Widget(const T& rhs);


template<typename T>
Widget& operator=(const T& rhs);
...
}