0%

【知识汇总】C++相关

本篇总结 C++ 相关知识点,提供之前笔记的索引,并补充部分内容,方便查找,将持续更新。

1 多态

多态的定义、种类、如何实现

多态简单概括就是“一种接口,多种方法”,是面向对象编程的核心,主要指不同的对象收到相同的消息做出不同的动作。C++ 通过指针或引用实现多态,具体查看笔记【C++ 对象模型】(一)关于对象

虚函数如何实现多态

基类指针指向派生类对象,可以调用不同派生类的方法,这是通过虚函数来实现的。每一个类都有一张虚函数表,类的对象中包含指向虚函数表的指针,派生类对象中包含基类子对象并且在内存布局的最前面,而虚函数表指针又位于实例对象内存的最前面,因此使用基类指针可以指向任何派生类对象并且正确寻址到虚函数表,这样通过一个基类指针就可以在运行时找到不同派生类的虚函数表从而调用对应的函数,实现多态。

虚表指针什么时候产生

首先每一个类的虚函数表在编译期间确定,虚函数表指针在虚函数表确定后,对象被构造出来的时候就可以确定了,实际上编译器会在构造函数时中加入对虚函数表指针的赋值操作,并且这些操作会在用户自己定义的初始化操作之前执行。同样的,虚基类指针也在编译期确定。

关于纯虚函数

纯虚函数没有定义,必须在派生类中实现,在虚函数表中会存在一个纯虚函数调用(pure_virtual_called() )实体,它既可以扮演纯虚函数的空间占用者,也可以当作执行期的异常处理函数,当调用一个基类中的纯虚函数(即未被定义)时,会抛出异常并终止程序。

重载、重写与覆盖

重载允许有多个同名的函数,而这些函数可以参数列表不同,返回类型不同,参数个数不同,参数类型不同等等。编译器会根据这些函数的不同列表,将同名的函数的名称做修饰,从而生成一些不同名称的预处理函数,来实现同名函数调用时的重载问题。但这并没有体现多态性。重写可以有两种,直接重写成员函数和重写虚函数,只有重写了虚函数的才能算作是体现了C++多态性。在派生类中重写了基类中的函数会覆盖基类中定义的函数,重写虚函数会覆盖虚函数表对应位置的函数指针。

多继承的实现及可能出现的问题

多继承会将基类子对象按照继承顺序放在派生类对象中。菱形继承中可能出现重复继承同一个基类的情况。解决办法是使用虚继承。

菱形继承类大小的计算

查看笔记【C++ 对象模型】(三)关于数据成员第2部分

类对象的内存存储形式

查看笔记【C++ 对象模型】(三)关于数据成员第3部分

override 和 final

override 关键字告诉编译器这个函数是重写基类的函数,如果该函数和基类函数的标识不是完全一样就会报错,比如基类函数中有 const 修饰,派生类重写的时候忘记了 const,如果没有 override,就会导致我们以为重写了基类的函数,但实际上因为没有加 const ,这是两个完全不同的函数而产生错误。同时因为 override 可以说明这是一个重写的基类虚函数,因此派生类中函数的 virtual 关键字就可以去掉了。

final 关键字用于类声明可以禁止继承该类,用于方法可以禁止该方法在派生类中被重写。

为什么构造函数不能是虚函数

因为虚函数会在放在虚函数表中,构造对象时给对象的 vptr 赋值,如果构造函数是虚函数,那调用构造函数的时候就要用 vptr 去找构造函数,但此时 vptr 还没有被赋值。

为什么析构函数可以是虚函数

与构造函数不同,vptr 已经完成初始化,析构函数可以声明为虚函数,且类有继承时,析构函数常常必须为虚函数。

当我们使用基类指针可以指向派生类的对象时,如果删除该指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。而如果析构函数不定义为虚函数,编译器会实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,从而存在内存泄露的风险。

更多关于析构函数可以查看笔记【C++ 对象模型】(五)对象复制和析构

关于构造函数和析构函数的细节

查看笔记【C++ 对象模型】(二)关于构造函数第 1 部分

关于拷贝构造函数

查看笔记【C++ 对象模型】(二)关于构造函数第 2 部分

new、placement new 和 malloc

查看笔记【C++ 对象模型】(六)关于执行期

2 智能指针

内存泄漏

指针指向内存后在使用完毕不释放内存导致这块内存既没有用处也无法再被分配出去。智能指针会在对象生命周期结束后自动释放内存。

内存泄漏如何定位

一种通用的方法是使用 CRT 自带的检测函数,C 运行时库(CRT)提供了内存泄露的检测函数,可以在主函数最后加上 _CrtDumpMemoryLeaks() 并进入调试使程序正常退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <crtdbg.h>

class test {
bool flag;
public:
test() = default;
virtual void func() {}
};

using namespace std;

int main()
{
test* t = new test();
cout << sizeof(t) << endl;
_CrtDumpMemoryLeaks();
return 0;
}

此时可以看到输出的内存泄露信息:

image-20220815091736205

大括号中的 173 表示在 173 次申请内存时发生了内存泄漏,泄露了 16 字节。

然后我们在主函数开始加上 _CrtSetBreakAlloc(173) 来使得第 173 次申请内存时程序中断:

1
2
3
4
5
6
7
8
int main()
{
_CrtSetBreakAlloc(173);
test* t = new test();
cout << sizeof(test) << endl;
_CrtDumpMemoryLeaks();
return 0;
}

此时会在申请内存时中断,然后在调用堆栈中找到我们程序最后调用的函数,双击:

image-20220815092139848

就可以定位到内存泄露的代码所在了:

image-20220815092206123

除了使用 CRT 外,还可以通过重载 new 和 delete 运算符,维护一个 list 或者 map,申请内存时加入节点,释放内存时删除节点,通过检查最后 list 或者 map 种是否还有节点来判断是否发生内存泄漏,也可以在重载时,输出一些信息帮助定位发生泄漏的代码所在。

unique_ptr

它持有对对象的独有权——两个unique_ptr 不能指向一个对象,即 unique_ptr 不共享它所管理的对象。它无法复制到其他 unique_ptr,无法通过值传递到函数,也无法用于需要副本的任何标准模板库 (STL)算法。只能移动 unique_ptr,即对资源管理权限可以实现转移。这意味着,内存资源所有权可以转移到另一个 unique_ptr,并且原始 unique_ptr 不再拥有此资源。

shared_ptr

shared_ptr 是一个标准的共享所有权的智能指针,允许多个指针指向同一个对象。shared_ptr 对资源做引用计数,当引用计数为 0 的时候,自动释放资源。

shared_ptr 需要维护的信息有两部分:

  1. 指向共享资源的指针。
  2. 引用计数等共享资源的控制信息——实现上是维护一个指向控制信息的指针。

所以,一个 shared_ptr 对象有两个指针。一个是指向共享资源的指针,一个是指向控制信息的指针。shared_ptr 的自定义 deleter 是保存在控制信息中,所以,是否有自定义 deleter 不影响 shared_ptr 对象的大小。

image-20220625132147566

weak_ptr

它更像是 shared_ptr 的一个助手而不是智能指针,因为它不具有普通指针的行为,没有重载 operator* 和 operator-> ,因此取名为 weak,表明其是功能较弱的智能指针。它的最大作用在于协助 shared_ptr 工作,可获得资源的观测权,像旁观者那样观测资源的使用情况。观察者意味着 weak_ptr 只对 shared_ptr 进行引用,而不改变其引用计数,当被观察的 shared_ptr 失效后,相应的 weak_ptr 也相应失效。

总结一下,std::weak_ptr 要与 std::shared_ptr 一起使用。 一个 std::weak_ptr 对象看做是 std::shared_ptr 对象管理的资源的观察者,它不影响共享资源的生命周期:

  1. 如果需要使用 weak_ptr 正在观察的资源,可以将 weak_ptr 提升为 shared_ptr。
  2. 当 shared_ptr 管理的资源被释放时,weak_ptr 会自动变成 nullptr。

为什么 shared_ptr 的控制信息中也要保存指向共享资源的指针?可不可以去掉 shared_ptr 对象中指向共享资源的指针,以节省内存开销?

不能。 因为在存在继承的情况下,shared_ptr 对象中的指针指向的对象不一定和控制块中的指针指向的对象一样。

比如基类指针指向派生类对象,基类指针指向的是派生类中的基类子对象,但控制信息中该基类指针也会为派生类对象的引用计数加 1,并且控制信息中指向的共享资源不是基类子对象,而是派生类对象本身。

关于循环引用

引用计数的一个问题就是不能处理循环引用的情况,当两个对象互相引用的时候,引用计数永远为 1,导致二者都无法释放从而造成内存泄露。这时将其中一个 shared_ptr 改为 weak_ptr,在需要获取操作权的时候使用 weak_ptr.lock()函数将 weak_ptr 提升为 shared_ptr 即可,这样既解决了循环引用问题也达到了原本的目的。

关于循环引用的例子可以查看c++ weak ptr解除指针循环引用加深理解。

shared_ptr 的实现

查看智能指针(shared_ptr的实现)

关于野指针

初始化指针时没有给初始值或者释放内存后没有将指针置空就会产生野指针,具体可以查看笔记【C++ 对象模型】(六)关于执行期中 2.2 部分。

避免野指针的方法是:

  • 初始化指针的时候将其置为 nullptr,之后再对其操作
  • 释放指针的时候将其置为 nullptr

3 STL

迭代器

查看笔记【STL】迭代器

序列式容器

查看笔记【STL】序列式容器

关联式容器

查看笔记【STL】关联式容器

4 多线程

线程与进程

线程是操作系统能够进行 CPU 调度的最小单位,它被包含在进程之中,一个进程可包含单个或者多个线程。而进程是资源分配的最小单位,同一个进程内的所有线程共享该进程的资源,因此线程共享进程的地址空间,而进程拥有独立的地址空间。

综上,一般情况下,存在两种类型的多任务处理:基于进程和基于线程

  • 基于进程的多任务处理是程序的并发执行。
  • 基于线程的多任务处理是同一程序的片段的并发执行。

操作系统如何管理进程

进程控制块(Process Control Block,PCB)是操作系统为了管理进程,在内核中设置的一种的数据结构。PCB 储存了高效管理进程所需的许多不同数据项。虽然这种数据结构的细节因系统而异,但是常见的部分大致可分为3大类:

  • 进程标识数据
  • 进程状态数据
  • 进程控制数据

image-20220705104317141

thread 构造

C++ 的 thread 对象构造需要传入函数名和函数参数,只要创建了线程对象,线程就开始执行。所以不应该在创建了线程后马上 join, 这样会马上阻塞主线程,创建了线程和没有创建一样,应该在晚一点的位置调用 join。另外 thread 类不支持拷贝构造,只支持移动构造,也就是说不能用一个已经存在的 thread 对象初始化另一个 thread 对象,只能用临时的 thread 对象进行初始化,也就是通过右值引用实现的移动构造函数。

创建线程时是如何处理函数参数的

创建线程时需要传递函数名作为参数,提供的函数对象会复制到新的线程的内存空间中执行与调用。

如果用于创建线程的函数为含参函数,那么在创建线程时,要一并将函数的参数传入。常见的,传入的参数的形式有基本数据类型(int,char,string等)、引用、指针、对象等,下面总结了传递不同形式的参数时 std::thread 类的处理机制:

  • 总体来说,std::thread 的构造函数会拷贝传入的参数
  • 当传入参数为基本数据类型(int,char,string等)时,会拷贝一份给创建的线程;
  • 当传入参数为指针时,会浅拷贝一份给创建的线程,也就是说,只会拷贝对象的指针,不会拷贝指针指向的对象本身;
  • 当传入的参数为引用时,实参必须用 ref() 函数处理后传递给形参,否则编译不通过,此时不存在“拷贝”行为,因为引用只是变量的别名,在线程中传递对象的引用,那么该对象始终只有一份,只是存在多个别名罢了;
  • 当传入的参数为类对象时,会拷贝一份给创建的线程,此时会调用类对象的拷贝构造函数。

join() 和 detach()

当线程启动后,一定要在和线程相关联的 std::thread 对象销毁前,对线程运用 join() 或者 detach() 方法。join() detach() 都是 std::thread 类的成员函数,是两种线程阻塞方法,两者的区别是是否等待子线程执行结束。

  • join() 等待调用线程运行结束后当前线程再继续运行,例如,主函数中有一条语句 th1.join(),那么执行到这里,主函数阻塞,直到线程 th1 运行结束,主函数再继续运行。

  • detach() 将当前线程和主线程分离,主线程无需等待当前线程完成,使用 detach() 时,可能存在主线程比子线程先结束的情况,主线程结束后会释放掉自身的内存空间;在创建线程时,如果 std::thread 类传入的参数含有引用或指针,则子线程中的数据依赖于主线程中的内存,主线程结束后会释放掉自身的内存空间,则子线程会出现错误。

调用 join() 会清理线程相关的存储部分,这代表了 join() 只能调用一次。使用 joinable() 来判断 join() 可否调用。同样,detach() 也只能调用一次,一旦 detach() 后就无法join() 了,有趣的是,detach() 可否调用也是使用 joinable() 来判断。

临界区、互斥量、信号量

访问共享资源的代码段称为临界区,临界区只能作用于同一进程下不同线程,不能作用于不同进程;临界区可确保某一代码段同一时刻只被一个线程执行。

互斥量(锁)是使用共享资源的许可,能保证同一时刻只有一个线程访问共享资源,但支持不同进程间的同步与互斥。

信号量能允许多个线程同一时刻访问共享资源,进行线程的计数,确保同时访问资源的线程数目不超过上限,当访问数超过上限后,不发出信号量。

什么是同步,什么是互斥

任务运行时,有些任务片段间存在严格的先后顺序,同步指维护任务片段的先后顺序。比如 A 片段执行完才能执行 B 片段,线程 1 执行 A 片段,在 A 片段执行结束后解锁;线程 2 执行 B 片段,在 B 片段执行前申请锁,于是 A 片段解锁了 B 片段才能申请到锁,保证了 A 片段执行结束了 B 片段才能运行,称之为同步.

互斥就是保证资源同一时刻只能被一个进程使用;互斥是为了保证数据的一致性,如果 A 线程在执行计算式 A 的时候,某个量被 B 线程改掉了,这可能会出现问题,于是要求资源互斥,我在用它你就不能用,等我用完了你再用,我们彼此互不干扰。

都有哪些锁,功能是什么

  • 互斥锁 mutex:加锁的资源支持互斥访问,即同一时刻只能被一个线程访问,通过 lock 加锁, unlock 解锁
  • 读写锁 shared_mutex:读写锁把对共享资源的访问者划分成读者和写者,多个读线程能同时读取共享资源,但只有一个写线程能同时读取共享资源,shared_mutex 通过 lock_shared,unlock_shared 进行读者的锁定与解锁;通过 lock,unlock 进行写者的锁定与解锁
  • 自旋锁:如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,因此自旋锁比较适用于锁使用者保持锁时间比较短的情况

一般不直接去调用成员函数 lock(),因为如果忘记 unlock(),将导致锁无法释放,通常使用 lock_guard 或者 unique_lock 避免忘记解锁带来的问题。

什么是死锁,产生的条件,解决办法

死锁就是多个线程争夺共享资源导致每个线程都不能取得自己所需的全部资源,从而程序无法向下执行。

产生死锁的四个必要条件:

  1. 互斥(资源同一时刻只能被一个进程使用)
  2. 请求并保持(进程在请资源时,不释放自己已经占有的资源)
  3. 不剥夺(进程已经获得的资源,在进程使用完前,不能强制剥夺)
  4. 循环等待(进程间形成环状的资源循环等待关系)

死锁预防:破坏死锁产生的四个条件

死锁避免:分配资源时进行安全性检测(银行家算法,预分配并检查状态,安全才正式分配资源)

死锁检测:允许死锁发生,但提供检测算法

死锁解除:产生死锁后,通过强制剥夺资源或者撤销进程来解除死锁

lock_guard

lock_guard 类似于智能指针,能帮助我们自动管理加锁与解锁,当我们声明一个局部的 std::lock_guard 对象时,会在其构造函数中进行加锁,在其析构函数中进行解锁。最终的结果就是:创建即加锁,作用域结束自动解锁。从而使用 std::lock_guard() 就可以替代 lock() 与 unlock()。

通过设定作用域,使得 std::lock_guard 在合适的地方被析构,通过使用 {} 来调整作用域范围,可使得互斥量 m 在合适的地方被解锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include<iostream>
#include<thread>
#include<mutex>

using namespace std;
mutex m; //实例化锁对象

void proc1(int a)
{
//用此语句替换了m.lock(),lock_guard传入一个参数时,该参数为互斥量,此时调用了lock_guard的构造函数,申请锁定m
lock_guard<mutex> g1(m);
cout << "proc1函数正在改写a" << endl;
cout << "原始a为" << a << endl;
cout << "现在a为" << a + 2 << endl;
} //此时不需要写m.unlock(),g1出了作用域被释放,自动调用析构函数,于是m被解锁

void proc2(int a)
{
//通过使用{}来调整作用域范围,可使得m在合适的地方被解锁
{
lock_guard<mutex> g2(m);
cout << "proc2函数正在改写a" << endl;
cout << "原始a为" << a << endl;
cout << "现在a为" << a + 1 << endl;
}
cout << "作用域外的内容3" << endl;
cout << "作用域外的内容4" << endl;
cout << "作用域外的内容5" << endl;
}
int main()
{
int a = 0;
thread t1(proc1, a);
thread t2(proc2, a);
t1.join();
t2.join();
return 0;
}

构造 std::lock_guard 时还可以传入 adopt_lock 标识,表示构造函数中不再进行互斥量锁定,因此此时需要提前手动锁定:

1
2
3
4
5
6
void proc1(int a)
{
m.lock(); //手动锁定
lock_guard<mutex> g1(m,adopt_lock);
...
} //自动解锁

unique_lock

std::unique_lock 类似于 lock_guard,但 std::unique_lock 用法更加丰富,同时支持 std::lock_guard() 的原有功能。 使用 std::lock_guard 后不能手动 lock() 与手动 unlock(),而使用 std::unique_lock 后可以手动 lock() 与手动 unlock(),并且 std::unique_lock 的第二个参数除了可以是 adopt_lock 外,还可以是 try_to_lock 与 defer_lock:

  • try_to_lock:尝试去锁定,得保证锁处于unlock的状态,然后尝试现在能不能获得锁;尝试用 mutex 的 lock() 去锁定这个 mutex,但如果没有锁定成功,会立即返回,不会阻塞在那里,并继续往下执行
  • defer_lock:初始化一个没有加锁的 mutex,在之后手动加锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include<iostream>
#include<thread>
#include<mutex>

using namespace std;
mutex m;

void proc1(int a)
{
unique_lock<mutex> g1(m, defer_lock);//初始化一个没有加锁的mutex
g1.lock();//手动加锁,注意,不是m.lock()
cout << "proc1函数正在改写a" << endl;
cout << "原始a为" << a << endl;
cout << "现在a为" << a + 2 << endl;
g1.unlock();//临时解锁
cout << "xxxxx" << endl;
g1.lock();
cout << "xxxxxx" << endl;
}//自动解锁

void proc2(int a)
{
//尝试加锁一次,但如果没有锁定成功,会立即返回,不会阻塞在那里,且不会再次尝试锁操作
unique_lock<mutex> g2(m, try_to_lock);
if (g2.owns_lock()) {//锁成功
cout << "proc2函数正在改写a" << endl;
cout << "原始a为" << a << endl;
cout << "现在a为" << a + 1 << endl;
}
else {//锁失败则执行这段语句
cout << "xxxxx" << endl;
}
}//自动解锁

int main()
{
int a = 0;
thread t1(proc1, a);
t1.join();
thread t2(proc2, a);
t2.join();
return 0;
}

条件变量 condition_variable

std::condition_variable 类需要搭配 std::mutex 类来使用,std::condition_variable 对象的作用不是用来管理互斥量的,它的作用是用来同步线程,它的用法相当于编程中常见的 flag 标志,比如 A、B 两个人约定 flag==true 为行动号角,默认 flag 为 false,A 不断的检查 flag 的值,只要 B 将 flag 修改为 true,A就开始行动。类比到 std::condition_variable,A、B两个人约定 notify_one 为行动号角,A 就等着(调用wait(),阻塞),只要 B 一调用 notify_one,A 就开始行动(不再阻塞)。

wait(locker):在线程被阻塞时,该函数会自动调用 locker.unlock() 释放锁,使得其他被阻塞在锁竞争上的线程得以继续执行。另外,一旦当前线程获得通知,通常是另外某个线程调用 notify_* 唤醒了当前线程,wait() 函数此时再自动调用 locker.lock() 来获得锁。

notify_* 方法包括:

  • notify_one():随机唤醒一个等待的线
  • notify_all():唤醒所有等待的线程

什么是同步和异步,阻塞和非阻塞

同步是执行或调用一个方法时,每次都需要拿到对应的结果才会继续往后执行;异步与同步相反,它会在执行或调用一个方法后就继续往后执行,不会等待获取执行结果。二者的区别就是处理请求发出后,是否需要等待请求结果,再去继续执行其他操作。

以下图为例,红色线条为主线程,其他线条为调用的方法,上面的为同步,下面的为异步。

2019059-20200501234839680-531092492

阻塞的概念通常会伴随着线程。阻塞是指当前执行的线程调用一个方法,在该方法没有返回值之前,当前执行的线程会被挂起,无法继续进行其他操作。非阻塞是指当前执行的线程调用一个方法,当前执行的线程不受该方法的影响,可以继续进行其他操作。

async、future 和 shard_future

async 用于创建一个异步线程,它返回一个 future 类模板对象,future 对象起到了占位的作用,刚实例化的 future 是没有储存值的,但在调用 future 对象的 get() 成员函数时,主线程会被阻塞直到异步线程执行结束,并把返回结果传递给 future,即通过 FutureObject.get() 获取函数返回值。

shard_future 和 future 一样,都是为了占位,但 future 的 get() 成员函数是转移数据所有权,而 shared_future 的 get() 成员函数是复制数据。因此 future 对象的 get() 只能调用一次;无法实现多个线程等待同一个异步线程,一旦其中一个线程获取了异步线程的返回值,其他线程就无法再次获取。shared_future 对象的 get() 可以调用多次;可以实现多个线程等待同一个异步线程,每个线程都可以获取异步线程的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <thread>
#include <mutex>
#include<future>
#include<Windows.h>

using namespace std;

double t1(const double a, const double b)
{
double c = a + b;
Sleep(3000); //假设t1函数是个复杂的计算过程,需要消耗3秒
return c;
}

int main()
{
double a = 2.3;
double b = 6.7;
future<double> fu = async(t1, a, b);//创建异步线程线程,并将线程的执行结果用fu占位;
cout << "正在进行计算" << endl;
cout << "计算结果马上就准备好,请您耐心等待" << endl;
cout << "计算结果:" << fu.get() << endl;//阻塞主线程,直至异步线程return
//cout << "计算结果:" << fu.get() << endl;//取消该语句注释后运行会报错,因为future对象的get()方法只能调用一次。
return 0;
}

原子类型 automic

原子操作指不可分割的操作;也就是说这种操作状态要么是完成的,要么是没完成的。互斥量的加锁一般是针对一个代码段,而原子操作针对的一般都是一个变量。automic 是一个模板类,使用该模板类实例化的对象,提供了一些保证原子性的成员函数来实现共享数据的常用操作。

可以这样理解:
在以前,定义了一个共享的变量(int i=0),多个线程会操作这个变量,那么每次操作这个变量时,都是用 lock 加锁,操作完毕使用 unlock 解锁,以保证线程之间不会冲突;
现在,实例化了一个类对象(automic i=0)来代替以前的那个变量,每次操作这个对象时,就不用 lock 与 unlock,这个对象自身就具有原子性,以保证线程之间不会冲突。

automic 对象提供了常见的原子操作(通过调用成员函数实现对数据的原子操作):store 是原子写操作,load 是原子读操作,exchange 是于两个数值进行交换的原子操作。

即使使用了automic,也要注意执行的操作是否支持原子性。一般 atomic 原子操作,针对 ++,–,+=,-=,&=,|=,^= 是支持的。

1
2
3
//原子类型的简单使用
std::atomic<bool> b(true);
b = false;

原子智能指针

C++ 20 引入了原子智能指针,使得原子类型支持智能指针,我们知道智能指针 shared_ptr 读写数据不是线程安全的,因为 shared_ptr 的复制操作分为两步,复制指向资源的指针和复制指向控制块的指针,因此这一操作不是原子的,就会存在多线程中的各种冲突问题,具体可以查看C++ 智能指针线程安全的问题中的例子。

而使用原子类型的变量可以解决这个问题,但在之前原子类型不支持智能指针操作,C++ 20 引入的原子智能指针 std::atomic<std::shared_ptr> 完美的解决了这个问题。

生产者消费者问题

生产者消费者问题是经典的多线程问题,通过该问题来理解上面说到的各种特性。

生产者用于生产数据,生产一个就往共享数据区存一个,如果共享数据区已满的话,生产者就暂停生产,等待消费者的通知后再启动。

消费者用于消费数据,一个一个的从共享数据区取,如果共享数据区为空的话,消费者就暂停取数据,等待生产者的通知后再启动。

生产者与消费者不能直接交互,它们之间所共享的数据使用队列结构来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#include<iostream>
#include<thread>
#include<mutex>
#include<queue>
#include<condition_variable>

using namespace std;

// 缓冲区存储的数据类型
struct CacheData
{
// 商品id
int id;
// 商品属性
string data;
};

// 共享缓冲区
queue<CacheData> Q;
// 缓冲区最大空间
const int MAX_CACHEDATA_LENGTH = 10;
// 互斥量,生产者之间,消费者之间,生产者和消费者之间,同时都只能一个线程访问缓冲区
mutex m;
condition_variable condConsumer;
condition_variable condProducer;
// 全局商品id
int ID = 1;

// 生产者
void ProducerTask()
{
while (1)
{
unique_lock<mutex> lockerProducer(m); // 获得锁
cout << "[" << this_thread::get_id() << "] 获取了锁" << endl;
while (Q.size() >= MAX_CACHEDATA_LENGTH)
{
cout << "因为队列为满,所以生产者Sleep" << endl;
cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl;
//缓冲区满,生产者停止生产,调用 wait,解锁,唤醒其他线程,等待消费者唤醒之后再加锁
condProducer.wait(lockerProducer);
cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl;
}
cout << "[" << this_thread::get_id() << "] ";
CacheData temp;
temp.id = ID++;
temp.data = "*****";
cout << "+ ID:" << temp.id << " Data:" << temp.data << endl;
Q.push(temp);
condConsumer.notify_one(); // 唤醒一个等待的线程
cout << "[" << this_thread::get_id() << "] 释放了锁" << endl;
}
}

//消费者
void ConsumerTask()
{
while (1)
{
unique_lock<mutex> lockerConsumer(m); // 获得锁
cout << "[" << this_thread::get_id() << "] 获取了锁" << endl;
while (Q.empty())
{
cout << "因为队列为空,所以消费者Sleep" << endl;
cout << "[" << this_thread::get_id() << "] 不再持有锁" << endl;
//缓冲区为空,消费者停止,调用 wait,解锁,唤醒其他线程,等待消费者唤醒之后再加锁
condConsumer.wait(lockerConsumer);
cout << "[" << this_thread::get_id() << "] Weak, 重新获取了锁" << endl;
}
cout << "[" << this_thread::get_id() << "] ";
CacheData temp = Q.front();
cout << "- ID:" << temp.id << " Data:" << temp.data << endl;
Q.pop();
condProducer.notify_one();
cout << "[" << this_thread::get_id() << "] 释放了锁" << endl;
}
}

//管理线程的函数
void Dispatch(int ConsumerNum, int ProducerNum)
{
vector<thread> thsC;
for (int i = 0; i < ConsumerNum; ++i)
{
thsC.push_back(thread(ConsumerTask));
}

vector<thread> thsP;
for (int j = 0; j < ProducerNum; ++j)
{
thsP.push_back(thread(ProducerTask));
}

for (int i = 0; i < ConsumerNum; ++i)
{
if (thsC[i].joinable())
{
thsC[i].join();
}
}

for (int j = 0; j < ProducerNum; ++j)
{
if (thsP[j].joinable())
{
thsP[j].join();
}
}
}

int main()
{
//一个消费者线程,5个生产者线程,则生产者经常要等待消费者
Dispatch(1, 5);
return 0;
}

线程池是什么,解决了什么问题

不采用线程池时,即使需要使用到大量线程,每个线程都要按照“创建线程 -> 由该线程执行任务 -> 任务执行完毕后销毁线程”的流程来创建、执行与销毁。虽然创建与销毁线程消耗的时间通常远小于线程执行的时间,但是对于需要频繁创建大量线程的任务,创建与销毁线程 所占用的时间与CPU资源也会有很大占比。

因此使用线程池技术来进行优化,线程池技术的基本思想是:

  • 程序启动后,预先创建一定数量的线程放入空闲队列中,这些线程都是处于阻塞状态,基本不消耗CPU,只占用较小的内存空间。
  • 接收到任务后,任务被挂在任务队列,线程池选择一个空闲线程来执行此任务。
  • 任务执行完毕后,不销毁线程,线程继续保持在池中等待下一次的任务。

线程池所解决的问题:

  • 需要频繁创建与销毁大量线程的情况下,由于线程预先就创建好了,接到任务就能马上从线程池中调用线程来处理任务,减少了创建与销毁线程带来的时间开销和CPU资源占用。
  • 需要并发的任务很多时候,无法为每个任务指定一个线程(线程不够分),使用线程池可以将提交的任务挂在任务队列上,等到池中有空闲线程时就可以为该任务指定线程。

5 其他

C++ 程序编译过程

  1. 预编译(.cpp -> .i),预编译主要进行代码文本替换工作。编译器执行预编译指令(以 # 开头,例如 #include),这个过程会得到不包含 # 指令的 .i 文件。这个过程会拷贝 #include 包含的文件代码,进行 #define 宏定义的替换 , 处理条件编译指令(#ifndef、#ifdef、#endif)等等。
  2. 编译优化(.i -> .s),通过预编译输出的 .i 文件中,只包含常量、字符串、变量的定义,以及关键字:main、if、else、for、while等。编译优化阶段则是通过语法分析和词法分析,确定所有指令是否符合规则,之后翻译成汇编代码。
  3. 汇编(.s -> .o 或 .obj),汇编过程就是把汇编语言翻译成目标机器指令的过程,生成目标文件。目标文件中存放的也就是与源程序等效的目标的机器语言代码。目标文件由段组成,通常至少有两个段:
    • 代码段:包换主要程序的指令。该段是可读和可执行的,一般不可写
    • 数据段:存放程序用到的全局变量或静态数据。可读、可写、可执行
  4. 链接(.o -> .exe / .a / .so / .lib / .dll 等),由汇编程序生成的目标文件并不能立即就执行,还要通过链接过程。这是因为某个源文件调用了另一个源文件中的函数或常量或在程序中调用了某个库文件中的函数,链接的主要工作就是将有关的目标文件连接起来,形成最终的可执行文件。

堆和栈

堆是由程序员控制的内存区域,通过 new 和 delete 来申请堆上的内存和释放堆上的内存。

栈是由编译器管理的内存,在需要时自动分配,不需要时自动清除。栈通常存放局部变量、函数参数等。

除了堆和栈之外,还有存储全局变量和静态变量的存储区以及存放常量的存储区。

堆和栈的对比:

堆(Heap) 栈(Stack)
管理方式 堆中资源由程序员控制(需要注意内存泄漏) 栈资源由编译器自动管理,无需手工控制
内存管理机制 系统维护一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序,系统还会将多余的部分重新放入空闲链表中,释放内存时将内存重新加入空闲内存链表中。 栈是一块连续的内存空间,只要栈的剩余空间大于所申请空间,系统就为程序提供栈内存,否则报错 stack overflow。
空间大小 堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。 在Windows下,栈是向低地址扩展的数据结构,栈是一块连续的内存区域,大小是操作系统预定好的,在编译时确定,可设置。
碎片问题 对于堆,频繁的 new / delete 会造成大量内存碎片,使程序效率降低。 对于栈,它是一个先进后出的队列,进出一一对应,不会产生碎片。
分配方式 堆都是动态分配,没有静态分配的堆 栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由 alloca() 函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。
效率 堆由 C++ 函数库提供,机制很复杂。所以堆的效率比栈低很多。 栈是极其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。

为什么要内存对齐

查看笔记【C++ 对象模型】(三)关于数据成员第 1 部分。

内联函数与宏的区别

宏在预编译时在调用处执行字符串的原样替换(宏展开)。而内联函数在编译时在调用处展开,同时进行参数类型检查,宏定义不会进行参数类型检查。

内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了。

一般来说内联函数可以完全替代宏,更多关于内联函数可以查看笔记【C++ 对象模型】(四)关于函数成员第 3 部分。

虚函数可以是内联函数吗

  • 虚函数可以是内联函数,内联是可以修饰虚函数的,但是当虚函数表现多态性的时候不能内联。
  • 内联是在编译期建议编译器内联,而虚函数的多态性在运行期,编译器无法知道运行期调用哪个代码,因此虚函数表现为多态性时(运行期)不可以内联。
  • inline virtual 唯一可以内联的时候是:编译器知道所调用的对象是哪个类(如 Base::who()),这只有在编译器具有实际对象而不是对象的指针或引用时才会发生。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>  
using namespace std;
class Base
{
public:
inline virtual void who()
{
cout << "I am Base\n";
}
virtual ~Base() {}
};
class Derived : public Base
{
public:
inline void who() // 不写inline时隐式内联
{
cout << "I am Derived\n";
}
};

int main()
{
// 此处的虚函数 who(),是通过类(Base)的具体对象(b)来调用的,编译期间就能确定了,所以它可以是内联的,但最终是否内联取决于编译器。
Base b;
b.who();

// 此处的虚函数是通过指针调用的,呈现多态性,需要在运行时期间才能确定,所以不能为内联。
Base *ptr = new Derived();
ptr->who();

// 因为Base有虚析构函数(virtual ~Base() {}),所以 delete 时,会先调用派生类(Derived)析构函数,再调用基类(Base)析构函数,防止内存泄漏。
delete ptr;
ptr = nullptr;

system("pause");
return 0;
}

浅拷贝和深拷贝

浅拷贝就是在拷贝时直接进行简单的对应赋值操作,类似于参数的引用传递,源对象与拷贝对象共用一份实体,对其中任何一个对象的改动都会影响另外一个对象。

深拷贝则类似于参数的值传递,源对象与拷贝对象互相独立 ,其中任何一个对象的改动都不会对另外一个对象造成影响。

什么时候需要深拷贝

当一个类中有对其他类对象成员时需要深拷贝,也就是不能直接将一个类对象的某个对象成员赋值给另一个类对象,而是要单独开辟一块内存,重新构造一个相同的对象成员,否则当类对象析构时,拷贝的类对象的该对象成员也被析构掉了就会产生错误。

关于拷贝构造函数可以查看笔记【C++ 对象模型】(二)关于构造函数第 2 部分。

C++ 的强制类型转换

C++ 提供四种强制类型转换关键字:static_cast、const_cast、reinterpret_cast 和 dynamic_cast。

  • static_cast 用于数据类型的强制转换,强制将一种数据类型转换为另一种数据类型。主要用于内置类型之间的转换或者将空指针转化为对应类型的指针,但不能用于基本数据类型指针之间的转化。static_cast 也可以用于类层次结构中基类和派生类之间指针或引用的转换,但需要注意:进行上行转换(把派生类的指针或引用转换成基类表示)是安全的,但进行下行转换(把基类的指针或引用转换为派生类表示),由于没有动态类型检查,所以是不安全的。
  • const_cast 用于强制去掉 const 修饰的常量特性,但需要特别注意的是 const_cast 不是用于去除变量的常量性,而是去除指向常量对象的指针或引用的常量性,其去除常量性的对象必须为指针或引用。具体的例子可以查看C++强制类型转换第 2 部分,总之非必要情况下尽量不要使用 const_cast。
  • reinterpret_cast 主要有三种强制转换用途:用于基本数据类型的指针或引用之间的转换、将指针或引用转换为一个足够长度的整型、将整型转换为指针或引用类型。reinterpret_cast 可以将指针或引用转换为一个足够长度的整形,这里的足够长度具体是多少则取决于操作系统,如果是 32 位的操作系统,就需要 4 个字节及以上的整型,如果是 64 位的操作系统则需要 8 个字节及以上的整型。
  • dynamic_cast 是 C++ 支持多态的方式之一,其他三种类型转换都是在编译时进行,而 dynamic_cast 则是在运行时处理。 在 C++ 中,编译期的类型转换有可能会在运行时出现错误,特别是涉及到类对象的指针或引用操作时,更容易产生错误。dynamic_cast 操作符则可以在运行期对可能产生问题的类型转换进行判定。dynamic_cast 具有以下特性:
    • 不能用于内置的基本数据类型的强制转换。
    • dynamic_cast 转换如果成功的话返回的是指向类的指针或引用,转换失败的话则会返回 nullptr。
    • 使用 dynamic_cast 进行转换时,基类中一定要有虚函数,否则编译不通过。这是因为类中存在虚函数,就说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义。
    • 在类层次间进行上行转换时,dynamic_cast 和 static_cast 的效果是完全一样的。在进行下行转换时,由于 dynamic_cast 具有运行时类型检查的功能,即要转换的指针指向的对象的实际类型与转换以后的对象类型一定要相同,否则转换失败,因此要比 static_cast 更安全。运行时类型检查需要运行时类型信息,这个信息存储在虚函数表中,关于这部分可以查看笔记【C++ 对象模型】(一)关于对象第 2 部分和【C++ 对象模型】(七)模板和 RTTI第 2 部分。
  • 总结:基本数据类型之间的转换使用 static_cast,基本数据类型指针或引用之间的转换用 reinterpret_cast,类对象的指针或引用之间的转换用 dynamic_cast 最为安全,但上行转换时使用 static_cast 也同样安全,const_cast 用于去掉指针或引用的常量性,较少使用。

关于模板的声明和实现

C++ 在写模版函数时(template<class T>之类的),头文件不能与 cpp 文件分离。这就意味者,你头文件定义的含模版的地方必须在头文件中实现,没用模版定义的地方可以放在 cpp 中实现。为什么会这样呢?

C++ 中每一个对象所占用的空间大小,是在编译的时候就确定的,在模板类没有真正的被使用之前,编译器是无法知道模板类中使用模板类型的对象的所占用的空间的大小的。只有模板被真正使用的时候,编译器才知道,模板套用的是什么类型,应该分配多少空间。这也就是模板类为什么只是称之为模板,而不是泛型的缘故。

既然是在编译的时候,根据套用的不同类型进行编译,那么,套用不同类型的模板类实际上就是完全不同的类型,也就是说 stack<int>stack<char> 是两个不同的数据类型,他们共同的成员函数也不是同一个函数,只不过具有相似的功能罢了。因此这两个模板类的成员函数也会被编译出完全不同的代码。

所以模板类的实现,脱离具体的使用,是无法单独的编译的;把声明和实现分开的做法也是不可取的,必须把实现全部写在头文件里面。为了清晰,实现可以不写在 class 后面的花括号里面,可以写在 class 的外面。

但也可以实现模板声明和实现分离,比如有下面的模板类:

1
2
3
4
5
6
7
8
9
10
11
template <typename Node>
class TestTemplate{
public:

TestTemplate(Node node):
data(node) { }

Node data;

void print();
}

它的实现如下:

1
2
3
4
template <typename node>
void TestTemplate<node>::print(){
std::cout << "TestTemplate " << data << std::endl;
}

如果把它们分别放在 .h 和 .cpp 文件中,链接器会报错,提示找不到实现。但在 .h 文件中模板类的声明下加这一句:

1
#include "TestTemplate.tpp"

然后把实现放在名为 TestTemplate.tpp 文件中,即可实现模板声明和实现的文件分离。

类的静态成员变量何时初始化

在 main 函数运行前,程序加载时进行初始化,分为静态初始化和动态初始化。

静态初始化是用常量对静态成员初始化,静态初始化在程序加载的过程中完成。

动态初始化主要是指需要经过函数调用才能完成的初始化,比如说:int a = foo(),或者是复杂类型(类)的初始化(需要调用构造函数)等。这些变量的初始化会在 main 函数执行前由运行时调用相应的代码从而得以进行(函数内的 static 变量除外)。

左值和右值

左值指既能够出现在等号左边,也能出现在等号右边的变量;右值则是只能出现在等号右边的变量。

左值是可寻址的变量,有持久性;右值一般是不可寻址的常量,或在表达式求值过程中创建的无名临时对象,短暂性的。

左值和右值主要的区别之一是左值可以被修改,而右值不能。

左值引用和右值引用

引用本质是别名,可以通过引用修改变量的值,传参时传引用可以避免拷贝,其实现原理和指针类似。

能指向左值,不能指向右值的就是左值引用:

1
2
3
int a = 5;
int &ref_a = a; // 左值引用指向左值,编译通过
int &ref_a = 5; // 左值引用指向了右值,会编译失败

引用是变量的别名,由于右值没有地址,没法被修改,所以左值引用无法指向右值。但是,const 左值引用是可以指向右值的:

1
const int &ref_a = 5;  // 编译通过

const 左值引用不会修改指向值,因此可以指向右值,这也是为什么通常要使用 const & 作为函数参数的原因之一,如 std::vector push_back

1
void push_back (const value_type& val);

如果没有 constvec.push_back(5) 这样的代码就无法编译通过了。

右值引用的标志是 &&,顾名思义,右值引用专门为右值而生,可以指向右值,不能指向左值:

1
2
3
4
5
6
int &&ref_a_right = 5; // ok

int a = 5;
int &&ref_a_left = a; // 编译不过,右值引用不可以指向左值

ref_a_right = 6; // 右值引用的用途:可以修改右值

move 和 forward 的作用

std::move 可以将左值强制转化为右值,从而让右值引用可以指向左值:

1
2
3
4
5
int a = 5; // a是个左值
int &ref_a_left = a; // 左值引用指向左值
int &&ref_a_right = std::move(a); // 通过std::move将左值转化为右值,可以被右值引用指向

cout << a; // 打印结果:5

那么 move 到底有什么用处呢?首先来总结一下左右值引用:

  1. 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
  2. 右值引用可以直接指向右值,也可以通过 std::move 指向左值;而左值引用只能指向左值,但 const 左值引用也能指向右值。
  3. 作为函数形参时,右值引用更灵活。虽然 const 左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。

上面已经给出了 move 的作用之一,就是比左值引用更加灵活,但其本身只是一个类型转换工具,不会对性能有任何提升,但使用 move 可以实现移动语义从而提升性能。

在没有右值引用之前,当一个类中有其他对象成员的时候,拷贝构造或者赋值运算都需要深拷贝,相当于复制了一份对象成员,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Array {
public:
Array(int size) : size_(size) {
data = new int[size_];
}

// 深拷贝构造
Array(const Array& temp_array) {
size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

// 深拷贝赋值
Array& operator=(const Array& temp_array) {
delete[] data_;

size_ = temp_array.size_;
data_ = new int[size_];
for (int i = 0; i < size_; i ++) {
data_[i] = temp_array.data_[i];
}
}

~Array() {
delete[] data_;
}

public:
int *data_;
int size_;
};

虽然拷贝构造函数的参数是左值引用的,左值引用就是为了防止拷贝,但函数内还是进行了深拷贝,如果被拷贝者之后就不需要了,那这样的拷贝函数会显得非常呆,于是有人提出是不是可以写一个移动构造函数,进行浅拷贝:

1
2
3
4
5
6
7
// 移动构造函数,可以浅拷贝
Array(const Array& temp_array, bool move) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止temp_array析构时delete data,提前置空其data_
temp_array.data_ = nullptr;
}

但是使用 const 左值引用实现移动构造函数有两个问题:

  • 不优雅,表示移动语义还需要一个额外的参数(或者其他方式)
  • 实际根本无法实现,temp_array是个 const 左值引用,无法被修改,所以 temp_array.data_ = nullptr; 这行根本无法编译通过。当然函数参数可以改成非 const:Array(Array& temp_array, bool move){...},但这样也有问题,由于左值引用不能接右值,Array a = Array(Array(), true);这种调用方式就没法用了。

可以看出左值引用使用起来非常不方便,而使用右值引用作为参数可以完美的解决这个问题,实现成员数据的移动。

1
2
3
4
5
6
Array(Array&& temp_array) {
data_ = temp_array.data_;
size_ = temp_array.size_;
// 为防止 temp_array 析构时 delete data,提前置空其data_
temp_array.data_ = nullptr;
}

在调用时使用 move 转化为右值传入函数:

1
2
3
4
5
6
7
8
9
int main(){
Array a;

// 做一些操作
.....

// 左值a,用std::move转化为右值
Array b(std::move(a));
}

事实上在 STL 的很多容器中都使用了以右值引用为参数的移动构造函数和移动赋值重载函数,或者其他函数,最常见的如 std::vector 的 push_backemplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void push_back (const value_type& val);
void push_back (value_type&& val);

void emplace_back (Args&&... args);

int main() {
std::string str1 = "aacasxs";
std::vector<std::string> vec;

vec.push_back(str1); // 传统方法,copy
vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}

除非设计不允许移动,STL 中的类大都支持移动语义函数。另外,编译器会默认在用户自定义的类和结构体中生成移动语义函数,但前提是用户没有主动定义该类的拷贝构造等函数。因此,可移动对象在需要拷贝且被拷贝者之后不再被需要的场景中,建议使用 std::move 触发移动语义,提升性能。

1
2
3
moveable_objecta = moveable_objectb; 
// 改为:
moveable_objecta = std::move(moveable_objectb);

还有些 STL 类是 move-only 的,比如智能指针中的 unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝)。

与 move 相比,forward 更强大,move 只能将左值转化为右值,forward 都可以。

std::forward<T>(u) 有两个参数 T 与 u。当 T 为左值引用类型时,u 将被转换为 T 类型的左值;否则 u 将被转换为 T 类型右值。

什么是引用折叠

右值引用比左值引用更加灵活,但如果右值引用绑定的对象类型是未知的话,就既可能是左值,又可能是右值。比如模板:

1
2
3
4
5
6
template<typename T>
void f(T&& param);

f(10); // 10是右值
int x = 10;
f(x); // x是左值

由于存在 T&& 这种未定的引用类型,当它作为参数时,有可能被一个左值引用或右值引用的参数初始化,这时相比普通的右值引用(&&)会发生类型的变化,这种变化就称为引用折叠。

引用折叠规则如下:

1.所有右值引用折叠到右值引用上仍然是一个右值引用。(A&& && 变成 A&&)

2.所有的其他引用类型之间的折叠都将变成左值引用。 (A& & 变成 A&; A& && 变成 A&; A&& & 变成 A&)

static 关键字

C++ 中 static 修饰不同的内容会有不同的效果:

  • static 修饰变量的时候,被修饰的静态局部变量只执行初始化一次,而且延长了局部变量的生命周期,直到程序运行结束以后才释放。static 修饰的变量存放在全局数据区的静态变量区,包括全局静态变量和局部静态变量,都在全局数据区分配内存。初始化的时候自动初始化为 0。
  • static 修饰全局变量的时候,这个全局变量只能在本文件中访问,不能在其它文件中访问,即便是 extern 外部声明也不可以。
  • static 修饰一个函数,则这个函数的只能在本文件中调用,不能被其他文件调用。
  • static 修饰类中的成员变量的时候,可以直接通过类名访问改变量,该变量值初始化一次,被所有对象共享
  • static 修饰类中的函数的时候,该函数可以直接通过类名调用,且静态成员函数只能访问类中的静态成员变量

const 的用法

  • 修饰不同变量可以防止变量被修改,通常用于定义程序运行时的常量
  • 传递参数时常与指向对象的指针或者对象的引用传递使用,避免对象拷贝的同时还可以防止指针或者指向的对象在函数中被意外的修改(const char* p 是指针指向的内容不可变,而 char* const p 是指针本身不可变,可以根据 const 所在 * 的位置概括为:左定值,右定向),值传递不需要使用 const,因为需要复制一份对象,肯定不会修改原来的对象
  • 修饰函数返回值时,表示该函数的返回值不能作为左值使用,即不能修改或被赋值
  • const 修饰类的成员函数,表示不允许该成员函数修改调用该函数的对象的值,所以 const 不能与 static 同时修饰函数,因为定义为静态的成员函数没有指向对象的指针,而定义为 const 的函数必须由对象调用。另外,如果类中的某些成员变量允许在任何情况下都被修改,可以在声明时加上 mutable 关键字,此时该成员可以在 const 函数中被修改
  • const 修饰对象,可以构造一个常量对象,该对象只能调用类中的 const 函数
  • const 还用于左值引用指向右值,前面已经提到过

extern 关键字

extern 可以引用不在同一个文件中的变量或者函数

指针和引用的区别

指针是一个地址,是一个单独的变量,指针本身和其指向的内容都可以被改变;而引用是对象的别名,依附于某一个对象,可以通过引用改变对象的内容,但引用本身不能改变,即引用在其生命周期内只能依附于固定的对象。

因此可以有空指针,但不能有空的引用。

模板函数和模板类

查看C++函数模板(模板函数)详解

模板函数可不可以是虚函数,为什么

不可以,因为模板函数在调用的时候才会被实例化,如果不调用模板,就不会实例化产生模板函数,而虚函数必须在编译的时候产生函数实体,并将指向其的指针放入虚函数表中,此时如果该虚函数是模板函数,并且还没有编译到调用该函数的地方,就不会产生函数实体,就会出错。

类模板中的 static 数据成员被所有模板类共享吗

当类模板中出现 static 修饰的静态类成员的时候,我们只要按照正常理解就可以了。static 的作用是将类的成员修饰成静态的,所谓的静态类成员就是指类的成员为类级别的,不需要实例化对象就可以使用,而且类的所有对象都共享同一个静态类成员,因为类静态成员是属于类而不是对象。那么,类模板的实现机制是通过二次编译原理实现的。C++ 编译器并不是在第一个编译类模板的时候就把所有可能出现的类型都分别编译出对应的类(太多组合了),而是在第一个编译的时候编译一部分,遇到泛型不会替换成具体的类型(这个时候编译器还不知道具体的类型),而是在第二次编译的时候再将泛型替换成具体的类型(这个时候编译器知道了具体的类型了)。由于类模板的二次编译原理再加上 static 关键字修饰的成员,当它们在一起的时候实际上一个类模板会被编译成多个具体类型的类,所以,不同类型的类模板对应的 static 成员也是不同的(不同的类),但相同类型的类模板的 static 成员是共享的(同一个类)。

strcpy、memcpy 和 memmove 的区别

strcpy 和 memcpy 主要有以下区别:

  • 复制的内容不同。strcpy 只能复制字符串,而 memcpy 可以复制任意内容,例如字符数组、整型、结构体、类等。
  • 复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符”\0”才结束,如果空间不够,就会引起内存溢出。memcpy则是根据其第 3 个参数决定复制的长度。
  • 实现功能不同,strcpy 主要实现字符串变量间的拷贝,memcpy 主要是内存块间的拷贝。
  • 操作对象不同,strcpy 的操作对象是字符串,memcpy 的操作对象是内存地址,并不限于何种数据类型。
  • 执行效率不同,memcpy 最高,strcpy 次之。

memcpy 与 memmove 二者的作用是一样的,唯一的区别是,当内存发生局部重叠的时候,memmove 保证拷贝的结果是正确的,memcpy 不保证拷贝的结果的正确。

memmove 的处理措施:

  • 当源内存的首地址等于目标内存的首地址时,不进行任何拷贝
  • 当源内存的首地址大于目标内存的首地址时,实行正向拷贝
  • 当源内存的首地址小于目标内存的首地址时,实行反向拷贝

this 指针存在哪

其实编译器在生成程序时加入了获取对象首地址的相关代码。并把获取的首地址存放在了寄存器 ECX 中( VC++ 编译器是放在 ECX 中,其它编译器有可能不同)。也就是成员函数的其它参数正常都是存放在栈中。而 this 指针参数则是存放在寄存器中。当一个对象调用某成员函数时会隐式传入一个参数, 这个参数就是 this 指针。this 指针中存放的就是这个对象的首地址。

在构造函数中使用 memset 会出现什么问题

通过一个例子来深入理解这个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <iostream>
#include <vector>
using namespace std;

class A {
public:
A() {
x = new char[10]();
}

~A() {
delete x;
}

virtual void getval() {
cout << "A" << endl;
}

char* x;
};

class B : public A {
public:
B() {
memset(this, 0, sizeof(*this));
}

void getval() override {
cout << "B" << endl;
}

int y;
};

int main()
{
A* a = new A();
cout << "1 ";
a->getval();
delete a;

B b1;
cout << "2 ";
b1.getval();

B* b2 = new B();
cout << "3 ";
b2->getval();
delete b2;

A* p = new B();
cout << "4 ";
p->getval();
delete p;

//_CrtDumpMemoryLeaks();
return 0;
}

上述程序输出为:

1
2
3
1 A
2 B
3

接下来我们来逐段分析,首先是第一段调用代码:

1
2
3
4
A* a = new A();
cout << "1 ";
a->getval();
delete a;

A 是一个正常的类,没有任何问题,因此这段代码可以正常输出结果,并且不会发生内存泄漏。

接下来一段:

1
2
3
B b1;
cout << "2 ";
b1.getval();

虽然类 B 的构造函数中执行了 memset(this, 0, sizeof(*this)),将虚函数表指针 vptr 置为了空指针,但由于这里的函数是通过对象调用的,所以在编译时就确定了函数执行地址,所以可以正常输出结果,但会发生内存泄漏,因为虽然对象 b1 存储在栈上,但其中的 char* 指针 x 指向堆上一块内存,x 指针通过 memset 被置空之后,堆上那块内存就无法被释放了,所以造成了内存泄漏。

下面是第三段代码:

1
2
3
4
B* b2 = new B();
cout << "3 ";
b2->getval();
delete b2;

这里通过指针调用函数,会通过 vptr 寻找虚函数表,再找到对应的虚函数去执行,而 memset 将 vptr 置为 nullptr,导致无法找到虚表,从而产生运行时错误。

第四段代码也是同理:

1
2
3
4
A* p = new B();
cout << "4 ";
p->getval();
delete p;

同样通过虚函数机制调用,产生运行时错误。

综上,在构造函数中使用 memset 时,必须保证类没有虚函数,没有虚继承且类成员中没有指针,如果一个类中只有基本数据成员和非虚函数,那么使用 memset 可以快速将所有成员初始化而不产生问题,除了这种情况外,都会产生问题,要么是运行时错误,要么是内存泄漏。

auto 和 decltype

Lambda 表达式

仿函数

Optional

游戏引擎常用的设计模式简介

关于设计模式具体的可以查看C++各类设计模式及实现详解,这里简单总结游戏引擎中可能用到的几种:

  • 单例模式(SingletonDesign Pattern):只允许创建一个类的一个实例。在游戏中,就像在电影里,应该只有一个导演。导演是一个类,这个类在游戏中指挥发生的事情。它控制对象的呈现。它控制位置更新。它将玩家的输入指向正确的游戏角色。引擎应该阻止创建一个以上的导演类的实例,通过单例设计模式来实现。此设计模式确保为给定类实例化有且只有一个对象。
  • 策略模式(StrategyDesign Pattern):通过解耦类行为从而提供灵活性。在游戏中,你应该将输入控制器和游戏逻辑之间的交互进行解耦。游戏的逻辑应该接收相同类型的输入,而不管输入控制器是什么(按钮,手势,操纵杆)。尽管对用户每个输入控制器的行为表现不同,但它们必须向游戏的逻辑提供相同的数据。此外,添加或删除输入控制器不应导致游戏崩溃。这种解耦行为和灵活性是可能的,这归功于策略设计模式。这种设计模式允许通过动态方式来改变行为,而不需要修改游戏的任何逻辑,为你的游戏提供了很高的灵活性。
  • 观察者模式(Observer Design Pattern):允许类在不知道任何事情的情况下相互交互。在游戏中,你的所有类耦合度应该设计的很低。这意味着你的类应该能够彼此交互且彼此之间应该知道对方尽量少的内容。使得你的类具有低耦合度,使得你的游戏可以模块化和灵活性的添加新的功能,且不会有意外的错误。
  • 组合模式(CompositeDesign Pattern):为所有类提供了统一的接入点。游戏通常包含许多视图。主视图中显示角色。有一个子视图,显示玩家的积分。有一个子视图,显示游戏中剩下的时间。如果你在移动设备上玩游戏,那么每个按钮都是一个视图。可维护性应该是游戏开发过程中的主要关注点。每个视图不应具有不同的函数名称或不同的访问点。相反,你想要为每个视图提供一个统一的访问点,即相同的函数调用应该既能够访问主视图也能够访问子视图。这种统一的接入点可以使用复合设计模式。此模式将每个视图放置在树状结构中,从而为每个视图提供统一的访问点。取代了需要用不同的函数来访问不同的子视图,组合模式可以用相同的函数访问任何视图。
---- 本文结束 知识又增加了亿点点!----

文章版权声明 1、博客名称:LycTechStack
2、博客网址:https://lz328.github.io/LycTechStack.github.io/
3、本博客的文章部分内容可能来源于网络,仅供大家学习与参考,如有侵权,请联系博主进行删除处理。
4、本博客所有文章版权归博主所有,如需转载请标明出处。