Modern Effective Cpp 阅读笔记

https://github.com/CnTransGroup/EffectiveModernCppChinese

条款一:理解模板类型推导

TL;DR 总是 pass-by-value,因此值(或指针)本身的 cv 属性不保留,引用也不会被保留,如果内部类型的声明中有 cv 类型,则再补上。通用引用 + 传左值 是例外,此时内部类型是左值引用

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 是右值,走情景一的规则
  • 情景三: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


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