Modern Effective Cpp 阅读笔记
https://github.com/CnTransGroup/EffectiveModernCppChinese
条款一:理解模板类型推导
TL;DR 总是按照 pass-by-value 得到模板类型的类型,因此值(或指针)本身的 cv 属性不保留(因为 pbv 下也没有意义),同理引用也不会被保留,如果内部类型的声明中有 cv 类型,则再补上。唯一例外是 模板形参为通用引用 + 传左值 的情况,此时模板形参类型推导为左值引用
举个例子:void f(T* param)
,传 const int* const
,指针自身的 const 不保留,其余做模式匹配,最终 T 被推导为 const int
1 |
|
- 情景一:ParamType是一个指针或引用,但不是通用引用
expr 忽略引用部分,然后与 ParamType 模式匹配得到 T - 情景二:ParamType是一个通用引用
如果 expr 是左值,T 和 ParamType 都会被推导为左值引用
如果 expr 是右值,走情景一的规则(也就是把通用引用 T&& 看做是 T 加上一个 右值引用) - 情景三:ParamType既不是指针也不是引用
当ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理
传值意味着拷贝,意味着 cv 属性失效,当然 ref-to-const、ptr-to-const 不会失效
1 |
|
条款二:理解 auto 类型推导
auto 类型推导与模板类型推导基本一致,区别在于模板类型推导无法处理 uniform initialization 的情况,需要在参数中手动写上 std::initializer_list<T>
才行
此外 C++14 lambda 函数允许形参使用 auto,这里使用的是模板类型推导那一套
1 |
|
条款三:理解 decltype
在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。
1 |
|
问题在于通常容器的 []
操作符返回的是引用,而 auto 走模板推导的话会忽略引用,这使得(假设是一个 int 容器)我们返回的是一个 int(右值),无法修改容器内的值。我们希望能够完全用 authAndAccess(c, i) = 1
替代 c[i] = 1
的效果
C++14通过使用 decltype(auto)
说明符使得这成为可能,auto 代表这个类型需要推导,默认使用 auto 对应的模板推导规则,而 decltype(auto) 意味着使用 decltype 对应的规则,意味着引用(与 cv 属性)会被保留。
1 |
|
另外,对于T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&
1 |
|
条款四:学会查看类型推导结果
观察类型(包括 cv 属性)的方法:IDE、编译器、运行时输出
1 |
|
条款五:优先考虑 auto 而非显式类型声明
- auto 变量确保必须被初始化
- 只有编译器知道类型的变量,用 auto 表示会更简单
1 |
|
- 使用 auto 指向可调用类型比用
std::function
效率更高(对象必定存在栈上) - 确保类型正确
std::vector::size
的返回值不一定与 unsigned 相同,使用 auto 我们则不必考虑这个问题std::unordered_map<std::string, int>
在用 pair 遍历时(由于 key 是 const 的),类型实际是std::pair<const std::string, int>
,误用非 const 版本的 pair 进行遍历会导致拷贝,并且会导致在临时变量上修改
- 重构时不用手动改 auto 变量的类型
条款六:auto 推导若非己愿,使用显式类型初始化惯用法
不可见的代理类可能会使auto从表达式中推导出“错误的”类型
1 |
|
这里的问题在于,std::vector<bool>
的 operator[]
返回的是一个std::vector<bool>::reference
的对象。
C++ 禁止对 bits 的引用,因此无法返回一个bool&
。std::vector<bool>
的 operator[]
返回一个行为类似于 bool&
的对象(上面提到的 reference 对象),之后再隐式转换为 highPriority 要求的 bool 类型。
std::vector<bool>::reference
是一个代理类(proxy class)的例子:所谓代理类就是以模仿和增强一些类型的行为为目的而存在的类
当类型声明为 auto 时,一种可能的情况是,这个 bit reference 存的是指向 vector 中某个字节的指针,以及这个指针中我们需要第几位,但由于 features 函数返回的 vector 只是一个临时变量,有可能在使用 highPriority 这个 reference 的时候真正指向的 vector 内存已经被销毁,产生 UB
另一个使用 auto
+ static_cast
的场景是需要手动转换类型,并且相对于 int x = func_return_float()
的写法更能表示“我希望做类型转换”的目的,后者无法看出(不考虑函数名)函数的返回值可能不是 int 类型
条款七:区别使用 () 和 {} 创建对象
C++11 使用花括号来统一初始化方式
- 与 auto 搭配时需要注意类型推导
- 不允许非兼容(变窄)转换
- 若有
std::initializer_list
构造函数,那么会优先尝试将花括号内元素转为 list 声明的类型,如果引入非兼容转换,会出现编译错误,如果无法转换,那么才考虑其他构造函数 type name();
是函数声明,type name{};
是变量声明,且不会调用 list 构造函数- 要么统一使用圆括号,要么统一使用化括号
条款八:优先考虑 nullptr 而非 0 和 NULL
0 或 NULL 都存在与整数类型重载混淆的问题
1 |
|
nullptr
可以隐式转换为所有类型的指针,同时它也不会匹配到整形的函数重载,也不会被模板类/函数推导为整形(which is the case of 0 or NULL)
条款九:优先考虑别名声明而非 typedefs
TODO
总之优先用 using <typeNameAlias> = xxx
而别用 typedef
条款十:优先考虑限域 enum 而非未限域 enum
- C++11 引入了 scoped enum,解决了以前 enum 中的元素实际上会占用 enum 被定义的空间内的名称的问题
- scoped enum 视为强类型(但可以 static_cast 显式转换),而 unscoped enum 会被隐式转换为整形
- C++11 中 scoped enum 可以前置声明,使用时引用此声明(而非引用完整定义)。若 enum 修改,不会导致所有引用到的地方都重新编译、此外也有助于优化头文件的可读性
- unscoped enum 没有默认底层类型,而 scoped enum 默认底层类型是 int,可以通过
enum class : <underlying type>
来指定底层类型
1 |
|
条款十一:优先考虑使用 deleted 函数而非使用未定义的私有声明
有时我们不希望用户调用某个特殊的函数(这里特指 拷贝构造函数或者赋值运算符)
在C++98中防止调用这些函数的方法是将它们声明为私有(private)成员函数并且不定义,而 C++11 起的推荐做法是用 deleted。它不止能应用于成员函数,也可以引用于普通函数、函数模板(举个例子,某个模板函数可以接收 int、char 以外的所有类型,就可以用 deleted 实现)
1 |
|
条款十二:使用 override 声明重写函数
用 override 声明重写函数,可以让编译器帮助我们检查是否真的重写了父类的函数
这里额外列一下重写函数的规则:
- 基类函数必须是virtual
- 基类和派生类函数名必须完全一样(除非是析构函数)
- 基类和派生类函数形参类型必须完全一样
- 基类和派生类函数常量性constness必须完全一样
- 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
- (C++11) 函数的引用限定符(reference qualifiers)必须完全一样。引用限定的左右值区分了调用函数时的 this 指针是左值还是右值
1 |
|
条款十三:优先考虑 const_iterator 而非 iterator
TODO
条款十四:如果函数不抛出异常请使用 noexcept
TL;DR 一方面是接口设计上的,对外保证我不会有异常;另一方面是性能上的,try catch 会需要记录调用栈,如果异常发生,按反向顺序析构 try 到 throw 间创建的资源,no except 则不会有这个负担
- 解释一下原文中的 unwinding
https://learn.microsoft.com/en-us/cpp/cpp/exceptions-and-stack-unwinding-in-cpp?view=msvc-170
When the catch statement is reached, all of the automatic variables that are in scope between the throw and catch statements are destroyed in a process that is known as stack unwinding.
… After the formal parameter is initialized, the process of unwinding the stack begins. This involves the destruction of all automatic objects that were fully constructed—but not yet destructed—between the beginning of the try block that is associated with the catch handler and the throw site of the exception. Destruction occurs in reverse order of construction.
一个例子是 vector 扩容时的数据搬运,vector push_back 提供 strong exception guarantee,意味着如果调用期间异常发生,程序状态能够回滚到函数调用前的时刻,如果我们希望使用 C++11 的移动提高拷贝性能,但移动拷贝不保真 noexcept,那么就会破坏这一保证。cppreference 上没写,我的猜测是这种情况就无法用到这一优化。cppreference 上有写的是,如果移动拷贝无 noexcept,且无法拷贝构造(not CopyInsertable),那么还是会使用移动拷贝,但是作为一个例外,不再提供 strong exception guarantee
noexcept 可以作为 specifier,也可以作为 operator(return bool),举个例子
noexcept(noexcept(swap(*a, *b)));
外层作为 specifier 使用,代表有条件的 nocexcept 声明,内层作为 operator,具体说明何种情况 noexcept
条款十五:尽可能的使用 constexpr
函数也可以用 constexpr 声明,当传入编译期常量,则产出的也是编译期常量,但也允许像普通函数那样使用
条款十七:理解特殊成员函数函数的生成
- C++98:
- 默认构造、析构、拷贝构造、拷贝赋值,仅当用到才生成,默认 public inline,仅当基类析构是 virtual 时,生成的析构才是 virtual 的
- 如果声明了构造,不会生成默认构造
- C++11:
- 如果移动的实例中的某些成员不支持移动,那么会使用它的拷贝
- 拷贝构造和拷贝赋值是独立的,但移动构造和移动赋值不是,声明其中一个会阻止另一个的生成(你可以两个都声明)
- 声明拷贝构造/赋值会阻止移动构造/赋值的生成,反之亦然(这条和上一条的 intutition 是一样的:如果声明了自定义移动/构造,默认的做法多半是错的,不如不生成,C++98 的部分需要保持不变,即拷贝构造与拷贝赋值仍然是独立的)
- 声明析构会阻止移动构造/赋值的生成(intuition 是自定义析构说明涉及了资源管理,那么默认移动多半是错的),但 C++98 允许自定义析构 + 默认生成拷贝构造/赋值,保持前向兼容所以不能改(不保证,deprecated)
- 析构默认 noexcept
条款十八:对于独占资源使用 std::unique_ptr
- 工厂方法是一个用 unique_ptr 的合适场景
- 可以用 unique_ptr 初始化 shared_ptr
- unique_ptr 默认与原始指针大小相同,Deleter 使用函数指针会是的 unique_ptr 占用双倍体积,而 lambda 则取决于它是否是有状态的,无状态的 lambda deleter 不占用额外空间
条款十九:对于共享资源使用 std::shared_ptr
- 引用计数的内存必须在堆上分配
shared_ptr
的引用计数是线程安全的- 使用移动构造初始化
shared_ptr
不会导致引用计数的变动 - deleter 不是
shared_ptr
类型的一部分(unique_ptr 中是) - 不要让一个指针被多个
shared_ptr
管理,这等同于 double free - 在类成员函数中使用
shared_ptr(this)
是错误的,因为外部肯定还有一个拥有 this 的 handle,释放这个shared_ptr
会产生悬挂引用,正确的方案是使用std::enable_shared_from_this
shared_ptr
相对于原始指针的开销:- 控制块大小(shared_ptr 有指向控制块的指针,引用计数在控制块中维护)
- 原子变量更新
- 析构时的虚函数调用
TODO 虚函数调用为什么有开销?
- 除非销毁,无法放弃
shared_ptr
对资源的管理,它不像unique_ptr
有release
方法,但还是有 get 方法的 - shared_ptr 不支持指向原始数组
条款二十:当 std::shared_ptr 可能悬空时使用 std::weak_ptr
std::weak_ptr通常从std::shared_ptr上创建。当从std::shared_ptr上创建std::weak_ptr时两者指向相同的对象,但是std::weak_ptr不会影响所指对象的引用计数