现代C++实战30讲(2)
本文记录学习吴咏炜老师的《现代C++实战》课程的心得体会,记录自认为值得记录的知识点。
重新认识的点
-
编译期间的多态
所有容器类都有
begin
和end
函数成员,这为通用遍历容器提供可能。很多容器都有size
成员,没有继承通用的类。虽然C++
标准容器之间没有对象继承关系,但彼此之间存在很多共性,C++
中有模板来表达这类共性。显示实例化: template class vector
外部实例化: extern template class vector; 告诉编译器此模板已在其他地方实例化,此处无需再实例化。
上述两项实例化可在大型项目中集中模板的实例化,加速编译过程,但也有额外开销,应当谨慎使用。要求不同的对象拥有共同的成员函数,名字和参数结构都要相同。
如果要在共性中有针对特殊类型的特殊处理,对函数模板使用重载,对类模板使用特化。
基于虚函数的多态,解决的是运行时的行为变化,强调对流程的复用。
基于模板的多态,解决的是同一套代码适用于不同的数据类型,强调对代码的复用。 -
模板元编程
最核心的一点,需要把计算转变为类型推导,具体来说是用编译器的类型推导和类型匹配来表达计算过程。 -
常量表达式
一个constexpr
常量是一个编译期间完全确定的常数。 -
Lambda
表达式
Lambda
表达式一般不需要说明返回值,相当于auto
;有特殊情况时,使用后置箭头语法说明: [](int x) -> intauto adder = [](int n){ return n + 2; } adder(2); // 或者更简洁的立即求值 [](int x){return x*x}(3); // 根据不同模式,进行不同处理。 不要求 obj 有默认无参构造函数,又省却了拷贝开销。 auto obj = [init_mode](){ switch(init_mode) { case init_mode1: return obj(xxx); case init_mode2: return obj(xxx2); } }();
显式的代码比隐式的代码更容易维护。一般采用按值捕获,如果是按引用捕获,必须小心确保被捕获变量和lambda表达式的生命周期至少一样长
并且在满足下列条件之一时才使用:- 需要在lambda表达式中修改变量并让外部观察到
- 需要看到这个变量在外部被修改的结果
- 这个变量的复制代价比较高
如果是捕获指针,则要保证指针指向的资源生命周期至少和Lambda表达式的一样长。
Lambda表达式从概念上,可理解为一个匿名struct,它实现了 operator() 运算符,支持调用,捕获过程就是给struct中的成员变量赋值的过程 -
函数式编程
标准算法库中有transform
,把一个范围内的每个对象经过相同的处理,变成另外一系列对象accumulate
,把一个范围内的对象,使用指定的初始值和处理方式,进行归并(默认的处理方式为+,也就是累加)copy_if
, 把一个范围内的对象,提取满足指定条件的对象集合partition
,把一个范围内的对象,根据过滤条件,分为两组
transform函数可通过在调用中加入 execution::par 来启动并行计算。 accumulate 可用 reduce 来替换,并启动并行计算
获得多核环境下的性能提升。C++17中才有。 -
多线程编程
thread
要求在线程变量析构前,要么通过join
(阻塞直到线程推出),要么detach
(放弃对线程的管理),否则程序会异常退出。
mutex output_lock; void func(const char* name) { this_thread::sleep_for(100ms); lock_guard<mutex> guard{ output_lock }; cout << "I am thread " << name << this_thread::get_id() <<endl; } // 包装一下 thread,使得能在析构时,自动调用 join 接口 class scoped_thread { public: // 通过可变模板和完美转发来构造 template<typename... Arg> scoped_thread(Arg&&... arg) :thread_(std::forward<Arg>(arg)...) {} // 可以移动 scoped_thread(scoped_thread&& other) :thread_(std::move(other.thread_)) {} // 不能拷贝 scoped_thread(const scoped_thread&) = delete; ~scoped_thread() { // 只有可joinable的才能调用join if (thread_.joinable()) { thread_.join(); } } private: thread thread_; }; void test_thread() { #if 0 thread t1{ func , "A" }; thread t2{ func , "B" }; t1.join(); t2.join(); #endif scoped_thread t1{ func, "A" }; scoped_thread t2{ func, "B" }; }
-
内存模型
编译器为了优化,可以调整代码执行顺序,保证外部可观测行为一致
处理器的乱序执行,也会调整代码执行顺序。volatile
的语义时防止编译器优化对内存的读写,每次都去读写内存,在多处理器环境下,无法保证多个线程能看到同样顺序的数据变化。c++提供原子对象(atomic)以及对应的获得(acquire)、释放(release)语义,可精确控制内存访问顺序。
- 获得是一个对内存的读操作,当前线程的任何后面的读写操作都不允许重排到这个操作前面去。
- 释放是对一个内存的写操作,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去。
atomic变量的写操作,默认是释放语义;读操作默认是获得语义。但是,缺省行为可能是对性能不利的,并不需要在任何情况下都保证操作的顺序性。
原子操作有三类:
- 读: 在读取过程中,读取位置内容不会发生任何变动
- 写: 在写入过程中,其他执行线程不会看到部分写入结果
- 读-修改-写入:读取内存、修改数值、然后写会内存,整个操作过程中间不会有其他写入操作插入、其他执行线程不会看到部分写入结果。
-
C++编译器
CLang作为LLVM项目的一部分,现在成为通用跨平台编译器,它在错误信息易用性上,相比于GCC有极大改善。另外,Clang上扩展新功能很容易,有很多流行的生态组件:- 代码自动完成
clang-complete
(Vim插件) - 代码格式化
clang-format
内置的风格格式需要根据实际情况调整 - 代码静态检查
clang-tidy
CLang在Windows平台上会使用MSVC的C++运行时,在Linux上使用libstdc++,在macOS上,才使用CLang的C++库,libc++
- 代码自动完成
-
辅助小工具
- CppCheck
- TscanCode
- 从编译器视角查看源码 C++ Insights
- 从汇编角度查看源码 ComplierExplorer
-
多数值对象
在没有optional
之前,多数值对象通常采用如下实现方式:struct FloatIntChar { enum ValueType{ FLOAT, INT, CHAR, STRING, }type; // 表示实际存储的数据类型 // 采用联合体,公用同一存储空间 union { float fValue; int nValue; char cValue; //string str; // 增加这个会编译失败 }; };
上述做法针对POD类型成员是可行的,一旦引入非POD类型,编译器会看到 union 中使用 string 类型带来构造和析构问题。
如果使用 c++17中引入的variant
,就会非常干净利落。variant<string, int, char> obj{ "hello", 1, 'c' }; cout << get<string>(obj) << endl;
-
C++工程库介绍
-
Concept
概念(Concept)是对模板参数的约束条件,符合这类约束条件的都能被该模板实例化。这里的条件不是某个具体类型,比如 int、long,而是满足某种操作的类型集合。template<typename N> requires Integer<N> N half(N n) { return n / 2; }
half
是一个函数模板,有一个模板参数N
,这个N
要满足Integer
的概念约束,该函数才能对N
进行实例化。 -
课后问答
- 为什么
stack
或queue
有pop
和top/front
两个函数,而不合并成一个函数?
解答:如果pop
返回元素,在元素拷贝时发生异常,那么这个元素就丢失了,为保证异常安全,pop
没有返回值,简化语义,取值使用另外的接口top/front
。
- 为什么