c++语言特性

一、关键字 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; // 输出2048

    const 和宏定义还是有本质区别的: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
2
3
4

void func_noexcept() noexcept { // 声明绝不会抛出异常
cout << "noexcept" << endl;
}

四、 lambda 表达式

lambda 表达式是一个变量,我们可以“按需分配”,随时随地在调用点“就地”定义函数,限制它的作用域和生命周期,实现函数的局部化。每个 lambda 表达式都会有一个独特的类型,而这个类型只有编译器才知道,所以必须用 auto

Lambda 的变量捕获。

  • “[=]”表示按值捕获所有外部变量,表达式内部是值的拷贝,并且不能修改
  • “[&]”是按引用捕获所有外部变量,内部以引用的方式使用,可以修改
  • 也可以在“[]”里明确写出外部变量名,指定按值或者按引用捕获
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int x = 33;               // 一个外部变量

auto f1 = [=]() // lambda表达式,用“=”按值捕获
{
//x += 10; // x只读,不允许修改
};

auto f2 = [&]() // lambda表达式,用“&”按引用捕获
{
x += 10; // x是引用,可以修改
};

auto f3 = [=, &x]() // lambda表达式,用“&”按引用捕获x,其他的按值捕获
{
x += 20; // x是引用,可以修改
};

泛型化的模板函数

1
2
3
4
5
6
7
8
9
10
auto f = [](const auto& x)        // 参数使用auto声明,泛型化
{
return x + x;
};

cout << f(3) << endl; // 参数类型是int
cout << f(0.618) << endl; // 参数类型是double

string str = "matrix";
cout << f(str) << endl; // 参数类型是string

五、字符串

std::string 的模板类 basic_string 的特化形式:using string = std::basic_string<char>;

(1)c++14 新增了一个字面量的后缀 ‘s’,明确的表示它是 string 字符串类型,而不是 C 字符串,这就可以利用 auto 来自动类型推导。注意:为了避免与用户自定义字面量的冲突,后缀 ‘s’ 不能直接使用,必须用 using 打开名字空间才可以。

1
2
using namespace std::literals::string_literals;  // 打开命名空间
auto str = "std string"s; // 后缀 s,表示是标准字符串,直接类型推导

(2)原始字符串。防止转义,保持原始字符串

1
2
auto str = R"(hello:world)";  // 原始字符串:hello:world
auto str2 = R"(\r\n\t\")"; // 原始字符串:\r\n\t\"

想要在原始字符串里面写 引号+圆括号 的形式,就需要在圆括号的两边加上最多 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
2
3
4
5
6
7
8
9
using namespace std::literals::string_literals;
auto str = "god of war"s;
std::smatch match;
auto reg = std::regex(R"((\w+)\s(\w+))");
auto found = std::regex_search(str, match, reg);
assert(found);
assert(!match.empty());
assert(match[1] == "god");
assert(match[2] == "of");

(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
2
3
4
5
6
7
std::vector<int> vev = {1,2,3,4};
auto begin = std::begin(vec);
auto end = std::end(vec);
distance(begin, end); // 计算两个迭代器之间的距离
advance(begin, 2); // 迭代器前进/后退两个位置
next(begin); // 迭代器后的某个位置
prev(end); // 迭代器前的某个位置

八、算法

  • for 循环

    1
    2
    3
    std::for_each(std::begin(vec), std::end(vec), [](const auto& x) {
    std::cout << x << ",";
    });

    for_each 将 for 循环要做的事情分成了两部分,一个遍历容器元素,另一个操纵容器元素。代码有了更好的封装,促使我们更多的以“函数式编程”来思考,使用 lambda 来封装逻辑,得到更干净、更安全的代码。

  • 排序算法

    1. sort:快排,不稳定的,而且是全排所有元素
    2. stable_sort:稳定的,排序后仍然保持了元素的相对顺序
    3. partial_sort:对于 TopN 的场景
    4. nth_element:选出前几名,但不要求排出名次,中位数、百分位数
    5. partition:按照某种规则把元素划分成两组
    6. 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
2
3
4
5
6
static std::once_flag flag;  // 全局的初始化标志
std::call_once(flag, // 仅一次调用,注意要传flag
[](){ // 匿名lambda,初始化函数,只会执行一次
cout << "only once" << endl;
} // 匿名lambda结束
);

线程局部缓存:C++ 中 thread_local 关键字。gcc 也有”__thread” 关键字

原子变量:原子变量禁用了拷贝构造函数,所以在初始化的时候不能用“=”的赋值形式,只能用圆括号或者花括号。还有一些原子操作(CAS, Compare and swap)

1
2
3
using atomic_bool = std::atomic<bool>;    // 原子化的bool
using atomic_int = std::atomic<int>; // 原子化的int
using atomic_long = std::atomic<long>; // 原子化的long

线程:

  • std::this_thread 命名空间中,yield()、get_id()、sleep_for()、sleep_until() 这些管理函数

  • async 异步执行一个任务,但不保证立刻启动线程(可以在第一个参数传递 std::launch::async,要求立即启动线程)。

    注意:不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数)。因此即使不关心返回值,也要获取返回值

    1
    2
    std::async(task, ...);            // 没有显式获取future,被同步阻塞
    auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行