一、关键字 auto / decltype / const / volatile / mutable
自动类型推导:auto 和 decltype。“自动类型推导”是给编译器下的指令,让编译器去计算表达式的类型。
const 与 volatile,如下代码
1
2
3
4
5
6// 需要加上volatile修饰,运行时才能看到效果
const volatile int MAX_LEN = 1024;
auto ptr = (int*)(&MAX_LEN);
*ptr = 2048;
cout << MAX_LEN << endl; // 输出2048const 和宏定义还是有本质区别的:const 定义的常量在预处理阶段并不存在,而是直到运行阶段才会出现。
对于只使用 const 修饰的变量,虽然用指针可以强制修改改常量的值,但这个值在运行阶段根本没有用到,因为它在编译阶段就被优化掉了。所以上述代码加上 volatile 才能看到效果。
volatile 关键词表示这个变量是易变的,不稳定的。编译器不会去优化,每次都从内存中取值。
对于指针,const 放在
*
的左边,表示指向常量的指针,指向一个只读变量,这个变量不允许修改。const 放在*
的右边,表示指针不能被修改,而指向的变量可以被修改。对于类中的函数,加上 const 修饰。表示函数的执行过程是 const 的,不会修改对象的状态(即成员变量),也就是说,成员函数是一个“只读操作”。
mutable 关键字,只能修饰类里面的成员变量,表示变量即使是在 const 对象里,也是可以修改的。
二、智能指针
unique_ptr 指针的所有权是唯一的,不允许共享。在向另一个 unique_ptr 赋值的时候,必须使用 std::move
显式的声明所有权转移
shared_ptr 引用计数、多人共享。注意循环引用(weak_ptr:只观察指针,而不增加引用计数;只在需要的时候获取强引用)
三、异常
异常就是针对错误码的缺陷而设计的,它有三个特点。
- 异常的处理流程是完全独立的,throw 抛出异常后就可以不用管了,错误处理代码都集中在专门的 catch 块里。这样就彻底分离了业务逻辑与错误逻辑,看起来更清楚。
- 异常是绝对不能被忽略的,必须被处理。如果你有意或者无意不写 catch 捕获异常,那么它会一直向上传播出去,直至找到一个能够处理的 catch 块。如果实在没有,那就会导致程序立即停止运行,明白地提示你发生了错误,而不会“坚持带病工作”。
- 异常可以用在错误码无法使用的场合,这也算是 C++ 的“私人原因”。因为它比 C 语言多了构造 / 析构函数、操作符重载等新特性,有的函数根本就没有返回值,或者返回值无法表示错误,而全局的 errno 实在是“太不优雅”了,与 C++ 的理念不符,所以也必须使用异常来报告错误。
异常的抛出和处理需要特别的栈展开(stack unwind)操作,如果异常出现的位置很深,但又没有被及时处理,或者频繁地抛出异常,就会对运行性能产生很大的影响
编译阶段指令:noexcept,放在函数的末尾,告诉编译器这个函数不会抛异常。但是只是承诺,程序员想抛异常也可以抛。
1 |
|
四、 lambda 表达式
lambda 表达式是一个变量,我们可以“按需分配”,随时随地在调用点“就地”定义函数,限制它的作用域和生命周期,实现函数的局部化。每个 lambda 表达式都会有一个独特的类型,而这个类型只有编译器才知道,所以必须用 auto
Lambda 的变量捕获。
- “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改
- “[&]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改
- 也可以在“[]”里明确写出外部变量名,指定按值或者按引用捕获
1 | int x = 33; // 一个外部变量 |
泛型化的模板函数
1 | auto f = [](const auto& x) // 参数使用auto声明,泛型化 |
五、字符串
std::string
的模板类 basic_string
的特化形式:using string = std::basic_string<char>;
(1)c++14 新增了一个字面量的后缀 ‘s’,明确的表示它是 string 字符串类型,而不是 C 字符串,这就可以利用 auto 来自动类型推导。注意:为了避免与用户自定义字面量的冲突,后缀 ‘s’ 不能直接使用,必须用 using 打开名字空间才可以。
1 | using namespace std::literals::string_literals; // 打开命名空间 |
(2)原始字符串。防止转义,保持原始字符串
1 | auto str = R"(hello:world)"; // 原始字符串:hello:world |
想要在原始字符串里面写 引号+圆括号 的形式,就需要在圆括号的两边加上最多 16 个字符的特别“界定符”,保证不与字符串内容发生冲突
1 | auto str = R"==(R"(xxx)")=="; // 输出:R"(xxx)" |
(3)字符串转换函数
stoi()、stol()、stoll()
等把字符串转换成整数stof()、stod()
等把字符串转换成浮点数to_string()
把整数、浮点数转换成字符串
(4)正则表达式
C++ 正则表达式主要有两个类:
- regex:表示一个正则表达式,是 basic_regex 的特化形式
- smatch:表示正则表达式的匹配结果,是 match_results 的特化形式
C++ 正则匹配有三个算法,注意:他们都是只读的,不会变动原字符串
regex_match()
:完全匹配一个字符串regex_search()
:在字符串里查找一个正则匹配regex_replace()
:正则查找在做替换
1 | using namespace std::literals::string_literals; |
(5)注意
- 在 string 转换 C 字符串时,注意
c_str()
和data()
的区别,两个函数都返回const char*
指针,但c_str()
会在末尾添加一个\0
六、容器
容器中存储的是元素的拷贝、副本,不是引用。
- 尽量为容器实现移动构造和移动赋值函数,减少元素拷贝的成本
- 尽量使用 emplace 操作函数,就地在容器上构造元素,去除拷贝或者移动的成本
- 不建议在容器中存储指针,来间接保存元素。这样无法利用容器自动销毁元素的特性,必须手动管理元素的生命周期,有内存泄漏的风险。如果必须要使用,那也建议使用智能指针。
容器按照元素的访问方式,分为顺序容器、有序容器和无序容器三大类别。
顺序容器:array、vector、deque、list、forward_list
- array:静态数组,底层是 C 数组。
- vector:可以动态增长的数组,底层是 C 数组。需要扩容时会一次性申请例如两倍当前大小的空间,并将旧元素拷贝过去。因此使用时最好 reserve 预留空间。
- deque:底层由中央控制器和多个缓冲区(堆)构成,每个堆中多个元素,堆与堆之间有指针指向。可以动态增长,可以在两端插入删除元素。扩容策略按照固定的 N 个字节去增加容量,但在短时间插入大量元素场景下会频繁申请内存,还不如 vector 一次性申请内存。
- list:双向链表。扩容策略按照固定的一个节点去增加容量。大量元素插入时会频繁申请内存。
- forward_list:单向链表。链表任意位置插入元素成本较低。使用指针链接,有一定的存储成本
有序容器:set/multiset 和 map/multimap。带 multi 前缀的表示可以容纳相同的 key。底层数据结构通常是红黑树。
在使用有序容器时,需要定义 key 的比较函数。一个是重载 “<” 操作符,一个是自定义模板参数。
1
2
3
4
5
6// 定义一个lambda,用来比较大小
auto comp = [](auto a, auto b) {
return a > b; // 定义大于关系
};
set<int, decltype(comp)> gs(comp) // 使用decltype得到lambda的类型有序容器在在插入时会自动排序,因此隐含了插入排序成本,当数据量很大时,内存位置查找、树的旋转平衡都会有性能开销。
如果你需要实时插入排序,那么选择 set/map 是没问题的。如果是非实时,那么最好还是用 vector,全部数据插入完成后再一次性排序,效果肯定会更好。
无序容器:unordered_set/unordered_multiset、unordered_map/unordered_multimap。底层是哈希表。带 multi 前缀的表示可以容纳相同的 key
要求 key 具备两个条件。一是可以计算 hash 值,二是能够执行相等比较操作。因为计算哈希值后才能放入哈希表中,并且如果哈希值冲突,需要比较 key 值。
要把自定义类型放入无序容器,需要重载”==”函数和实现哈希函数。哈希函数最好使用标准 hash 函数,不然容易哈希冲突。
1
2
3
4// 定义一个lambda表达式
auto hasher = [](const auto& p) {
return std::hash()(p.x); // 调用标准 hash 函数对象计算
};
容器适配器像 stack、queue,一般底层由 list 或 deque 实现。priority_queue 一般由 vector 实现,以 heap 方式管理存储。
七、迭代器
算法操作容器,实际上它看到的并不是容器,而是指向起始位置和结束位置的迭代器,算法只能通过迭代器去”间接“访问容器以及元素。
- 这种间接的方式就是泛型编程的理念,与面向对象正好相反,分离了数据和操作。算法可以不关心容器的内部结构,以一致的方式去操作元素,适用范围更广,用起来也更灵活。
1 | std::vector<int> vev = {1,2,3,4}; |
八、算法
for 循环
1
2
3std::for_each(std::begin(vec), std::end(vec), [](const auto& x) {
std::cout << x << ",";
});for_each 将 for 循环要做的事情分成了两部分,一个遍历容器元素,另一个操纵容器元素。代码有了更好的封装,促使我们更多的以“函数式编程”来思考,使用 lambda 来封装逻辑,得到更干净、更安全的代码。
排序算法
- sort:快排,不稳定的,而且是全排所有元素
- stable_sort:稳定的,排序后仍然保持了元素的相对顺序
- partial_sort:对于 TopN 的场景
- nth_element:选出前几名,但不要求排出名次,中位数、百分位数
- partition:按照某种规则把元素划分成两组
- minmax_element:第一名和最后一名
注意:使用这些排序算法时,他们对迭代器要求比较高,通常都是随机访问迭代器(minmax_element 除外),所以最好在顺序容器 array/vector 上调用。如果是 list 容器,应该调用其成员函数 sort,他对链表结构做了特别优化。对于无序容器,则不要调用排序算法,哈希表的结构中元素无法交换位置。
查找算法
binary_search:二分查找,返回 bool 值,告知元素是否存在
lower_bound:返回第一个“大于或等于”值的位置迭代器。因此还需要判断迭代器是否有效?迭代器的值是否为要找的值。
upper_bound:返回第一个“大于”值的元素的迭代器
find:查找算法,找到第一个出现的位置
find_if:查找算法,用 lambda 判断条件
find_first_of:查找一个子区间
九、多线程
仅调用一次:可以轻松解决“double check”问题,代替锁来初始化
1 | static std::once_flag flag; // 全局的初始化标志 |
线程局部缓存:C++ 中 thread_local 关键字。gcc 也有”__thread” 关键字
原子变量:原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号。还有一些原子操作(CAS, Compare and swap)
1 | using atomic_bool = std::atomic<bool>; // 原子化的bool |
线程:
std::this_thread 命名空间中,yield()、get_id()、sleep_for()、sleep_until() 这些管理函数
async 异步执行一个任务,但不保证立刻启动线程(可以在第一个参数传递 std::launch::async,要求立即启动线程)。
注意:不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数)。因此即使不关心返回值,也要获取返回值
1
2std::async(task, ...); // 没有显式获取future,被同步阻塞
auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行