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
2
3
4
template<typename T>
void f(ParamType param);

f(expr); //从expr中推导T和ParamType
  • 情景一:ParamType是一个指针或引用,但不是通用引用
    expr 忽略引用部分,然后与 ParamType 模式匹配得到 T
  • 情景二:ParamType是一个通用引用
    如果 expr 是左值,T 和 ParamType 都会被推导为左值引用
    如果 expr 是右值,走情景一的规则(也就是把通用引用 T&& 看做是 T 加上一个 右值引用)
  • 情景三:ParamType既不是指针也不是引用
    当ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理
    传值意味着拷贝,意味着 cv 属性失效,当然 ref-to-const、ptr-to-const 不会失效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int x = 27;      
// 情景一
template<typename T>
void f(T* param);

const int *cpx = &x;
const int* const cpxc = &x;
const int& rx=x;

f(&x); // &x 的类型是 int*,T是int,param 的类型是int*
// ref-to-const,希望 target 的不可变性保留,因此 T 保留了 const
f(cpx); // T 是 const int,param的类型是const int*
// 指针本身是 pass-by-value 的,因此指针自身的 const 不会保留
f(cpxc); // T是 const int,param 是 const int*;如果改成 f(T* const),param 会变为 const int* const

template<typename T>
void f(T& param);

f(rx); // T是const int,param的类型是const int&

// 情景二
template<typename T>
void func(T &&ref);

f(&x); // &x 是右值,类型是 int*,param 是 int *&&
f(x); // x 是左值,类型是 int,param 是 int &

// 情景三

template<typename T>
void f(T param);

const int cx=x;
f(cx); // T 和 param的类型都是int
const char* const ptr = "Fun with pointers"; // const char* 可变指针指向 const 字符串

条款二:理解 auto 类型推导

auto 类型推导与模板类型推导基本一致,区别在于模板类型推导无法处理 uniform initialization 的情况,需要在参数中手动写上 std::initializer_list<T> 才行

此外 C++14 lambda 函数允许形参使用 auto,这里使用的是模板类型推导那一套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const auto & rx=cx; 
// 类型推导等价于如下代码
template<typename T> //概念化的模板用来推导rx的类型
void func_for_rx(const T & param);
func_for_rx(cx);

// 举个例子
auto x = 27;
const auto cx = x;
auto&& uref2 = cx; // 对应条款一的情景二,uref2类型为const int&

// 唯一例外
auto x1 = 27; //类型是int,值是27
auto x2(27); //同上
auto x3 = { 27 }; //类型是std::initializer_list<int>,
//值是{ 27 }
auto x4{ 27 }; //同上

条款三:理解 decltype

在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。

1
2
3
4
5
6
7
8
template<typename Container, typename Index>    // C++14版本,
auto authAndAccess(Container&& c, Index i) // 允许传入左值或右值
// 如果是 C++11 需要写成
// auto authAndAccess(Container& c, Index i) ->decltype(c[i])
{
// 根据传入的值类型(左值/右值)调用对应的 operator[] 重载
return std::forward<Container>(c)[i]; // 从c[i]中推导返回类型
}

问题在于通常容器的 [] 操作符返回的是引用,而 auto 走模板推导的话会忽略引用,这使得(假设是一个 int 容器)我们返回的是一个 int(右值),无法修改容器内的值。我们希望能够完全用 authAndAccess(c, i) = 1 替代 c[i] = 1 的效果

C++14通过使用 decltype(auto) 说明符使得这成为可能,auto 代表这个类型需要推导,默认使用 auto 对应的模板推导规则,而 decltype(auto) 意味着使用 decltype 对应的规则,意味着引用(与 cv 属性)会被保留。

1
2
3
4
Widget w;
const Widget& cw = w;
auto myWidget1 = cw; // Widget 类型
decltype(auto) myWidget2 = cw; // const Widget& 类型

另外,对于T类型的不是单纯的变量名的左值表达式,decltype总是产出T的引用即T&

1
int x = 0; // decltype(x)是int,但 decltype((x)) 是 int&,其中 (x) 是一个左值

条款四:学会查看类型推导结果

观察类型(包括 cv 属性)的方法:IDE、编译器、运行时输出

1
2
3
4
5
6
7
8
// 通过编译器
template<typename T> //只对TD进行声明
class TD; //TD == "Type Displayer"

TD<decltype(param)> tType; // 看编译器报错可知类型

// 运行时,但不靠谱
std::cout << typeid(x).name() << '\n';

条款五:优先考虑 auto 而非显式类型声明

  • auto 变量确保必须被初始化
  • 只有编译器知道类型的变量,用 auto 表示会更简单
1
2
3
4
5
template<typename It>  
void dwim(It b, It e) {
// 需要用这么长的类型来表示迭代器中元素的类型
typename std::iterator_traits<It>::value_type currValue = *b;
}
  • 使用 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
2
3
4
5
6
7
8
9
// 根据 features 的第五个 bit 判断优先级
std::vector<bool> features(const Widget& w);
Widget w;
bool highPriority = features(w)[5];
// 错误的用法
// auto highPriority = features(w)[5];
// 正确的用法
// auto highPriority = static_cast<bool>(features(w)[5]);
processWidget(w, highPriority);

这里的问题在于,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
2
3
4
5
6
7
8
void f(int);        //三个f的重载函数
void f(bool);
void f(void*);

f(0); //调用f(int)而不是f(void*)

f(NULL); //可能不会被编译,一般来说调用f(int),
//绝对不会调用f(void*)

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum Color { black, white, red };   //black, white, red在
//Color所在的作用域
auto white = false; //错误! white早已在这个作用
//域中声明

enum class Color { black, white, red }; //black, white, red
//限制在Color域内
auto white = false; //没问题,域内没有其他“white”
Color c = white; //错误,域中没有枚举名叫white
auto c = Color::white; //没问题

enum class Status; //前置声明
void continueProcessing(Status s); //使用前置声明enum

// 完整声明
enum Status { good = 0,
failed = 1,
incomplete = 100,
corrupt = 200,
audited = 500,
indeterminate = 0xFFFFFFFF
};

条款十一:优先考虑使用 deleted 函数而非使用未定义的私有声明

有时我们不希望用户调用某个特殊的函数(这里特指 拷贝构造函数或者赋值运算符)

在C++98中防止调用这些函数的方法是将它们声明为私有(private)成员函数并且不定义,而 C++11 起的推荐做法是用 deleted。它不止能应用于成员函数,也可以引用于普通函数、函数模板(举个例子,某个模板函数可以接收 int、char 以外的所有类型,就可以用 deleted 实现)

1
2
3
4
5
6
7
8
9
10
11
// 禁用 iostream 的拷贝与赋值
class basic_ios : public ios_base {
private:
basic_ios(const basic_ios& ); // not defined
basic_ios& operator=(const basic_ios&); // not defined
}

// 在 C++11 中的做法则是这样的
public:
basic_ios(const basic_ios& ) = delete;
basic_ios& operator=(const basic_ios&) = delete;

条款十二:使用 override 声明重写函数

用 override 声明重写函数,可以让编译器帮助我们检查是否真的重写了父类的函数

这里额外列一下重写函数的规则:

  • 基类函数必须是virtual
  • 基类和派生类函数名必须完全一样(除非是析构函数)
  • 基类和派生类函数形参类型必须完全一样
  • 基类和派生类函数常量性constness必须完全一样
  • 基类和派生类函数的返回值和异常说明(exception specifications)必须兼容
  • (C++11) 函数的引用限定符(reference qualifiers)必须完全一样。引用限定的左右值区分了调用函数时的 this 指针是左值还是右值
1
2
3
4
5
6
auto vals2 = makeWidget().data(); // 此时 this 是右值,如果 data 能够被移动出来,调用移动构造,就会很好

class Widget {
DataType& data() & { return values; }
DataType data() && { return std::move(values); }
}

条款十三:优先考虑 const_iterator 而非 iterator

TODO

条款十四:如果函数不抛出异常请使用 noexcept

TL;DR 一方面是接口设计上的,对外保证我不会有异常;另一方面是性能上的,try catch 会需要记录调用栈,如果异常发生,按反向顺序析构 try 到 throw 间创建的资源,no except 则不会有这个负担

  1. 解释一下原文中的 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.

  1. 一个例子是 vector 扩容时的数据搬运,vector push_back 提供 strong exception guarantee,意味着如果调用期间异常发生,程序状态能够回滚到函数调用前的时刻,如果我们希望使用 C++11 的移动提高拷贝性能,但移动拷贝不保真 noexcept,那么就会破坏这一保证。cppreference 上没写,我的猜测是这种情况就无法用到这一优化。cppreference 上有写的是,如果移动拷贝无 noexcept,且无法拷贝构造(not CopyInsertable),那么还是会使用移动拷贝,但是作为一个例外,不再提供 strong exception guarantee

  2. 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_ptrrelease 方法,但还是有 get 方法的
  • shared_ptr 不支持指向原始数组

条款二十:当 std::shared_ptr 可能悬空时使用 std::weak_ptr

std::weak_ptr通常从std::shared_ptr上创建。当从std::shared_ptr上创建std::weak_ptr时两者指向相同的对象,但是std::weak_ptr不会影响所指对象的引用计数


Modern Effective Cpp 阅读笔记
https://vicety.github.io/2023/08/18/Modern-Effective-Cpp-中文阅读/
作者
vicety
发布于
2023年8月18日
许可协议