一文搞懂ModernCPP
CPP
q1:使用cpp运算符重载,重载类的=运算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
public:
int* data;
A(int data_) : data(new int(data_)) {}
// 拷贝构造函数
A(const A& a) : data(new int(*a.data)) {}
// 重载=运算符
A& operator=(const A& a) {
// 容易遗漏这一步,防止自我复制
if(this == &a) return *this;
delete data;
data = new int(*a.data);
return *this;
}
}
野指针和悬垂指针的区别?
- 野指针:指向未知内存的指针,产生的原因:没有初始化、释放后未置空
- 悬垂指针:指向已经释放的内存的指针,产生的原因:释放后未置空
虚函数相关
q2:虚函数是怎么实现的?它存放在哪里,在内存的哪个区里?什么时候生成的?
在CPP中,虚函数的实现原理基于两个关键概念:虚函数指针指针(vptr)和虚函数表(vtable)。
- 虚函数指针:每个包含虚函数的类对象中都会生成一个指向虚表的指针,这个指针被称为虚表指针。虚表是一个函数指针数组,里面存放着虚函数的地址。这个虚表在编译期间生成,并且会放在文本段,由所有的类对象共享。这个指针的初始化是在构造函数中执行的
- 虚函数表:本质上就是函数指针数组,存放着类中所有虚函数的实现的地址(在代码段中)
当基类和派生类中都包含虚函数时,在构造的时候就会初始化虚函数表。同时派生类会继承父类的基函数表,如果派生类没有重写基类中的某个虚函数,表中就继承这个父类中实现这个虚函数的函数指针。
当一个指针/引用调用一个函数时,被调用的函数是取决于这个指针/引用指向的对象。如果是基类对象,就调用对象的指针;如果是派生类就调用派生类对象的方法。如果派生类中没有实现,由于虚表的继承特性,会直接调用到继承下来的基类的虚函数实现。
虚函数指针存放在对象内存的头四个字节(64位8个字节),虚函数存放在代码区,在编译的时候生成。
q3:父类的构造函数和析构函数是否能为虚函数?
- 构造函数不能为虚函数,虚函数的调用是通过虚函数表来查找的,而虚函数表由类的实例化对象的vptr指针指向,该指针存放在对象的内部空间之中,需要调用构造函数完成初始化,如果构造函数为虚函数,那么调用构造函数就需要去寻找vptr,但此时vptr还没有完成初始化,导致无法构造对象。
- 析构函数必须设为虚函数:当我们使用父类指针指向子类时,只会调用父类的析构函数,子类的析构函数不会被调用,容易造成内存泄漏。
1
2
A* a = new B();
delete a;
如果没有定义析构函数为虚函数,这个时候只会执行A的析构函数,而不会执行B的析构函数,导致B的资源没有被释放,造成内存泄漏。
q4: 在构造函数(析构函数)中调用虚函数会发生什么?
这个问题的本质就是想跟你说,虚函数在构造函数和析构函数中无法进行动态联遍,只能进行早绑定这个问题。
会导致未定义的行为, 也就是程序会出现莫名其妙的行为. 给个代码的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
class A {
public:
virtual void show(){
cout<<"in A"<<endl;
}
virtual ~A(){show();}
};
class B:public A {
public:
void show(){
cout<<"in B"<<endl;
}
};
int main() {
A a;
B b;
}
在这个例子中, 你来猜一猜最后会输出什么? 答案是:
1
2
in A
in A
为什么子类定义的虚函数在父类的析构函数中没有被动态联遍呢? 在构造和析构函数里实际上会发生的是静态联编,也就是不会对虚函数去动态绑定;如果你在一个父类的构造函数中使用了虚函数,那么子类构造的时候会先调用父类的构造函数, 这个时候不会是我们以为的那样会调用重写的虚函数,导致一些意想不到的情况发生. 比如你以为在子类重写后会调用子类的版本。
q5: 在多继承的情况下, 子类中会有多少个虚表指针?
对于当前子类继承多少个父类就有多少个虚表指针
深拷贝和浅拷贝的区别?以及手写一个深拷贝
从包含指针成员的类起手说起。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Myclass {
public:
char* data;
Myclass(const char* str = "") {
data = new char[strlen(str) + 1];
strcpy(data, str);
}
// 深拷贝
Myclass(const Myclass& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
// 深拷贝 =符号重载
Myclass& operator=(const Myclass& other) {
if(this == &other) return *this;
delete data[];
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
return *this;
}
}
手搓一个share_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
template <typename T>
class SharedPtr {
public:
explicit SharedPtr(T* ptr = nullptr) : _ptr(ptr), _count(new int(1)) {};
// 拷贝构造函数
SharedPtr(const SharedPtr& other) {
_ptr = other._ptr;
_count = other._count;
++(*_count);
}
// =运算符重载
SharedPtr& operator=(const SharedPtr& other) {
if(this == &other) return *this;
release();
_ptr = other._ptr;
_count = other._count;
++(*_count);
}
// 移动构造
SharedPtr(SharedPtr && other) : _ptr(other.ptr), _count(other._count) {
other._ptr = nullptr;
other._count = nullptr;
}
// 析构函数
~SharedPtr() {
realease();
}
private:
void release() {
if(_count && --(*_count) == 0) {
// 计数为0,释放资源
delete _ptr;
delete _count;
}
}
T* _ptr;
std::atomic<int>* _count;
}
手搓一个unique_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
template <typename T>
class UniquePtr {
public:
explicit UniquePtr(T* ptr = nullptr) : _ptr(ptr) {};
// 独占指针不能使用拷贝函数和拷贝复制运算符
UniquePtr(const UniquePtr&) = delete;
UniquePtr& operator=(const UniquePtr&) = delete;
// 移动构造
UniquePtr(UniquePtr&& other) : _ptr(other._ptr) {
other._ptr = nullptr;
}
UniquePtr& operator=(UniquePtr&& other) {
if(this == &other) return *this;
reset();
_ptr = other.ptr;
other.ptr = nullptr;
return *this;
}
// 析构函数
~UniquePtr() {
reset();
}
// 独占函数有两个重要的函数,一个是reset,一个是release
void reset() {
delete _ptr;
_ptr = nullptr;
}
// 释放所有权
T* release() {
T* temp = _ptr;
_ptr = nullptr;
return temp;
}
private:
T* _ptr;
}
常见的类型占用字节数以及类对象内存大小计算
- char: 1 字节
- bool: 1 字节
- short: 2 字节
- int: 4 字节
- long: 4 字节(在某些平台上可能是8字节)
- long long: 8 字节
- float: 4 字节
- double: 8 字节
- long double: 16 字节(在某些平台上可能是12字节)
类对象内存大小计算方式:
- 数据成员大小:类中所有非静态数据成员大小之和。
- 继承的父类的大小
- 虚函数表的指针(4个字节)/ 如果存在虚函数
内存对齐问题(类内存大小计算问题)
内存对齐是指在内存当中储存数据的时候,数据的起始位置需要满足一定的对齐要求。如果内存大小不是对齐的,那么CPU在读取数据的时候需要进行两次读取,效率低下。
内存对齐的原则:
- 先看数据成员,数据成员的地址必须是其大小(对齐值)的整数倍,即地址能够被对齐值整除。
- 结构体的最后一个成员要填充到最大对齐值
步骤:
- 确定每个成员变量是否需要对齐,如果需要对齐,就按照对齐值进行对齐。对齐值为下个元素的大小,如果是最后一个元素,需要看结构体大小是否为最大对齐值的整数倍。
- 确定结构体的大小,结构体的大小是最大对齐值的整数倍。
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
#include <iostream>
struct A {
char c1;
char c2;
int i;
};
struct B {
char c1;
int i;
char c2;
};
struct C {
int i;
char c1;
char c2;
};
结构体A:
char c1:1字节
char c2:1字节
填充字节:2字节(为了对齐到4字节边界)
int i:4字节
总大小:8字节
结构体B:
char c1:1字节
填充字节:3字节(为了对齐到4字节边界)
int i:4字节
char c2:1字节
填充字节:3字节(为了对齐到4字节边界)
总大小:12字节
结构体C:
int i:4字节
char c1:1字节
char c2:1字节
填充字节:2字节(为了对齐到4字节边界)
总大小:8字节
注意:在继承中同样需要注意到内存对齐的问题
- 单继承 ```cpp class Example1 { // 12字节 private: char a; // 1字节 int b; // 4字节 short c; // 2字节 };
// 对齐4字节
class Example2 : private Example1 { // 16字节 public: double a; // 8字节 char b; // 1字节 float c; // 4字节 };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
类与类之间同样需要内存对齐,要保证类的大小是最大对齐值的整数倍。(类的大小加上后面的填充量)
- 多继承
```cpp
class Base1 {
public:
int a; // 4字节
};
// 4字节填充
class Base2 {
public:
char b; // 1字节 // 7字节填充
double c; // 8字节
};
class Derived : public Base1, public Base2 {
public:
float d; // 4字节
};
// 4字节填充
多继承也是同样的道理
helloworld程序从执行到显示在屏幕上的全过程?(实际上考察的是你对操作系统的综合理解)
首先我们在编辑器中编写到helloworld程序,然后在命令行调用gcc来编译程序。程序编译有四个过程,预处理、编译、汇编、链接。预处理阶段处理#开头的预编译执行,比如像helloworld里的#include,如果有#define也要处理,处理过程就是进行替换,头文件替换呀,宏替换页;编译阶段就是将预处理之后的文件进行编译,进行一系列的词法分析、语法分析、语义分析、优化等操作,生成汇编代码;汇编阶段就是将汇编代码转换成机器码,生成二进制文件;链接阶段就是将各个模块的机器码链接成一个可执行文件。链接分为静态链接和动态链接。然后这个可执行文件就被gcc程序调用write接口保存在磁盘中。
然后我们在命令行中调用./helloworld来执行这个可执行文件。操作系统会出现什么动作呢?
首先我们要知道可执行文件在Linux中式如何布局的。Linux中的可执行文件是ELF格式的,ELF格式的文件分为三个部分:头部、代码段、数据段。头部包含了程序的入口地址、程序的长度、程序的加载地址等信息。代码段包含了程序的代码,数据段包含了程序的数据。
然后我们看看程序是如何写入到内存当中的。从磁盘内复制可执行文件的代码和数据到内存中,这个过程叫做加载。
首先我们执行一个程序的时候,操作系统会从当前进程fork出一个新的进程,这里讲讲该新进程的内存布局。Linux中的进程内存布局分为五个部分:代码段、数据段、堆、栈、内存映射区。代码段存放程序的代码,数据段存放程序的数据,堆是动态分配的内存,栈是函数调用的内存,内存映射区是共享库的内存。
我们知道,在汇编的时候,会给每段代码和地址分配一个地址,这个地址其实不是虚拟地址,而是偏移地址,从零开始的。所以需要在虚拟地址和偏移地址之间建立一个映射关系,这个映射表就是程序映射表。他会和你说在虚拟内存的什么地方加载什么程序以及在文件中的偏移量是多少,然后你在头文件信息表中可以找到0号的地址所在的文件偏移量,然后就根据这个映射表把磁盘中的内容加载进内存。
把程序的入口地址写入PC寄存器后,就可以开始执行程序了。
接下来讲一讲helloworld程序是在屏幕上显示的。
讲讲系统调用,讲讲IO操作。讲讲如何把字符串写到显示器的寄存器中。
右值引用、移动语义、完美转发
- 移动语义:使用开销更低的移动操作替代拷贝操作,通过控制资源的所有权的所有者来实现资源的转移;
- 完美转发:在函数模板中接收多个参数,可以将实参转发到其他的函数,使目标函数接收到的实参与被传递的转发函数的实参操持一致;
左值右值
什么是左值和右值?
- 左值:能对表达式取地址,或具有名字的变量或对象。一般指表达式结束后依然存在的持久对象。
- 右值:不能对表达式取地址,或没有名字的匿名变量,一般指表达式结束后就消失的临时对象。
什么是左值引用和右值引用?
- 左值引用:绑定到左值的引用类型,用于传递和操作左值。
- 右值引用:&&声明,专门用来绑定右值,可以实现资源的移动而不是拷贝。
- 左值可以被寻址,右值不可以被寻址。左值引用只能绑定到左值上,不能绑定到右值上。
注意:对于一个形参,记住形参本身是个左值,哪怕它的定义是个右值引用!
为什么要有左值和右值
- 为了区分临时对象和持久对象,临时对象的生命周期短,持久对象的生命周期长。
- 左值是一个持久的对象,可以被修改;
- 右值是个临时的对象,可以将一个左值通过move得到其右值引用,实现资源的移动而不是拷贝,减小开销。
Move的底层实现
1
2
3
4
5
6
namespace std {
template<typename T>
typename remove_reference<T>::type&& move(T&& arg) noexcept {
return static_cast<typename remove_reference<T>::type&&>(arg);
}
}
解析:
- T&& 是一个万能引用,可以绑定到左值和右值上。
- 传入左值转换为左值引用,传入右值转换为普通类型。
- remove_reference
::type 移除T的引用,得到T的原始类型。 - static_cast<T&&>(arg) 将arg转换为右值引用。
Forward的底层实现
1
2
3
4
5
6
template<typename T> //在std命名空间
T&& forward(typename
remove_reference<T>::type& param)
{
return static_cast<T&&>(param);
}
理解std::move和std::forward
不管是move还是forward,其底层其实根本没有执行任何代码,本质上就是个类型转化 / cast的过程。move无条件的把所有实参转化为右值,而forward则是在一些特定的情况下进行转换。所以别看move这个函数名,感觉好像发生了什么移动上的动作,实际上根本就什么都没发生,就是发生些类型转换而已。
注意移动通常意味着发生修改,不要把希望被移动的对象声明为const,否则你的移动操作可能会被隐形的修改成拷贝操作,这是不愿意看到的;关于move函数,它不会移动任何东西,也不保证它执行转换的对象可以被移动,只需要知道,你使用move函数可以得到一个右值,就是声明这个左值成为一个很快被销毁的对象。
关于forward,和move的区别是它的转换的发生是有条件的。
1
2
3
4
5
6
7
8
9
void process(const Widget& lvalArg);
void process(Widget&& rvalArg);
template<typename T>
void logAndProcess(T&& param) {
auto now = std::chrono::system_clock::now();
makeLogEntry("Calling function", now);
process(std::forward<T>(param));
}
现在调用函数模板两次,一次传入左值,一次传入右值;我们希望process能够根据传入实参的类型来进行不同的调用,但是我们前面提到,所有的形参都是左值(实参类型信息丢失),那要如果实参被绑定到右值,那我们要怎么把形参转换为右值呢?这里就是forward的作用了,也是为什么说forward是有条件的:在实参被绑定到右值时,对形参进行右值转换。至于是怎么识别的,涉及到模板推导规则和引用折叠,我们往下看便能理解。
区分通用引用和右值引用
1
2
3
4
template<typename T>
void f1(vector<T>&& param); // 右值引用
void f2(T&& param); // 通用引用, 不是右值引用
auto&& var1 = var2; // 通用引用
别看通用引用和右值引用长得好像一模一样,但是本质上有极大的区别。通用引用其实是指接收被绑定到任何类型的对象上的引用(比如左值、右值、const、non-const等等)。如果模板没有严格参照Typename&&的格式,会被认为是右值引用。
理解引用折叠 / reference collapsing
首先我们要知道对于void func(T&& param)
中T的推导遵循下面的规则:
- 实参为左值,T被推导为左值引用;
- 实参为右值,T被推导为非引用 / 普通类型;
通用引用的推导规则对于理解后面引用折叠的部分非常重要,需要理解并记住。
cpp中是禁止你声明引用的引用的,但是编译器会在特定的情况下生成这些,这便是引用折叠的作用。引用根据规则折叠成单个引用:
- 如果任一引用为左值引用,则结果为左值引用。否则(即,如果引用都是右值引用),结果为右值引用。
举个例子,T&&中的T被推导为int&时,T&&就被推导为int& &&,根据引用折叠规则,存在左值引用,所以被折叠为int&。同样的,如果实参是个右值,T被推导为int,T&& 就被推导为int&&。这就是为什么在通用引用下,forward实现完美转发的原因,就是使用这个应用折叠。
赋值左右值评估顺序
赋值操作从右往左执行,先执行右边,再执行左边,最后将右边赋值给左边。
几种类型转换的区别
C++中有四种主要的类型转换操作符:static_cast
、dynamic_cast
、const_cast
和reinterpret_cast
。每种类型转换操作符都有其特定的用途和限制。以下是它们的详细说明和区别:
static_cast
:- 用于在相关类型之间进行转换,例如基本数据类型之间的转换(如
int
到float
),以及具有继承关系的类指针或引用之间的转换。 - 编译时进行检查,但不执行运行时类型检查。
- 不能用于移除
const
或volatile
限定符。
1 2 3 4 5 6 7
int a = 10; float b = static_cast<float>(a); // int 转 float class Base {}; class Derived : public Base {}; Base* basePtr = new Derived; Derived* derivedPtr = static_cast<Derived*>(basePtr); // 基类指针转派生类指针
- 用于在相关类型之间进行转换,例如基本数据类型之间的转换(如
dynamic_cast
:- 用于在具有继承关系的类指针或引用之间进行安全的向下转换(即从基类转换为派生类)。
- 需要运行时类型信息(RTTI),因此会有一定的运行时开销。
- 如果转换失败,指针类型返回
nullptr
,引用类型抛出std::bad_cast
异常。
1 2 3 4 5 6 7 8 9
class Base { virtual void foo() {} }; // 需要有虚函数以启用RTTI class Derived : public Base {}; Base* basePtr = new Derived; Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 安全的向下转换 if (derivedPtr) { // 转换成功 } else { // 转换失败 }
const_cast
:- 用于添加或移除
const
或volatile
限定符。 - 不能用于转换不同类型之间的转换,只能用于相同类型的不同限定符之间的转换。
1 2 3
const int a = 10; int* b = const_cast<int*>(&a); // 移除 const 限定符 *b = 20; // 未定义行为,因为 a 是 const
- 用于添加或移除
reinterpret_cast
:- 用于在完全不相关的类型之间进行低级别的转换,例如指针类型之间的转换。
- 不进行任何类型检查,可能导致未定义行为,因此应谨慎使用。
1 2 3
int a = 10; void* ptr = reinterpret_cast<void*>(&a); // int* 转 void* int* intPtr = reinterpret_cast<int*>(ptr); // void* 转 int*
总结:
static_cast
:用于相关类型之间的转换,编译时检查。dynamic_cast
:用于安全的向下转换,运行时检查。const_cast
:用于添加或移除const
或volatile
限定符。reinterpret_cast
:用于不相关类型之间的低级别转换,无类型检查。
智能指针
介绍下三种智能指针、阿把阿把说说独占、共享、弱引用指针,在介绍弱指针的时候,可以不仅仅说为了解决共享指针的循环引用问题,还可以说说可以解决生命周期的问题。A持有B的弱指针,在需要延长生命周期B的地方去将弱指针 升级为共享指针,不需要时再降级。
shared_ptr底层有几个计数器?
答案是两个,分别是强引用计数器和弱引用计数器。强引用计数器用于记录有多少个shared_ptr指向同一个对象,弱引用计数器用于记录有多少个weak_ptr指向同一个对象。那当什么时候shared_ptr指向的对象才会被释放呢?首先我们要知道shared_ptr的底层有个叫控制块的结构,其包含了指向共享对象的指针,强引用计数器,弱引用计数器。当强引用计数器会0时,会把控制块内指向共享对象的内存释放掉,并重置为nullptr。当弱引用计数器为0的时候,才会把这个shared_ptr对象给释放掉。如果你此时还想去尝试升级弱指针为共享指针,那么会返回nullptr。
注意所有的弱引用只能通过shared_ptr降级而来。
atomic原子性是如何实现的 atomic
std::atomic
- 硬件指令:
现代处理器提供了专门的原子操作指令,例如x86架构上的LOCK前缀指令(如LOCK XADD、LOCK CMPXCHG等),ARM架构上的LDREX和STREX指令等。 这些指令能够在多处理器环境中保证操作的原子性,即操作不可分割,不会被其他线程中断。
- 内存屏障(Memory Barrier)用于确保内存操作的顺序,防止编译器和处理器对内存操作进行重排序,从而保证多线程环境中的内存可见性。 std::atomic使用内存屏障来确保原子操作的正确性。
lambda函数是如何实现的 / 闭包、捕获列表、调用运算符重载
在C++中,lambda函数是一种匿名函数,可以在函数内部定义并立即使用。lambda函数的实现涉及到编译器生成一个闭包对象(closure object),该对象包含lambda函数的代码和捕获的变量。以下是lambda函数实现的关键点:
- 闭包对象:
- 编译器为每个lambda函数生成一个唯一的闭包类,该类包含lambda函数的代码和捕获的变量。
- 闭包对象是该闭包类的实例,用于存储捕获的变量和执行lambda函数的代码。
- 捕获列表:
- 捕获列表指定了lambda函数如何捕获外部变量,可以按值捕获(
[=]
)或按引用捕获([&]
)。 - 捕获的变量会成为闭包类的成员变量。
- 捕获列表指定了lambda函数如何捕获外部变量,可以按值捕获(
- 调用运算符:
- 闭包类重载了
operator()
,使得闭包对象可以像普通函数一样被调用。 operator()
包含lambda函数的代码。
- 闭包类重载了
以下是一个简单的示例,展示了lambda函数的实现原理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <functional>
int main() {
int x = 10;
int y = 20;
// 定义一个lambda函数,按值捕获x,按引用捕获y
auto lambda = [x, &y](int z) {
return x + y + z;
};
// 调用lambda函数
std::cout << "Result: " << lambda(5) << std::endl;
return 0;
}
编译器会将上述代码转换为类似于以下的形式:
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
#include <iostream>
#include <functional>
// 编译器生成的闭包类
class LambdaClosure {
private:
int x; // 按值捕获的变量
int& y; // 按引用捕获的变量
public:
// 构造函数,初始化捕获的变量
LambdaClosure(int x, int& y) : x(x), y(y) {}
// 调用运算符,包含lambda函数的代码
int operator()(int z) const {
return x + y + z;
}
};
int main() {
int x = 10;
int y = 20;
// 创建闭包对象,捕获变量
LambdaClosure lambda(x, y);
// 调用闭包对象
std::cout << "Result: " << lambda(5) << std::endl;
return 0;
}
lambda的捕获规则有哪些?
- 按值捕获 /
[=]
:复制捕获所有的外部变量的值,以传值的形式传递给lambda表达式,lambda函数内部使用的是外部变量的副本。 - 按引用捕获 /
[&]
:引用捕获所有的外部变量,以引用的形式传递给lambda表达式,lambda函数内部使用的是外部变量的原始值。 - 显式按值捕获 /
[x, y]
:显式捕获外部作用域中的特定变量,并以值的方式传递给 lambda 表达式。 - 显式按引用捕获([&a, &b]):显式捕获外部作用域中的特定变量,并以引用的方式传递给 lambda 表达式。
- 混合捕获([=, &a] 或 [&a, b]):可以混合使用按值捕获和按引用捕获。
- 捕获 this 指针([this]):捕获当前对象的 this 指针,以便在 lambda 表达式中访问对象的成员变量和成员函数。
lambda函数和bind有什么区别
- Lambda函数需要在定义时定义函数体,而
std::bind
可以在调用时绑定已有的函数。 - Lambda函数可以捕获外部变量,而
std::bind
需要通过类似于传参的方式传递。
sort的底层实现
sort的底层是由三种排序算法实现的。当数据量较小时使用插入排序,当数据量较大时使用快速排序,当快排的迭代次数过大的使用堆排序;插入排序的阈值一般是16,快排的阈值一般是2^16。
Map
map是一个关联容器,保存键到值的映射,然后还具有有序性,就是for range遍历是有序的,根据键的大小去排序,所以键需要实现less比较运算符。底层是红黑树,保证其有序性。
为什么map不用其他的数据结构来实现
和他说下hash表无序,然后二叉搜索树的查找效率不如红黑树。
cpp中struct和class的区别?
- class内有权限概念,struct没有,讲讲class内的权限机制
cpp继承中的权限机制
- public 继承:
- 基类的 public 成员在派生类中仍然是 public。
- 基类的 protected 成员在派生类中仍然是 protected。
- 基类的 private 成员在派生类中不可访问。
- protected 继承:
- 基类的 public 成员在派生类中变为 protected。
- 基类的 protected 成员在派生类中仍然是 protected。
- 基类的 private 成员在派生类中不可访问。
- private 继承:
- 基类的 public 成员在派生类中变为 private。
- 基类的 protected 成员在派生类中变为 private。
- 基类的 private 成员在派生类中不可访问。
全局变量和静态变量的区别
- 全局变量在函数和类外定义的变量,整个程序全局可见,如果声明extern,那么可以在多文件中可见;
- 静态变量分为局部静态变量和全局静态变量
- 局部静量在函数或者类中定义的静态变量,只在定义的函数或者类中可见,生命周期和全局变量一样,但是作用域只在定义的函数或者类中;也就是编译的时候会在内存的bss段分配空间,但是只有在第一次调用的时候才会初始化,并移入data段。
- 全局静态变量在函数和类外定义的静态变量,只在定义的文件中可见,生命周期和全局变量一样,但是作用域只在定义的文件中;也就是编译的时候会在内存的bss段分配空间,但是只有在第一次调用的时候才会初始化,并移入data段。
全局变量的声明顺序对程序有什么影响吗?
全局变量和文件域的静态变量和类的静态成员变量在main程序执行之前就执行初始化;局部变量中的静态变量(函数内定义的局部静态变量)则在第一次调用的时候进行初始化。
对于同一个文件内的全局变量来讲,初始化顺序和声明顺序一致,所以会存在依赖问题,被依赖的全局变量必须先初始化。全局静态变量也是一样。不同文件的全局变量初始化相互依赖的情况需要避免,因为不同的编译单元的执行顺序是不确定的。
在Cpp中创建一个类,在一开始中有什么
默认的函数,包括默认的构造函数、析构函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数
类中什么都没有,类对象会占用几个字节,为什么还会占用字节?
在没有成员变量的类中仍然会占用一个字节。为什么,因为哪怕类中没有成员变量,也会给每个类分配一个字节的地址来区分不同的对象,用作地址标识。
new和malloc的区别
- new是C++的关键字,malloc是C的函数,这是根本区别
- new和malloc的返回值不同,new返回的是对象的指针,malloc返回的是void*,需要强制类型转换
- new通常会调用构造函数,malloc不会
- new会自动计算需要分配的内存大小,malloc需要手动计算
如果使用new[]申请个动态数组,但是你却使用delete去释放内存,会发生什么?
首先我们要知道new[]是和delete[]搭配使用的,new[num] 是指申请num个对象的内存,delete[]是释放num个对象的内存,会依次调用所有成员的析构函数。如果你使用delete去释放new[],那么只会调用第一个对象的析构函数,并且其他对象的内存没有被释放。如果你在使用头指针去偏移找元素可能会出现未定义的行为。
int a[10] / 静态数组 和 int* a = new int[10] / 动态数组 有什么区别?
最本质的区别就是申请的内存的位置不同:
- int a[10] 是在栈上申请的内存,会随函数执行的结束而被释放,不能改变大小;
- int* a = new int[10] 是在堆上申请的内存,需要手动释放,可以改变大小,即重新分配内存。
a[0] 和 a[1] 到底发生了什么?
这里考察的知识点是指针偏移的问题。a[1] = (a+1)。对于指针运算,指针加1实际上是加上指针指向的类型的大小,比如int a = new int[10],a+1实际上是加上4个字节。这就是为什么你用delete去删除new[]申请的内存的时候,只会调用第一个对象的析构函数,但是你通过a[1]去访问的时候,会访问到第二个对象的内存。
如何限制对象创建在栈堆上
- 栈:就是把类的new和delete操作符给禁了(用=delete关键字)。
- 堆:在类内定义一个静态的构造函数和析构函数,然后在构造函数中new一个对象,然后在析构函数中delete这个对象,这样就可以限制对象的创建在堆上。
线程的栈和进程的栈的关系?
- 线程的栈是自己私有的,和进程的栈相互隔离。
- 进程的栈在进程创建的时候就被划分好了,负责存储进程的函数调用信息和局部变量。
- 线程的栈是调用mmap或brk在堆或文件映射区上创建的,线程与线程之间的栈是相互独立的。
讲一下异常规范?
异常规范是一种C++的异常说明,用于指定函数可能抛出的异常类型。异常规范的语法如下:
1
2
3
void foo() throw (ExceptionType1, ExceptionType2) {
// 函数体
}
在函数内抛出异常,然后在调用函数的时候,调用try catch来捕获异常。
进程的栈的大小
在Linux里面进程的栈的大小一般是8M,可以通过ulimit -s来查看和修改。
虚函数和普通函数的区别
普通函数是早绑定的,就是在编译阶段就确定函数调用哪个具体的函数;虚函数是晚绑定的,就是在运行阶段才确定函数调用哪个具体的函数。
谈一谈你对移动构造的理解
移动构造能够减少不必要的对象创建工作。在一般的构造函数中,我们会在传参的时候创建一个临时的左值进入构造函数供构造使用,然后把这个临时的左值的成员变量使用new拷贝到这个对象的成员变量当中。在移动构造中,我们会传入一个右值引用,这个右值引用是通过move函数把外界的左值对象转换为右值引用,然后在构造函数中,我们直接把这个右值引用的成员变量赋值到这个对象的成员变量当中,然后将这个右值引用的成员置为空(nullptr),这样就减少了一次拷贝的过程。
手撕互斥锁
1
手撕自旋锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Spinlock {
private:
std::atomc_int lock;
public:
Spinlock() {
atomic_store(&lock, 0);
}
void lock() {
while(!atomic_compare_exchange(&lock, 0, 1)) {
// 自旋
}
}
void unlock() {
atomic_store(&lock, 0);
}
};
类型傻傻分不清!弱类型、强类型、动态类型、静态类型
- 动态类型语言 / dynamically typed language:在运行时才确定变量的类型,不会给任何变量指定类型,只有在运行时,第一次赋值给变量的时候,将变量的类型确定下来。
- 静态类型语言 / statically typed language:在编译时需要检查数据类型的语言,在写程序的过程中需要显示确定变量的基本类型,要为它们预先分配好内存空间。
- 强类型定义语言 / explicit type conversion:一旦变量被指定是某个数据类型,如果不经强制转换,就永远是那个类型
- 弱类型定义语言 / implicit type conversion:变量的数据类型可以随时改变,不需要强制转换,一个变量可以赋值多个不同数据类型的值。
掌握 auto、decltype
auto:变量类型推断;decltype:表达式类型推断
类型推断可以在编译器就推导出变量或者表达式的类型,方便开发者编码简化代码。
- decltype:
decltype(expression) var
将 var 的类型定义为 expression 的类型。- decltype 只会返回表达式的类型,不会对表达式进行求值。
- 如果表达式是一个变量,decltype 返回该变量的类型;如果表达式是一个函数调用,decltype 返回函数的返回类型。
auto的推导规则是怎么样的
详情参见推导规则!
cpp内存模型
指针和引用的区别
总结一下四点区别:
- 指针是一个变量,还是个左值,是可以被寻址的,用来保存另一个变量的地址。而引用时另一个变量的别名,和那个变量共享内存地址。所以地址不同是第一个区别。
- 指针的值可以被修改,也就是可以重新指向其他的地方;但是引用在初始化之后就不能再指向其他的地方了,这是第二个区别。
- 指针可以被初始化为nullptr,引用不可以;这是第三个区别。
- 指针需要解引用来使用或者修改指向的对象的值;引用可以直接使用,无需解引用。
我们在深入一点,深入到汇编来理解理解指针和引用的区别?答案是没区别,引用在编译层面会被当成const指针来处理。
继承中,子类的内存中包含父类的成员变量吗?
子类在构造的时候会调用父类的构造函数,所以子类的内存中会包含父类的所有成员变量。
值传递和指针传递 / 引用传递的本质—函数调用栈
函数调用栈的主要组成部分有两个,一个是局部变量,一个是参数。其他的比如返回地址,ebd寄存器这些。那么调用栈里的参数是如何传递的呢?答案就一个Copy,拷贝。根据拷贝对象的不同,来决定能不能影响到原来的实参。
我们来开始讨论一下函数调用栈是如何生成的?假设有个main函数,内部调用了func。
1
2
3
int main() {
func(1, 2);
}
在调用func之前,main以及之前的函数调用栈就已经存在,这里不再讨论。
- 参数入栈:main调用func方法,先把参数1和参数2入栈,这里是值传递,所以会把1和2的值拷贝到栈上。顺序是从右往左
- 返回地址入栈:返回地址是当前call指令的下一条指令的地址。
- 代码跳转到被调用函数执行,在此之后,堆栈帧的其他部分是由callee来构建
- EBP指针入栈:将EBP压入栈中,将ESP的值赋给EBP,这样EBP指向当前栈帧的顶部。所以可以通过EBP快速找到函数返回地址或者传入的参数;
- 局部变量入栈:首先所有的局部变量分配地址,然后将ESP减去分配的地址空间
- 最后将通用寄存器入栈,这样就完成了函数调用栈的构建。通用寄存器包括EBX、ESI、EDI
函数调用栈的销毁就是相反的方向进行。
这里深入一点,知道函数的返回值存放在哪吗?这个问题和函数的调用约定有关。函数的调用约定 / calling convention
函数调用预定 / calling convention
函数的调用约定 (calling convention) 指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。常见的函数调用约定有三种:
1)__cdecl。这是 VC 编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由 caller 清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如 printf 方法。由于 callee 不知道caller到底将多少参数压入堆栈,因此callee 就没有办法自己清理堆栈,所以只有函数退出之后,由 caller 清理堆栈,因为 caller 总是知道自己传入了多少参数。
2)__stdcall。所有的 Windows API 都使用 __stdcall。其规则是:参数从右向左压入堆栈,函数退出时由 callee 自己清理堆栈中的参数。由于参数是由 callee 自己清理的,所以 __stdcall 不支持可变数量的参数。
3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86 构架下 this 指针通过 ECX 寄存器传递,函数退出时由 callee 清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。
那是如何使用函数的调用约定来协商返回值的存放地址的呢?
caller 会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer 指向 caller 局部变量区的一块未命名的地址,这块地址将用来存储 callee 的返回值。函数返回时,callee 把返回值拷贝到ReturnValuePointer 指向的地址中,然后把 ReturnValuePointer 的地址赋予 EAX 寄存器。函数返回后,caller 通过 EAX 寄存器找到 ReturnValuePointer,然后通过ReturnValuePointer 找到返回值,最后,caller 把返回值拷贝到负责接收的局部变量上(如果接收返回值的话)。
cpp内存问题
- 缓冲区溢出 / buffer overrun
- 悬垂指针 / 野指针
- 重复释放 / double delete
- 内存泄漏 / memory leak
- 不配对的new[]和delete
- 内存碎片
未定义行为 / undefined behavior
表示运行时表现是不可预测的。
common_type_t / 共用类型
common_type_t 是 C++11 引入的一个类型工具,用于推导出一组类型的公共类型。它位于
1
2
3
4
template<typename... Args, typename RT = std::common_type_t<Args...>>
RT sum(Args... args) {
return (args + ...);
}
模板专题
推导规则
在现代cpp中,类型推导一共分为三种:模板类型推导,auto类型推导,decltype类型推导。
模板类型推导
1
2
3
4
template<typename T>
void f(ParamType param);
f(expr);
本质上就是根据expr先推导T,然后再推导ParamType。推导T的过程分为几种情况,下面具体介绍。
上述是一个函数模板以及他的调用。编译器会使用expr进行两个类型推导:一个是针对T的,另一个是针对ParamType的。这两个东西是不一样的,ParamType是T加上一些修饰,比如const T& x
。
ParamType有三种类型:
- 指针或者引用,但不是万能引用
- 万能引用(通用引用)/ T&&
- 既不是指针也不是引用
请记住一下四条推导规则:
- 在模板类型推到中,有引用的实参会被视为无引用,即引用会被忽略。指针类型的实参会被保留为指针类型。
- 对于万用引用的推导,右值直接推导为基础类型,但是左值会特殊对待,T一律被推导为左值引用;注意指针还是被保留为指针
- 对于传值类型的推导(值传递),const和volatile实参会被认为是non-const和non-volatile的,也就是被新拷贝一遍同时消去其不可变性;
- 在模板类型推导的时候,数组名和函数名实参会被退化为指针,除非它们被用户初始化引用。
那如何防止数组被退化为指针呢?可以使用引用的方式来防止数组退化为指针。
1
2
3
4
5
6
template<typename T, unsigned N>
void printArr(T(&arr)[N]) {
for(int i = 0; i < N; i++) {
std::cout << arr[i] << " ";
}
}
auto类型推导
在知道模板类型推导后,再来看看auto类型推导将会非常简单。因为本质上它们的推导规则没什么差别。
1
2
3
auto x = 27;
const auto cx = x;
const auto& rx = x;
如何将auto类型推导和模板类型推到联系在一起呢?当一个变量在使用auto进行声明时,auto扮演了模板中T的角色,变量的类型说明符扮演了ParamType的角色。auto =》 T, const auto =》ParamType;其余的推导规则和模板类型推导一样。
我们再挖深一点,auto难道就完全和模板类型推导一模一样吗?其实并不是。在cpp11中新加入了用于支持统一初始化的语法:
1
2
3
4
auto x1 = 27;
auto x2(27);
auto x3 = {27};
auto x4{27};
但是在auto类型推导中,花括号会被推导为std::initializer_list,而模板并不会。这是和模板类型推导的一个区别。
可变参数模板 / 形参包
1
2
template<typename...Args>
void func(Args...args) {}
对于这样的函数模板,可以接收不同类型的任意个数的参数调用。这里的Args被称为类型形参包、args被称为参数形参包;可以理解为多个类型的集合。既然是包,必然有拆包的过程,也就是常说的包展开。
1
2
3
4
5
6
7
8
9
10
11
12
void f(const char*, int, double) { puts("值"); }
void f(const char**, int*, double*) { puts("&"); }
template<typename...Args>
void sum(Args...args){ // const char * args0, int args1, double args2
f(args...); // 相当于 f(args0, args1, args2)
f(&args...); // 相当于 f(&args0, &args1, &args2)
}
int main() {
sum("luse", 1, 1.2);
}
包展开的过程是怎样的呢?这里要明确一个概念,叫模式。后随省略号且其中一个形参包的名字的模式会被展开成零个或多个逗号分隔的模式实例。比如&args...
内的&args就是模式,展开的时候,会把包内的所有参数取引用,然后用逗号分隔开。这里涉及到逗号表达式。
什么是逗号表达式?逗号表达式是一个包含多个子表达式的表达式,这些子表达式由逗号分隔。逗号表达式的值是最后一个子表达式的值,而所有子表达式都会被依次求值。
Cpp17中新加折叠展开式,让我们能够更加方便的展开参数包。下面简单介绍一下几种语法:
- 形参包 运算符 … / 一元右折叠
- … 运算符 形参包 / 一元左折叠
- 形参包 运算符 … 运算符 形参包 / 二元右折叠
- 初值 运算符 … 运算符 形参包 / 二元左折叠
- 一元右折叠 (E 运算符 …) 成为 (E1 运算符 (… 运算符 (EN-1 运算符 EN)))
- 一元左折叠 (… 运算符 E) 成为 (((E1 运算符 E2) 运算符 …) 运算符 EN)
- 二元右折叠 (E 运算符 … 运算符 I) 成为 (E1 运算符 (… 运算符 (EN−1 运算符 (EN 运算符 I))))
- 二元左折叠 (I 运算符 … 运算符 E) 成为 ((((I 运算符 E1) 运算符 E2) 运算符 …) 运算符 EN) (其中 N 是包展开中的元素数量)
折叠表达式时左折叠还是右折叠取决于…在E的左边还是右边
这里的形参包,不只是形参包,还可以是带有形参包的运算符表达式
模板不可以分文件
很多人知道不可以,但很少人知道为什么不可以。
在解释为什么不可以之前,先来cpp多文件编程模型,帮助我们理解为什么cpp不允许模板多文件编程。
首先我们知道在预处理阶段,编译器会对#进行文本内容的替换。我们将函数声明放在h头文件中,把函数定义放在cpp文件中,在需要函数的地方包含头文件,也就是只是将函数声明copy到使用函数的地方。那我们要怎么通过这些声明去找到函数的定义呢?这是链接器的工作。如果我们在编译一个编译单元的时候,找不到函数的定义,就会空着一个符号地址,将它编译为目标文件。期待链接器在链接阶段去其他编译单元找到定义来填充符号。
所以模板不能分文件的原因这里就找到了!模板有个特性,就是在使用它的时候才进行实例化,也就是说如果你放在cpp文件中,他不会生成任何可执行的代码,不会有函数定义,也就没有符号,链接器就找不到定义了。那么你在别的编译单元使用这个模板的时候就会链接失败了。所以模板不能定义在cpp内,应该完整的定义在头文件中,并在使用它的编译单元中出现。
那么如何解决呢?TODO
变量模板
就是给全局变量产生的模板,和函数模板类似,只不过是变量而已。
1
2
template<typename T>
T pi = T(3.1415926535897932385L);
模板全特化和模板偏特化
模板全特化
当我们需要对某种特定类型进行特殊处理的时候,我们可以使用模板全特化。模板全特化是指对一个模板的所有模板参数进行特化。模板全特化的语法如下:
- 函数模板全特化
1 2 3 4
template<> auto f<double, int>(const double& a, const int& b) { return }
- 类模板全特化 ```cpp template
struct X{ void f()const{ puts("f"); } };
template<> struct X
1
int n; };
int main(){ X
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
我们要清楚!对一个主模板进行全特化的时候,相当于我们在编写一个新的类,它们的静态变量和函数不是共享的。
- 变量的全特化
```cpp
#include <iostream>
template<typename T>
constexpr const char* s = "??";
template<>
constexpr const char* s<void> = "void";
template<>
constexpr const char* s<int> = "int";
int main(){
std::cout << s<void> << '\n'; // void
std::cout << s<int> << '\n'; // int
std::cout << s<char> << '\n'; // ??
}
语法和上面大致相同,这里看看示范即可。
全特化有几个需要注意的点!
- 全特化的声明需要在首次使用隐式实例化之前!隐式实例化:
f(1)
模板偏特化
模板偏特化是指对一个模板的部分模板参数进行特化。模板偏特化的语法如下:
- 变量模板偏特化 ```cpp
templateconst char* s = "?"; // 主模板
template
template
std::cout « s
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
语法就是正常写主模板那样,然后再定义这个 s 的时候,指明模板实参。或者你也可以定义非类型的模板形参的模板,偏特化,都是一样的写法。
不过与全特化不同,全特化不会写 template<typename T>,它是直接 template<>,然后指明具体的模板实参。
它与全特化最大的不同在于,全特化基本必写 template<>,而且定义的时候(如 s)是指明具体的类型,而不是一类类型(T*、T[])。
注意有多个模板形参的时候,偏特化的时候,只能对其中一个(**好像是第一个**)进行偏特化,而不能对多个进行偏特化。
### 待决名 / dependent name
### 类型描述符
### SFINAE / 替换失败不是错误
**SFINAE:**当函数调用的备选方案中出现函数模板,编译器根据函数参数确定 / 替换函数模板的参数类型及返回类型,最后评估替换后的函数匹配程度。替换过程中可能会失败,此时编译器会忽略这一替换结果。
替换和实例化是不同的!替换只涉及到函数函数模板的参数类型及返回类型,最后编译器选择匹配程度最高的函数模板进行实例化。
```cpp
#include<vector>
#include<iostream>
// 返回裸数组长度的模板,只有用裸数组替换时才能成功
template<typename T, unsigned N>
std::size_t len (T(&)[N])
{
return N;
}
// 只有含有T::size_type的类型才能替换成功
template<typename T>
typename T::size_type len (T const& t)
{
return t.size();
}
// ... 表示为可变参数,匹配所有类型, 但匹配程度最差
std::size_t len(...)
{
return 0;
}
int main()
{
int a[10];
std::cout << len(a) <<std::endl; // 匹配裸数组
std::cout << len("sjx") <<std::endl; // 匹配裸数组
std::vector<int> v;
std::cout << len(v) <<std::endl; // T::size_type
int *p;
std::cout << len(p) <<std::endl; // 函数模板均不匹配,最后调用变参函数
std::allocator<int> x;
/* std::allocator 定义了size_type,所以匹配T::size_type和变参函数,
前者匹配程度更高,因此选择该模板。但在实例化时会发现allocator不存在size成员 */
std::cout << len(x) <<std::endl; //error
}
在模板中,会对函数模板的形参进行两次替换:
- 在模板实参推到前,对显式指定的模板实参进行替换;
- 在模板实参推导后,对推导出的实参进行替换;
讲点概念性比较强的话:
只有在函数类型或其模板形参类型或其 explicit 说明符 (C++20 起)的立即语境中的类型与表达式中的失败,才是 SFINAE 错误。如果对代换后的类型/表达式的求值导致副作用,例如实例化某模板特化、生成某隐式定义的成员函数等,那么这些副作用中的错误都被当做硬错误。
代换失败就是指 SFINAE 错误。
看不懂没关系,我们加下来举几个例子来加深下理解。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
template<typename A>
struct B { using type = typename A::type; }; // 待决名,C++20 之前必须使用 typename 消除歧义
template<
class T,
class SFINAE = typename T::type, // 如果 T 没有成员 type 那么就是 SFINAE 失败(代换失败)
class V = typename B<T>::type // 如果 T 没有成员 type 那么就是硬错误 不过标准保证这里不会发生硬错误,因为到 U 的默认模板实参中的代换会首先失败
void foo(int) { std::puts("SFINAE T::type B<T>::type"); }
template<typename T>
void foo(double) { std::puts("SFINAE T"); }
int main(){
struct C { using type = int; };
foo<B<C>>(1); // void foo(int) 输出: SFINAE T::type B<T>::type
foo<void>(1); // void foo(double) 输出: SFINAE T
}
我们来看看foo<void>(1)
,编译器会先对void foo(1)进行模板匹配,把void = T代入进去,发现void没有type成员,发生替换失败,SFINAE失败,然后编译器会去找其他的备选方案,找到了void foo(double),然后调用这个函数。注意,替换失败不是错误,编译器不会报错,而是丢弃这个重载转向其他的备选方案。
而硬错误是指在模板实例化的过程中,发生了错误,这样会使编译器报错导致编译失败。
那我们要如何使用SFINAE去在模板实例化前检查是否有替换失败来防止错误的实例化导致性能的开销呢?标准库其已经提供给我们一些设施来帮助我们使用SFINAE。
- std::enable_if ```cpp template<bool B, class T = void> struct enable_if {};
template
template< bool B, class T = void > using enable_if_t = typename enable_if<B,T>::type; // C++14 引入
1
2
3
4
5
6
7
用法很简单,如果第一个模板参数为true,说明包含type,如果没有就发生SFINAE。
```cpp
template<typename T,typename SFINAE =
std::enable_if_t<std::is_same_v<T,int>>>
void f(T){}
要求T必须为int,否则发生SFINAE。
std::void_t
1
2
template<typename...>
using void_t = void;
使用:
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
#include <iostream>
#include <type_traits>
template<typename T,
typename SFINAE = std::void_t<
decltype(T{} + T{}), typename T::type, decltype(&T::value), decltype(&T::f) >>
auto add(const T& t1, const T& t2) {
std::puts("SFINAE + | typename T::type | T::value");
return t1 + t2;
}
struct Test {
int operator+(const Test& t)const {
return this->value + t.value;
}
void f()const{}
using type = void;
int value;
};
int main() {
Test t{ 1 }, t2{ 2 };
add(t, t2); // OK
//add(1, 2); // 未找到匹配的重载函数
}
std::declval
TODO
CPP 并发模型
进程、线程、协程
进程间通信 IPC
常用的进程间通信的机制有:管道、消息队列、信号量、共享内存、信号、套接字等。
线程间通信
消息队列、信号量、锁机制、条件变量等。对于线程间通信,大致分为两个问题:资源竞争问题和资源同步问题。下面分别介绍这两种问题在cpp这门语言中如何解决。
共享资源
当有一个资源,在多个线程中都需要访问,那么就会出现资源竞争的问题。那么如何处理资源竞争的问题呢?通常使用互斥量和条件变量来解决。
互斥量(Mutex)有锁定和未锁定两个状态。互斥锁的解锁不推荐使用lock和unlock这样的方法,而是使用管理类std::lock_guard,std::unique_lock等。
std::lock_guard
1
2
3
4
std::mutex m;
{
std::lock_guard<std::mutex> lc{m};
}
我们来关注一下底层:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
_EXPORT_STD template <class _Mutex>
class _NODISCARD_LOCK lock_guard { // class with destructor that unlocks a mutex
public:
using mutex_type = _Mutex;
explicit lock_guard(_Mutex& _Mtx) : _MyMutex(_Mtx) { // construct and lock
_MyMutex.lock();
}
lock_guard(_Mutex& _Mtx, adopt_lock_t) noexcept // strengthened
: _MyMutex(_Mtx) {} // construct but don't lock
~lock_guard() noexcept {
_MyMutex.unlock();
}
lock_guard(const lock_guard&) = delete;
lock_guard& operator=(const lock_guard&) = delete;
private:
_Mutex& _MyMutex;
};
这个锁的特点是会持有互斥量的引用。
可以看到,当lock_guard对象创建的时候,会将传入的互斥量进行上锁,在析构的时候解锁。这样能够防止忘记解锁导致的死锁问题。所以我们通常会重新声明一个作用域来访问临界区。什么是临界区,就是我们在访问共享资源的上下文。讲到这里可以引出另一个概念,这个概念对多线程性能优化的问题有很大的帮助,就是锁的粒度问题。什么是锁的粒度,就是持有锁的作用域的大小。如果锁的粒度太大,那么就会导致其他线程在等待锁的时候,无法访问共享资源,从而导致性能的下降。如果锁的粒度太小,那么就会导致频繁的上锁和解锁,也会导致性能的下降。所以我们要根据实际情况来调整锁的粒度。但是为了最求效率,我们更加希望锁的粒度能够小一点。
std::try_lock
try_lock 是互斥量中的一种尝试上锁的方式。与常规的 lock 不同,try_lock 会尝试上锁,但如果锁已经被其他线程占用,则不会阻塞当前线程,而是立即返回。利用这个特性,可以实现另一种锁,叫做自旋锁。自旋锁什么时候使用呢,就是等待锁释放的时间要比线程上下文切换的时间要更短的时候使用。也就是锁的粒度非常小的时候。
我来提个问题:有锁保护的临界区是绝对线程安全的吗?答案肯定不是,如果外部持有锁内的资源的指针或者引用,那么就可以直接突破锁的控制直接访问资源。所以我们要注意不能让受保护的数据的指针或者引用传递到互斥量的作用域外。
死锁 / deadlock
在操作系统中,我们对死锁的研究比较深入。同样的,在cpp中使用互斥量同样会导致死锁的问题。死锁是怎么造成的,可以在我另一篇文章关于操作系统的文章中查看。这里讲cpp如何防止死锁情况的发生。
避免死锁有三个原则:
- 避免嵌套锁,线程不要在一个作用域内获取多个锁;如果需要,就不要使用lock_guard而是lock手动上锁解锁;
- 避免在持有锁时去调用外部代码。
- 使用固定的顺序获取锁
std::unique_lock / 灵活的锁
1
2
3
4
5
6
7
void swap(X& lhs, X& rhs) {
if (&lhs == &rhs) return;
std::unique_lock<std::mutex> lock1{ lhs.m, std::defer_lock };
std::unique_lock<std::mutex> lock2{ rhs.m, std::defer_lock };
std::lock(lock1, lock2);
swap(lhs.object, rhs.object);
}
和lock_guard的不同是,unique_lock的底层是持有互斥量的指针和一个表示对象是否拥有互斥量所有权的bool类型。当上锁时,这个bool会被设置为true,解锁时会被设置为false。所以unique_lock是一个灵活的锁,可以在任何时候上锁和解锁。
这里unique_lock有两个上锁策略:
- defer_lock:构造函数不上锁,要求在构造之后手动上锁;
- adopt_lock:构造函数不上锁,要求在构造之前手动上锁;
- 默认:构造函数会上锁,但构造函数前后不能上锁。
那为什么说这个锁灵活呢?答案就是其有lock和unlock方法,可以随时上锁和解锁。而不用像lock_guard那样只能在构造和析构的时候上锁和解锁,不用限定作用域。
互斥锁不可复制不可移动!所谓的在多个作用域传递互斥量,本质就是传递它的指针或引用而已。这里就可以体现unique_lock的灵活性了,可以通过定义unique_lock的移动构造来实现所有权的转移。
共享数据的初始化
这里只介绍三种方式:双检锁、std::call_once、静态局部变量初始化从 C++11 开始是线程安全
双检锁: 上锁前检查,上锁后修改前检查,这样可以避免多次初始化。但是仍然存在问题。就是修改可能修改一半就被调度了,导致初始化完成一半,但是被其他线程访问,发现已经有内存但是实际上没有初始化完成。
std::call_once / std::once_flag 为解决上述问题,c++11提供了std::call_once和std::once_flag。std::call_once接受一个函数和一个标志,这个函数只会被调用一次。这个函数会被调用一次,然后标志会被设置为已调用,之后再调用这个函数就不会再执行了。
1
2
3
4
5
6
7
8
9
10
11
std::shared_ptr<some> ptr;
std::once_flag resource_flag;
void init_resource(){
ptr.reset(new some);
}
void foo(){
std::call_once(resource_flag, init_resource); // 线程安全的一次初始化
ptr->do_something();
}
cpp中的读写锁–std::shared_timed_mutex / std::shared_mutex
如果存在多读少写的情况,使用mutex去保护资源会降低多线程程序的资源。std::shared_mutex 同样支持 std::lock_guard、std::unique_lock。和 std::mutex 做的一样,保证写线程的独占访问。而那些无需修改数据结构的读线程,可以使用 std::shared_lock<std::shared_mutex> 获取访问权,多个线程可以一起读取。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Settings {
private:
std::map<std::string, std::string> data_;
mutable std::shared_mutex mutex_; // “M&M 规则”:mutable 与 mutex 一起出现
public:
void set(const std::string& key, const std::string& value) {
std::lock_guard<std::shared_mutex> lock{ mutex_ };
data_[key] = value;
}
std::string get(const std::string& key) const {
std::shared_lock<std::shared_mutex> lock(mutex_);
auto it = data_.find(key);
return (it != data_.end()) ? it->second : ""; // 如果没有找到键返回空字符串
}
};
来个面试问题:new、delete是线程安全的吗?
在C++11之后,new和delete操作时线程安全的。但是如果我们自己重载了new和delete操作符,那么其线程安全性就需要我们自己来保护了。
同步操作
多个线程之间访问共享资源,除了有资源竞争的问题,还有资源同步的问题,也就是资源的操作可能需要有先后顺序,如果多线程访问没有顺序,就会出现问题。同步就是为了保证这个问题而存在的。
现代cpp中保证同步的最佳实践是 条件变量;
现代cpp中对于条件变量有两套实现:condition_variable 和 condition_variable_any。condition_variable_any 是对 condition_variable 的一个封装,它可以接受任何类型的互斥量,而 condition_variable 只能接受 std::mutex。
下面给个例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::mutex mtx;
std::condition_variable cv;
bool arrived = false;
void wait_for_arrival() {
std::unique_lock<std::mutex> lck(mtx);
cv.wait(lck, []{ return arrived; }); // 等待 arrived 变为 true
std::cout << "到达目的地,可以下车了!" << std::endl;
}
void simulate_arrival() {
std::this_thread::sleep_for(std::chrono::seconds(5)); // 模拟地铁到站,假设5秒后到达目的地
{
std::lock_guard<std::mutex> lck(mtx);
arrived = true; // 设置条件变量为 true,表示到达目的地
}
cv.notify_one(); // 通知等待的线程
}
可以看到条件变量的使用就是获取锁、判断条件是否为真、如果为真直接执行,如果为假,就释放锁,并进入阻塞态等待被其他线程唤醒,条件为真的时候被唤醒。
条件变量的wait调用的声明其实有两种:
1
2
3
4
void wait(std::unique_lock<std::mutex>& lock); // 1
template<class Predicate>
void wait(std::unique_lock<std::mutex>& lock, Predicate pred); // 2
第二种就是要传入一个谓词,就是一个返回bool的函数,用来判断条件是否为真。
来看看底层实现:
1
2
3
4
5
6
7
8
9
10
void wait(std::unique_lock<std::mutex>& _Lck) {
_Wait_impl(_Lck, _Pred());
}
template <class _Predicate>
void wait(unique_lock<mutex>& _Lck, _Predicate _Pred) {
while (!_Pred()) {
wait(_Lck);
}
}
使用future
future这个特性可是多线程编程中非常重要的特性。用于处理线程中需要等待某个事件的情况,线程知道预期结果。等待的同时也可以执行其他的任务。是不是有点异步的味道。
首先futrue的重要作用就是,如果我们线程任务有返回值,可以通过future来捕获!下面的主题是创建异步任务获取其返回值。假设需要执行一个耗时任务并获取其返回值,但是并不急切的需要它。那么就可以启动新线程计算,然而 std::thread 没提供直接从线程获取返回值的机制。所以我们可以使用 std::async 函数模板。
使用 std::async 启动一个异步任务,它会返回一个 std::future 对象,这个对象和任务关联,将持有最终计算出来的结果。当需要任务执行完的结果的时候,只需要调用 get() 成员函数,就会阻塞直到 future 为就绪为止(即任务执行完毕),返回执行结果。valid() 成员函数检查 future 当前是否关联共享状态,即是否当前关联任务。还未关联,或者任务已经执行完(调用了 get()、set()),都会返回 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <thread>
#include <future> // 引入 future 头文件
int task(int n) {
std::cout << "异步任务 ID: " << std::this_thread::get_id() << '\n';
return n * n;
}
int main() {
std::future<int> future = std::async(task, 10);
std::cout << "main: " << std::this_thread::get_id() << '\n';
std::cout << std::boolalpha << future.valid() << '\n'; // true
std::cout << future.get() << '\n';
std::cout << std::boolalpha << future.valid() << '\n'; // false
}
像thread一样,async也有自己的执行策略:
- std::launch::async 在不同的线程上执行异步任务
- std::launch::deferred 惰性求值,不创建线程,等待future对象调用wait或get在执行任务。
future and packaged_task
packaged_task / 封装任务,可以包装任何可调用的对象(函数,lambda表达式,function对象,bind表达式),并异步调用它们。其返回值或者抛出的异常能够被储存于能通过future对象访问的共享状态中,它通常与future一起使用。
来看看如何异步获取封装任务的返回值:
1
2
3
4
5
6
std::packaged_task<double(int, int)> task([](int a, int b){
return std::pow(a, b);
});
std::future<double>future = task.get_future();
task(10, 2); // 此处执行任务
std::cout << future.get() << '\n'; // 不阻塞,此处获取返回值
注意这样的任务不会在新的线程中执行,要想要新的线程中执行,需要调用 std::thread 来执行。
1
2
3
4
5
6
7
8
9
std::packaged_task<double(int, int)> task([](int a, int b){
return std::pow(a, b);
});
std::future<double> future = task.get_future();
std::thread t{ std::move(task),10,2 }; // 任务在线程中执行
// todo.. 幻想还有许多耗时的代码
t.join();
std::cout << future.get() << '\n'; // 并不阻塞,获取任务返回值罢了
使用promise 类模板promise用于储存一个值或一个异常,之后通过promise对象所创建的future对象来异步获得。
例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 计算函数,接受一个整数并返回它的平方
void calculate_square(std::promise<int> promiseObj, int num) {
// 模拟一些计算
std::this_thread::sleep_for(std::chrono::seconds(1));
// 计算平方并设置值到 promise 中
promiseObj.set_value(num * num);
}
// 创建一个 promise 对象,用于存储计算结果
std::promise<int> promise;
// 从 promise 获取 future 对象进行关联
std::future<int> future = promise.get_future();
// 启动一个线程进行计算
int num = 5;
std::thread t(calculate_square, std::move(promise), num);
// 阻塞,直到结果可用
int result = future.get();
std::cout << num << " 的平方是:" << result << std::endl;
t.join();
通俗的来讲就是线程函数中传入一个类似于引用的对象,将任务的返回值放入到这个对象中,然后再另一个线程可以访问到。
C++20 信号量
熟悉操作系统进程间通信机制IPC的同学可能对信号量并不陌生,虽然信号量是个非常老的同步技术,但是它在C++20才被正式采用。
信号量是什么?信号量维护一个计数,这个计数不能小于0。信号量提供两个基本操作:释放(V)/ 增加计数 和 等待(P)/ 减少计数。当你执行等待操作时,在减少计数之前,如果计数为0,就会进入阻塞,直到计数大于0。正如我们在操作系统中讨论的那样,信号量被分为两种:二元信号量 / std::binary_semaphore 和计数信号量 / std::counting_semaphore。注意binary_semaphore 只是 counting_semaphore 的一个特化别名: counting_semaphore<1>。
信号量通常用于发信/提醒,而不是互斥(你互斥直接用互斥量不就完事了)。通过初始化该信号量为 0 从而阻塞尝试 acquire() 的接收者,直至提醒者通过调用 release(n) “发信”。在此方面可把信号量当作条件变量的替代品,通常它有更好的性能。
这里来看下限制web并发数的例子:
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
// 定义一个信号量,最大并发数为 3
std::counting_semaphore<3> semaphore{ 3 };
void handle_request(int request_id) {
// 请求到达,尝试获取信号量
std::cout << "进入 handle_request 尝试获取信号量\n";
semaphore.acquire();
std::cout << "成功获取信号量\n";
// 此处延时三秒可以方便测试,会看到先输出 3 个“成功获取信号量”,因为只有三个线程能成功调用 acquire,剩余的会被阻塞
std::this_thread::sleep_for(3s);
// 模拟处理时间
std::random_device rd;
std::mt19937 gen{ rd() };
std::uniform_int_distribution<> dis(1, 5);
int processing_time = dis(gen);
std::this_thread::sleep_for(std::chrono::seconds(processing_time));
std::cout << std::format("请求 {} 已被处理\n", request_id);
semaphore.release();
}
int main() {
// 模拟 10 个并发请求
std::vector<std::jthread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(handle_request, i);
}
}
STL模块
STL六大组件:
- 容器
- 算法
- 迭代器
- 仿函数
- 适配器
- 分配器
区分一下unordered_map和map的区别
map:使用红黑树作为底层数据结构,元素按键值自动排序(升序和降序)
- unordered_map: 使用哈希表作为底层结构,元素没有特定的顺序。
容器 / Container
- 序列容器:array、vector、queue、deque、priority_queue、stack、list、forward_list
关联容器:map、set multimap、multiset 关联容器:unordered_map、unordered_set unordered_multimap、unordered_multiset
vector
基本操作:
迭代器:
vector的底层原理 vector底层是一个动态数组,维护着三个指针,start指针 / 指向储存元素的首地址,finish指针 / 指向当前储存元素的末尾的下一个地址,end_of_storage指针 / 指向当前分配的内存空间的末尾地址。start和finish可以计算size,end_of_storage可以计算capacity。
vector如何实现自动缩扩容?
- 扩容的过程:当finish指针超过end_of_storage指针时,会触发扩容。会申请新的动态数组,大小为之前的两倍。拷贝旧数据,释放旧数据,更新内部指针和容量值。
- 缩容的过程:实际上vector不会自动缩容,而是需要我们自己手动调用shrink_to_fit()函数来缩容。这个函数会将容量缩小到当前的size大小。
array
底层和vector很像,就是大小固定,并且没有所扩容机制。
deque
deque是双端队列,底层是一个动态数组,但是是一个双向的动态数组。deque的特点是可以在两端进行插入和删除操作,而vector只能在尾部进行插入和删除操作。deque的底层是一个动态数组的数组,每个动态数组的大小是固定的,当一个动态数组满了,会再申请一个新的动态数组。deque的迭代器是随机访问迭代器。
map
map保存的是键值对,可以通过key来快速查找添加删除,但是还是O(logN)。底层使用红黑树。map中的元素是有序的。
基本操作:
- 迭代器: ```cpp for(auto it = m.begin(); it != m.end(); ++it) { std::cout « it->first « ” “ « it->second « std::endl; }
for(auto& [key, value] : m) { std::cout « key « ” “ « value « std::endl; } ```
- CURD
unordered_map
unordered_map是基于哈希表实现的,查找、插入、删除的时间复杂度是O(1)。unordered_map中的元素是无序的。
基本操作可以上网查
unordered_map 与 map的区别
为什么容器操作中的emplace的执行效率要更高?
如果要将一个结构体类型的实例,放入到容器中,一般有两个步骤:
- 构造这个实例
- 将这个实例copy到容器中
而这个copy的过程可以使用两个函数,一是拷贝构造函数,二是移动构造函数。push_back()和insert()函数就是按照这个步骤来的。
但是对于emplace_back() 和 emplace()函数,它们是直接在容器中的指定直接构造这个实例,而不是先构造再拷贝。所以效率更高。这就是区别。只有一个步骤
为什么map需要比vector多一次移动构造,应该是在构造pair的时候多出来的。
字符串string
字符流stringstream
RUST
为什么rust是内存安全的?(cpp的优点)
rust内存安全是通过所有权和借用来实现的。
首先什么是所有权。rust中每个值都只能被一个对象所拥有,该变量称为值的所有者。当这个变量赋值给另一个变量的时候,就会发生值所有权的转移。这个时候你再去访问原来的变量就会发生编译错误。
这样的好处就是,如果你的变量的值是指向堆区的一块内存,那么这块内存在某个时刻只会有一个所有者,不会出现多个所有者离开作用域后都去释放这个内存,导致的内存错误。
接下来讲讲借用。如果一个变量只是想访问或者修改一个堆内存,而不是占有他的所有权,要怎么办?答案就是使用借用。和cpp里的引用比较类似。就是通过解引用可以去访问或修改这个变量的值,但是不会改变这个变量的所有权。
但是rust又是如何避免悬垂引用的问题呢?悬垂引用是引用了一个已经被释放的内存。这里就要提到生命周期了。rust的编译器不会允许某个对象的引用的生命周期比这个对象更长。
c++用了智能指针后和rust上述你说的安全性有什么区别
- cpp的unique_ptr指针和rust的box指针比较类型,都能保证堆上内存的独占性。但是有个比较细微的区别,rust是在编译时期保证其独占性,而cpp是在运行时期保证其独占性。
- cpp的shared_ptr和rust的arc指针好像就没什么安全性上的差距了。
tokio
tokio是基于rust的异步运行时的。
首先讲讲异步运行时。他的核心时一个reactor和多个executor组成。reactor用于提供外部事件的订阅机制,像IO事件,进程间通信,定时器等等。executor用于调度和执行相应的任务(Future)。比如说你现在有个socket对象,你调用了他的accept方法,这个时候这个accept方法就是一个future,由exector负责调度,并且向reactor注册了一个对socket对象的监听事件。当有外部连接到来的时候,reactor就会监听到,然后通知调度器发生事件的future对象,然后调度器就会分配task给这个future去执行。
然后就是future的状态机。future在有事件到来和没事件到来是两个状态,然后分配到task和没有分配到task是两个状态,所以future就被划分出了三个状态,pending,ready,running。通过状态的转化实现调度。