effective modern C++阅读笔记,持续更新......
effective modern C++
型别推导
条款1:理解模板型别推导
模板型别推导时有一些规则:
首先要注意,模板型别T
与模板函数的参数型别ParamType
,往往是不一样的
-
ParamType
是一个指针或引用,但不是个万能引用-
这种情况下推导模板类型
T
时,先将引用部分忽略,然后推导T
的型别
如果传入的实参类型是
const
的,而模板参数并未声明为const
,那么推导出的ParamType
和T
也是const
的类型。而如果模板参数声明为const
,无论传入实参类型是否为const
,推导出的ParamType
总是const
的,T
则不是const
-
-
ParamType
是一个万能引用template<typename T> void f(T&& param)
- 当传入的实参类型是右值时,表现和前面一样
- 当传入的实参类型是左值时,推导出的
ParamType
和T
都会是一个左值引用(只有在这种情况下,T
会被推导为引用类型) ,至于为什么会产生这样的结果,可以参见条款24
-
ParamType
既不是指针,也不是引用- 这种情况就是按值传递,此时会为函数传入参数的一个副本,并且,推导的
T
和ParamType
会忽略const
和volatile
修饰符(因为既然是传入的副本,这些关键词也就没有意义了)
- 这种情况就是按值传递,此时会为函数传入参数的一个副本,并且,推导的
有一种特殊的情况,在C和C++中,数组类型可以退化为指针类型,但在进行模板类型推导时,有一些奇怪的表现:
- 将数组作为按值传递的模板实参时,模板会推导出指针类型
- 将数组作为按引用传递的模板实参时,
T
会推导为实际的数组型别,ParaType
会推导为数组的引用型别,这些型别中会包含数组尺寸此外,用函数类型作为模板类型时,也会发生与数组类型类似的转化
利用声明数组引用的这个能力,可以创造出一个模板,它可以在编译期推导出数组含有的元素个数:
// 以编译期常量的形式返回数组尺寸 template<typename T, std::size_t N> constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; } // 使用示例 int keyVals[] = {1, 3, 7, 9, 11, 22, 35}; std::array<int, arraySize(kayVals)> mappedVals; // mappedVals被指定为7个元素
条款2:理解auto型别推导
auto
的型别推导规则与模板是类似的,当变量用auto
来声明时,auto
就扮演了模板中的T
这个角色。
但是,在有一种情况下例外:
C++11后,如果我们想声明并初始化一个变量,可以有以下四种写法:
int x1 = 27;
int x2(27);
// 为了支持统一初始化,C++11增加了一下的初始化方法
int x3 = {27};
int x4{27};
如果使用auto
替代上面的int
,我们会发现,前两种声明方式推导出的型别为int
,而后面两个语句,却声明了一个型别为std::initalizer_list<int>
的变量,且含有单个值为27的元素。这是因为auto
的型别推导总是会认为由大括号起的初始化表达式是一个std::initalizer_list
,而模板在这种情况下则是无法通过编译的,除非在模板声明时就声明它是std::initalizer_list
。
在C++14中,允许使用
auto
说明函数返回值需要推导,也允许在lambda
表达式的型参声明中使用auto
,但这些地方的auto
推导使用的是模板型别的推导,如果使用大括号表达式初始化,也是无法通过编译的。
在C++17中,有了新的初始化列表推导规则,对于具有单个值的大括号表达式初始化,会推导为普通的类型,而不是初始化列表,如:
auto x3 = {27}
。 在C++17中x3
会被推导为int
类型
条款3:理解decltype
-
绝大多数情况下,
decltype
会得出变量或表达式的型别而不做任何修改 -
对于型别为
T
的左值表达式,除非该表达式仅有一个名字,decltype
总是得出型别T&
int x = 0; decltype(x); // x是一个变量名,返回的结果是int decltype((x)); // (x)是一个左值表达式,返回的结果是int&
-
C++14支持
decltype(auto)
,和auto
一样,它会从其初始化表达式出发来推导型别,但是它的型别推导使用的是decltype
的规则
如果使用auto
对函数返回值类型进行推导,而返回值是一个左值引用,根据条款2的内容,我们知道auto
推导出的类型为一个引用类型,但这显然是具有隐患的——如果返回值是一个引用类型,而该引用是一个作用域仅在函数内的引用,这就导致了一个定义行为。基于第二点和第三点,如果我们像这样使用decltype
,就会导致悬垂引用的问题:
decltype(auto) f()
{
int x = 0;
...
return (x); // decltype((x))是int&,因此f()返回的是int&
}
在C++14定义一个模板,用于获取容器中第i个元素的引用
template<typename Container, typename Index> decltype(auto) // 推导模板返回值 authAndAccess(Container&& c, Index i) // 同时可以接受左值和右值 { authenticateUser(); return std::forward<Container>(c)[i]; // 对万能引用,使用std::forward(条款25) }
如果是C++11中,则需要用
auto f() -> decltype(std::forward<Container>(c)[i])
进行声明
auto
条款5:优先选用auto,而非显式型别声明
表面上看,auto
可以让我们在有些时候少打一些字,但在某些情况下,auto
关键字具有更加精妙的作用
-
使用
auto
声明的变量会自动进行初始化 -
使用
auto
可以解决一些兼容性和隐含的效率问题,如:std::vector<int> v; // 有时程序员会这样写,但这并不是size()返回的实际类型 // 它的实际类型应该是std::vector<int>::size_type,它的位数根据机器可能有所不同 unsigned sz = v.size(); // 如果不想写这一长串类型名称,可以使用auto代替 std::unordered_map<std::string, int> m; for(const std::pair<std::string, int>& p, m) { ... } // 上面的代码看起来没什么问题,却隐含了一个类型转换,因为unordered_map键值部分是const // 所以哈希表中的型别应该是std::pair<const std::string, int> // 如果按照上面的写法,每一次循环时,就会在这里创建一个临时的std::pair<std::string, int>变量 // 如果使用auto,则可以避免这些因为粗心导致的效率问题
-
auto型别的变量都具有条款2和条款6的毛病
条款6:当auto推导的型别不符合要求时,使用带显式型别的初始化物习惯用法
-
隐形的代理型别可以导致
auto
推导出错误的型别如当我们使用
std::vector<bool>
时,使用operator[]
返回的是一个std::vector<bool>::reference
的代理类型,而非bool&
类型的变量。当使用auto
时,它会返回std::vector<bool>::reference
类型,而这个类型通常只是一个临时类型,在operator[]
语句结束后就被析构,因此,此处会产生一个空悬指针,这是一个未定义行为。 -
使用带显式型别的初始化可以解决这个问题。
不赞同,这种情况下还使用auto进行声明没什么道理——既然已经显式指明了类型,说明我们已经知道应该用什么类型了
转向现代C++
条款7:在创建对象时注意区分 () 和 {}
-
C++11引入了统一初始化,它的基础是大括号的形式
-
可以用来指定容器的初始内容
std::vector<int> v {1, 2, 3};
-
可以用来为非静态成员指定默认初始化值(C++11新增特性),使用
=
也可以初始化,但小括号()
不行
=
不能为不可拷贝的对象进行初始化,而{}
和()
可以,总的来说,{}
的适用范围最为广泛 -
-
大括号初始化禁止内建型别之间进行隐式窄化类型转换,如
double x, y, z; ... int sum{x + y + z}; //错误,double型别之和可能无法用int表达,使用=初始化则不会指出这个错误
另外,它还可以避免语法解析时的一些歧义:
Widget w1(10); // 调用Widget的构造函数,传入形参10 Widget w2(); // 原意是调用Widget的无参构造函数,但这个语句会声明一个函数! Widget w3{}; // 调用Widget的无参构造函数
-
但是,大括号初始化也并非总是更好用。在重载决议阶段,如果大括号初始化物与带有
std::initializer_list
的形参匹配,则总是会匹配该重载函数,即使其它函数有貌似更匹配的形参表class Widget { public: Widget(int i, bool b); Widget(std::initializer_list<long double> il); ... } Widget w1(10, true); // 使用第一个构造函数 Widget w2{10, true}; // 使用第二个构造函数,10和true都强制转换为long double
因此,
std::vector
并不是一个好的设计,当进行以下两种初始化时,会产生截然不同的效果std::vector<int> v1(10, 20); // v1含有10个元素,初始化值为20 std::vector<int> v2{10, 20}; // v2具有两个元素,初始化为10, 20
条款8:优先选用nullptr,而非0或NULL
条款9:优先选用别名声明,而非typedef
typedef
不支持模板化,但别名声明支持- 别名模板可以让人免写"::type"后缀,并且在模板内,对于内嵌
typedef
的引用经常要求加上typename
前缀
条款10:优先选用限定作用域的枚举型别
如果在公共空间直接声明enum Color {...};
,那么它的作用于存在于整个空间;在C++11后,可以声明enum class Color {...};
,将枚举类型的名字空间限定在Color
中,也可以称它为枚举类。除了命名空间的优点之外,这种写法还有其它优点:
enum class
的声明,可以限制枚举类型的隐式转换- 使用
enum
声明的枚举类型,没有默认底层型别,而使用enum class
声明的枚举类型,其默认的底层型别为int
。在C++11中,可以对枚举类型进行前向声明,但使用前向声明时,必须指明其枚举型别的尺寸。——也就是说使用enum class
进行前置声明时,不需指定枚举型别底层类型,而使用enum
进行前置声明,指定其底层类型则是必要的。(因为enum
如果不声明其底层类型,那么这个类型的大小是不确定的,如果这个枚举类型发生了修改,则应当对所有相关文件的重新编译)
条款11:优先选用删除函数,而非private未定义函数
-
优先选用删除函数,而非使用private未定义函数
使用
= delete
的好处:-
有助于IDE和编译器的错误提示,将删除函数定义在public中,编译器会明确告知你该函数已被删除
-
可以阻止一些隐式的类型转换,例如:
bool isLucky(int number); bool isLucky(double) = delete;
如果只想让
isLucky
函数处理整数,那么可以将一些会发生隐式类型转换的型别定义为delete
,例如上面将double
类型的形参声明为了delete
,编译时编译器就会阻止由isLucky(double)
的调用。
-
-
任何函数都可以删除,包括非成员函数和模板具现
在指针中有两个异类,一个是
void*
指针,因为无法对其执行提领、自增、自减等操作;还有一个是char*
指针,它通常用来代表C风格的字符串,而不是单个字符的指针。如果我们创建一个指针类型的模板,并且在这两种情况时拒绝对模板进行实例化,就可以这样做:template<typename T> void processPointer(T* ptr); template<> void processPointer<void>(void*) = delete; template<> void processPointer<char>(char*) = delete; // 当然,你可能还需要delete它们的const版本和const volatile版本,以及其它重载的字符型指针 // 如std::wchar_t, std::char16_t等
条款12:为意在改写的函数添加override声明
-
添加override可以避免一些声明时的错误(重载的函数参数、修饰符必须完全匹配)
-
成员函数引用修饰词可以让左值和右值对象的处理区分开来
class Widget { public: using DataType = std::vector<double>; DataType& data() & // 对于左值Widget型别,返回左值 {return values}; DataType data() && {return std::move(values)}; // 对于右值Widget型别,返回右值 private: DataType values; }
条款13:优先选用 const_itertor,而非 itertor
- 任何时候你需要你已迭代器,而其指涉的内容没有必要修改,就应该使用
const_itertor
条款14:只要函数不会发射异常,就将上noexcept声明
-
使用
noexcept
有助于程序优化:在C++98中,以
vector
为例,使用std::vector::push_back
时,如果数组需要扩容,则会开辟一片新的内容,然后对原数组的内容进行拷贝,这个过程时异常安全的。在C++11中,理所当然的对此进行了优化——使用移动语义可以减少拷贝的开销,但是,如果移动过程中出现了异常,数组想恢复到原始状态也是难以做到的,因此不能保证这个操作的异常安全。C++11的做法是,如果函数声明了noexcept
,则说明这个函数不会产生异常,此时使用移动操作,否则使用复制操作。需要注意的是,
noexcept
固然有优点,但只有你保证函数不会抛出异常时才能使用它 -
C++11中,析构函数都隐式的具备
noexcept
的性质,除非它的某些数据成员的析构函数显式的声明了noexcept(false)
条款15:只要有可能使用 constexpr,就使用它
constexpr
对象都具备const
属性,并由编译期已知的值完成优化constexpr
函数在调用时若传入的实参值是编译期已知的,则会产出编译期结果
C++11中,constexpr
只可用于包含一条可执行语句的函数,即函数内必须是return 表达式;
的形式,而在C++14里则没有这个限制
条款16:保证 const 成员函数的线程安全性
多线程情景下,使用const
函数时,一般情况可以直接使用,因为const
意味着不会修改类中的成员变量,但如果函数中使用了mutable
变量时,这个约束就无效了,这种情况下,应当由函数实现者保证它的线程安全性。
你可以选择使用std::mutex
来保证线程安全,这样做会使这个类失去可复制性(因为mutex
不可复制)。一个更轻量级的方法是使用std::atomic
,在仅限单个互斥量的情况下,它会提供比std::mutex
更好的性能,但多个互斥量联动时,atomic
并不能保证并行操作的正确性。(atomic
同样不可复制)
条款17:理解特种成员函数的生成机制
- C++会自动生成一些类成员函数,这些函数仅在需要时才会生成,并且仅当一个类没有声明任何构造函数时,才会生成默认构造函数
class Widget {
Widget(); // 默认构造函数
~Widget(); // 默认析构函数
Widget(Widget&); // 复制构造函数
Widget& operator=(Widget&); // 复制赋值运算符
// C++11中,新增了两种新的特种成员函数
Widget(Widget&&); // 移动构造函数
Widget& operator=(Widget&&); // 移动赋值运算符
}
对于移动构造函数和移动赋值运算符,对于其内部可移动的数据成员,会使用它相应的移动构造函数或移动赋值运算符,对于不可移动的数据成员,则使用它的复制构造函数或复制赋值运算符。其核心在于将std::move
应用于每一个移动源对象,其返回值被用于函数重载决议,最终决定是执行一个移动还是复制操作。
- 特种成员函数的机制如下:(默认为C++11中)
- 默认构造函数:仅当类中不包含用户声明的构造函数时才生成
- 析构函数:C++11中,默认析构函数为
noexcept
,除非它或它的内部成员的析构函数被显式声明为noexcept(false)
;当基类的析构函数为虚函数时,派生类的析构函数才是虚函数。析构函数只能有一个,但在C++20中,一个类可以有一个或多个预期的析构函数,其中之一会在重载决议时被选为类的析构函数。 - 复制构造函数:按成员进行非静态数据成员的复制构造,仅当类中不包含用户声明的复制构造函数时才生成。如果该类声明了移动操作,则复制构造函数将被删除
- 复制赋值运算符:类似复制构造函数
- 移动构造函数和移动赋值运算符:都按成员进行非静态数据成员的移动操作。仅当类中不包含用户声明的赋值操作、移动操作和析构函数时才生成
- 成员函数模板在任何情况下都不会抑制特种成员函数的生成
智能指针
条款18:使用 std::unique_ptr 管理具备专属所有权的资源
- 可以认为
unique_ptr
具有与裸指针相同的尺寸,并且大多数操作的值令与直接使用裸指针并无不同。unique_ptr
可以移动但不能复制。 - 资源的析构默认采用
delete
运算符来实现,但可以指定自定义删除器,有状态的删除器和采用函数指针实现的删除器会增加unique_ptr
对象的尺寸。 std::unique_ptr
转换成std::shared_ptr
是容易实现的