The Cherno C++ 系列教程笔记(中)

1,164 阅读12分钟

本篇笔记将记录 Cherno 的 C++ 系列的所有令初学 C++ 者眼前一亮的知识点,而简单的语法知识和基本操作在此不做笔记,强烈建议新手完整地观看全系列教程。

注意,每 P 的知识点不是孤立的,可能会在后面更加深入地、全面地拓展讲解,有些简单的知识点可能会更多地讲底层和优化,因此都是值得认真学习和细细琢磨的。

P36. C++ 初始化成员列表

初始化成员列表的好处是:

  1. 代码风格更简洁明了。
  2. 能避免类两次初始化浪费性能

如下代码所示,在无参数初始化 Entity 时,其私有成员变量 m_Example 实际上会先调用默认构造器,然后再在 Entity 的默认构造器中构造一次有参的。也就是说 m_Example 实际上会被构造两次,这显然是性能浪费。如果将该初始化过程改为初始化成员列表中进行,就不会出现这种情况了。

class Entity{
private:
    std::string m_Name;
    Example m_Example;
public:
    Entity(){
        m_Name = std::string("Unkown");
        m_Example = Example(8);
    }
    Entity(std::string name){
        m_Name = name;
    }
}
class Example{
public:
    Example(){
        std::cout << "default" << std::endl;
    }
    Example(int x){
        std::cout << x << std::endl;
    }
}

P38. 创建对象

C++ 中创建一个对象,可以分为两种,一种是在栈上创建,一种是在堆上创建。其中栈上创建的对象其作用域只在当前作用域中存活,如果离开了该作用域栈,那么函数栈上的内存空间就会被释放,变量以及其所占用的内存空间都一并被释放掉。但如果是在堆上创建对象,只要你没有将其所占用的内存空间主动删去,即使堆内存的指针已经被作用域结束后释放掉,但堆上的内存空间依旧会存在。

此外相比于栈的内存空间,堆的内存空间非常大,因此如果创建的对象数据极为庞大,那么无论如何都要在堆上创建对象。相应地,堆上创建对象比栈上创建对象耗时高,因此也不要保留 JAVA/C# 开发者喜欢用 new 创建对象的习惯,你需要谨慎地在堆上创建对象。

//在栈上创建对象
Entity entity;
Entity entity("Cherno");
Entity entity = Entity("Cherno");

//在堆上创建对象
Entity* entity = new Entity;
Entity* entity = new Entity("Cherno")

当我们创建一个对象时,即使该类型中什么也没有,也会占用一个字节的内存,以用于寻址。如果有变量,那么该类型的实例所占内存大小完全取决于变量的内存大小之和。

P39. C++ 中的 new 关键字

new 做了什么

使用 new 关键字来创建对象时,他会判断该类型需要多大的空间,例如 int 类型,需要 4 个字节的空间。然后其会向 C 标准库申请内存空间。也就是说我们需要在内存中寻找有 4 个字节大小的连续内存空间。一旦找到了合适的空间,就会返回该空间的地址(指针),并在该空间存储数据、读写访问等等。

在堆内存中寻找空间实际上是通过堆内存中的一个空闲列表来进行:通过维护这个列表来为创建的新变量寻找适合的内存地址。

int* i = new int;
int* array = new int[5];

new 的前世今生

new 关键字实际上是一个操作符,这意味着我们可以重载他的行为,以 size 作为参数,返回一个指向那个分配的内存地址的 void* 类型指针。new 所做的事情依赖于 C++ 库,如果自己写 C++ 编译器和库,理论上你可以让他做任何事。

但是一般地,调用 new 就会调用隐藏在里面的 C 函数 malloc() 他代表了内存分配。也就是说 new 等价于调用一个传入类型内存大小的 malloc() 函数,并将返回值的 void* 类型指针转为指定类型的指针。但是 new 还会调用该类型的构造函数,而 malloc 只分配了内存并返回指向该内存的指针。

Entity* e = new Entity();
//equals
Entity* e = (Entitiy*)malloc(sizeof(Entity));

记得要 delete

必须要记住的是,使用 new 创建的对象必须记得用 delete 释放其内存。堆上的内存不会主动释放,这并不是一件完全的好事,内存空间没有被释放掉这意味着内存溢出的可能。

使用 delete 关键字可以释放指针指向的堆内存空间,其调用了 C 语言的底层函数 free()。而在堆上创建一维数组时:int* array = new int[];,实际上调用的是另一个函数 new[],因此在 delete 时要这么用:delete[] array;

placement new

Placement New 就是由决定你决定类型实例在哪块内存上创建,其只调用构造函数。

Entity* e= new(0) Entity();

P40. 隐式转换和 C++ 中的 explicit 关键字

除了前两者这种常见的构造对象的方式,还有直接等于的形式,之所以能够直接赋值是因为它隐式地将其转换成了你所需要的类型,当然前提是有对应类型参数的构造器。

class Entity{
public:
    Entity(int age){}
    Entity(std::string name){}
}

Entity a("Cherno");
Entity b = Entity(12);
Entity a = "Cherno";
Entity b = 12;

如果一个函数参数是以上类型,编译器会将整形参数 12 隐式转换为一个 Entity 类型,然后传入,但如果是穿入字符串,他需要将默认为 const char[] 类型的字符串先转为 std::string 类型的字符串再转为 Entity 类型,两次转换才能传入,这是不被允许的,因此我们需要将字符串包在 std::string 类型的构造器或者 Entity 的构造器中。

void Print(const Entity& e){}
Print(12);
Print("Cherno");//no
Print(std::string("Cherno"));//yes
Print(Entity("Cherno"))//yes


explicit 关键字只能用于修饰构造器函数,被修饰的函数意味着禁止进行隐式转化,使用该参数类型的构造器时必须以显式的方式创建对象。

class Entity{
public:
    explicit Entity(int age){}
    Entity(std::string name){}
}
Entity a(12);//no
Entity b = 12;//no

P44. 智能指针

unique_ptr

智能指针是原始指针的包装。unique_ptr 智能指针的构造器是 explicit 修饰的,因此不能使用隐式转换。而使用 make_unque<type>() 的形式来构造对象是最为推荐的,如果构造函数抛出异常这种形式稍微安全一些,失败了也不会得到一个没有引用的悬空指针,从而导致内存泄漏。

std::unique_ptr<Entity> entity = std::make_unque<Entity>();

unique_ptr 智能指针也不能被复制,因为只要作用域结束了,在堆上分配的内存也会被释放掉,因此在该指针在实现上强制地将拷贝构造函数和拷贝构造符都删除,避免错误的行为。

unique_ptr(const _Myt&) = delete;
_Myt& operator=(const _Myt&) = delete;

shared_ptr

shared_ptr 实现的方式取决于编译器和在编译器中使用地标准库。但基本都是引用计数法:引用计数会追踪指针有多少个引用,一旦数量为零就会删除指针所指向的内存空间。

unique_ptr 由于异常安全的原因而不推荐使用 new 的方式创建智能指针不同,因为 shared_ptr 需要分配另一块内存,称为控制块,用来存储引用计数,所以如果使用 new 的形式来创建一个对象再将其传给 shared_ptr 的构造函数,他就必须做两次内存分配。

std::shared_ptr<Entity> entity = std::make_shared<Entity>();

weak_ptr

和复制 shared_ptr 所做的一样,但当你把 shared_ptr 赋值给一个 weak_ptr,假如唯一的那个 shared_ptr 指针死去,内存就会被删除,因为赋值给 weak_ptr 并不会增加引用数量。例如你在排序一个集合,你不需要关注指针是否有效,你只需要存储一个他们的引用就好了。

std::weak_ptr<Entity> entity = std::make_weak<Entity>();

P45. C++ 中的拷贝和拷贝构造函数

有时候我们只需要获得内存的引用,避免发生拷贝以浪费性能,但有时候我们又需要拷贝内存中的数据到另一个内存中。在不想拷贝时避免拷贝,想拷贝时准确地拷贝是非常重要地。

在栈中创建地对象,其数值存储在不同地内存地址,当赋值发生时,就是将值准确地拷贝到对方地内存那里。但在堆中创建的对象,我们所持有的变量是一个指针,这个指针存储着内存地址,并指向堆中的内存,当我们进行赋值时,实际上改变的是地址,也就是两个变量都将指向同一块内存了。

如果两个堆上的类型实例只是简单地赋值,他们只会交换内存地址,这被称为浅拷贝。如果其中一个调用了析构函数并且删除了堆上内存,当另一个在调用析构函数试图删除内存时,就会抛出异常,因为同一块内存不能被销毁两次。

想要实现深拷贝,希望被赋值的变量拥有自己的唯一的内存块,有自己的指针,就需要使用到拷贝构造函数,该构造函数会在你赋值复制时调用。

class String{
private:
    char* m_Buffer;
    unsigned int m_Size;
public:
    String(const char* string){
        m_Size = strlen(string);
        m_Buffer = new char[m_Size + 1];//注意数组大小要比实际长度多1
        memcpy(m_Buffer,string,m_Size);
        m_Buffer[m_Size] = 0;
    }
    String(const String& other)
        :m_Buffer(other.m_Buffer),m_Size(other.m_Size){
    }
    ~String(){
        delete[] m_Buffer;
    }
}

实际上默认的拷贝构造函数就是如下形式:

String(const String& other){
    memcpy(this, &other, sizof(String));
}

但是我们不能只复制指针的地址,还需要复制指针指向的内存地址, m_Size 是一个在栈上创建的整数可以直接浅拷贝,但 m_Size 是一个在堆上创建的对象,需要深拷贝:

String(const String& other)
    :m_Size(other.m_Size){
        m_Buffer = new char[m_Size + 1];
        memcpy(m_Buffer,other.m_Buffer,m_Size + 1);
}

在函数参数值建议使用 const int& 的形式,因为这样可以避免不必要的复制,让复制仅仅发生在复制时。如果真的需要复制,可以类似如下操作:

void Print(const String& string){
    String str = string;
}

P46. C++ 中的箭头操作符

将一个空指针 nullptr 或者 0 转为 (Vector*) 指针。指针使用箭头符号指向类型中的成员变量,然后用取地址符 & 取其地址,也就是在该类型的内存分布中,指定成员变量的内存位置,转为 int 类型后就可得到整数型的内存偏移量。当我们把数据序列化为一串字节流时,需要计算某些变量的偏移量时,我们就需要这种黑魔法了。

struct Vector3{
    float x,y,z;
};
int main(){
    int offset = (int)&((Vector*)nullptr)->x;
}

P48. C++ 中的 std::vector 的优化

当你创建的 vector 内存不够用时,就需要重新分配内存,将数组移动到新的内存,删去旧有的区域。显然复制是一个很耗时的操作,因此我们应该尽量避免,一个较为简单的思路就是你所需要的数组大小进行一个预评估,尽量避免多次复制。

vector 数组调用两次拷贝构造函数,第一次添加复制一次,第二次添加需要扩容于是复制两个元素到新的内存,第三次添加有需要扩容因此又复制了三次。如果一开始就将数组的大小设置为 3 就不会发生以上的不必要的复制了。调用 reserve() 函数进行扩容这和直接声明 std::vector<Type> nums(3) 是不同的,我们不需要立即获得三个数组元素,只是需要一块能存放 3 个数组元素的空间,有需要再放进去。

std::vector<Type> nums;
nums.push_back(1);
nums.push_back(2);
nums.push_back(3);

std::vector<Type> nums;
nums.reserve(3);
nums.push_back(1);
nums.push_back(2);
nums.push_back(3);

实际上使用 push_back() 函数传入的对象,会先再当前调用的函数栈作用域中创建对象,因此以上代码每次放入都会发生一次拷贝。但使用 emplace_back() 函数则不会传递我们已经构建的对象,我们将只传递构造函数的参数列表,它在我们实际的类型内存中使用提供的参数来构造一个类型对象,这样一次拷贝行为都不会发生了。

P.54 C++ 中的堆栈内存比较

应用程序启动后,操作系统将整个程序加载到内存中,并分配一大堆物理 RAM,以提供应用程序的运行。而堆栈时在 RAM 中实际存在的两个区域。栈时一个预定义大小的内存区域,通常时 2MB 字节大小,堆也是一个预定义了默认值大小的区域,但可以随着应用程序的进行而改变。栈并不是存储在 CPU 缓存中,堆栈都是存储在 RAM 中的物理位置上的内存区域,二者不同之处就在于如何为我们分配内存。

在栈中分配的内存,变量之间他们都挨得很近,栈顶的指针移动一些字节就可以为我们分配新的内存空间,所以栈分配是非常快的,只需要一个 CPU 指令移动下栈顶指针就可以了。其次是,你在堆中申请的内存都要记得及时删除,而栈上的内存,当作用域结束后,栈上的所有内存就会很容地弹出释放,栈指针只需要移动到进入作用域之前的位置即可,栈的内存分配和释放基本没有开销。

当你使用 new 关键字时,背后就是在调用一个叫 malloc 函数,他会调用操作操作系统底层的函数或者平台的特定函数,在堆上为你分配内存。程序启动时会得到一定的 RAM 内存,并维护一个空闲列表,他会跟踪哪些内存块是空闲的。malloc 函数会浏览空闲列表,给你分配一块合适的内存,并记录分配的大小、分配的情况等等,然后我们获得了这块内存的指针,不过实际需要做的不止这么多。当所需内存超过了第一次分配的堆大小,需要付出很大的代价来迁移。

由于栈上的内存是连续的、活跃的、不断被你访问的,因此 缓存未命中 (Cache Misses) 的发生概率会非常小。

P55. C++ 中的宏

宏能一定程度上自动化我们的代码。在编译 C++ 代码时,预处理器会先过一遍所有的 C++ 中的预处理语句。预处理阶段基本是一个文本编辑阶段,在这个阶段我们可以决定什么代码要喂给编译器,这就是宏的用武之地:将代码中的文本替换为其他东西。利用宏的特性,在 Debug 模式打印日志,Release 模式下相关代码则被宏替换为空,这是一个很适合宏来处理的需求。

P58. C++ 中的函数指针

Print() 是在调用函数,而 auto f = Print 则是在获取函数指针,就像 &Print 一样(有隐式转换无需补 &),我们在试图获得这个函数的内存地址。编译代码时,函数就在二进制文件的某个地方,每个函数都会编译成 CPU 指令。对函数取地址,就是在获取那些 CPU 指令的内存地址。

void Print(){}

int main(){
    auto f = Print;
    f();
}

f 函数指针的类型实际上是 void(*f),如果函数有参数就需要写成:void(*f)(int,int) 这样的形式。或者写成如下形式:

void PrintInt(int num){
    std::cout<<num<<std::endl;
}
int main(){
    typedef void(*PrintIntFunction)(int);
    PrintIntFunction function = PrintInt;
    function(8);
}

P59. C++ 中的 lambda

[]lambda捕获区域 (Captures),我们可以什么都不捕获,也可以捕获很多个参数。lambda 本质是一个稍后调用的函数,如果我们需要在函数中使用外部的变量,这时就需要用到捕获了。捕获区域 [] 的目的就是设置如何捕获变量。

//设置值传递捕获变量a、引用传递捕获变量b
auto lambda = [a, &b](int value)={};
//捕获 this 指针
auto lambda = [this](int value)={};
//通过引用传递捕获所有变量
auto lambda = [&](int value)={};
//通过拷贝传递捕获所有变量
auto lambda = [=](int value)={};
//什么都不捕获
auto lambda = [](int value)={};

使用 lambda 时,其类型和函数指针不同,我们需要做些修改。在普通函数中,我们通过值传递获的局部变量是可以修改的,只是不会影响拷贝的那个原变量的值,但在 lambda 函数中即使是值传递也不能进行赋值操作,你需要为 lambda 函数加上一个 mutable 关键字进行修饰。

#include <functional>

void ForEach(const std::vector<int>& values, std::function<void(int)>& func){
    for(int value : values)
        func(value);
}

int main(){
    std::vector values = {1,2,3,4,5};
    int num = 1;
    auto lambda = [=] mutable (int value){
        num = 2;
        std::cout<<value<<std::endl;
    };
    ForEach(values, lambda);
}

P62. C++ 中的线程

线程的工作方式就是接受一个函数指针,传入后就会立即启动线程执行函数,并一直运行,直到我们等待他退出。调用 join 函数后,当前线程会一直等待,等待 join 的线程完成,在完成之前当前线程会一直被阻塞。

#include <thread>

void DoWork(){}
int main(){
    std::thread worker(DoWork);
    worker.join();
}

P64. C++ 中的多维数组

二维数组只是数组的数组。当我们使用 int** 来声明二维数组时,我们声明了一个指向整型指针的指针,而不是指向整型的指针,这意味着,这个二维数组大小应该是数组大小乘以整型的大小,因为无论是什么类型的指针,他都是存放着地址,是一个整型数值。

int* array = new int[50];
int** a2d = new int*[50];

我们在声明数组、二维数组的指针时,并没有创建任何实际的类型,只是在分配内存, int[50] 就是在设置分配的内存大小,50 个整型也就是 50 * 4 = 200 个字节的空间,并没有进行任何地初始化。

对于二维数组我们依旧是分配了 int*[50] 即 50 个指针共 200 个字节的内存大小。类型只是一种语法,设置类型是用来处理数据的。我们可以遍历并设置每个指针都指向一个数组,这样我们就得到了 50 个数组,即数组的数组,包含了 50 个数组的内存地址的数组。

注意,如果我们是在堆上分配的二维数组甚至是更高维的数组,就不能只是简单地通过 delete[] array 来销毁数组在堆上的内存了,我们需要遍二维数组,删除里面指针所有指向的数组内存空间。

for(int i = 0; i < 50; i++){
    delete[] a2d[i];
}
delete[] a2d;

以多维数组的形式处理数组实际上会造成内存碎片,因为存储的只是指针指向数组的地内存地址,这些数组基本上不会是一个连续的内存地址,而是分散在内存各个地方,这会导致缓存不命中,会浪费更多的时间从 RAM 中获取数据。如果直接声明一个一维数组,即使是一维数组我们也可以自己组织结构进行操作,并且可以享受到内存连续、缓存命中带来的速度优势。

for(int y = 0; y < 5; y++){
    for(int y = 0; y < 5; y++){
        array[x + y * 5] = 2;
    }
}

P66. C++ 中的类型双关

C++ 中的类型双关实际上就是绕过类型系统的限制。虽然强类型的 C++ 有着类型系统的限制,但是 C++ 可以访问内存,因此相比 Java/C# 这些语言的类型系统,C++ 可以很轻易地绕开限制。

如下代码所示,我们将一个整型变量,取其地址后转为 double* 类型的指针,然后再解引用,这样我们就实现了用 double 类型来解释这段声明为 int 类型的内存。只是读取后的结果可能与你所期望的不一样,因为声明 int 类型时只会分配 4 个字节的内存,而我们要用 8 个字节的 double 类型解释这段内存。即使我们拷贝时是将这些值拷贝到一个安全的 double 类型的内存空间进行写入,但还是读取了不属于我们的内存,这肯定是不好的。

int main(){
    int a = 50;
    double value = *(double*)&a;
    double& da = *(double*)&a;
}

如果将 double 改为 double& ,就不会将这些值拷贝到一个安全的 double 类型的内存空间进行写入,而是直接在编辑该 int 类型的内存空间。当我们继续试图向里面写入 double 类型的数值,由于只要 4 字节的空间,某些情况下可能会导致崩溃。

结构体 struct 本身并包含任何数据,一个空的结构体至少包含 1 个字节,以寻址到该段内存。如果结构体内有变量,那么结构体所占据的内存空间就是这些变量的内存大小之和,没有任何多余的数据。因此我们可以将结构体看作是一个数组。我们可以直接将结构体的对象取地址后转为类型指针,然后就像一个数据一样使用它们。

struct Entity{
    int x,y;
};
int main(){
    Entity e = {5, 8};
    int* position = (int*)&e;
    position[0] = 1;
} 

同样的我们可以使用之前提到的黑魔法:先获取结构体中的变量在内存在的位置,转为 char* 类型指针后移动 4 个字节的位置到 Entity 中的变量 y 的地址处,再转为 int* 类型指针,用 int 类型解释这段内存,再解引用对地址指向的值进行赋值操作。

int y = e.y;
//equals
int y = *(int*)((char*)&e + 4);

虽然前面的黑魔法很疯狂,当我们不想处理某种类型的复制和转换时,我们就可以通过简单的黑魔法来实现。比如结构体需要一个返回位置的函数,需要返回多个值,我们可以返回一个数组,然而构造一个数组需要拷贝,需要开销,如果直接返回第一个变量的地址 &x,也相当于返回了一个数组,并且没有任何的开销。

struct Entiy{
    int x, y;
    int* GetPosition(){
        return &x;
    }
}

这就是类型双关,把我们所拥有的内存,当作不同类型的内存来对待,我们只需要将该类型作为指针,然后转换为另一个指针,有需要还可以解引用对值进行操作。

P67. C++ 中的联合体

和别名有所区别的时,C++ 中的联合体中的变量名虽然都是在指向同一块内存,但联合体可以用不同的类型来解释这块内存,这是语法层面上实现的类型双关。如下例子中,声明的 Vector4 类型有四个 int 类型参数,我们就可以通过 union 以两个 Vector2 类型来解释这段内存。

strcut Vector2{
    float x, y;
}
struct Vector4{
    union{
        struct{
            float x, y, z, w;
        };
        struct{
           Vector2 a,b; 
        };
    };
}

P68. C++ 中的虚析构函数

当父类变量赋值为子类对象时: Father* son = new Son();。如果销毁该对象,那么就只会执行父类的析构函数,而不会执行子类的析构函数。

如果我们需要同时执行子类的析构函数,就需要将父类的析构函数设置为 virtual。但和普通的虚函数不同,子类实现父类的虚函数后,执行时会覆盖父类的虚函数,而虚析构函数会先执行子类的析构函数再执行父类的析构函数,就像销毁了一个正常声明的子类调用析构函数一样调用: Son* son = new Son();

public:
    Father() {
        std::cout << "Created with Father!" << std::endl;
    }
    virtual ~Father() {
        std::cout << "Destroyed with Father!" << std::endl;
    }

};
class Son :public Father {
public:
    Son() {
        std::cout << "Created with Son!" << std::endl;
    }
    ~Son() {
        std::cout << "Destroyed with Son!" << std::endl;
    }
};

P.69 C++ 中的类型转换

static_cast

简单的隐式转换不需要我们说明,但我们以可以写成显示的形式。(int) 这种圆括号形式的类型转换是 C 风格的强制转换。而 C++ 的方式则是使用 static_cast<int>(value), C++ 的类型转换相比 C 语言的实现可能会做些别的事情,但实际的结果只是一个成功的类型转换而已,这不是一个新功能,只是一个语法糖。

static_cast 会做一些编译时检查,以确认该转换能否成功。还有很多其他的类型转换,使用这种形式的类型转换的好处还有:我们可以通过搜索英文单词来查找、管理变量的转换类型,而 C 语言的转换我们无法搜索,难道你要用正则表达式来搜索吗。

dynamic_cast

动态转换会在转换失败时做些额外的事情,例如父类有两个子类,我们如果将父类变量赋值为子类 A 的对象,再试图将这个父类变量静态转换为子类 B 类型,是不会得到保护的,而动态转遇到这种情况就会返会空指针,我们只需要进行一个空指针判断就可以得知是否转换成功。总而言之,这种转换会让类型转换更加可靠,因为会做编译时检查,而 dynamic_cast 会做运行时检查,这会使得你的代码更加可靠。

const_cast

const_cast 是用来添加或者移除 const 的,可以利用该转换隐式添加 const,但大多数是用来移除 const 的。

reinterpret_cast

前面所提到的类型双关所做的事情,所有试图将一段内存解释为另一种类型的需求,都可以用 reinterpret_cast 更安全地做到。

class Base{}
class Derived:public Base{}
class Another:public Base{}

int main(){
    Derived* derived = new Derived();
    Base* base = derived;
    Another* ac = dynamic_cast<Another*>(base);
    
    if(!ac){
        std::cout<<"Fail to cast"<<std::endl;
    }
}