《现代C++语言核心特性解析》阅读笔记

这段时间因为个人兴趣爱好一直在网上搜寻有没有一本有关于现代C++相关的书籍。为什么我会需要一本这样的书籍呢?因为C++标准委员会只制定标准,但不会强制要求你该怎么做,而各家编译器也是自己支持自己的。这时候对于我来说就需要一本专门讨论现代C++的数据告诉我现代C++有哪些特性,该怎么用,诞生的原因以及编译器的支持情况。毕竟没人会希望自己写的优美的、现代化的、简洁的代码因为编译器的支持问题而需要修改回旧版的写法。

字符类型char16_t与char32_t

C++对于Unicode的支持很有限,虽然它在C++11标准中添加了新的字符类型char8_t和char16_t与char32_t用于对应Unicode字符集中的UTF-8、UTF-16和UTF-32两种编码方式,但是C++不提供std::cout对于Unicode字符集的支持。而且C++20也不支持将与上述字符类型对应的字符串u8string、u16string和u32string转换成string类型,虽然在C++17中你可以将任何 UTF-8 数据视为"字符"数据,这使得可以使用 std::regex、std::fstream、std::cout 等而不会丢失的性能。按照标准委员会的说法这是因为市面上有很多用于处理UTF的库,但就我个人的看法这只会导致C++高版本中大家都不使用该特性。

内联和嵌套命名空间

内联和嵌套命名空间是个好东西,就我个人的看法所有能有避免嵌套写法的都是好东西,不过相对来说内联命名空间的重要性会比嵌套命名空间大。因为内联命名空间可以帮助库作者无缝切换代码版本而无需库的使用者参与。

auto占位符

讨论auto的时候需要知道它和delctype的区别。
先了解auto的推导规则:

  1. 如果auto声明的变量是按值初始化,则推导出的类型会忽略cv限定符
  2. 使用auto声明变量初始化时,目标对象如果是引用,则引用属性会被忽略
  3. 使用auto和万能引用&&声明变量时,对于左值会将auto推导为引用类型。
  4. 使用auto声明变量,如果目标对象是一个数组或者函数,则auto会被推导为对应的指针类型
  5. 当auto关键字与列表初始化结合时:
    1. 直接使用列表初始化,列表中必须为单元素,否则无法编译,auto类型被推导为单元素的类型。
    2. 用等号加列表初始化,列表中可以包含单个或多个元素,auto类型被推导为std::initializer_list,其中T是元素类型。

再观察decltype(e)(其中e的类型为T)的推导规则:

  1. 如果e是一个未加括号的标识符表达式(结构化绑定除外)或者未加括号的类成员访问,则decltype(e)推断出的类型是e的类型T。如果不存在这样的类型,或者e是一组重载函数,则无法进行推导。
  2. 如果e是一个函数调用或者仿函数调用,那么decltype(e)推断出的类型是其返回值的类型。
  3. 如果e是一个类型为T的左值,则decltype(e)是T&。
  4. 如果e一个类型为T的将亡值,则decltype(e)是T&&。
  5. 除去以上情况,则decltype(e)是T。

通过对auto和decltype推导规则的学习,可以发现auto相比于decltype会忽略cv限定符,忽略引用属性。因此decltype相比于auto在很大程度上加强了C++的泛型能力。所以在C++14中引入了decltype(auto)用来告诉编译器用decltype的推导表达式规则来推导auto。

右值引用

虽然C++中将值的类型分为左值,将亡值和纯右值,但平时使用时 一般只需知道左值是一个指向特定内存的具有名称的值,右值则是不指向稳定内存地址的匿名值。
这里默认读者了解引用和指针的区别,跳过左值引用直接直接讨论右值引用。右值引用的一个特点是延长临时对象生命周期。比如如下代码:

# include <iostream>

class X {
public:
  X() { std::cout << "X ctor" << std::endl; }
  X(const X&x) { std::cout << "X copy ctor" << std::endl; }
  ~X() { std::cout << "X dtor" << std::endl; }
  void show() { std::cout << "show X" << std::endl; }
};

X make_x()
{
  X x1;
  return x1;
}

int main()
{
  X &&x2 = make_x();
  x2.show();
}

通过右值引用可以将函数make_x内部返回的临时对象生命周期延长到make_x函数外,且可以正常调用show函数。因此活用该特性可以实现减少对象复制,提升程序性能。

移动构造函数与移动赋值运算符

点击查看代码
class BigMemoryPool {
public:
  static const int PoolSize = 4096;
  BigMemoryPool() : pool_(new char[PoolSize]) {}
  ~BigMemoryPool()
  {
      if (pool_ != nullptr) {
            delete[] pool_;
      }
  }

  BigMemoryPool(BigMemoryPool&& other)
  {
      std::cout << "move big memory pool." << std::endl;
      pool_ = other.pool_;
      other.pool_ = nullptr;
  }

  BigMemoryPool& operator=(BigMemoryPool&& other)
  {
      std::cout << "move(operator=) big memory pool." << std::endl;
      if (pool_ != nullptr) {
            delete[] pool_;
      }
      pool_ = other.pool_;
      other.pool_ = nullptr;
      return *this;
  }

  BigMemoryPool(const BigMemoryPool& other) : pool_(new char[PoolSize])
  {
      std::cout << "copy big memory pool." << std::endl;
      memcpy(pool_, other.pool_, PoolSize);
  }

private:

  char *pool_;
};

将上述移动构造函数与移动赋值运算符和拷贝构造函数进行对比,可以发现移动语义和拷贝语义有一个很大的不同就是

  1. 它接受的是一个右值
  2. 不进行内存复制,而是直接修改指针
  3. 将实参对象的内存指针置空以防析构时影响内存的生命周期。

因此可以简要概括为移动语义的核心思想是通过转移实参对象的数据以达成构造目标对象的目的,也就是说实参对象是会被修改的。

将左值转换为右值

将左值转换为右值的目的是让左值可以使用移动语义,实际目的是在一个右值被转换为左值后可以在需要的时候将它再次转换为右值。
具体做法如下:

1. static_cast<Type &&>(lvalue);
2. std::move(lvalue);

引用折叠与万能引用

引用折叠规则

类模板型 T实际类型 最终类型
T& R R&
T& R& R&
T& R&& R&
T&& R R&&
T&& R& R&
T&& R&& R&&

由上图可以很清楚知道万能引用T&&或者auto&&传入类型时左值引用时推导类型就是左值引用,传入类型为非引用或者右值引用时推导类型就是右值引用。但是,这有什么用呢?

完美转发

完美转发就是万能引用的一个最经典用途:

#include <iostream>
#include <string>

template<class T>
void show_type(T t)
{
  std::cout << typeid(t).name() << std::endl;
}

template<class T>
void perfect_forwarding(T &&t)//例1,万能引用
{
  show_type(static_cast<T&&>(t));//作为形参的t是左值。为了让转发将左右值的属性也带到目标函数中,这里需要进行类型转换。t为左值时T被推导为左值引用,此时T&&为左值引用;t为右值时T被推导为右值,T&&为右值引用。
}

template<class T>
void perfect_forwarding(T &&t)//例2,万能引用
{
  show_type(std::forward<T>(t));//在C++11的标准库中提供了一个std::forward函数模板,在函数内部也是使用static_cast进行类型转换,但更明确
}

std::string get_string()
{
  return "hi world";
}

int main()
{
  std::string s = "hello world";
  perfect_forwarding(s);
  perfect_forwarding(get_string());
}

C++20后对隐式移动的优化

可隐式移动的对象

  1. return或者co_return语句中的返回对象是函数或者lambda表达式中的对象或形参。
  2. throw语句中抛出的对象是函数或try代码块中的对象。

lambda表达式

如果你愿意翻看lambda编译后的代码,可以发现lambda其实就是仿函数。不过可能有些读者不太了解什么是仿函数。我下面举一个例子:

class Bar//仿函数
{
public:
    Bar(int x, int y) : x_(x), y_(y) {}
    int operator () ()
    {
        return x_ * y_;
    }
private:
int x_;
int y_;
};

int main()
{
    int x = 5, y = 8;
    auto foo = [=] { return x * y; };//lambda表达式
    Bar bar(x, y);
    std::cout << "foo() = " << foo() << std::endl;
    std::cout << "bar() = " << bar() << std::endl;
}

比较上面仿函数和用GCC输出的lambda表达式的GIMPLE的中间代码的核心部分:

main ()
{
  {
    int x;
    int y;
    struct __lambda0 foo;
    int z;
    try
      {
        foo.__x = x;
        foo.__y = y;
        z = main()::<lambda()>::operator() (&foo);
      }
  }
}

main()::<lambda()>::operator() (const struct __lambda0 * const __closure)
{
  int D.39255;
  const int x [value-expr: __closure->__x];
  const int y [value-expr: __closure->__y];
  _1 = __closure->__x;
  _2 = __closure->__y;
  D.39255 = _1 * _2;
  return D.39255;
}

可以发现仿函数和lambda本质上都是闭包,因此lambda只是一种语法糖,没有什么一定只能使用它来编写的场景,但是相比仿函数lambda确实能提高编写的效率。

广义捕获

  1. \(\color{#4ABDAC}{简单捕获}\)
    1. [identifier]——捕获lambda表达式定义作用域的变量的值
      \(\color{#FC4A1A}{变体}\):[=]——捕获lambda表达式定义作用域的全部变量的值,包括this
    2. [&identifier]——捕获lambda表达式定义作用域的变量的引用
      \(\color{#FC4A1A}{变体}\):[&]——捕获lambda表达式定义作用域的全部全部变量的值,包括this
    3. [this]——捕获this指针,实现使用this类型的成员变量和函数
  2. \(\color{#4ABDAC}{初始化捕获}\):捕获列表是一个赋值表达式,通过等号跨越了两个作用域,左侧为lambda表达式所在作用域,右侧为外部作用域。

初始化捕获的其中一个使用场景是在异步调用时复制this对象,防止lambda表达式被调用时因原始this对象被析构造成未定义的行为

class Work
{
  private:
    int value;

  public:
    Work() : value(42) {}
    std::future<int> spawn()
    {
        return std::async([=, tmp = *this]() -> int { return tmp.value; });
    }
};

我们可以使用*this来代替tmp = *this(C++17)
此时该lambda从
[=, tmp = *this]() -> int { return tmp.value; }
变成
[=, *this]() -> int { return value; }
此时lambda表达式不需要通过tmp而是可以直接访问对象成员。
在lambda引入this后,为避免程序员混淆this和this,现在捕获列表[=]不再隐式捕获指针this了,当需要捕获this时使用[=,this](C++20)

泛型lambda表达式

  1. 利用auto占位符的泛型(C++14)
    int main()
    {
    	auto foo = [](auto a) { return a; };
    	int three = foo(3);
    	char const* hello = foo("hello");
    }
    
  2. 模板语法的泛型(C++20)
    []<typename T>(T t) {}
    

无状态lambda表达式

lambda表达式如何区分是否有状态:如果lambda捕捉上下文变量形成闭包则为有状态,否则则为无状态
无状态lambda表达式的特性:

  1. 可以隐式转换为函数指针
  2. 允许了无状态lambda表达式类型的构造和赋值(C++20)

位域

C语言声明拥有以位数表示的显式宽度的成员。
位域

列表初始化

C++11中用以支持可以使用大括号初始化标准容器

std::vector<T> arr{t1,t2,t3,...};

隐式缩窄转换

此外注意使用列表初始化可能带来隐式缩窄问题:

  1. 浮点数转整数
  2. 长的类型长度的浮点数转短类型长度的浮点数。除非转换前是常数或者转换前的数在转换后变量的存储范围
  3. 整数或非强枚举类型转浮点数。除非转换前是常数或者转换前的数在转换后变量的存储范围且可以转换回原来的类型
  4. 整数或非强枚举类型转浮点数转另一个类型的整数。除非转换前是常数或者转换前的数在转换后变量的存储范围

列表初始化的优先级问题

列表初始化既可以支持普通的构造函数,也能够支持std::initializer_list为形参的构造函数,若上述两种构造函数同时存在则std::initializer_list为优先的参数构造函数。

聚合类型的指定初始化

  1. 如图所示,只能初始化聚合类型,指定的数据类型不能为静态数据成员,每个成员最多只能初始化一次,必须按照声明顺序构造。

    struct Point {
      int x;// int x = 100;
      int y;
    };
    
    Point p{ .x = 4, .y = 2 };
    //Point p{ .y = 4, .y = 2 };编译报错
    
  2. 此外联合体的数据成员只能初始化一次,如

    union u {
      int a; 
      const char* b; 
    };
    
    u f = { .a = 1 };        // 编译成功
    u g = { .b = "asdf" };   // 编译成功
    u h = { .a = 1, .b = "asdf" };    // 编译失败,同时指定初始化联合体中的多个数据成员
    
  3. 不能嵌套指定初始化数据成员,如果确实需要需要使用这种方法,如

    struct Line {
      Point a;
      Point b;
    };
    
    Line l{ .a.y = 5 }; // 编译失败, .a.y = 5访问了嵌套成员,不符合C++标准
    Line l{ .a {.y = 5} };//编译成功
    
  4. 此外C++20还禁止了混用数据成员的初始化以及数组初始化

    Point p{ .x = 2, 3 };    // 编译失败,混用数据成员的初始化
    int arr[3] = { [1] = 5 };    // 编译失败,与lambda冲突
    

显式默认和显式删除

显式默认

class NonTrivial
{
  int i;
public:
  NonTrivial(int n) : i(n), j(n) {}
  NonTrivial() {}
  int j;
};

class Trivial
{
  int i;
public:
  Trivial(int n) : i(n), j(n) {}
  Trivial() = default;
  int j;
};

int main()
{
  Trivial a(5);
  Trivial b;
  b = a;
  std::cout << "std::is_trivial_v<Trivial>   : " << std::is_trivial_v<Trivial> << std::endl;
  std::cout << "std::is_trivial_v<NonTrivial> : " << std::is_trivial_v<NonTrivial> << std::endl;
}

Trivial类虽然定义了一个构造函数Trivial(int n),它导致编译器抑制添加默认构造函数,于是Trivial类转变为非平凡类型。但是将构造函数NonTrivial() {}替换为显式默认构造函数Trivial() = default,类就从非平凡类型恢复到平凡类型了。

显式删除

class NonCopyable
{
public:
  NonCopyable() = default;                       // 显式添加默认构造函数
  NonCopyable(const NonCopyable&) = delete;      // 显式删除复制构造函数
  NonCopyable& operator=(const NonCopyable&) = delete;  // 显式删除复制赋值
                                                        // 运算符函数
};

int main()
{
  NonCopyable a, b;
  a = b;              //编译失败,复制赋值运算符已被删除
}

删除了类NonCopyable的复制构造函数和复制赋值运算符函数,这样就禁止了该类对象相互之间的复制操作。
显式删除还可以用于类的new运算符和类析构函数。

  1. 删除new运算符
    void * operator new(std::size_t) = delete;
    
    显式删除特定类的new运算符可以阻止该类在堆上动态创建对象,换句话说它可以限制类的使用者只能通过自动变量、静态变量或者全局变量的方式创建对象
  2. 删除类的析构函数
    struct type 
    {
      ~type() = delete;
    };
    
    与删除new运算符相反,它阻止类通过自动变量、静态变量或者全局变量的方式创建对象,但是却可以通过new运算符创建对象。当然通过new运算符创建的对象也无法通过delete销毁

非受限联合类型

在旧的C++标准中联合类型的成员变量的类型不能是一个非平凡类型,新版本中解除了该限制,但要求程序员至少提供联合类型的构造和析构函数。但因为程序员无法在编写构造函数时就确定哪个成员被真正的使用也无法自动调用成员的析构函数,因此推荐让联合类型的构造和析构函数为空,在需要使用时调用对应联合成员的构造和析构函数。如代码所示:

#include <iostream>
#include <string>
#include <vector>

union U
{
  U() {}
  ~U() {}
  int x1;
  float x2;
  std::string x3;
  std::vector<int> x4;
};

int main()
{
  U u;
  new(&u.x3) std::string("hello world");
  std::cout << u.x3 << std::endl;
  u.x3.~basic_string();

  new(&u.x4) std::vector<int>;
  u.x4.push_back(58);
  std::cout << u.x4[0] << std::endl;
  u.x4.~vector();
}

联合的静态成员变量初始化和类的静态成员变量初始化相似。

委托构造函数

在这里不讨论委托构造函数的使用,只讨论要注意的点:

  1. 不要递归循环委托

  2. 如果一个构造函数为委托构造函数,那么其初始化列表里就不能对数据成员和基类进行初始化:
    原因很简单,C++标准规定一旦有一个类型构造函数完成,则其构造的对象也已经构造完成。

  3. 委托构造函数的执行顺序是先执行代理构造函数的初始化列表,然后执行代理构造函数的主体,最后执行委托构造函数的主体

  4. 如果在代理构造函数执行完成后,委托构造函数主体抛出了异常,则自动调用该类型的析构函数,原因由3可知

继承构造函数

本来不想讲继承构造函数,但是看了一下和委托构造函数一样有需要特别说明的点:

  1. 用的时候才生成
  2. 不继承基类的默认构造和复制构造函数
  3. 由2与继承构造函数不会影响派生类默认构造函数的隐式说明可知,派生类依然会自动生成默认构造函数
  4. 继承构造函数的基类构造函数不能为私有

强枚举类型

强枚举类型在保留之前枚举类型特性的前提下增加了三个新特性:

  1. 枚举标识符属于强枚举类型的作用域。
  2. 枚举标识符不会隐式转换为整型。
  3. 能指定强枚举类型的底层类型,底层类型默认为int类型。

强枚举类型具体写法是在枚举类型enum关键字后面加上class,第三个特性的具体使用方法为

enum struct|class name { enumerator = constexpr , enumerator = constexpr , ... }
enum struct|class name : type { enumerator = constexpr , enumerator = constexpr , ... }
enum struct|class name ;
enum struct|class name : type ;

C++17开始允许使用列表初始化
C++20开始扩展了using功能:using enum nested-name-specifier(optional) name ;

聚合类型

在讨论C++17对于聚合类型定义做出的修改之前我们应该先回过头提出一个问题——什么是聚合类型?

一般而言聚合是和继承、组合、关联放在一起讨论的工程问题,它并不是由几个委员会的专家一拍脑袋就给出来的,而是经过工程实践被加入C++语法中的。

它就相当于“我”有一台电脑,但是没了电脑的“我”依然是“我”,而没了“我”的的电脑也依然是电脑,虽然互有统属关系,但达不到一荣俱荣,一损俱损的地步。

现在回过头来看C++中的聚合类型,C++怎么定义聚合类型呢?只需要满足以下要求:
数组类型
符合以下条件的类类型(通常是 structunion
没有私有或受保护的直接 \(\color{green}{(C++17起)}\)非静态数据成员

没有用户声明的构造函数$\color{green}{(C++11前)}$
没有用户提供、继承或 explicit 的构造函数$\color{green}{(C++11起)}$
$\color{green}{(C++20前)}$
没有用户声明或继承的构造函数$\color{green}{(C++20起)}$

没有虚、私有或受保护 \(\color{green}{(C++17起)}\)的基类
没有虚成员函数

没有默认成员初始化器$\color{green}{(C++11起)}$
$\color{green}{(C++14前)}$

聚合体的 元素 有:

对于数组,按下标顺序包含所有数组元素,或者,对于类,按声明顺序包含所有不是匿名位域的非静态数据成员。$\color{green}{(C++17前)}$
对于类,按声明顺序包含所有直接基类,然后按声明顺序包含所有不是匿名位域或匿名联合体成员的非静态数据成员。$\color{green}{(C++17起)}$

聚合类型的初始化

由上图对聚合类型的要求可知,聚合类型由于没有构造函数,所以使用另一种特殊的方式进行初始化,这种方式又被称为直接初始化。

聚合由于C++17,C++20的多次改版导致代码可能出现兼容性问题,这在C++历史上也是比较少出现的。不过具体的解决方法我这里不展开讲。

重写、重载、隐藏

重写(override):可以类比为派生类覆盖了基类的具有相同函数名和函数签名的虚函数
重载(overload):可以类比为同一个类内具有相同函数名但是函数签名不同
隐藏(overwrite):相比重写区别在于函数签名不同以及不要求基类函数为虚函数,且这里既然说的是隐藏就可以借用using将基类函数重新使用。

override说明符

可以在派生类的函数尾部加上override说明符,此时编译器就会在对应基类寻找对应被重写的虚函数,如果找不到则直接在编译期报错而不会将问题延后到执行排查时。

final说明符

final与override相反,它被用在基类的虚函数尾部用于阻止被重写,此外它还能被直接用于声明类来阻止这个类作为基类被继承。

基于范围的for循环

for ( range_declaration : range_expression ) loop_statement

上述代码就是基于范围的for循环语法,这段代码剔除了以往for循环包含的初始化语句、条件表达式以及更新表达式,取而代之的是范围声明(一个变量的声明)和范围表达式(可以是数组或对象),基于范围的for循环对于范围声明和范围表达式有如下要求:

  • 范围声明:要求类型是范围表达式中元素的类型或者元素类型的引用

  • 范围表达式:如果是对象则必须满足以下两点中的任意一个

    • 对象类型定义了begin和end成员函数
    • 定义了以对象类型为参数的begin和end普通函数

支持基于范围的for循环的类

要让自定义类型支持基于范围的for循环,需要满足以下需求

  1. 参照对于范围表达式的要求,该类型必须有一组和其类型相关的beginend函数,可以是该类型的成员函数也可以是普通函数
  2. beginend函数需要返回一组类似迭代器的对象,并且这组对象必须支持operator *operator !=operator ++(前缀版本)运算符函数

关于为什么需要支持上述三种运算符函数这里可以查看C++17标准具体实现的伪代码

{
    auto && __range = range_expression;
    auto __begin = begin_expr;
    auto __end = end_expr;
    for(; __begin != __end; ++_begin) {
        range_declaration = *__begin;
        loop_statement
    }
}

__begin__end获取了begin、end迭代器,operator *用于编译器生成解引用代码,operator !=用于生成循环条件代码,operator ++用于更新迭代器。

支持初始化语句的if和switch

可以认为这类语法的作用是在执行条件语句之前先执行一个初始化语句,但由于if初始化语句中声明的变量拥有和整个if结构一样长的生命周期,所以需要注意如else if使用的初始化语句的生命周期只存在于else if以及后续存在的else if和else语句中。而switch则没有这种情况,生命周期能直接贯穿整个switch结构。

static_assert声明

断言会直接显示错误信息并终止程序

运行时断言

程序运行时才能触发,也就是必须让程序运行到断言代码的位置才能触发断言

静态断言

  1. 所有处理必须在编译期间执行,不允许有空间或时间上的运行时成本。
  2. 它必须具有简单的语法。
  3. 断言失败可以显示丰富的错误诊断信息。
  4. 它可以在命名空间、类或代码块内使用。
  5. 失败的断言会在编译阶段报错。
语法
static_assert ( 布尔常量表达式 , 消息 ) (C++11 起)
static_assert ( 布尔常量表达式 ) (C++17 起)

要求是布尔常量表达式是因为编译器无法计算运行时才能确定结果的表达式。

结构化绑定

attr(optional) cv-auto ref-qualifier(optional) identifier-list expression[] = ;	(1)	

attr(optional) cv-auto ref-qualifier(optional) identifier-list expression[]{ };	(2)	

attr(optional) cv-auto ref-qualifier(optional) identifier-list expression[]( );	(3)	
  • attr - 任意数量的属性的序列
  • cv-auto - 可有 cv 限定的 auto 类型说明符,也可以包含存储类说明符 staticthread_local;在 cv 限定符中包含 volatile 是被弃用的 (C++20 起)
  • ref-qualifier - & 或 && 之一
  • identifier-list - 此声明所引入的各标识符的逗号分隔的列表
  • expression - 顶层没有逗号运算符的表达式(文法上为赋值表达式),且具有数组或非联合类之一的类型。如果 表达式 涉及任何来自 标识符列表 的名字,那么声明非良构。
posted @ 2022-01-15 23:19  丸子球球  阅读(562)  评论(0编辑  收藏  举报