快速上手现代C++黑魔法

前言

C++ 是一个用户群体相当大的语言。从 C++98 及其之前的传统 C++ 到 C++11/14/17 的现代 C++,二者甚至可以说不是同一语言。本文记录现代 C++的独特特性,快速上手现代 C++ 的黑魔法。

弃用特性

弃用并非彻底不能用,只是用于暗示程序员这些特性将从未来的标准中消失,应该尽量避免使用。但是,已弃用的特性依然是标准库的一部分,并且出于兼容性的考虑,大部分特性其实会永久保留。
例如:

  • 如果一个类有析构函数,不在自动为其生成拷贝构造函数和拷贝赋值运算符。
  • 不再允许字符串字面值常量赋值给一个 char *。应该使用 const char * 或者 auto。
  • C++98 异常说明、 unexcepted_handler、set_unexpected() 等相关特性被弃用,应该使用 noexcept。
  • auto_ptr 被弃用,应使用 unique_ptr。
  • register 关键字被弃用,不再具有实际含义。
  • bool 类型的 ++ 操作被弃用。
  • C 语言风格的类型转换被弃用,应该使用 static_cast、reinterpret_cast、const_cast 来进行类型转换。
  • C++17 中弃用了 <ccomplex>、<cstdalign>、<ctgmath> 等 C 标准库

兼容 C

注:C++ 不是 C 的一个超集!
C 和 C++ 兼容性

extern "C"

extern "C" 的主要作用就是为了能够正确实现 C++ 代码调用其他 C语言代码。加上 extern "C" 后,会指示编译器这部分代码按 C 语言(而不是 C++)的方式进行编译。区别:

  • C++ 支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名。
  • C 语言并不支持函数重载,因此编译 C 语言代码的函数时不会带上函数的参数类型,一般只包括函数名。

extern 是 C/C++ 语言中表明函数和全局变量的作用范围的关键字,该关键字告诉编译器,其申明的函数和变量可以在本模块或其他模块中使用。
注:extern int a; 仅仅是一个变量的声明,其并不是在定义变量 a,也并未为 a 分配空间。变量 a 在所有模块中作为一种全局变量只能被定义一次,否则会出错。
extern 对应的关键字是 static,static 表明变量或者函数只能在本模块中使用,因此,被 static 修饰的变量或者函数不可能被 extern C修饰。

使用语法

头文件

#ifndef _ADD_H    // 防止该头文件被重复引用
#define _ADD_H

#ifdef __cplusplus //告诉编译器,这部分代码按C语言的格式进行编译,而不是C++的
extern "C"{
#endif
 
/*…*/
 
#ifdef __cplusplus
}
#endif

#endif 

单一语句

extern "C" double sqrt(double);

复合语句

extern "C"
{
      double sqrt(double);
      int min(int, int);
}

语言可用性

1. nullptr 替代 NULL, 且 不为 0

nullptr 的类型为 nullptr_t,能够隐式的转换为任何指针或成员指针的类型,也能和他们进行相等或者不等的比较。

2. constexpr 显式的声明函数或对象构造函数在编译期会成为常量表达式

注:cosnt 常数不等价于常量表达式

int val = 10;
const int len = val + 1;
constexpr int len_two = 1 + 2;
char arr_one[len];    // 非法
char arr_two[len_two] // 合法 

注:C++14 开始,constexpr 函数内部可以使用局部变量、循环和分支等简单语句

// C++14
constexpr int fibonacci(const int n) {
if(n == 1) return 1;
if(n == 2) return 1;
return fibonacci(n-1)+fibonacci(n-2);
}

// C++11
constexpr int fibonacci(const int n) {
return n == 1 || n == 2 ? 1 : fibonacci(n-1)+fibonacci(n-2);
}

3. if/switch 语句中支持声明临时变量

if (int n = func(); n != val){

}

4. 初始化列表

C++11 把初始化列表的概念绑定到类型上,称其为 std::initializer_list,允许构造函数或其他函数像参数一样使用初始化列表,这就为类对象的初始化与普通数组和的初始化方法提供了统一的桥梁

int arr[3] = {1,2,3};

void foo(std::initializer_list<int> list) {}
foo({1,2,3});

5. 结构化绑定

结构化绑定提供了类似其他语言中提供的多返回值的功能。

std:: tuple<int, double, std::string> f() {
    return std::make_tuple(1,2.3,"str");
}
auto [x,y,z] = f();
std::cout << x << y << z << std::endl;

6. 类型推导

auto

编译器自动推导变量类型

auto i = 5; // int
auot arr = new auto(10); // int *

// C++14
auto add = [](auto x, auto y) ->int {
    return x + y;
}

// C++20
int add(auot x, auto y) {
    return x + y;
}

注:auto 不能用于推导数组类型

decltype

decltype关键字是为了解决关键字只能对变量进行类型推导的缺陷而出现的。

decltype(val1) val2; // val2 类型由 val1 类型推导实现 

std::is_same<T,U> 用于判断 T 和 U 类型释放相同

尾返回类型推导

  1. 传统 C++
template<typename R, typename T, typename U>
R add(T x, U y) {
    return x + y;
}
  1. C++11
template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
    return x + y;
}
  1. C++14
template<typename T, typename U>
auto add(T x, U y) {
    return x + y;
}
  1. decltype(auto)

decltype(auto) 用于对转发函数或封装的返回类型进行推导,无需显式的
指定的参数表达式。

decltype(auto) look_string() {
    reterun look();  
}

7. if constexpr

允许在代码中声明常量表达式的判断条件 (C++17)

if constexpr(std::is_integral<T>::value){ 
} 
else {
}

8. 区间 for

int array[] = {1,2,3,4,5};
for(auto &x : array) {
std::cout << x << std::endl;
}

注:& 启用了引用, 如果没有则对 arr 中的元素只能读取不能修改

9. 外部模板

传统 C++11 中,模板只有在使用时才会被编译器实例化。即只要在每个编译单元(文件)中编译的代码中遇到了被完整定义的模板,都会实例化。这就产生了重复实例化而导致的编译时间的增加。且没有办法通知编译器不要触发模板的实例化。
为此,引入了外部模板,扩充了原来的强制编译器在特定位置实例化模板的语法,能够显式的通知编译器何时进行模板的实例化。

template class std::vector<MagicClass>; // 强行实例化
extern template class std::vector<MagicClass>; // 不在该编译文件中实例化模板

10. 尖括号 “>”

连续的右尖括号将变得合法,并且能够顺利通过编译 (C++11)

template<bool T> SuckType;
std::vector<SuckType<(1>2)>> v; // 合法, 但不建议写出这样的代码

11. 类型别名模板

**模板是用来产生类型的!**using 为模板定义一个新的名称

使用 typedef 定义别名的语法是:typedef 原名称 新名称;

typedef int (*process)(void *); // 定义了一个返回类型为 int,参数为 void* 的函数指针类型,名字叫做 process
using process = int(*)(void *); // 同上, 更加直观
using NewType = SuckType<std::vector, std::string>;


template<typename T, typename U>
class SuckType;
typedef SuckType<std::vector, std::string> NewType; // 不合法
template<typename T, typename U>
class SuckType;
using  NewType = SuckType<std::vector, std::string> // 合法

12. 变长参数模板

允许任意个数、任意类别的模板参数,同时也不需要在定义时将参数的个数固定

template<typename... Ts> class Magic;

sizeof...() 计算变长参数模板参数个数

  • 递归模板函数

不断递归地向函数传递模板参数,进而达到递归遍历所有模板参数的目的

template<typename T>
    void printf(T value) {
    std::cout << value << std::endl;
}

template<typename T, typename... Args>
void printf(T value, Args... args) {
    std::cout << value << std::endl;
    printf(args...);
}
  • 变参模板展开 (C++17)
template<typename T, typename... Args>
void printf(T value, Args... args) {
    std::cout << value << std::endl;
    if constexpr (sizeof...(args) > 0) printf(Args... args);
}
  • 初始化列表展开

通过初始化列表,表达式将会被展开。由于逗号表达式的出现,首先会执行
前面的表达式,完成参数的输出。

// 编译这个代码需要开启 -std=c++14
template<typename T, typename... Args>
auto print(T value, Args... args) {
std::cout << value << std::endl;
return (void)std::initializer_list<T>{([&] {
std::cout << args << std::endl;
}(), value)...};
}
  • 折叠表达式 (C++17)

特性进一步拓展到表达式

template<typename ...T>
suot sun(T ...t) {
    reutrn (t + ...);
}
  • 非类型模板参数推导 (C++17)
template<auto value> void foo() {
    std::cout << value << std::endl;
    return;
}
foo<10>(); // value 推导为 int 类型

13. 面对对象

委托构造

构造函数可以在同一个类中一个构造函数调用另一个构造函数,从而达到简化代码的目的

class Base {
public:
int value1;
int value2;
Base() {
    value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = 2;
}
};

int main() {
Base b(2);
std::cout << b.value1 << std::endl;
std::cout << b.value2 << std::endl;
}

继承构造

class Base {
public:
int value1;
int value2;
Base() {
    value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 构造函数
    value2 = 2;
}
};
class Subclass : public Base {
public:
using Base::Base; // 继承构造
};
int main() {
Subclass s(3);
std::cout << s.value1 << std::endl;
std::cout << s.value2 << std::endl;
}

显式虚函数重载

在传统 C++中,经常容易发生意外重载虚函数的事情。
C++11 引入了 overridefinal 这两个关键字来防止上述情形的发生。

当重载虚函数时,引入 override 关键字将显式的告知编译器进行重载,编译器将检查基函数是否存在这样的虚函数,否则将无法通过编译。

struct Base {
virtual void foo(int);
};
struct SubClass: Base {
virtual void foo(int) override; // 合法
virtual void foo(float) override; // 非法, 父类没有此虚函数
};

final 则是为了防止类被继续继承以及终止虚函数继续重载引入的。

struct Base {
virtual void foo() final;
};
struct SubClass1 final: Base {
}; // 合法

struct SubClass2 : SubClass1 {
}; // 非法, SubClass1 已 final

struct SubClass3: Base {
void foo(); // 非法, foo 已 final
};

显式禁用默认函数

在传统 C++ 中,如果程序员没有提供,编译器会默认为对象生成默认构造函数、复制构造、赋值算符以及析构函数。另外,C++ 也为所有类定义了诸如 new delete 这样的运算符。当程序员有需要时,可以重载这部分函数。

C++11 允许显式的声明采用或拒绝编译器自带的函数。

class Magic {
public:
Magic() = default; // 显式声明使用编译器生成的构造
Magic& operator=(const Magic&) = delete; // 显式声明拒绝编译器生成构造
Magic(int magic_number);
}

强类型枚举

在传统 C++ 中,枚举类型并非类型安全,枚举类型会被视作整数,则会让两种完全不同的枚举类
型可以进行直接的比较(虽然编译器给出了检查,但并非所有),甚至同一个命名空间中的不同枚举类型的枚举值名字不能相同

C++11 引人枚举类 enum class

enum class new_enum : int {
    value1,
    value2 = 10;
}

这样定义的枚举实现了类型安全。

  • 能够被隐式的转换为整数
  • 不能够将其与整数数字进行比较,
  • 不可能对不同的枚举类型的枚举值进行比较
  • 但相同枚举值之间如果指定的值相同,那么可以进行比较

注:未指定类型时将默认使用int。
重载 << 输出枚举值的值

std::ostream& operator<<(
    typename std::enable_if(std::is_enum<T>::value,
    std::ostream>::type& stream, const T& e)
{
    return stream << static_cast<typename std::underlying_type<T>::type>(e);
}

语言运行器

1. Lambda 表达式

Lambda 表达式是 C++11 中最重要的新特性之一
Lambda 表达式:提供了一个类似匿名函数的特性
匿名函数:在需要一个函数,但是又不想费力去命名一个函数的情况下去使用的

基础语法

[捕获列表](参数列表) mutable 异常属性 -> 返回类型{
    // 函数体
}

捕获列表

实质是参数的一种类型,lambda 表达式内部函数体在默认情况下是不能够使用函数体外部的变量的,这时候捕获列表可以起到传递外部数据的作用

  • 值捕获

前提:变量可以拷贝,被捕获的变量在 Lambda 表达式被创建时拷贝

  • 引用捕获

保存的是引用,可以改变值

  • 隐式捕获

捕获列表中使用 & 或 = 向编译器声明捕获类型

[] // 空捕获列表
[name1,name2,...] // 捕获参数
[&]  // 引用捕获
[=]  // 值捕获

表达式捕获

C++14 允许捕获的成员用任意的表达式进行初始化,这就允许了右值的捕获,被声明的捕获变量类型会根据表达式进行判断,判断方式与使用 auto 本质上是相同的:

#include <iostream>
#include <utility>

int main() {
auto important = std::make_unique<int>(1);
auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
return x+y+v1+(*v2);
};
std::cout << add(3,4) << std::endl;
return 0;
}

在上面的代码中,important 是一个独占指针,是不能够被捕获到的,这时候我们需要将其转移为右值,在表达式中初始化。

泛型 Lambda

C++14 开始,Lambda 函数的形式参数可以使用 auto 关键字来产生意义上的泛型:

auto add = [](auto x, auto y) {
return x+y;
};

add(1, 2);
add(1.1, 2.2);

2. 函数对象包装器

std::function

Lambda 表达式的本质是一个函数对象,当 Lambda 表达式的捕获列表为空时,Lambda 表达式还能够作为一个函数指针进行传递

#include <iostream>

using foo = void(int); 
void functional(foo f) {
    f(1);
}

int main() {
auto f = [](int value) {
std::cout << value << std::endl;
};
functional(f); // 函数指针调用
f(1); // lambda 表达式调用
return 0;
}

C++11 中,将能够被调用的对象的类型,统一称之为可调用类型。而这种类型,便是通过 std::function 引入的。

C++11 std::function 是一种通用、多态的函数封装,它的实例可以对任何可以调用的目标实体进行存储、复制和调用操作,它也是对 C++中现有的可调用实体的一种类型安全的包裹(相对来说,函数指针的调用不是类型安全的),换句话说,就是函数的容器。当我们有了函数的容器之后便能够更加方便的将函数、函数指针作为对象进行处理。

#include <functional>
#include <iostream>

int foo(int para) {
return para;
}

int main() {
// std::function 包装了一个返回值为 int, 参数为 int 的函数
std::function<int(int)> func = foo;

int important = 10;
std::function<int(int)> func2 = [&](int value) -> int {
return 1+value+important;
};
std::cout << func(10) << std::endl;
std::cout << func2(10) << std::endl;
}

std::bind/std::placeholder

std::bind 是用来绑定函数调用的参数的,它解决的需求是我们有时候可能并不一定能够一次性获得调用某个函数的全部参数,通过这个函数,我们可以将部分调用参数提前绑定到函数身上成为一个新的对象,然后在参数齐全后,完成调用。

int foo(int a, int b, int c) {
;
}
int main() {
// 将参数1,2绑定到函数 foo 上,但是使用 std::placeholders::_1 来对第一个参数进行占位
auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
// 这时调用 bindFoo 时,只需要提供第一个参数即可
bindFoo(1);
}

3. 右值引用

右值引用是 C++11 引入的与 Lambda 表达式齐名的重要特性之一
消除了诸如 std::vectorstd::string 之类的额外开销,也才使得函数对象容器 std::function 成为了可能。

左值、右值的纯右值、将亡值、右值

  • 左值(lvalue, left value):赋值符号左边的值。准确来说,左值是表达式(不一定是赋值表达式)后依然存在的持久对象。
  • 右值(rvalue, right value):右边的值,是指表达式结束后就不再存在的临时对象。
  • 纯右值(prvalue, pure rvalue):纯粹的右值,要么是纯粹的字面量,例如 10, true;要么是求值结果相当于字面量或匿名临时对象,例如 1+2。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda 表达式都属于纯右值。
  • 将亡值(xvalue, expiring value):即将被销毁、却能够被移动的值。
std::vector<int> foo() {
std::vector<int> temp = {1, 2, 3, 4};
return temp;
}

std::vector<int> v = foo();

在这样的代码中,函数 foo 的返回值 temp 在内部创建然后被赋值给 v,然而 v 获得这个对象时,会将整个 temp 拷贝一份,然后把 temp 销毁,如果这个 temp 非常大,这将造成大量额外的开销。在最后一行中,v 是左值、foo() 返回的值就是右值(也是纯右值)。但是,v 可以被别的变量捕获到,而 foo() 产生的那个返回值作为一个临时值,一旦被 v 复制后,将立即被销毁,无法获取、也不能修改。

将亡值就定义了这样一种行为:临时的值能够被识别、同时又能够被移动。

右值引用和左值引用

需要拿到一个将亡值,就需要用到右值引用的申明:T &&,其中 T 是类型。右值引用的声明让这个临时值的生命周期得以延长、只要变量还活着,那么将亡值将继续存活。
C++11 提供了 std::move 这个方法将左值参数无条件的转换为右值

#include <iostream>
#include <string>

void reference(std::string& str) {
std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
std::cout << "右值" << std::endl;
}

int main()
{
std::string s1 = "string,"; // s1 是一个左值
// std::string&& r1 = s1; // 非法, 右值引用不能引用左值
std::string&& rv1 = std::move(s1); // 合法, std::move可以将左值转移为右值
std::cout << rv1 << std::endl; // string,

const std::string& s2 = s1 + s1; // 合法, 常量左值引用能够延长临时变量的申明周期
// s2 += "Test"; // 非法, 引用的右值无法被修改
std::cout << s2 << std::endl; // string,string

std::string&& rv2 = s1 + s2; // 合法, 右值引用延长临时对象声明周期
rv2 += "Test"; // 合法, 非常量引用能够修改临时变量
std::cout << rv2 << std::endl; // string,string,string,

reference(rv2); // 输出左值

return 0;
}

注意rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。

移动语义

传统 C++ 通过拷贝构造函数和赋值操作符为类对象设计了拷贝/复制的概念,但为了实现对资源的移动操作,调用者必须使用先复制、再析构的方式。
传统的 C++ 没有区分移动和拷贝的概念,造成了大量的数据移动,浪费时间和空间。右值引用的出现恰好就解决了这两个概念的混淆问题

#include <iostream>
class A {
public:
int *pointer;
A():pointer(new int(1)) { std::cout << "构造" << pointer << std::endl; }
A(A& a):pointer(new int(*a.pointer)) { std::cout << "拷贝" << pointer << std::endl; } // 无意义的对象拷贝
A(A&& a):pointer(a.pointer) { a.pointer = nullptr;std::cout << "移动" << pointer << std::endl; }
~A(){ std::cout << "析构" << pointer << std::endl; delete pointer; }
};
// 防止编译器优化
A return_rvalue(bool test) {
A a,b;
if(test) return a;
else return b;
}
int main() {
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;

return 0;
}

在上面的代码中:

  1. 首先会在 return_rvalue 内部构造两个 A 对象,于是获得两个构造函数的输出;
  2. 函数返回后,产生一个将亡值,被 A 的移动构造(A(A&&))引用,从而延长生命周期,并将这个右值中的指针拿到,保存到了 obj 中,而将亡值的指针被设置为 nullptr,防止了这块内存区域被销毁。

完美转发

一个声明的右值引用其实是一个左值。

void reference(int& v) {
std::cout << "左值" << std::endl;
}
void reference(int&& v) {
std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v); // 始终调用 reference(int& )
}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1); // 1是右值, 但输出左值

std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v); // r 是左引用, 输出左值

return 0;
}

虽然传递的是右值,但由于 v 是一个引用,所以同时也是左值。因此 reference(v) 会调用 reference(int&),输出『左值』。

而对于 pass(v)而言,v是一个左值,为什么会成功传递给 pass(T&&) 呢?

引用坍缩规则: C++ 由于右值引用的出现,从而产生了引用坍缩规则,允许我们对引用进行引用,既能左引用,又能右引用。

规则如下:

函数形参类型 实参参数类型 推导后函数形参类型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

无论模板参数是什么类型的引用,当且仅当实参类型为右引用时,模板参数才能被推导为右引用类型

完美转发:在传递参数的时候,保持原来的参数类型(左引用保持左引用,右引用保持右引用)。使用 std::forward 来进行参数的转发(传递):

#include <iostream>
#include <utility>
void reference(int& v) {
std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
std::cout << "普通传参:";
reference(v);
std::cout << "std::move 传参:";
reference(std::move(v));
std::cout << "std::forward 传参:";
reference(std::forward<T>(v));

}
int main() {
std::cout << "传递右值:" << std::endl;
pass(1);

std::cout << "传递左值:" << std::endl;
int v = 1;
pass(v);

return 0;
}

输出结果为:

传递右值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:右值引用
传递左值:
普通传参:左值引用
std::move 传参:右值引用
std::forward 传参:左值引用
  • std::forwardstd::move 一样,没有做任何事
  • std::move 单纯的将左值转化为右值
  • std::forward 也只是单纯的将参数做了一个类型的转
    换,
  • std::forward<T>(v)static_cast<T&&>(v) 是完全一样的。

容器

1. 线性容器

std::array

  1. 为什么要引入 std::array 而不是直接使用 std::vector

使用 std::array 保存在栈内存中,相比堆内存中的 std::vector,我们就能够灵活的访问这里面的元素,从而获得更高的性能;同时正式由于其堆内存存储的特性,有些时候需要手动释放资源

  1. 已经有了传统数组,为什么要用 std::array?

使用 std::array能够让代码变得更加现代,且封装了一些操作函数,同时还能够友好的使用标准库中的容器算法等等,比如 std::sort

  • std::array 会在编译时创建一个固定大小的数组
  • std::array 不能够被隐式的转换成指针
  • 使用 std::array 很简单,只需指定其类型和大小即可
std::array<int, 4> arr= {1,2,3,4};

兼容 C 风格的接口

void foo(int *p, int len) {
return;
}

std::array<int, 4> arr = {1,2,3,4};

// C 风格接口传参
// foo(arr, arr.size()); // 非法, 无法隐式转换
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());

// 使用 `std::sort`
std::sort(arr.begin(), arr.end());

std::forward_list

  • std::list 采用双向链表实现
  • std::forward_list 使用单向链表进行实现
    • 提供了 O(1) 复杂度的元素插入
    • 不支持快速随机访问(这也是链表的特点)
    • 是标准库容器中唯一一个不提供 size() 方法的容器
    • 当不需要双向迭代时,具有比 std::list 更高的空间利用率。

2. 无序容器

  • 有序容器 std::map/std::set

    • 内部通过红黑树进行实现
    • 插入和搜索的平均复杂度均为 O(log(size))
    • 在插入元素时候,会根据 < 操作符比较元素大小并判断元素是否相同,并选择合适的位置插入到容器中。
    • 当对这个容器中的元素进行遍历时,输出结果会按照 < 操作符的顺序来逐个遍历。
  • 无序容器中的元素是不进行排序的

    • 内部通过 Hash 表实现
    • 插入和搜索元素的平均复杂度为 O(constant)
    • 在不关心容器内部元素顺序时,能够获得显著的性能提升。

C++11 引入了两组无序容器:std::unordered_map/std::unordered_multimapstd::unordered_set/std::unordered_multiset

3. 元组 std::tuple

了解过 Python 的程序员应该知道元组的概念,纵观传统 C++ 中的容器,除了 std::pair 外,似乎没有现成的结构能够用来存放不同类型的数据(通常我们会自己定义结构)。但 std::pair 的缺陷是显而易见的,只能保存两个元素。

元组基本操作

  1. std::make_tuple: 构造元组
  2. std::get: 获得元组某个位置的值
  3. std::tie: 元组拆包

std::get 除了使用常量获取元组对象外,C++14 增加了使用类型来获取元组中的对象

std::tuple<std::string, double, double, int> t("123", 4.5, 6.7, 8);
std::cout << std::get<std::string>(t) << std::endl;
std::cout << std::get<double>(t) << std::endl; // 非法, 引发编译期错误
std::cout << std::get<3>(t) << std::endl;

运行期索引

std::get<> 依赖一个编译期的常量

int index = 1;
std::get<index>(t); // 非法

那么要怎么处理?答案是,std::variant<>

#include <variant>
template <size_t n, typename... Args>
std::variant<Args...> _tuple_index(size_t i, const std::tuple<Args...>& tpl) {
if (i == n)
return std::get<n>(tpl);
else if (n == sizeof...(Args) - 1)
throw std::out_of_range("越界.");
else
return _tuple_index<(n < sizeof...(Args)-1 ? n+1 : 0)>(i, tpl);
}
template <typename... Args>
std::variant<Args...> tuple_index(size_t i, const std::tuple<Args...>& tpl) {
return _tuple_index<0>(i, tpl);
}

这样我们就能:

int i = 1;
std::cout << tuple_index(i, t) << std::endl;

元组合并与遍历

合并两个元组,可以通过 std::tuple_cat 来实现:

auto new_tuple = std::tuple_cat(get_student(1), std::move(t));

知道一个元组的长度,可以:

template <typename T>
auto tuple_len(T &tpl) {
return std::tuple_size<T>::value;
}

对元组进行遍历:

// 迭代
for(int i = 0; i != tuple_len(new_tuple); ++i)
// 运行期索引
std::cout << tuple_index(i, new_tuple) << std::endl;

内存管理

1. RAII 与引用计数

引用计数这种计数是为了防止内存泄露而产生的。
基本想法是对于动态分配的对象,进行引用计数,每当增加一次对同一个对象的引用,那么引用对象的引用计数就会增加一次,每删除一次引用,引用计数就会减一,当一个对象的引用计数减为零时,就自动删除指向的堆内存。

RAII 资源获取即初始化技术:在构造函数的时候申请空间,而在析构函数(在离开作用域时调用)的时候释放空间

C++11 引入了智能指针的概念,使用了引用计数的想法,让程序员不再需要关心手动释放内存。这些智能指针就包括 std::shared_ptr/std::unique_ptr/std::weak_ptr,使用它们需要包含头文件 <memory>

注意:引用计数不是垃圾回收,引用技术能够尽快收回不再被使用的对象,同时在回收的过程中也不会造成长时间的等待,更能够清晰明确的表明资源的生命周期。

2. std::shared_ptr

std::shared_ptr 是一种智能指针,它能够记录多少个 shared_ptr 共同指向一个对象,从而消除显示的调用 delete,当引用计数变为零的时候就会将对象自动删除。

std::make_shared 用来消除显示的使用 new

std::make_shared 会分配创建传入参数中的对象,并返回这个对象类型的 std::shared_ptr指针。

std::shared_ptr 可以通过 get() 方法来获取原始指针,通过 reset() 来减少一个引用计数,并通过 get_count()来查看一个对象的引用计数。

3. std::unique_ptr

std::unique_ptr 是一种独占的智能指针,它禁止其他智能指针与其共享同一个对象,从而保证代码的安全:

make_unique 并不复杂,C++11 没有提供 std::make_unique,可以自行实现:

template<typename T, typename ...Args>
std::unique_ptr<T> make_unique( Args&& ...args ) {
return std::unique_ptr<T>( new T( std::forward<Args>(args)... ) );
}

既然是独占,换句话说就是不可复制。但是,可以利用 std::move 将其转移给其他的 unique_ptr

4. std::weak_ptr

std::shared_ptr 依然存在着资源无法释放的问题

智能指针互相引用,对象指向的内存区域引用计数不为零,而外部无法找到这块区域,造成了内存泄露,如图所示:

std::weak_ptr是一种弱引用(相比较而言 std::shared_ptr 就是一种强引用)。弱引用不会引起引用计数增加,当换用弱引用时候,最终的释放流程如下图所示:

std::weak_ptr 没有 * 运算符和 -> 运算符,所以不能够对资源进行操作,它的唯一作用就是用于检查 std::shared_ptr 是否存在,expired() 方法在资源未被释放时,会返回 true,否则返回 false

正则表达式

1.简介

正则表达式描述了一种字符串匹配的模式。一般使用正则表达式主要是实现下面三个需求:

  1. 检查一个串是否包含某种形式的子串;
  2. 将匹配的子串替换;
  3. 从某个串中取出符合条件的子串。

正则表达式是由普通字符(例如 a 到 z)以及特殊字符组成的文字模式。模式描述在搜索文本时要匹配的一个或多个字符串。正则表达式作为一个模板,将某个字符模式与所搜索的字符串进行匹配。

普通字符

普通字符包括没有显式指定为元字符的所有可打印和不可打印字符。这包括所有大写和小写字母、所有数字、所有标点符号和一些其他符号。

特殊字符

特殊字符是正则表达式里有特殊含义的字符,也是正则表达式的核心匹配语法。参见下表:

特别字符 描述
$ 匹配输入字符串的结尾位置。
(,) 标记一个子表达式的开始和结束位置。子表达式可以获取供以后使用。
* 匹配前面的子表达式零次或多次。
+ 匹配前面的子表达式一次或多次。
. 匹配除换行符 \n 之外的任何单字符。
[ 标记一个中括号表达式的开始。
? 匹配前面的子表达式零次或一次,或指明一个非贪婪限定符。
\ 将下一个字符标记为或特殊字符、或原义字符、或向后引用、或八进制转义符。例如,n 匹配字符 n\n 匹配换行符。序列 \\ 匹配 '\' 字符,而 \( 则匹配 '(' 字符。
^ 匹配输入字符串的开始位置,除非在方括号表达式中使用,此时它表示不接受该字符集合。
{ 标记限定符表达式的开始。
\ 指明两项之间的一个选择。

限定符

限定符用来指定正则表达式的一个给定的组件必须要出现多少次才能满足匹配。见下表:

字符 描述
* 匹配前面的子表达式零次或多次。例如,foo* 能匹配 fo 以及 foooo* 等价于 {0,}
+ 匹配前面的子表达式一次或多次。例如,foo+ 能匹配 foo 以及 foooo,但不能匹配 fo+ 等价于 {1,}
? 匹配前面的子表达式零次或一次。例如,Your(s)? 可以匹配 YourYours 中的 Your? 等价于 {0,1}
{n} n 是一个非负整数。匹配确定的 n 次。例如,f{2} 不能匹配 for 中的 o,但是能匹配 foo 中的两个 o
{n,} n 是一个非负整数。至少匹配 n 次。例如,f{2,} 不能匹配 for 中的 o,但能匹配 foooooo 中的所有 oo{1,} 等价于 o+o{0,} 则等价于 o*
{n,m} mn 均为非负整数,其中 n 小于等于 m。最少匹配 n 次且最多匹配 m 次。例如,o{1,3} 将匹配 foooooo 中的前三个 oo{0,1} 等价于 o?。注意,在逗号和两个数之间不能有空格。

2. std::regex 及其相关

对字符串内容进行匹配的最常见手段就是使用正则表达式。

C++11 正式将正则表达式的的处理方法纳入标准库的行列,从语言级上提供了标准的支持,不再依赖第三方。

C++11 提供的正则表达式库操作 std::string 对象,模式 std::regex (本质是 std::basic_regex)进行初始化,通过 std::regex_match 进行匹配,从而产生 std::smatch (本质是 std::match_results 对象)。

  • [a-z]+\.txt: 在这个正则表达式中, [a-z] 表示匹配一个小写字母, + 可以使前面的表达式匹配多次,因此 [a-z]+ 能够匹配一个小写字母组成的字符串。在正则表达式中一个 . 表示匹配任意字符,而 \. 则表示匹配字符 .,最后的 txt 表示严格匹配 txt 则三个字母。因此这个正则表达式的所要匹配的内容就是由纯小写字母组成的文本文件。

std::regex_match 用于匹配字符串和正则表达式,有很多不同的重载形式。最简单的一个形式就是传入 std::string 以及一个 std::regex 进行匹配,当匹配成功时,会返回 true,否则返回 false。例如:

#include <iostream>
#include <string>
#include <regex>

int main() {
std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
// 在 C++ 中 `\` 会被作为字符串内的转义符,为使 `\.` 作为正则表达式传递进去生效,需要对 `\` 进行二次转义,从而有 `\\.`
std::regex txt_regex("[a-z]+\\.txt");
for (const auto &fname: fnames)
std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}

另一种常用的形式就是依次传入 std::string/std::smatch/std::regex 三个参数,其中 std::smatch 的本质其实是 std::match_results,在标准库中, std::smatch 被定义为了 std::match_results<std::string::const_iterator>,也就是一个子串迭代器类型的 match_results。使用 std::smatch 可以方便的对匹配的结果进行获取,例如:

std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
if (std::regex_match(fname, base_match, base_regex)) {
// sub_match 的第一个元素匹配整个字符串
// sub_match 的第二个元素匹配了第一个括号表达式
if (base_match.size() == 2) {
std::string base = base_match[1].str();
std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
std::cout << fname << " sub-match[1]: " << base << std::endl;
}
}
}

以上两个代码段的输出结果为:

foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar

并发与并行

1、std::thread 线程

std::thread 用于创建一个执行的线程实例,所以它是一切并发编程的基础,使用时需要包含 <thread> 头文件,它提供了很多基本的线程操作,例如 get_id()来获取所创建线程的线程 ID,例如使用 join() 来加入一个线程等等,

std::thread t(foo);
t.join();

2. std::mutex, std::unique_lock 互斥量和临界区

C++11引入了 mutex 相关的类,其所有相关的函数都放在 <mutex> 头文件中。

std::mutex 是 C++11 中最基本的 mutex 类,通过实例化 std::mutex 可以创建互斥量,而通过其成员函数 lock() 可以仅此能上锁,unlock() 可以进行解锁。

在实际编写代码的过程中,最好不去直接调用成员函数,因为调用成员函数就需要在每个临界区的出口处调用 unlock(),当然,还包括异常。

C++11 还为互斥量提供了一个 RAII 语法的模板类 std::lock_gurad。RAII 在不失代码简洁性的同时,很好的保证了代码的异常安全性。

在 RAII 用法下,对于临界区的互斥量的创建只需要在作用域的开始部分,由于 C++保证了所有栈对象在声明周期结束时会被销毁,所以这程序是否正常返回都会引发堆栈回退,自动调用 unlock()

std::unique_lock 则相对于 std::lock_guard 出现的,std::unique_lock 更加灵活,std::unique_lock 的对象会以独占所有权(没有其他的 unique_lock 对象同时拥有某个 mutex 对象的所有权)的方式管理 mutex 对象上的上锁和解锁的操作。所以在并发编程中,推荐使用 std::unique_lock

3. std::future, std::packaged_task 期物

std::future 则是提供了一个访问异步操作结果的途径

试想,如果主线程 A 希望新开辟一个线程 B 去执行某个预期的任务,并返回一个结果。而这时候,线程 A 可能正在忙其他的事情,无暇顾及 B 的结果,所以我们会希望能够在某个特定的时间获得线程 B 的结果。

通常的做法是:创建一个线程A,在线程A里启动任务 B,当准备完毕后发送一个事件,并将结果保存在全局变量中。而主函数线程 A 里正在做其他的事情,当需要结果的时候,调用一个线程等待函数来获得执行的结果。

而 C++11 提供的 std::future 简化了这个流程,可以用来获取异步任务的结果。

此外,std::packaged_task 可以用来封装任何可以调用的目标,从而用于实现异步的调用。

#include <iostream>
#include <future>
#include <thread>

int main()
{
// 将一个返回值为7的 lambda 表达式封装到 task 中
// std::packaged_task 的模板参数为要封装函数的类型
std::packaged_task<int()> task([](){return 7;});
// 获得 task 的 future
std::future<int> result = task.get_future(); // 在一个线程中执行 task
std::thread(std::move(task)).detach(); std::cout << "Waiting...";
result.wait();
// 输出执行结果
std::cout << "Done!" << std:: endl << "Result is " << result.get() << '\n';
}

在封装好要调用的目标后,可以使用 get_future() 来获得一个 std::future 对象,以便之后实施线程同步。

4. std::condition_variable 条件变量

std::condition_variable 是为了解决死锁而生的。当互斥操作不够用而引入的。
condition_variable 实例被创建出现主要就是用于唤醒等待线程从而避免死锁。std::condition_variablenotify_one() 用于唤醒一个线程;notify_all() 则是通知所有线程。

5. 原子操作与内存模型

  • 提供线程间自动的状态转换,即锁住这个状态
  • 保障在互斥锁操作期间,所操作变量的内存与临界区外进行隔离

C++11,中引入了 std::atomic 模板,使得我们能实例化原子类型,并将一个原子写操作从一组指令,最小化到单个 CPU 指令。并为整数或浮点数的原子类型提供了基本的数值成员函数。

一致性模型

线性一致性:又称强一致性或原子一致性。它要求任何一次读操作都能读到某个数据的最近一次写的数据,并且所有线程的操作顺序与全局时钟下的顺序是一致的。

顺序一致性:同样要求任何一次读操作都能读到数据最近一次写入的数据,但未要求与全局时钟的顺序一致

因果一致性:要求进一步降低,只需要有因果关系的操作顺序得到保障,而非因果关系的操作顺序则不做要求

最终一致性:是最弱的一致性要求,只保障某个操作在未来的某个时间节点上会被观察到,但并未要求被观察到的时间。

内存顺序

C++11 为了追求极致的性能,实现各种强度要求的一致性,为原子操作定义了六种不同的内存顺序 std::memory_order 的选项,表达了四种多线程间的同步模型:

  1. 宽松模型:在此模型下,单个线程内的原子操作都是顺序执行的,不允许指令重排,但不同线程间原子操作的顺序是任意的。类型通过 std::memory_order_relaxed 指定。
  2. 释放/消费模型:在此模型中,开始限制进程间的操作顺序,如果某个线程需要修改某个值,但另一个线程会对该值的某次操作产生依赖,即后者依赖前者。
  3. 释放/获取模型:在此模型下,进一步加紧对不同线程间原子操作的顺序的限制,发生在释放操作之前的所有写操作,对其他线程的任何获取操作都是可见的
  4. 顺序一致模型:在此模型下,原子操作满足顺序一致性,进而可能对性能产生损耗。可显式的通过 std::memory_order_seq_cat 进行指定。

杂项

1. long long int (64bit)

2. noexcept 的修饰和操作

C++11 将异常的声明简化为以下两种情况:

  1. 函数可能抛出任何异常
  2. 函数不能抛出任何异常

并使用 noexcept 对这两种行为进行限制

void may_throw(); // 可能抛出异常
void no_throw() noexcept; // 不可能抛出异常

使用 noexcept 修饰过的函数如果抛出异常,编译器会使用 std::terminate() 来立即终止程序运行。

noexcept 还能够做操作符,用于操作一个表达式,当表达式无异常时,返回 true,否则返回 false

noexcept 修饰完一个函数之后能够起到封锁异常扩散的功效,如果内部产生异常,外部也不会触发。

3. 字面量

原始字符串字面量

C++11 提供了原始字符串字面量的写法,可以在一个字符串前方使用 R 来修饰这个字符串,同时,将原始字符串使用括号包裹。

std::string str = R"(C:\\What\\The\\Fxxk)";

自定义字面量

C++11 引进了自定义字面量的能力,通过重载双引号后缀运算符实现:


// 字符串字面量自定义必须设置如下的参数列表
std::string operator"" _wow1(const char *wow1, size_t len) {
return std::string(wow1)+"woooooooooow, amazing";
}

std::string operator"" _wow2 (unsigned long long i) {
return std::to_string(i)+"woooooooooow, amazing";
}

int main() {
auto str = "abc"_wow1;
auto num = 1_wow2;
std::cout << str << std::endl;
std::cout << num << std::endl;
return 0;
}

自定义字面量支持四种字面量:

  1. 整型字面量:重载时必须使用 unsigned long longconst char *、模板字面量算符参数,在上面的代码中使用的是前者;
  2. 浮点型字面量:重载时必须使用 long doubleconst char *、模板字面量算符;
  3. 字符串字面量:必须使用 (const char *, size_t) 形式的参数表;
  4. 字符字面量:参数只能是 char, wchar_t, char16_t, char32_t 这几种类型。

4. 内存对齐

C++11 引入了两个新的关键字和来支持对内存对齐进行控制。

  • 关键字 alignof能够获得一个与平台相关的 std::size_t 类型的值,用于查询该平台的对齐方式。
  • 关键字 alignas重新修饰某个结构的对齐方式,自定定义结构的对齐方式