1. 多态,虚函数 链接到标题
1. 什么是多态,如何实现多态 (⭐⭐) 链接到标题
所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++ 的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现
2. 虚函数的实现机制 (⭐⭐⭐) 链接到标题
虚函数是通过虚函数表来实现的,虚函数表包含了一个类 (所有) 的虚函数的地址,在有虚函数的类对象中,它内存空间的头部会有一个虚函数表指针 (虚表指针),用来管理虚函数表。当子类对象对父类虚函数进行重写的时候,虚函数表的相应虚函数地址会发生改变,改写成这个虚函数的地址,当我们用一个父类的指针来操作子类对象的时候,它可以指明实际所调用的函数
3. 虚函数调用是在编译时确定还是运行时确定的,如何确定调用哪个函数 链接到标题
当使用指针或引用调用虚函数的时候,是运行时确定,通过查找虚函数表中的函数地址确定
而使用普通变量调用虚函数的时候,是编译器确定的,此时没有多态
虚函数表是在编译阶段生成的,存放在只读数据段.rodata
(和全局常量、字符串常量存放在一起)
4. 在 (基类的) 构造函数和析构函数中调用虚函数会怎么样 (⭐⭐) 链接到标题
从语法上讲,调用没有问题,但是从效果上看,往往不能达到需要的目的(不能实现多态);因为调用构造函数的时候,是先进行父类成分的构造,再进行子类的构造。在父类构造期间,子类的特有成分还没有被初始化,此时下降到调用子类的虚函数,使用这些尚未初始化的数据一定会出错;同理,调用析构函数的时候,先对子类的成分进行析构,当进入父类的析构函数的时候,子类的特有成分已经销毁,此时是无法再调用虚函数实现多态的
5. C 语言可以实现虚函数机制吗,如何实现 链接到标题
需要做的工作:手动构造父子关系、创建虚函数表、设置虚表指针并指向虚函数表、填充虚函数表;当虚函数重写的时候还需要手动修改函数指针等等
6. 重载、重写和隐藏的区别 (⭐⭐) 链接到标题
-
重载指的是同一个名字的函数,具有不同的参数列表(参数类型、个数),或不同的返回类型,根据参数列表和返回类型决定调用哪一个函数;
-
重写(覆盖)指的是,派生类中的函数重写了基类中的虚函数,重写的基类的中函数必须被声明为
virtual
,并且返回值,参数列表和基类中的函数一致 (除非返回类型是基类/派生类的指针/引用); -
隐藏是指,派生类中的同名函数把基类中的同名函数隐藏了,包括所有的重载版本,不论是不是虚函数都会隐藏
7. 模板类可以有虚函数吗,模板函数可以是虚函数吗 链接到标题
-
模板类可以使用虚函数。但使用模板类定义不同的类型则是两个完全不同的类,即使两个泛型参数是父类和子类关系,两个泛型变量也没有任何父子关系
-
模板函数不能是虚函数。编译器都期望在处理类的定义的时候就能确定这个类的虚函数表的大小,如果允许有类的虚成员模板函数,那么就必须要求编译器提前知道程序中所有对该类的虚成员模板函数有多少个版本,才能分配虚函数表的大小,而这是不可行的
8. 内联函数可以是虚函数吗,静态函数可以是虚函数吗 (⭐⭐) 链接到标题
- 内联函数表示在编译阶段进⾏函数体的替换操作 (内联函数实际上不是函数),⽽虚函数意味着在运⾏期间进⾏类型确定,要经过函数调用的过程,所以内联函数不能是虚函数 (将函数同时声明
inline virtual
编译器会直接忽略inline
) - 静态函数不属于类对象而属于类,静态成员函数没有 this 指针,所以无法找到虚表,也就无法实现虚函数重写的功能,所以不能是虚函数
9. 多个基类的同名虚函数覆写问题 (⭐) 链接到标题
假设一个子类继承两个基类,这两个基类都有一个虚函数func
,那么直接用子类变量调用func
就会出现歧义,因为不知道是哪一个func
,其实就是不知道使用哪个虚表的信息
如果用这两个基类的指针指向这个子类变量,那么用这两个基类指针就能成功调用这个虚函数了,因为两个基类指针确定了是使用自己类的虚表
若子类又覆写了这个同名的虚函数,此时会将两个虚表中的该函数全部覆写,那直接用子类变量也能直接调用func
,此时没有歧义,因为两个虚函数实现是一样的了
2. 内存与继承 链接到标题
1. C++ 中类对象的内存模型 (布局) 是怎么样的 (⭐⭐⭐) 链接到标题
-
如果是有虚函数的话,虚函数表的指针始终存放在内存空间的头部
-
除了虚函数之外,内存空间会按照类的继承顺序 (父类到子类) 和字段的声明顺序布局
-
如果有多继承,每个包含虚函数的父类都会有自己的虚函数表,并且按照继承顺序布局 (虚表指针 + 字段);如果子类重写父类虚函数,都会在每一个相应的虚函数表中更新相应地址;如果子类有自己的新定义的虚函数或者非虚成员函数,也会加到第一个虚函数表的后面
-
如果有菱形继承,并采用了虚继承,则内存空间排列顺序为:各个父类 (包含虚表)、子类、公共基类 (虚基类,包含虚表),并且各个父类不再拷贝虚基类中的数据成员
2. 菱形继承存在什么问题,如何解决 链接到标题
会存在二义性的问题,因为两个父类会对公共基类的数据和方法产生一份拷贝,因此对于子类来说读写一个公共基类的数据或调用一个方法时,不知道是哪一个父类的数据和方法,也会导致编译错误。可以采用虚继承的方法解决这个问题,这样就只会创造一份公共基类的实例,不会造成二义性
在钻石继承时,使用最高层的公共基类指向最底层的派生类,就会报错,因为编译器不知道是指向哪个派生类的基类区域
3. C++ 是如何做内存管理的(有哪些内存区域)(⭐⭐⭐) 链接到标题
- 堆,使用
malloc
、free
动态分配和释放空间,能分配较大的内存 - 栈,为函数的局部变量分配内存,能分配较小的内存
- 全局/静态存储区,用于存储全局变量和静态变量
- 常量存储区,专门用来存放常量
- 自由存储区:通过
new
和delete
分配和释放空间的内存,具体实现可能是堆或者内存池
4. C++ 内存有哪些段 链接到标题
- 代码段
.text
存放函数代码 .bss
段存放未初始化的全局变量,未初始化的全局静态变量和局部静态变量- 数据段
.data
存放已经初始化的全局变量,已初始化的全局静态变量和局部静态变量 - 只读数据段
.rodata
存放全局常量、字符串常量和虚函数表 - 堆存放动态分配内存的数据
- 栈存放局部变量和函数中声明的非静态变量
5. 堆和栈的内存有什么区别 (⭐⭐⭐) 链接到标题
-
堆中的内存需要手动申请和手动释放,栈中内存是由 OS 自动申请和自动释放
-
堆能分配的内存较大(4G:32 位机器),栈能分配的内存较小(1M)
-
在堆中分配和释放内存会产生内存碎片,栈不会产生内存碎片
-
堆的分配效率低,栈的分配效率高
-
堆地址从低向上,栈由高向下
6. C++ 和 C 分别使用什么函数来做内存的分配和释放,有什么区别,能否混用 (⭐⭐) 链接到标题
C 使用malloc / free
,C++ 使用new / delete
,前者是 C 语言中的库函数,后者是 C++ 语言的运算符,对于自定义对象,malloc / free
只进行分配内存和释放内存,无法调用其构造函数和析构函数,只有new / delete
能做到,完成对象的空间分配和初始化,以及对象的销毁和释放空间,不能混用,具体区别如下:
new
分配内存空间无需指定分配内存大小,malloc
需要;new
返回类型指针,类型安全,malloc
返回void*
,再强制转换成所需要的类型;new
是从自由存储区获得内存,malloc
从堆中获取内存;- 对于类对象,
new/delete
会调用构造函数/析构函数,malloc/free
不会(核心区别)
7. 什么是内存对齐 (字节对齐),为什么要做内存对齐,如何对齐 (⭐⭐⭐) 链接到标题
- 内存对齐的原因:为了提高 CPU 存取数据的效率,计算机从内存中取数据是按照一个固定长度的。比如在 32 位机上,CPU 每次都是取 32bit 数据的,也就是 4 字节;若不进行对齐,要取出两块地址中的数据,进行掩码和移位等操作,写入目标寄存器内存,效率很低。内存对齐一方面可以节省内存,一方面可以提升数据读取的速度
- 对其原则 (类和结构体同样适用):
- 结构体变量的首地址能够被其最宽基本类型成员的对齐值所整除
- 结构体内每一个成员的相对于起始地址的偏移量能够被该变量的大小整除
- 结构体总体大小能够被最宽成员大小整除
- 如果不满足这些条件,编译器就会进行一个填充 (padding)
- 如何对齐:声明数据结构时,字节对齐的数据依次声明,然后小成员组合在一起,能省去一些浪费的空间,不要把小成员参杂声明在字节对齐的数据之间
- 空类 / 结构体占 1 字节 (为了能对空类取地址,从而用了 1 字节的占位符),若有了一个虚函数,那就有了虚指针,不再是空类,64 位系统下指针占 8 字节,此时类大小为 8 字节
8. delete 和 delete[] 的区别,delete[] 如何知道要 delete 多少次 (⭐) 链接到标题
-
若是基本类型,
delete
和delete[]
效果是一样的,因为系统会自动记录分配的空间,然后释放;对于自定义数据类型而言(比如类)就不行了,delete 仅仅释放数组第一个元素的内存空间,且仅调用了第一个对象的析构函数,但delete[]
会调用数组所有元素的析构函数,并释放所有内存空间 -
在
new []
一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,这个数据就存在分配空间的最前面*p
的位置,返回的指针是*p + 4
,在delete[]
时就可以取出这个保存的数,就知道了需要调用析构函数多少次了
9. 在类的成员函数中能否 delete this(⭐) 链接到标题
delete this
就是显式调用了析构函数,并释放内存。在析构函数被私有,或者智能指针引用计数归零时,会使用到这句话
在类的成员函数可以调用delete this
,并且delete this
之后还可以调用该对象的其他成员,但是有个前提:被调用的方法不涉及这个对象的数据成员和虚函数。当一个类对象声明时,系统会为其分配内存空间。在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中
因为成员函数并不在类的内存中,所以即使用一个类的空指针,比如A *p = nullptr
,这个指针p
依旧能调用类的函数,但前提依旧是这个成员函数不访问任何类的数据成员
10. 如何在已经分配好的内存进行对象构造 (⭐) 链接到标题
一种方式是使用placement new
,调用方式为new(ptr) XXX()
,XXX()
表示调用类的构造函数,比如下面代码
class A
{
int val;
};
int main()
{
auto mem = malloc(sizeof(A));
auto p = new(mem) A();
# 此时两个指针指向同一位置
assert((void*)p == (void*)mem);
return 0;
}
使用placement new
有三个注意点:必须有足够内存放对象;指针地址应该对齐,比如 32 位系统,指针的地址应该是 4 的整数倍;需要显式的调用析构函数来销毁对象
在new
的过程中,经过了三次操作:调用operator new
分配内存,调用构造函数,返回指针,这个operator new
可以重载,原型为void* operator new(size_t sz)
将内存和构造分离,可以使用allocator
类,先分配内存,再显式构造对象
int main()
{
std::allocator<A> alloc;
# 分配一个A对象的内存,用指针 p 指向
auto p = alloc.allocate(1);
# 调用 A 的构造函数,构造在 p 指向的内存,构造函数的形参写在 p 后面
alloc.construct(p, ...);
# 调用析构函数 p->~A(),但是不释放内存
alloc.destroy(p);
# 释放内存
alloc.deallocate(p, 1);
return 0;
}
11. new / delete 的重载 链接到标题
重载new
和delete
,实际上是重载operator new
和operator delete
函数,若重载这两个函数,就必须保证是正确的,并且担负了控制动态内存分配的职责
这两个函数会被误以为重载了new
和delete
,实际上只是改变了内存分配的方式,我们不能改变new
运算符和delete
运算符的基本含义
标准库定义了这两个函数共八个版本,可以重载任意的版本,下面列出四个
void* operator new(size_t); # 分配一个对象
void* operator new[](size_t); # 分配一个数组
void* operator delete(void*) noexcept; # 释放一个对象
void* operator delete[](void*) noexcept; # 释放一个数组
注意到operator new
返回值必须是void*
,并且所有的operator delete
承诺不抛出异常
我们可以使用malloc
和free
来重载上述两个函数
void* operator new(size_t size)
{
if(void *mem = malloc(size))
return mem;
else
throw bal_alloc();
}
void operator delete(void *mem) noexcept
{
free(mem);
}
析构函数可以显式调用,析构函数只负责销毁对象,但是并不释放内存
string *sp = new string("value");
sp->~string();
12. 如何让类只能在堆/栈上创建 (⭐) 链接到标题
- 这涉及到 C++ 创建类的两种方法,静态建立和动态建立:
- 静态建立:由编译器为对象在栈空间上分配内存,直接调用类的构造函数创建对象。例如
A a;
- 动态建立:使用
new
关键字在堆空间上创建对象,首先调用operator new()
函数,在堆空间上寻找合适的内存并分配;然后,调用类的构造函数创建对象。例如A *p = new A;
-
只能在堆上构建,就是不能直接调用构造函数,只能通过
new
进行调用,可以将构造函数声明为protected
,提供一个public
的静态函数来调用new + 构造函数
来返回对象,并将析构函数设置为protected
,为保证能释放内存,需要写一个额外的destory
函数来释放内存delete this
,这就类似于单例模式 -
只能在栈上构建,就是不能调用
operator new
,只要将这个函数声明为私有即可,并且要把delete
也声明为私有,如下代码所示,注意operator new
是一个固定的函数,参数和返回值都是固定的
class A
{
private:
# 注意函数的第一个参数和返回值都是固定的
void *operator new(size_t t) {}
# 重载了 new 就需要重载 delete
void operator delete(void *ptr) noexcept {}
public:
A() {}
~A() {}
};
13. 类成员的初始化顺序 链接到标题
使用构造函数初始化类的成员变量,构造函数分为初始化列表和构造函数体两个部分。在初始化列表中,每个变量的初始化顺序只和在类内定义变量的顺序有关;在构造函数体内,则和写的赋值语句顺序有关
class A
{
private:
int a;
int b;
public:
# 出错,先初始化 a, 此时 b 还没有值, 初始化列表的初始化顺序和语句顺序没关系, 只和变量定义顺序有关
A(int v1): b(v1), a(b + v1) {}
};
14. 数组访问越界会访问到什么结果 链接到标题
数组中的元素作为连续的内存地址存储,当访问数据越界时,它会尝试读取或写入内存中的未分配空间,这可能包括其他变量、数据结构、程序代码等。这可能会导致覆盖其他变量或数据结构的值,结果是不可预测的,可能会导致程序崩溃或产生不正确的结果,甚至会对系统产生影响
并非每次越界访问都会导致程序崩溃,程序崩溃一般发生在下面的情况:
- 内存读取和写入非法地址时:试图读取或写入未分配的内存空间,这个地址可能并非指向任何有效的内存位置,这将导致程序崩溃
- 覆盖其他变量或数据结构的值时:越界访问时可能会覆盖其他变量或数据结构的值,导致程序状态受到破坏
- 发生了缓冲区 (IO) 溢出时:当存储在缓冲区中的数据超过了缓冲区的容量时,可能会发生缓冲区溢出。缓冲区溢出有时会导致程序崩溃或者产生不正确的结果
- 堆栈溢出时:如果在函数调用过程中,堆栈空间不足以支持函数所需的所有局部变量及其上下文信息(例如函数调用和返回地址等),就会发生堆栈溢出
15. public 继承和 private 继承 链接到标题
-
公有继承意味着 is - a 的关系,也就是派生类是基类的一种特例,在基类的所有行为在派生类也应该成立,基类的所有成员在派生类也有意义,所以成员函数的实现和接口均被继承了
-
私有继承意味着根据某物实现,也就是派生类具备基类的某些特性,但是基类和派生类并没有实际的关系。首先,私有继承会导致派生类不能转换成基类,其次,所有基类的成员都会在派生类内变成私有成员。因此,基类的所有成员函数只能在类内调用,在类外全都无法调用,这就是成员函数的实现被继承,而接口全被略去 (因为在类外都无法调用)
-
一个空类会占有 1 个字节大小,但是继承一个空类,就会优化掉这个 1 个字节大小
-
组合就是在一个类中将其他类声明为成员变量,复合意味着 has - a 的关系,这和公有继承的含义完全不同
3. 类型转换 链接到标题
1. C++ 有哪些类型转换的方法 (关键字),各自有什么作用 (⭐) 链接到标题
-
const_cast
:把const
属性去掉,或者添加const
,const_cast
只能用于指针或引用,并且只能改变底层const
-
static_cast
:可以实现 C++ 中内置基本数据类型之间的相互转换,能进行类层次间的向上类型转换和向下类型转换(向下不安全,因为没有进行动态类型检查),它不能进行无关类型 (如非基类和子类) 指针之间的转换,也不能作用于包含底层const
的对象 -
dynamic_cast
:动态类型转换,用于将基类的指针或引用安全地转换成派生类的指针或引用,若指针转换失败返回NULL
,若引用返回失败抛出bad_cast
异常。dynamic_cast
是在运行时进行安全性检查;使用dynamic_cast
父类一定要有虚函数,否则编译不通过 -
reinterpret_cast
:此标识符的意思即为将数据的二进制形式重新解释,但是不改变其值,有着和 C 风格的强制转换同样的能力。它可以转化任何内置的数据类型为其他任何的数据类型,也可以转化任何指针类型为其他的类型。它甚至可以转化内置的数据类型为指针,无须考虑类型安全或者常量的情形,这和操作符并不安全
2. static_cast 和 dynamic_cast 的异同点 链接到标题
static_cast
和dynamic_cast
有以下区别:
static_cast
用于非多态类型的转换,如基本数据类型之间的转换。它的转换是在编译期完成的,在运行期不会进行任何检查。如果static_cast
的转换不安全,可能会导致程序运行时错误 (将父类对象转换成子类对象就是不安全行为,static_cast
不进行运行期间检查,这种转换可能会导致内存访问错误)dynamic_cast
主要用于多态类型之间的转换,例如基类指针向下转换为派生类指针、派生类指针向上转换为基类指针等。它的操作是在运行期完成的,会检查是否可以安全地进行类型转换 (需要有虚函数,才能进行运行期间检查)。如果失败,它会返回一个空指针或者抛出一个std::bad_cast
异常static_cast
的性能比dynamic_cast
更高,因为它不需要进行运行时的类型检查static_cast
和dynamic_cast
两者都有一个共同的限制:它们只适用于有继承关系的类之间的转换,对于没有继承关系的类,不能使用这两种类型的转换
3. dynamic_cast 的原理 链接到标题
dynamic_cast
用于将基类指针或引用安全地转换成派生类指针或引用。它能够在运行时检查类型信息,从而实现安全的转换,实现原理主要包括两个步骤:
- 通过类型信息表
type_info
进行类型检查:dynamic_cast
在执行类型转换时,首先会获取当前对象的type_info
指针,然后匹配目标类型的type_info
指针,如果匹配成功,则进行类型转换;反之,返回一个空指针或抛出std::bad_cast
异常 - 计算偏移量进行指针调整:如果类型检查成功,
dynamic_cast
会计算当前对象和目标类型之间的偏移量,然后进行指针调整,这个偏移量是通过虚函数表动态计算的,因此dynamic_cast
只适用于具有继承关系且包含虚函数的类对象之间的转化
4. RTTI 链接到标题
RTTI(Run-Time Type Information,运行时类型信息)是 C++ 的一个特性,用于在程序运行时确定对象的类型。RTTI 可以通过dynamic_cast
和typeid
实现,在需要检查对象类型的时候,可以用dynamic_cast
进行类型转换,如果转换失败则返回 NULL 指针,否则返回指向目标类型的指针;也可以使用typeid
获取对象的类型信息,返回类型为std::type_info
RTTI 的主要作用有:
- 确定对象的类型:当需要在程序运行时检查对象的类型时,RTTI 提供了一种简单而有效的方法
- 实现多态:RTTI 是实现多态的基础。在面向对象编程中,多态是一个重要的概念,它指的是同一个函数调用可能会根据传递参数的不同而产生不同的行为。RTTI 可以识别出对象在继承层次结构中的位置,从而可以正确地调用相应的函数
- 辅助代码调试:当程序出现异常或错误时,可以使用 RTTI 输出对象的类型信息,从而帮助开发人员更快地找到问题所在
需要注意的是,使用 RTTI 可能会带来一定的性能损失,因此在一些性能敏感的场景下应该尽量避免使用
5. 浮点数和误差 (⭐⭐) 链接到标题
float
的存储,是包括符号位 (1 位)+指数位 (8 位)+尾数位 (23) 位,可以表示为公式
$$
float = (1.a)*2^b
$$
指数位的表示值范围为$[2^{-8}, 2^8 - 1]$,所以浮点数的最大值为$1.1….×2^{127}$,逼近$2^{128}$,约等于$3.4×10^{38}$,所以通常说浮点数可以表示的数字范围是$[-3.4×10^{38},3.4×10^{38}]$
但这个看似比int
范围广的多的数据范围,是以精度损失为代价的,虽然底层存储的指数位和小数位值是固定的,但是当指数位表示的值从负数变成正数,变成十进制时,也就是从一个很小的小数不断变成一个很大的整数,小数位不断丢失 (因为乘以了 2 的次方,小数全部变成了整数部分),所以浮点数可以表示的有效数字只有 7 位,整数值 + 小数值顶多只有 7 位有效
考虑整数情况,因为有$23$位尾数,因此float
类型可以精确表示$2^{23}$个不重复的二进制小数。这意味着,float
类型可以精确表示$23$位二进制小数,再乘上指数项$2^n$,加上科学计数法默认的整数部分的$1$,所以浮点数可以完全精确表示的整数范围是$[-16777216,16777216]=[-2^{24},2^{24}]$
但是这个范围以外的整数,除非能表示成$[0, 16777215]$之间的一个数字进行位移$×2^n$的模式,否则都会丢失精度,会出现对这个值$+1$,但是浮点数显示的值不变,也就是损失了整数位的精度
考虑小数情况,一个小数要拆分成多个二进制次方的小数和形式,比如$0.75=0.5+0.25$,转换成二进制$0.11$,但一些小数会导致二进制无限循环,只能逼近小数,导致精度误差,比如$0.7$只能表示成$0.1011001100…$
下图为float
和double
的精度范围
可见,当float
在千万级别时,精度是 1,已经无法感知小数位的变化,为了保证有一位小数的精度,至少要保证float
在百万以内,所以说,浮点数的整数 + 小数部门是固定长度的,大的浮点数基本没有小数精度了
4. 指针 链接到标题
1. C++ 中的智能指针有哪些,各自有什么作用 (⭐⭐) 链接到标题
智能指针主要解决一个内存泄露的问题,它可以自动地释放内存空间。因为它本身是一个类,当函数结束的时候会调用析构函数,并由析构函数释放内存空间。智能指针分为共享指针shared_ptr
, 独占指针unique_ptr
和弱指针weak_ptr
-
shared_ptr
:多个共享指针可以指向相同的对象,采用了引用计数的机制,当最后一个引用销毁时,释放内存空间 -
unique_ptr
:保证同一时间段内只有一个智能指针能指向该对象(可通过move
操作来传递unique_ptr
) -
weak_ptr
:用来解决shared_ptr
相互引用时的死锁问题。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr
之间可以相互转化,shared_ptr
可以直接赋值给它,它可以通过调用lock
函数来获得shared_ptr
2. shared_ptr 的实现原理是什么?构造函数、拷贝构造函数和赋值运算符怎么写?shared_ptr 是不是线程安全的 (⭐) 链接到标题
-
shared_ptr
是通过引用计数机制实现的,引用计数存储着有几个shared_ptr
指向相同的对象,当引用计数下降至 0 时就会自动销毁这个对象 -
具体实现:
- 构造函数:将指针指向该对象,引用计数置为 1
- 拷贝构造函数:将指针指向该对象,引用计数++
- 赋值运算符:= 号左边的
shared_ptr
的引用计数 -1,右边的shared_ptr
的引用计数 +1,如果左边的引用计数降为 0,销毁shared_ptr
指向对象,释放内存空间
-
shared_ptr
的引用计数本身是安全且无锁的 (使用了原子操作),但是对对象的访问并不是线程安全的
3. 如何创建 shared_ptr,能不能使用普通指针创建 (⭐) 链接到标题
可以调用make_share
方法,或者使用new
关键字 (但必须直接初始化)
shared_ptr<int> p1 = make_shared<int>(42);
shared_ptr<int> p2(new int(1024));
不要直接使用普通指针给智能指针初始化,否则当智能指针变量的引用计数归零后,会自动释放内存,此时的普通指针就成了空悬指针
void process(shared_ptr<int> ptr)
{
# 某些操作
}
int *p = new int(1024);
process(shared_ptr<int>(p));
# 用内置指针创建智能指针,引用计数为1,函数结束后引用计数为0,释放了指向的内存,也就是内置指针指向的内存
int j = *p; # 报错,p已经是空悬指针
补充一点:
shared_ptr
的大小是裸指针的两倍,因为还要保存引用计数的信息,若自定义了删除器和分配器也会保存,这些额外信息统一存在一个叫控制块的数据结构内
一个对象的控制块由首次指向该对象的shared_ptr
得到。在使用make_shared
时候会创建一个控制块;使用一个unique_ptr
变量创建shared_ptr
对象也会创建控制块;当用一个裸指针作为参数创建一个shared_ptr
时候也会得到一个控制块
正是因为用裸指针创建shared_ptr
会创建一个控制块,若用同一个裸指针创建两个shared_ptr
对象,那就会得到两个不同的控制块,且每个控制块记录的引用计数都是 1,如下面代码所示
auto pw = new Widget;
std::shared_ptr<Widget> spw1(pw);
std::shared_ptr<Widget> spw2(pw);
那么当这两个shared_ptr
变量离开作用域被销毁后,会对同一对象执行两次析构函数,第二次析构函数就会导致未定义情况
4. 创建智能指针什么时候使用 make 函数,什么时候使用 new 链接到标题
创建智能指针,一般都是使用make
系列函数,即make_shared, make_unique
,不仅在创建变量的时候很方便
auto upw1(std::make_unique<Widget>()); # 只要声明一次类型
std::unique_ptr<Widget> upw2(new Widget); # 要声明两次类型
而且可以避免潜在可能的内存泄漏,比如下面的代码,一个函数接受一个shared_ptr
和一个int
值,我们用一个函数的返回值来给这个int
形参传值
void processWidget(std::shared_ptr<Widget> spw, int priority);
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
编译器对这个函数调用,会先执行new Widget
,生成一个指向Widget
的裸指针,但是第二步不一定是创建shared_ptr
,也可能是调用computePriority
函数,若在调用computePriority
过程中出现异常,并且此时shared_ptr
还没有接管这个裸指针指向的对象,就导致了内存泄漏
并且使用make
函数,可以只进行一次内存分配,如下面代码所示
std::shared_ptr<Widget> spw(new Widget);
auto spw2 = std::make_shared<Widget>();
因为shared_ptr
是需要给控制块分配内存的,所以用裸指针创建shared_ptr
对象需要两次内存分配,先给裸指针指向的Widget
分配内存,再给shared_ptr
需要的控制块分配内存,而make_shared
直接将分配对象内存和控制块内存一步解决
但是make
系列函数不能传入自定义的删除器,所以当想使用自定义删除器的时候,还是要用new
创建智能指针
auto widgetDeleter = [](Widget *pw) {...};
std::unique_ptr<Widget, decltype(widgetDeleter)> upw(new Widget, widgetDeleter);
5. 指针和引用的区别 (⭐⭐) 链接到标题
- 指针本质是一个变量,有自己的内存空间;引用只是一个别名,并不占内存
- 指针可以指向其他的对象;引用不能指向其他的对象,初始化之后就不能改变了
- 指针可以初始化为
nullptr
;引用必须被初始化为一个已有对象的引用 - 指针可以是多级指针;引用只能是一级
- 对指针
sizeof
得到的是指针变量的大小;对引用sizeof
得到的是指向对象的大小
6. 什么时候应该用指针,什么时候应该用引用 (⭐⭐) 链接到标题
引用比指针更安全,并且可读性更高,引用在大部分情况下都是优先选项,除非有以下情况:
- 当可能需要指向空对象的时候,使用指针而非引用,因为引用必须指向一个对象不能置空
- 当可能要改变指向的情况下,使用指针而非引用,因为引用不可以改变指向
尤其当在重载运算符的时候,比如[]
,这肯定要返回引用,不然每次使用该运算符访问对象都要加解引用符号*
引用用于对象的表面,而指针用于对象的内部
7. 函数指针参数传递和引用参数传递有什么区别 (⭐) 链接到标题
-
指针参数传递本质上是值传递,它所传递的是⼀个地址值。值传递过程中,被调函数的形参作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从⽽形成了实参的⼀个副本
-
引⽤参数传递过程中,被调函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。因此,被调函数对形参的任何操作都会影响主调函数中的实参变量
-
引⽤传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的⼀个局部变量,但是任何对于引⽤参数的处理都会通过⼀个间接寻址的⽅式操作到主调函数中的相关变量。⽽对于指针传递的参数,如果改变被调函数中的指针地址,不会改变实参指针的指向地址,若想用形参指针改变实参指针的指向,必须使用指针的指针或指针的引用
-
从编译的⻆度来讲,程序在编译时分别将指针和引⽤添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,⽽引⽤在符号表上对应的地址值为引⽤对象的地址值。符号表⽣成之后就不会再改,因此指针可以改变其指向的对象(指针变量中的值可以改),⽽引⽤对象则不能修改
8. 野指针和空悬指针的区别 链接到标题
野指针 (wild pointer):就是没有被初始化过的指针
悬空指针:是指针最初指向的内存已经被释放了的⼀种指针
⽆论是野指针还是悬空指针,都是指向⽆效 (不安全不可控) 内存区域的指针。访问无效的内存区域将导致未定义行为
9. RAII(⭐) 链接到标题
RAII(Resource Acquisition Is Initialization)含义为资源获取即初始化,也就是使用局部对象来管理资源的技术。这里的资源主要是指操作系统中有限的东西如内存、网络套接字等等,局部对象是指存储在栈的对象,它的生命周期是由操作系统来管理的,无需人工介入
比如说,可以用一个类封装一个动态内存,调用构造函数时申请空间分配内存,变量离开作用域会自动调用析构函数释放内存
智能指针就是 RAII 的一种实现,将内存的申请和释放全部交给了编译器处理
5. 关键字 链接到标题
1. const 的作用,指针常量和常量指针,const 修饰的函数能否重载 (⭐) 链接到标题
const
修饰符用来定义常量,具有不可变性。在类中,被const
修饰的成员函数,不能修改类中的数据成员;- 若形参是值类型,加
const
不构成重载;若为指针或引用类型,加const
构成重载 - 若给成员函数的
this
加上const
构成重载,返回值加static
不构成重载,修改返回值类型也不构成重载,函数的签名是函数名称和参数列表,是通过不同的参数实现重载的 - 常量指针 (const pointer) 是指指针本身是常量,不能修改指向的地址值,但是能修改指向的对象的值
- 指针常量 (pointer to const) 是指指针指向一个常量,可以修改指针的指向地址,但是不能通过指针修改指向对象的值
int x = 2;
int y = 3;
// 常量指针,不能修改指向
int *const p1 = &x;
// 报错,常量指针不能修改指向
p1 = &y;
// 指针常量
const int* p2 = &x;
// 报错,指针常量不能修改指向对象的值
*p2 = 3;
2. static 的作用,static 变量什么时候初始化 (⭐⭐⭐) 链接到标题
static
即静态的意思,可以对变量和函数进行修饰。分三种情况
-
当用于文件作用域的时候(即在
.h/.cpp
文件中直接修饰变量和函数),static
意味着这些变量和函数只在本文件可见,其他文件是看不到也无法使用的,可以避免重定义的问题 -
当用于函数作用域时,即作为局部静态变量时,意味着这个变量是全局的,只会进行一次初始化,不会在每次调用时进行重置,但只在这个函数内可见
-
当用于类的声明时,即静态数据成员和静态成员函数,
static
表示这些数据和函数是所有类对象共享的一种属性,而非每个类对象独有 -
static
变量在类的声明中不占用内存,因此必须在.cpp
文件中定义类静态变量以分配内存 (需要在main
函数外给其分配内存,比如int A::a = 2
,或者直接在类内赋初值),全局变量、文件域的静态变量和类的静态成员变量在main
执行之前的静态初始化过程中分配内存并初始化;局部静态变量在第一次使用时分配内存并初始化
3. extern 的作用 链接到标题
当它与"C"
一起连用时,如:extern "C" void fun(int a, int b);
则告诉编译器在编译fun
这个函数名时按着 C 的规则去翻译相应的函数名而不是 C++ 的;当它作为一个对函数或者全局变量的外部声明,提示编译器遇到此变量或函数时,在其它模块.cpp
中寻找其定义
4. explicit 的作用 链接到标题
标明类的构造函数是显式的,不能进行隐式转换,比如
class A
{
public:
int val;
explicit A(int v): val(v) {}
};
int main()
{
vector<A> ve;
ve.push_back(1); # 报错,禁止隐式转换
ve.push_back(A(1)); # 正确,显式调用构造函数
return 0;
}
5. constexpr 的作用,和 const 区别 链接到标题
constexpr
是 C++11 引入的关键字,用于在编译时求值的常量表达式。它可以用于定义常量变量或函数,并具有类型和编译时类型检查。constexpr
定义的常量必须是能在编译时确定值的表达式,不能包含运行时的操作,有点类似于define
,在编译期直接替换,就不占用内存了
const
和constexpr
都是 C++ 中用于定义常量的关键字,但它们有以下几个不同点:
-
修饰符的适用范围:
const
:可以用于修饰变量、函数参数、成员函数、成员变量等。它可以用于运行时常量和编译时常量constexpr
:主要用于修饰变量和函数,用于声明编译时常量
-
求值时机:
const
:在运行时求值,即在程序运行期间才确定其值constexpr
:在编译时求值,即在编译期间就能确定其值
-
表达式限制:
const
:可以用任何常量表达式来初始化,包括运行时的函数调用和动态内存分配等constexpr
:要求表达式在编译期间就能确定其值,不能包含运行时操作,如函数调用、动态内存分配等
-
类型限制:
const
:可以用于任何数据类型,包括内置类型、用户自定义类型等constexpr
:要求常量的类型必须是字面值类型(Literal Type),包括内置字面值类型和满足一定条件的用户自定义字面值类型(如构造函数是constexpr
)
-
编译时优化:
const
:不一定会进行编译时优化constexpr
:在编译时求值,可以进行更多的编译时优化,例如在编译期间替换常量表达式
6. volatile 的作用 (⭐⭐) 链接到标题
跟编译器优化有关,告诉编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的备份
更重要的作用是避免指令重排,一个变量被加上volatile
,就必然会按照程序写的顺序执行,保证了多线程时对值读写和判断的正确性
7. mutable 的作用 链接到标题
可变的意思,类中声明为const
的函数可以修改类中声明了mutable
的非静态成员
8. auto 和 decltype 的作用和区别 链接到标题
用于实现类型自动推导,让编译器来推导变量的类型;auto
不能用于函数传参 (在 C++14 允许对lambda
表达式的参数使用auto
) 和推导数组类型,但deltype
可以
auto
默认推断的类型是没有任何const
和引用,需要手动添加,比如const auto &
decltype
会保留传入参数的所有类型关键词,包括const
和引用,比如
const int i = 0;
const int &ref = i; # decltype(ref) 类型为 const int&
并且可以给decltype
传入的参数加一个小括号,表示转化成引用类型,比如
int x = 0; # decltype((x)) 类型为 int&
在 C++11 中,decltype
可以用于函数的尾后返回类型,表示函数的返回类型依赖于函数的某个参数,一般用于模板函数
template<typename Container, typename Index>
auto authAndAccess(Container &&c, Index i) -> decltype(std::forward<Container>(c)[i])
{
return std::forward<Container>(c)[i];
}
在 C++14 中,可以直接将decltype
和auto
放在一起decltype(auto)
,让编译器自行推导返回类型,省略尾后返回类型
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container &&c, Index i)
{
return std::forward<Container>(c)[i];
}
auto
可以在同行声明多个变量,但是必须auto
推导出来的类型相同,可以把auto
就看成某个特定类型,这是唯一的
auto
也能推导出指针和函数指针,任何函数的名字本身就是指向这个函数的指针,不论加不加&
都可以进行推导
9. using 和 typedef 区别 链接到标题
两个关键字都可以用于给类型取别名,使用using
会更直观一些
typedef void (*FP)(int, const std::string&);
using FP = void (*)(int, const std::stting&);
但只有using
可以直接给模板取别名,称为别名模板
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
并且使用using
可以消除对依赖类型加typename
的过程,比如先使用typedef
创建一个别名模板,必须用一个结构体包装
template<typename T>
struct MyAllocList {
typedef std::list<T, MyAlloc<T>> type;
};
现在想使用这个别名模板的类型创建一个变量,就必须在前面加上typename
强调这是一个类型
template<typename T>
class Widget{
private:
typename MyAllocList<T>::type list;
};
但直接使用using
创建别名模板,就不用使用结构体封装,也就不需要加typename
了,编译器知道这是一个类型而非变量
template<typename T>
using MyAllocList = std::list<T, MyAlloc<T>>;
template<typename T>
class Widget{
private:
MyAllocList<T> list;
};
10. 限定作用域枚举类型 链接到标题
C++98 的枚举类型称为不限定作用域的枚举类型enum
,而 C++11 新加的枚举类型称为限定作用域的枚举类型enum class
enum Color { black, white, red }; # C++98
enum class Color { black, white, red }; # C++11
前者声明的几个枚举名字在Color
有效的作用域都有效,所以就不能创建同名的变量了 (污染了变量名空间),而后者只在枚举内部可见,在外部使用必须用作用域符号才能得到
enum Color { black, white, red };
auto white = false; # 错误,white 已经声明过了
enum class Color { black, white, red };
auto white = false; # 正确
auto a = Color::white; # 需要使用作用域符进行访问
并且enum class
没有任何转化到其他类型的隐式转换 (只能使用显式转换),而enum
可以隐式转换到int
这也就意味着,enum class
类型的变量只能和自己类型的变量进行比较,不存在加减法,就和一个类变量没什么区别了
隐式转换在大部分都会出现问题,只有在和tuple
一起使用的时候enum
的隐式转换才有一定意义
# 元组包括用户名称,用户的邮件地址和用户的数值
using UerInfo = std::tuple<std::string, std::string, std::size_t>;
# 枚举变量名与元组每个值的含义对应
enum UserInfoFields { uiName, uiEmail, uiValue };
UerInfo uInfo;
# 避免记忆元组每个变量是什么含义,直接用 enum 变量隐式转换到 int 来解决
auto val = std::get<uiName>(uInfo);
编译器会选择一个整数型别作为枚举类型的底层型别,enum class
默认是int
,enum
会根据枚举变量的值来确定性,所以enum class
可以只做声明,先不定义内部的枚举变量,而enum
要么需要定义内部的枚举变量,要么必须显式指定底层型别
enum class Color; # 允许前置声明,默认底层类型为 int
enum Color; # 不允许前置声明,必须指定类型
enum Color: std::uint32_t; # 指定底层类型为 uint32
11. 删除函数 链接到标题
给函数加上delete
关键字,保证其无法被调用,这一般是禁止编译器为类生成拷贝构造函数和赋值运算符函数,比如对于流对象,不能进行拷贝和赋值,所有的流对象继承于basic_ios
类
template <class charT, class traits = char_traits<charT>>
class basic_ios : public ios_base
{
public:
basic_ios(const basic_ios &) = delete;
basic_ios& operator=(const basic_ios&) = delete;
}
删除函数访问等级一般设定为public
,编译器会先校验函数可访问性,再判断是否为删除函数,这样可以确保delete
特性得到表现
任何函数都可以是删除函数,包括普通函数和模板函数
对于一个普通函数,使用删除函数可以避免隐式转换导致出现预期的结果
bool isLucky(int number); # 判断一个数字是不是幸运数字
# 但是 int 类型可以由非常多的类型隐式转换得到,比如 char,bool,float,double,这些调用就不符合预期了
# 为避免隐式转换出现,将这些类型作为形参的函数设定为删除函数
bool isLucky(char) = delete;
bool isLucky(bool) = delete;
bool isLucky(double) = delete;
# 此时再使用 char,bool,double 调用此函数就会报错了
对于一个模板函数,使用删除函数可以阻止特定类型的模板被实例化
比如一个模板函数,需要一个指针类型参数,但是不希望传入一个void*
或者char*
template <typename T>
void processPointer(T* ptr);
template <>
void processPointer<void>(void*) = delete;
template <>
void processPointer<char>(char*) = delete;
12. lambda 和 function 区别,内存大小 (⭐) 链接到标题
C++ 的 lambda 和 function 都是函数对象,它们可以在函数中定义并作为参数传递
lambda 表达式,使得开发人员可以在代码块中编写匿名函数。lambda 表达式通常被用来编写轻量级的回调函数或即席函数,以便将其传递到 STL 算法中。lambda 表达式可以“捕获”它们所在函数的变量,并且具有比函数对象更简单的语法
function 对象则是一个可调用对象封装,是一个通用的函数包装器,允许将任何可调用对象存储为单一、多态的函数类型 (就是可以封装虚函数) 并调用它们。function 对象可以存储于容器或在其他地方进行复制或传递
使用auto
创建一个变量表示一个 lambda 表达式,一个 lambda 表达式是一个 lambda 类,可认为其重载了operator ()
运算符,变成了可调用对象
其内存大小和捕捉的变量有关,每个捕捉的变量都会存储在这个 lambda 类中。值捕捉就是将这个变量值直接拷贝在类中,根据其在 lambda 出现的顺序依次拷贝在类中,所以会出现内存对齐,比如说char + short + int + long long
大小为 16,而char + int + short + long long
大小为 24;引用捕捉就是将这个变量的地址,也就是一个指针拷贝在类中,在 32 位系统中,一个指针占 4 个字节,所以大小为引用捕捉个数乘以 4
注意空类占 1 个字节,所以若 lambda 表达式没有捕捉任何变量,只使用了函数传入的参数,那么这个 lambda 类占 1 字节
lambda 表达式不能捕捉静态变量,但是可以使用静态变量,所以在 lambda 表达式中使用的任何静态变量均不计算内存大小
13. C++ 如何实现 C# 多播委托机制 (⭐) 链接到标题
多播委托就是可以注册多个函数,然后调用一次委托,调用多个函数,可以直接使用函数指针或者函数对象来实现,其实两者的声明都可以看成是委托的一种声明,有返回类型和参数,只需要用一个数组存储函数指针或者函数对象即可
对于全局函数和成员函数,在绑定到函数对象或者函数指针时,有着不同的操作:全局函数的名称本身就是函数指针,可以直接传递进去;非静态成员函数不可以直接被类名访问到,所以需要一个类对象间接调用,可以使用lambda
的方式
下面代码展示了实现多播委托的方式,分别添加了全局函数和成员函数
#include <iostream>
#include <functional>
#include <vector>
// 定义委托类型
typedef std::function<void(const std::string&)> MyDelegate;
// 委托的调用列表
std::vector<MyDelegate> delegates;
// 添加委托函数到调用列表
void AddDelegate(const MyDelegate& delegate)
{
delegates.push_back(delegate);
}
// 调用所有委托函数
void InvokeDelegates(const std::string& message)
{
for (const auto& delegate : delegates)
{
delegate(message);
}
}
// 全局函数作为委托调用对象
void GlobalFunction(const std::string& message)
{
std::cout << "Global Function: " << message << std::endl;
}
// 类成员函数作为委托调用对象
class MyClass
{
public:
void MemberFunction(const std::string& message)
{
std::cout << "Member Function: " << message << std::endl;
}
};
int main()
{
// 添加全局函数和类成员函数到调用列表
AddDelegate(GlobalFunction);
MyClass obj;
AddDelegate([&obj](const std::string& message) { obj.MemberFunction(message); });
// 调用所有委托函数
InvokeDelegates("Hello, World!");
return 0;
}
14. lambda 表达式的值捕捉和引用捕捉 (⭐⭐) 链接到标题
-
值捕捉将在 lambda 表达式创建时外部局部变量的值复制到 lambda 对象内部,以保证在 lambda 对象被调用时,外部变量的值不会被修改,并且值捕捉的变量值是在定义 lambda 表达式时候的值决定的
-
引用捕捉则是使用外部局部变量的引用,在 lambda 表达式执行期间对外部变量进行修改时,也会影响到外部变量本身,并且引用捕捉的值是由调用 lambda 表达式时候的值决定的
-
注意,lambda 表达式捕捉的不包括静态变量,静态变量可以在里面使用,但是并没有捕捉这个静态变量,所以在外部修改静态变量,就会影响 lambda 表达式内部的实际值
int main()
{
static int x = 2;
auto f = [=] ()
{
return x;
};
x++;
# 此时输出 3,外部修改这个静态变量会影响 lambda 内捕捉到的这个静态变量值
cout << f() << endl;
return 0;
}
15. sizeof(⭐) 链接到标题
sizeof
用于求一个变量的大小,对于数组int nums[10]
和指针int *p = nums
,会输出数组的大小40
和一个指针的大小4
(假设在 32 位系统,64 位是8
),sizeof
能通过变量的类型信息判断这个变量的大小,其实和反射时候的元数据差不多
编译器在分析源代码的过程中,会对每个变量、表达式、函数等进行语法和语义分析,并为其确定类型信息。编译器会根据变量的声明、赋值、函数调用等语句来推断变量的类型,并将这些类型信息保存在符号表中
在编译过程中,当遇到sizeof
运算符时,编译器会根据运算符后面的表达式来查找符号表,获取相应的类型信息。根据类型信息,编译器可以计算出相应的大小
16. typeid 链接到标题
在 C++ 中,typeid
是一个运算符,用于获取对象的类型信息,typeid
的实现是通过使用一种叫做类型信息结构(type_info)的机制来实现的。每个具体的类型在编译时都会生成一个唯一的type_info
对象,用于表示该类型的信息
type_info
对象通常包含了以下类型信息:
- 类型的名称
- 类型的大小
- 类型的对齐要求
在运行时,当使用typeid
运算符获取对象的类型信息时,编译器会生成一段代码来检索该对象的type_info
对象,并返回该对象的引用或指针
具体的实现细节可能因编译器和标准库的不同而有所差异。在某些实现中,type_info
对象可能是一个全局变量,而在其他实现中,可能是一个虚表或其他数据结构的一部分
typeid
运算符只能用于具有多态性(即至少有一个虚函数)的类型。对于非多态类型或非完整类型(如不完整的类声明或不完整的数组类型),使用typeid
运算符会导致编译器错误
6. 左值右值,构造函数 链接到标题
1. 什么是左值和右值,什么是右值引用,为什么要引入右值引用 (⭐⭐) 链接到标题
- 左值就是具有可寻址的存储单元,并且能由用户改变其值的量,比如常见的变量。左值具有持久的状态,直到离开作用域才销毁;右值表示即将销毁的临时对象,具有短暂的状态,比如字面值常量,返回非引用类型的表达式等,会生成右值
- 右值引用就是必须绑定到右值的引用,右值引用只能绑定到即将销毁的对象,因此可以自由地移动其资源
- 右值引用是为了支持移动操作而引出的一个概念,它只能绑定到一个将要销毁的对象,使用右值引用的移动操作可以避免无谓的拷贝,提高性能。使用
std::move()
函数可以将一个左值转换为右值引用
补充:i++ 返回的是右值,返回递增前的值,而 ++i 返回的是左值,递增后再返回这个变量
2. 为什么要自己定义拷贝构造函数,什么是深拷贝和浅拷贝 链接到标题
-
拷贝构造函数的作用就是定义了当我们用同类型的另外一个对象初始化当前对象时做了什么,在某些情况下,如果我们不自己定义拷贝构造函数,使用默认的拷贝构造函数就会出错。比如一个类里面有一个指针,如果使用默认的拷贝构造函数,会将指针拷贝过去,即两个指针指向同个对象,那么其中一个类对象析构之后,这个指针也会被
delete
掉,那么另一个类里面的指针就会变成空悬指针 -
这也正是深拷贝和浅拷贝的区别,浅拷贝只是简单直接地复制指向某个对象的指针,而不复制对象本身,新旧对象还是共享同一块内存。但深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存,修改新对象不会改到原对象
3. 什么是移动构造函数,和拷贝构造函数的区别 链接到标题
移动构造函数需要传递的参数是一个右值引用,移动构造函数不分配新内存,而是接管传递而来对象的内存,并在移动之后把源对象销毁
4. 什么情况下会调用拷贝构造函数 链接到标题
类的对象需要拷⻉时,拷⻉构造函数将会被调⽤,以下的情况都会调⽤拷⻉构造函数:
- ⼀个对象以值传递的⽅式传⼊函数体,需要拷⻉构造函数创建⼀个临时对象压⼊到栈空间中
- ⼀个对象以值传递的⽅式从函数返回,需要执⾏拷⻉构造函数创建⼀个临时对象作为返回值
- ⼀个对象需要通过另外⼀个对象进⾏初始化
补充:函数返回值类型的临时变量,编译器会进行 RVO(Return Value Optimization) 优化,直接将这个临时变量构造在接受返回值的变量上,只进行了一次构造函数,省略了拷贝构造函数
RVO 发生有两个条件:局部对象的类型和函数返回值类型相同,返回的就是局部变量本身
如下面代码所示,先构造出对象a
,输出一次INIT
,然后再调用get
函数获取一个类变量给c
,此时只输出了一次INIT
,所以只触发了一次构造,没有拷贝的过程
class A
{
public:
int val;
A(int v): val(v) { cout << "INIT" << endl; }
A(const A& a): val(a.val) { cout << "KK" << endl;}
A(A&& a): val(a.val) { cout << "YY" << endl;}
A get() { return A(3); }
};
int main()
{
A a(2);
A c = a.get();
return 0;
}
5. 为什么拷贝构造函数的形参必须是引用类型,不能是值类型 (⭐) 链接到标题
为了防⽌递归调⽤。当⼀个对象需要以值⽅式进⾏传递时,编译器会⽣成代码调⽤它的拷⻉构造函数⽣成⼀个副本,如果类 A 的拷⻉构造函数的参数不是引⽤传递,⽽是采⽤值传递,那么就⼜需要为了创建传递给拷⻉构造函数的参数的临时对象,⽽⼜⼀次调⽤类 A 的拷⻉构造函数,这就是⼀个⽆限递归
6. 构造/析构函数可以抛出异常吗 (⭐⭐) 链接到标题
构造函数中可以抛出异常,但必须保证在构造函数抛出异常之前,把系统资源释放掉,防止内存泄露,可以使用智能指针构造对象来确保构造函数出现异常时会析构对象,或者使用函数try
语句块,这不仅能处理构造函数还能处理析构函数
template <typename T>
Bolb<T>::Blob(std::initializer_list<T> il) try:
data(std::make_shared<std::vector<T>>(il)) {
# 执行初始化赋值操作
}catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
析构函数不能抛出异常,会导致析构函数未全部释放内存,从而出现内存泄漏
7. 完美转发,引用折叠 (⭐) 链接到标题
当函数模板参数为右值引用的时候,即使是一个左值也能绑定到右值上,这就是std::move
函数能正常工作的基础
template <typename T>
void f3(T&&);
f3(i); # i 是一个int,此时 T = int&
那么就出现了f3<int&>(int& &&)
的情况,此时会发生引用折叠,int& &&
会被简化成int&
,有下面两种引用折叠:
X& &
,X& &&
,X&& &
都被折叠成X&
,X&& &&
被折叠成X&&
总而言之,当型别推导和类型参数中有一个引用为左值引用,折叠后得到左值引用,否则得到右值引用
所以,当函数参数是一个指向模板参数类型参数的右值引用,比如T&&
,则它可以绑定到一个左值,并且若实参是一个左值,则推断出的模板参数类型将为左值引用,发生引用折叠后变成一个普通的左值引用T&
所以右值引用类型的模板参数称为万能引用,既可以传入左值也能传入右值,并且会保留传入参数的左值和右值特性
模板转发就是一些模板函数将一个或多个实参带类型原封不动的转发给别的函数,也就是在模板函数中调用其他函数
为了保持原始实参的类型可以被转发到其他函数,可以使用utility
库中的forward
函数,forward
必须通过显式模板参数来调用,返回显示模板参数的右值引用,也就是forward<T>
返回类型为T&&
比如下面的代码,实现里面完美转发
template <typename F, typename T1, typename T2>
void flip(F f, T1 &&t1, T2 &&t2)
{
f(std::forward<T1>(t1), std::forward<T2>(t2));
}
因为有引用折叠的特点,传递进入一个左值,函数实参将发生引用折叠,得到一个左值引用,经过forward
传递后,再通过引用折叠,得到的还是一个左值引用,所以传入的左值会保留左值引用的特性,传入的右值会保留右值引用的特性
8. move 和 forward 链接到标题
标准库中,两个函数的实现为
template <typename T>
typename std::remove_reference<T>::type&&
move(T&& arg) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
template <typename T>
T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
return static_cast<T&&>(arg);
}
当形参类别为右值引用,证明该绑定的对象可以移动,所以需要使用move
对其进行操作以优化性能
class Widget
{
public:
Widget(Widget&& rhs): name(std::move(rhs.name)), p(std::move(rhs.p)) {}
private:
std::string name;
srd::shared_ptr<int> p;
}
当形参类别为万能引用,他实际上可能是左值,也可能是右值,所以需要forward
对其进行操作
万能引用的前提:必须是T&&
的形式,这个T
必须是进行推导的模板参数,不允许加上const
class Widget
{
public:
template<typename T>
void setName(T&& newName)
{
name = std::forward<T>(newName);
}
}
在值返回的函数中,若返回的对象是一个绑定到右值引用或者万能引用的对象,在返回这个值的时候,应该使用move
或者forward
对其进行操作
比如一个实现矩阵加法的函数,位于运算符左侧的矩阵已知是一个右值 (因此其存储空间可以复用,来保存矩阵的和)
使用move
之后,lhs
会被移入函数返回值存储的位置,避免了拷贝,否则直接返回return lhs
会发生一次拷贝
Matrix operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs);
}
9. 编译器默认生成的特种函数 (⭐) 链接到标题
类的特种函数是指六个函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符
类的设计有一个三大律的原则,也就是声明了拷贝构造函数、拷贝赋值运算符和析构函数的任意一者,那就应该把另外两者也进行声明,因为声明了其中一个函数,都说明类存在资源管理情况,最常见的就是有动态内存,所以这三个函数的默认行为对于动态内存的处理都不得当,应该手动实现
类会自动生成这六大特种函数的情况如下:
- 默认构造函数:当类内不包含任何构造函数 (包括普通构造、拷贝构造和移动构造) 的时候才会自动生成
- 析构函数:当类内不包含任何析构函数的时候会自动生成,仅当基类的析构函数为虚函数,派生类的析构函数才是虚函数
- 拷贝构造函数:当类内不包含声明的拷贝构造函数时会自动生成,但当声明了移动操作,则会隐式删除此函数
- 拷贝赋值运算符:当类内不包含声明的拷贝赋值运算符时会自动生成,但当声明了移动操作,则会隐式删除此函数
- 移动构造函数和移动赋值函数:仅当类内不包含声明的拷贝操作,移动操作和析构函数时才会自动生成
有一个细节,当类中有常量或引用成员,就不会自动生成拷贝赋值运算符
但是若类内声明了成员模板函数实现了构造函数等功能,编译器仍然会生成默认的特种函数,比如
class Widget
{
template<typename T>
Widget(const T& rhs); # 以任意类型构造Widget
template<typename T>
Widget& operator = (const T& rhs); # 以任意类型对Widget赋值
};
编译器会始终生成Widget
类的拷贝和移动操作,即使这些模板函数的实例化实现了拷贝构造函数和拷贝赋值运算符的功能
由于基类是否包含拷贝和移动操作会对派生类的拷贝和移动产生影响,所以基类可以显式的将所有特种函数写出来,并使用default
指定编译器生成默认行为
class Base
{
public:
virtual ~Base() = default;
Base(const Base&) = default;
Base& operator = (const Base&) = default;
Base(Base &&) = default;
Base& operator = (Base&&) = default;
}
7. 内联函数与宏 链接到标题
1. 内联函数有什么作用,存不存在什么缺点 (⭐⭐) 链接到标题
-
作用是使编译器在函数调用点上展开函数,可以避免函数调用的开销
-
内联函数的缺点是可能造成代码膨胀,尤其是递归的函数,会造成大量内存开销。内联函数难以调试,每次修改会重新编译头文件,增加编译时间
2. 什么时候不能实现内联 (⭐) 链接到标题
虚函数,函数体积过大,有递归 (递归是在运行期间才知道执行多少次的,内联发生在编译期间,自然不知道展开多少次)
有可变数目参数,通过函数指针调用,调用者异常类型不同
3. define 和 const 有什么区别 (⭐⭐) 链接到标题
- 定义不同:
define
是 C++ 预处理器的指令,用于定义宏;const
是 C++ 关键字,用于定义常量 - 作用对象不同:
define
定义的宏,可以是函数,对象,类型;const
只能定义常量 - 编译器处理时间不同:
define
会在预处理阶段展开;const
常量会以在编译期进行分析和处理 - 类型和安全检查不同:
define
没有任何类型检查,仅仅是代码展开;const
常量有具体类型,编译器会进行类型检查 - 存储方式不同:
define
在预处理阶段直接进行代码展开,存储在程序的代码段中;const
常量会分配内存,存储在程序的数据段中 - 作用域规则不同:
define
宏不存在作用域规则,直到遇到undef
时,才会失效;const
常量存在作用域规则
8. STL 链接到标题
1. STL 各种容器的底层实现 (⭐⭐⭐) 链接到标题
-
vector
,底层是一块具有连续内存的数组,vector
的核心在于其长度自动可变。vector
的数据结构主要由三个迭代器 (指针) 来完成:指向首元素的start
,指向尾元素的finish
和指向内存末端的end_of_storage
。vector
的扩容机制是:当目前可用的空间不足时,分配目前空间的两倍或者目前空间加上所需的新空间大小(取较大值),容量的扩张必须经过“重新配置、元素移动、释放原空间”等过程 -
list
,底层是一个循环双向链表,链表结点和链表分开独立定义的,结点包含pre
、next
指针和data
数据 -
deque
,双向队列,由分段连续空间构成,每段连续空间是一个固定的大小的缓冲区 (数组),由一个中控器来控制。它必须维护一个中控器指针,还要维护start
和finish
两个迭代器,分别指向第一个缓冲区,和最后一个缓冲区。deque
可以在前端或后端进行扩容,这些指针和迭代器用来控制分段缓冲区之间的跳转 -
stack
和queue
,栈和队列。它们都是由deque
作为底层容器实现的,他们是一种容器配接器,修改了deque
的接口,具有自己独特的性质(此二者也可以用list
作为底层实现);stack
是deque
封住了头端的开口,先进后出,queue
是deque
封住了尾端的开口,先进先出 -
priority_queue
,优先队列。是由以vector
作为底层容器,以heap
作为处理规则,heap
的本质是一个完全二叉树 -
set
和map
,底层都是由红黑树实现的。红黑树是一种二叉搜索树,但是它多了一个颜色的属性。红黑树的性质如下:1)每个结点非红即黑;2)根节点是黑的;3)如果一个结点是红色的,那么它的子节点就是黑色的;4)任一结点到树尾端(NULL)的路径上含有的黑色结点个数必须相同。通过以上定义的限制,红黑树确保没有一条路径会比其他路径多出两倍以上;因此,红黑树是一种弱平衡二叉树,相对于严格要求平衡的平衡二叉树来说,它的旋转次数少,所以对于插入、删除操作较多的情况下,通常使用红黑树
补充:平衡二叉树 (AVL) 和红黑树的区别:AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的 rebalance(旋转操作),导致效率下降;红黑树不是高度平衡的,算是一种折中,插入最多两次旋转,删除最多三次旋转
2. STL 怎么做内存管理的,Allocator 次级分配器的原理,内存池的优势和劣势 (⭐⭐) 链接到标题
- 为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,STL 采用了两级配置器,当分配的空间大小超过 128byte 时,会使用第一级空间配置器,直接使用
malloc(), realloc(), free()
函数进行内存空间的分配和释放。当分配的空间大小小于 128byte 时,将使用第二级空间配置器,采用了内存池技术,通过自由链表来管理内存 - 二级配置器的内存池管理技术:每次配置一大块内存,并维护对应的自由链表。配置器共要维护 16 个自由链表,存放在一个数组里,分别管理大小为 8 - 128byte 不等的内存块。分配空间的时候,首先根据所需空间的大小(调整为 8byte 的倍数)找到对应的自由链表中相应大小的链表,并从链表中取出第一个可用的区块;回收的时候也是一样的步骤,先找到对应的自由链表,并插到第一个区块的位置
- 优点:避免外部内存碎片,不需要频繁从用户态切换到内核态,性能高效
- 缺点:仍然会造成一定的内存浪费,会产生内部碎片,比如申请 120byte 就必须分配 128byte
3. STL 容器的 push_back 和 emplace_back 的区别 链接到标题
-
push_back
实现插入元素,是先创建一个临时变量 (一次构造函数),再将这个临时对象移动 (调用移动构造函数) 或者拷贝 (当无法移动时,调用拷贝构造函数,并且拷贝完会销毁这个临时变量) 到vector
后面 -
emplace_back
实现插入元素,则直接在vector
尾部的空间创建这个元素,只调用一次构造函数 -
但当添加的元素已经是构造好的,比如直接添加一个类对象,那么这两个都只会调用一次拷贝构造函数,性能相同
总而言之,emplace_back
在以下三种情况下,能得到比push_back
更好的性能:
- 待添加的值是以构造而非赋值的方式加入容器 (即传递了一个临时变量,直接在容器的内存上构造,从而减少了构造和析构的成本)
- 传递的实参型别与容器持有之物的型别不同 (即传入的是构造函数参数时,不需要创建和析构临时对象)
- 容器不会因为存在重复值而拒绝添加此值 (也就是除了
map, unordered_map, set, unordered_set
以外的容器都行)
4. STL 容器的 reserve 和 resize 区别 (⭐⭐) 链接到标题
这两个函数只适用于vector, string
reserve(n)
只实现分配内存,并不创建对象,如果要求的内存大于当前容量capacity
,则会重新分配内存,然后移动元素,释放之前的元素;当需求的内存小于等于当前容量,则啥事情也不会发生,永远不会减少内存空间
resize(n, t)
可以将容器大小改变成n
,新添的元素初始化为t
,对于类类型,这个t
要么是已有的类变量,要么显式调用构造函数,若当前长度大于n
,就会删去多的元素,并释放内存
对于一个空的vector
,使用reserve(10)
只会改变capacity = 10
,不改变size
;但使用resize(10)
不仅会改变capacity = 10
,还会填充元素,使得size = 10
还有一个shrink_to_fit(n)
,适用于vector, string, deque
,请求将capacity
减小到和size
相同,从而退回不需要的内存空间,但是具体会不会释放空间也不确定,可能会忽略这个请求
5. STL 容器中的迭代器作用,与指针的区别 链接到标题
迭代器⽤于提供⼀种⽅法顺序访问⼀个聚合对象中各个元素,⽽⼜不需暴露该对象的内部表示
迭代器不是指针,是类模板,表现的像指针。迭代器封装了指针,重载了指针的⼀些操作符,提供了⽐指针更⾼级的⾏为,可以根据不同类型的数据结构来实现不同的递增递减等操作。迭代器返回的是对象引⽤⽽不是对象的值
6. STL 容器调用 insert,erase 后哪些迭代器会失效 链接到标题
容器底层数据结构类型 | 具体容器 | insert 后 |
erase 后 |
---|---|---|---|
数组类型 | vector, string, deque, array |
若未重新分配内存,插入点之后的所有迭代器失效;若重新分配内存,所有迭代器失效 | 删除节点之后的所有迭代器失效 |
链表类型 | list, forward_list |
不会使得任何迭代器失效 | 指向删除节点的迭代器失效 |
树状类型 | map, set, multimap, multiset |
不会使得任何迭代器失效 | 指向删除节点的迭代器失效 |
哈希表类型 | unordered_map, unordered_set, ... |
不会使得任何迭代器失效 | 指向删除节点的迭代器失效 |
所以在可能出现insert, erase
,或者push_back, pop_back
等删改元素个数的操作中,begin, end
迭代器一定要每次循环重新获得,不能用变量预存这两个迭代器,尤其是end
迭代器,基本都会失效
insert
会返回插入点的迭代器,erase
会返回删除点下一个元素的迭代器,所以想在循环中用迭代器遍历,且添加删除元素,一定要用函数的返回值更新迭代器,比如
unordered_map<int, int> hash;
# 假设添加了一些元素后进行遍历
# 一定要手动更新迭代器的位置, 不能依赖于循环
for(auto it = hash.begin(); it != hash.end(); )
{
# 一定要获取新的迭代器, 删除之前的 it 已经失效了
if(it->second == 0) it = hash.erase(it);
else ++it;
}
7. string 的内存大小 链接到标题
string
的实现有很多版本,string
对象大小范围可能是char*
指针大小的 1 倍到 7 倍,详见《Effective STL》第 15 条
一个常见的实现是指针大小的四倍,每个string
对象包括四项内容,第一项是其分配器allocator
的一份拷贝,第二项是字符串的大小,第三项是字符串的容量,第四项是一个指针,该指针指向一个动态分配的内存,包括一个引用计数和字符串的值
另一种实现的大小和指针相同,每个string
对象只包含了一个指针,指向一个动态内存,动态内存包含与string
的一切数据:大小、容量、引用计数和值
引用计数使得一个string
对象被拷贝或复制时,只有指向同一字符串的引用计数被增加,而不是整个字符串被复制;并且string
实现中使用了写时复制,即只有当一个string
对象被修改时才发生复制,避免了不必要的内存分配和复制
并不是所有的string
实现都使用了引用计数,可以使用预处理宏关闭引用计数
8. STL 的线程安全问题 链接到标题
-
线程安全的情况:多个读取者是安全的,多线程可能同时读取一个容器的内容,这将正确地执行;当然,在读取时不能有任何写入者操作这个容器。对不同容器的多个写入者是安全的,多线程可以同时写不同的容器
-
线程不安全的情况:对同一个容器进行多线程的读写、写操作。在每次调用容器的成员函数期间都要锁定该容器。在每个容器返回的迭代器的生存期之内都要锁定该容器。在每个在容器上调用的算法执行期间锁定该容器
9. STL 的 sort 有哪些排序算法 (⭐) 链接到标题
- 若数据长度较小 (小于等于 16),就会使用插入排序
- 若递归深度过大 (里面有个专门计算递归深度的函数),则使用堆排序
- 其他情况下使用快速排序
- 优先使用快速排序,是因为:快速排序是顺序访问数据的,而堆排序是跳着访问的,影响缓存命中率;对于同样的数据,堆排序所需要的交换次数大于快速排序,快速排序的交换次数不会比逆序数多
10. 基于比较的排序算法的最低复杂度为什么是 NlgN 链接到标题
排序算法的最低时间复杂度为O(NlgN)
是由于排序算法中最常用的比较排序的性质决定的。比较排序的基本思想是通过比较两个记录大小来确定它们在序列中的相对位置,所以其时间复杂度与比较次数有关。一般情况下,一个排序算法都需要进行O(NlgN)
次比较才能完成对含有 N 个数据元素的数据序列进行排序,可以通过决策树模型来证明
决策树模型是一种描述在最坏情况下排序算法执行次数的方法。每一次比较都可以看作是树上的一个节点。如果有 N 个待排序的数据元素,那么它们共有N!
种排列组合(即叶子结点数量)。由于每一次比较将会减少数据元素的可交换状态,因此我们可以假设该判定树是一棵二叉树,按照二叉树的结构分布各个叶节点。同时,由于一棵深度为h
的二叉树最多拥有2 ^ h
个叶节点,所以得到N! ≤ 2 ^ h
,即h ≥ logN!
,进而证明决策树的最低深度下限为logN! ≈ NlogN
因此,基于比较的排序算法的最低时间复杂度为O(NlgN)
11. vector 扩容 (⭐) 链接到标题
vector
在尾部插入一个元素,最优复杂度为O(1)
,最坏复杂度为O(N)
,平均复杂度是O(1)
,考虑下面的情况
假设有n
个元素插入vector
,倍增因子为m
,那么会触发$log_m^n$次的扩容,每次扩容需要拷贝所有元素,所以n
次插入的总操作次数为
$$
\large
\begin{align}
n+\sum_{i=1}^{log_m^n}m^i&=n+m+m^2+…+m^{log_m^n} \
&=n+\frac{m(1-m^{log_m^n})}{1-m} \
&=n+\frac{m(n-1)}{m-1} \
&\approx n + \frac{mn}{m-1}
\end{align}
$$
所以,均摊下来每次操作时间为$O((n+\frac{mn}{m-1})/n)=O(\frac{m}{m-1})$,这是一个常数,因此平均复杂度为O(1)
在扩容的时候,要对元素进行拷贝,如果这个元素的类型定义了移动操作,并且该移动操作承诺不抛出异常noexpect
,vector
扩容时候就会使用移动操作而非拷贝操作
noexcept
通常使用在移动构造,移动赋值和swap
这些函数中
noexcept
不能随便使用,若一个声明noexcept
的函数抛出异常,会直接终止进程,调用std::terminate()
不应该过早的给函数加上noexcept
,否则调用此函数的调用方也会声明成noexcept
,如果后面把该函数的noexcept
去掉,却不修改调用方的代码,就会导致异常抛出到调用方时直接终止进程
9. 泛型与多线程 链接到标题
1. 为什么模板函数的定义和实现要放在一个程序文件中 链接到标题
当主函数调用了模板函数,就会发生实例化,模板函数的实例化是在编译阶段完成的,若在编译阶段找不到模板函数的实现,就无法实例化 (假如和普通函数一样,定义写在.h
文件中,实现写在.cpp
文件中,.h
文件不会#include
这个.cpp
文件,所以.h
文件就找不到这个模板函数的定义)
而非模板函数是在链接阶段处理的,此时所有文件被整合在一起,所以就没有了找不到实现的问题了
2. 原子 atomic(⭐⭐) 链接到标题
C++ 的atomic
是一种线程安全的特殊数据类型,用于实现多线程编程中的原子操作。它可以保证对一个共享变量的读写操作在不同线程之间具有原子性,即不会出现因多线程的交互带来的数据竞争、死锁等问题。C++11 标准引入了<atomic>
头文件,并增加了一组atomic
库函数和模板,使得在 C++ 中实现并发编程变得更加容易
atomic
类型提供了一些原子操作函数,通过这些原子操作函数可以对共享变量进行原子操作,同时保证各个线程对于共享变量的访问操作的互斥性。通常情况下,使用atomic
会比使用mutex
和lock_guard
等同步机制更加高效,因为它们不需要进行上下文切换和线程阻塞操作
但是原子操作只是保证多线程读写的原子性,不能保证指令不会重排,必须对原子变量进行原子操作,才能保证操作的指令不会重排,比如下面的例子
std::atomic<bool> flag1(false);
std::atomic<bool> flag2(false);
// thread1 执行
void setFlags()
{
// 使用原子操作,保证了 flag1,flag2 赋值顺序不会被重排
// 若直接写 flag1 = true, flag2 = true,那可能会指令重排
flag1.store(true, std::memory_order_relaxed);
flag2.store(true, std::memory_order_relaxed);
}
// thread2 执行
void readFlags()
{
// 此时 flag1 必然在 flag2 前赋值,所以 flag2 值必然是 true
if(flag1 == true)
cout << flag2 << endl;
}
也可以给变量加上volatile
避免指令重排,但是不能解决多线程对同一变量的读写一致性问题
mutex
用于加锁,通过lock, unlock
解决线程安全问题
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx; // 全局互斥锁
void printMessage(const std::string& message)
{
mtx.lock(); // 加锁
for (int i = 0; i < 5; ++i) {
std::cout << message << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 暂停一段时间
}
mtx.unlock(); // 解锁
}
int main()
{
std::thread t1(printMessage, "Thread 1");
std::thread t2(printMessage, "Thread 2");
t1.join();
t2.join();
return 0;
}
由于在加锁解锁过程中,可能出现异常,那么mutex.unlock()
有可能就不会被执行出现死锁,使用lock_guard
可以通过RAII
的方式管理锁,实现自动的加锁和解锁,避免了异常出现时导致的死锁情况
#include <iostream>
#include <thread>
#include <mutex>
// 全局互斥锁
std::mutex mtx;
void printMessage(const std::string& message)
{
// 使用lock_guard自动管理互斥锁
std::lock_guard<std::mutex> lock(mtx);
for (int i = 0; i < 5; ++i) {
std::cout << message << std::endl;
// 暂停一段时间
std::this_thread::sleep_for(std::chrono::milliseconds(500));
}
}
int main()
{
std::thread t1(printMessage, "Thread 1");
std::thread t2(printMessage, "Thread 2");
t1.join();
t2.join();
return 0;
}
3. 模板的特例化 链接到标题
在某些情况下,通用模板的定义对一些特定类型是不适用的,此时需要写特定类型的模板,可以特例化函数模板和类模板
当特例化一个函数模板的时候,必须为原模板的每一个模板参数提供实参,为了强调这个是模板函数,虽然没有了模板参数类型,但是还应该在前面写上template<>
,这就是全特例化
template <>
int compare(const char* const &p1, const char* const &p2)
{
return strcmp(p1, p2);
}
与函数模板不同,类模板特例化不必为所有模板参数提供实参 (注意和全特例化区别,仍然有尖括号的类型参数),这种称为部分特例化,也可以只特例化类的成员函数
template <>
void Foo<int>::Bar()
{
# 定义实例化函数
}
重载模板和特例化模板并不相同,重载会影响函数匹配,编译器在匹配过程中,将重载版本作为候选之一来选择最佳匹配函数,但是特例化不影响函数匹配,并没有给编译器在函数匹配中多一个选择,而是为模板的一个特殊实例提供不同于原模板的特殊定义
下面代码展现了函数模板,函数模板全特例化和偏特例化
#include <iostream>
// 函数模板
template <typename T>
void print(const T& value)
{
std::cout << "General template: " << value << std::endl;
}
// 函数模板的全特例化
template <>
void print<int>(const int& value)
{
std::cout << "Specialization for int: " << value << std::endl;
}
// 函数模板的偏特例化
template <typename T>
void print<T*>(T* value)
{
std::cout << "Partial specialization for pointer: " << *value << std::endl;
}
int main()
{
int num = 10;
int* ptr = #
print("Hello"); // 调用通用模板
print(3.14); // 调用通用模板
print(num); // 调用全特例化模板
print(ptr); // 调用偏特例化模板
return 0;
}
3. 元编程 链接到标题
为了实现运行高性能,元编程将运行期间的消耗转移到编译期,下面代码用模板实现计算斐波那契数列
template<int N>
struct Fibonacci {
static constexpr int value = Fibonacci<N-1>::value + Fibonacci<N-2>::value;
};
template<>
struct Fibonacci<0> {
static constexpr int value = 0;
};
template<>
struct Fibonacci<1> {
static constexpr int value = 1;
};
int main() {
constexpr int n = 10;
int result = Fibonacci<n>::value;
std::cout << "Fibonacci(" << n << ") = " << result << std::endl;
return 0;
}
4. move 和 forward(⭐) 链接到标题
两个函数用于在泛型中传播参数,其中move
用于传递右值对象,forward
用于传递万能引用对象
这两个函数都用到了remove_reference
,这是一个模板类,其中用typedef
定义了别名,如下所示
template<typename _Tp>
struct remove_reference
{ typedef _Tp type; };
// 左值引用版本
template<typename _Tp>
struct remove_reference<_Tp&>
{ typedef _Tp type; };
// 右值引用版本
template<typename _Tp>
struct remove_reference<_Tp&&>
{ typedef _Tp type; };
可见,不论传入的是什么类型,type
均为该变量最原始的类型,去除了一切引用
move
函数将传入的参数强制转换成右值类型,然后返回该参数类型的右值引用类型
template <typename T>
typename std::remove_reference<T>::type&&
move(T&& arg) noexcept {
return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
forward
函数分为两种,一种是左值版本,一种是右值版本
template <typename T>
T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
return static_cast<T&&>(arg);
}
template <typename T>
T&& forward(typename std::remove_reference<T>::type&& arg) noexcept {
static_assert(!std::is_lvalue_reference<T>::value, "Invalid rvalue reference");
return static_cast<T&&>(arg);
}
注意传入的参数类型,对于左值版本,类型为type&
,对于右值版本,类型为type&&
,并且在右值版本中,增加了静态断言static_assert
,当不是左值时候,也就是右值时候,才能调用该函数
在调用std::move
时,只需要将变量传入即可,比如std::move(arg)
,但是调用std::forward
时候,必须传入变量类型,比如std::forward<T>(arg)
,这是因为两个函数实现不同
move
的参数就是一个万能引用,不论传入左值右值都可以,forward
中参数的引用类型其实是泛型类型T
的一部分,不能直接通过模板推导得到,注意typename std::remove_reference<T>::type&&
并不是万能引用,只有T&&
才是
5. 类型萃取 Type Traits 链接到标题
C++ 的类型萃取(Type Traits)是一种编程技术,用于在编译时获取与类型相关的信息。它可以通过模板元编程技术来检查和操作类型的特性,以便在编译时进行类型安全的操作
类型萃取可以用于以下情况:
1.类型判断:可以通过类型萃取来判断一个类型是否具有某些特性,例如判断一个类型是否是指针类型、是否是整数类型等
2.类型转换:可以使用类型萃取来进行类型转换,例如将一个类型转换为另一个类型,或者将一个类型转换为相应的指针类型
3.类型属性查询:可以通过类型萃取来查询一个类型的属性,例如查询一个类型的大小、对齐方式等
比如判断一个类是否继承了另一个类,可以通过std::is_base_of<Base, Derived>::value
这个值,如果为true
则是有直接继承关系,间接继承需要使用std::is_convertible
6. 多线程 join 和 detach 链接到标题
在 C++ 中,线程的join
和detach
是用于管理线程的两种方法
join
: 当一个线程调用join
方法时,它会等待被调用的线程执行完毕,然后再继续执行。换句话说,调用join
的线程会阻塞直到被调用的线程完成。这样可以确保在主线程退出之前,所有的子线程都已经执行完毕。在调用join
之后,被调用的线程的资源会被回收,包括线程的堆栈和线程控制块detach
: 当一个线程调用detach
方法时,它会将被调用的线程分离,使得该线程可以独立运行,与调用detach
的线程无关。被调用的线程在运行结束后会自动释放资源,而不需要等待调用detach
的线程。这意味着调用detach
之后,你不能再通过join
来等待被调用的线程执行完毕
10. 工程问题 链接到标题
1. 编译链接原理,从 C++ 源文件到可执行文件的过程 (⭐⭐⭐) 链接到标题
包括四个阶段:预处理阶段、编译阶段、汇编阶段、连接阶段
-
预处理阶段处理头文件包含关系,对预编译命令进行替换,生成预编译文件;
-
编译阶段将预编译文件编译,生成汇编文件
-
汇编阶段将汇编文件转换成机器码,生成可重定位目标文件(
.obj
文件) -
链接阶段,将多个目标文件和所需要的库连接成可执行文件(
.exe
文件)
2. 在 main 函数之前,程序进行了哪些操作 链接到标题
- 设置栈指针:为栈分配位置,用来放一些局部变量和其他数据
- 初始化静态和全局变量:把全局和静态变量初始化,放入全局/静态存储区
- 将未初始化的全局变量赋初值:将未设置初值的值类型全局变量赋初值 (注意这是赋初值,和初始化不同),将全局的类类型变量调用默认构造函数 (没有默认构造函数就会报错)
main
函数传入参数:传入argc
和argv
,分别表示main
函数的参数和参数内容
3. 写个函数,在 main 函数之前会执行 链接到标题
1.使用 gcc 扩展,在函数前加上__attribute((constructor))
,标记该函数在main
前执行
2.全局静态变量初始化和赋值在main
函数前执行,只要让该变量的赋值为一个函数返回值即可
3.在main
函数前写一个lambda
表达式,让一个全局变量的值为这个表达式的返回值
__attribute((constructor))void before()
{
printf("before main 1\n");
}
int test1()
{
cout << "before main 2" << endl;
return 1;
}
static int i = test1();
int a = []()
{
cout << "before main 3" << endl;
return 0;
}();
4. 动态库和静态库含义,区别和优缺点 (⭐⭐⭐) 链接到标题
库是写好的现有的,成熟的,可以复用的代码。库有两种:静态库.lib
和动态库.dll
。所谓静态、动态是指链接阶段采用静态链接还是动态链接
静态库,是因为在链接阶段,会将汇编生成的目标文件与引用到的库一起链接打包到可执行文件中,这种链接方式称为静态链接。一个静态库可以简单看成是一组目标文件的集合,即很多目标文件经过压缩打包后形成的一个文件
静态库的优点:
-
库加载的速度很快
-
程序在运行时则不再需要该静态库,移植方便
静态库的缺点:
- 浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件
- 一旦静态库发生改变,全部文件都要重新编译 (因为静态库结合到了可执行文件中)
一旦有多个程序都包含这个静态库,那就会出现静态库的多份拷贝,十分浪费内存
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题
动态库在程序运行是才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新
动态库的优点:
-
动态库把对一些库函数的链接载入推迟到程序运行的时期
-
节省内存,多个程序、进程使用一个动态库(因此动态库也称为共享库)
-
用户只要更新动态库,无需像静态库更新时候要重编译全部文件
动态库的缺点:
- 发布程序时,需要将动态库提供给用户,因为程序运行时需要动态库的存在
- 动态库没有被打包到应用程序中,加载速度相对较慢
5. 函数调用的过程 (⭐⭐) 链接到标题
函数调用分为:函数参数传递,保存现场、函数栈帧开辟、函数执行、返回值传递、函数栈帧回退、恢复现场
1.函数参数传递:在调用函数时,需要向函数传递参数
2.保存现场:在调用函数前,需要将当前函数的现场保存起来,以备后续返回到该函数时能够继续执行。现场包括当前函数中需要用到的所有寄存器以及其他资源(如栈帧等)
3.函数栈帧开辟:当函数被调用时,CPU 会将函数的返回地址、参数等信息压入栈中
4.函数执行:在函数体内,按照函数定义的顺序执行函数内的语句
5.返回值传递:当函数执行完毕后,需要将结果返回给调用函数。在调用函数时,函数返回值一般通过寄存器或栈来传递
6.函数栈帧回退:将当前函数开辟的栈帧释放掉
7.恢复现场:在返回函数之前,需要先将之前保存的现场信息恢复,以保证程序的正常执行
比如以下代码
int fun1(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int a = fun1(10, 20);
return 0;
}
首先知道ebp
为栈底寄存器,esp
为栈顶寄存器,push
为压入操作,在栈顶存放数据,栈顶寄存器esp
上移
在未调用函数前,main
的栈顶和栈底如下
然后进行函数代入
将函数参数代入,压入栈,参数在main
函数的栈顶之上
接着开辟fun
函数的栈帧
重新设置了栈顶和栈底,并保存了main
函数的栈底
然后函数执行完成,函数返回值,将寄存器的值写入接受返回值的常量
最后将fun
的栈顶esp
指向fun
的栈底ebp
再将栈顶esp
移动 8 位,消除函数调用的参数,实现函数栈帧回退,回到main
的栈帧
6. .exe 文件包含哪些信息 (⭐⭐) 链接到标题
- 机器码(Machine Code):.exe 文件包含了可执行程序的机器码,即计算机可以直接执行的二进制指令。这些指令由编译器将源代码编译生成,并且按照特定的格式和结构组织
- 数据段(Data Segment):.exe 文件中还包含了程序所需的静态数据(如全局变量、静态变量等)和常量数据。这些数据在程序执行期间是不可修改的
- 代码段(Code Segment):.exe 文件中还包含了程序的代码段,即程序的指令序列。代码段包含了程序的函数、方法、过程等,以及它们的入口点和执行顺序
- 资源(Resources):.exe 文件可能还包含了程序所需的附加资源,如图标、位图、字符串、音频文件等。这些资源可以通过调用相关的 API 函数来访问和使用
- 符号表(Symbol Table):符号表记录了程序中的符号(如函数名、变量名)和对应的地址信息。这些信息可以在调试和符号解析时使用
- 导入表(Import Table)和导出表(Export Table):导入表记录了程序所依赖的外部函数和库,导出表记录了程序暴露给其他程序使用的函数和库。这些信息在链接和运行时起到重要的作用
- 其他元数据:.exe 文件可能还包含一些元数据信息,如编译器版本、编译时间、程序的入口点等
7. SOA 和 AOS 内存分布 链接到标题
SOA(Structure of Arrays)和 AOS(Array of Structures)是两种不同的数据布局方式,常用于优化内存访问和数据并行性
在 AOS 布局中,数据以结构体(或对象)的形式存储在连续的内存块中。每个结构体包含多个字段,这些字段在内存中彼此相邻。这种布局方式适合于以结构体为单位进行操作和访问数据的场景。例如,如果需要按照每个对象的属性进行迭代或访问,AOS 布局可以提供更好的局部性,因为相关的数据在内存中是连续的
而在 SOA 布局中,数据按字段拆分并存储在不同的内存块中。每个内存块只包含一个字段的数据,并且相同字段的数据在内存中是连续的。这种布局方式适合于按字段进行操作和访问数据的场景。例如,在进行向量计算或数据并行处理时,SOA 布局可以提供更好的内存访问模式和数据并行性,因为相同字段的数据可以在同一时间内被并行处理
8. C++ 内存泄漏检查 链接到标题
检查 C++ 的内存泄漏,除了对new / delete
进行匹配检查以外,还能使用一些内存检查工具,比如 Memcheck
Memcheck 工具利用 Valgrind 框架模拟了一个虚拟的 CPU 和内存管理器,通过在目标程序的二进制代码中插入额外的指令,将每个内存访问操作都重定向到 Valgrind 的内存管理器中来进行内存错误检测
Valgrind 的内存管理器会维护一个影子内存,它与目标程序的内存空间大小相同,用于记录每个内存块的使用情况。当目标程序进行内存读取或写入操作时,Valgrind 会拦截这些操作,并通过检查影子内存来判断是否存在内存错误
基本的流程如下:
- 代码解析:Memcheck 通过解析目标程序的二进制代码,获取程序的控制流信息和内存操作指令
- 内存跟踪:Memcheck 在运行目标程序的过程中,通过为每个分配的内存块维护一张内存映射表,跟踪每个内存块的使用情况
- 内存访问检测:当目标程序进行内存读取或写入操作时,Memcheck 会拦截这些操作,并通过检查内存映射表来判断该内存操作是否存在错误
- 内存错误检测:如果 Memcheck 检测到目标程序中的内存操作存在错误,如使用未初始化的内存、内存泄漏、越界访问等,它会在运行时给出警告或报错信息,并指出具体的错误位置
- 内存泄漏检测:Memcheck 还会跟踪目标程序中的内存分配和释放操作,并记录已分配但未释放的内存块。在程序运行结束时,Memcheck 会检查是否存在内存泄漏,并给出相应的报告