文章目录
- 前言
- 一、c++基础
- 二、C++内存管理
- 三、C++ 面向对象
- 1. 面向对象的三大特性
- 2. public、protected和private访问和继承权限
- 3. 什么是多重继承?
- 4. 简述一下 C++ 的重载和重写,以及它们的区别
- 5. c++的多态如何实现
- 6. 成员函数/成员变量/静态成员函数/静态成员变量
- 7. 构造函数和析构函数
- 8. 虚函数
- 9. 深拷贝和浅拷贝
- 10. 运算符重载
- 11. 初始化方式
- 12. 静态类型和动态类型,静态绑定和动态绑定
- 13.继承和组合的缺点
- 14.如果有⼀个空类,它会默认添加哪些函数?
- 15.关于this指针你知道什么?
- 16.在成员函数中调用delete this
- 17.请说以下几种情况下,下面几个类的大小各是多少?
- 18.多态的流程
- 四、C++STL
- 五、C++ 泛型编程
- 六、C++ 新特性
前言
总结c++语法、内存等知识。仅供个人学习记录用
一、c++基础
1.指针和引用
指针和引用的区别
指针存放某个对象的地址,其本身就是变量(命了名的对象),本身就有地址,所以可以有指向指针的指针;可变,包括其所指向的地址的改变和其指向的地址中所存放的数据的改变
引用就是变量的别名,从一而终,不可变,必须初始化
- 定义和声明
指针是一个变量,其值是另一个变量的地址。声明指针时,使用*
符号。
引用是一个别名,它是在已存在的变量上创建的。在声明引用时,使用&
符号。 - 使用和操作
指针可以通过解引用操作符 * 来访问指针指向的变量的值,还可以通过地址运算符 & 获取变量的地址。
引用在声明时被初始化,并在整个生命周期中一直引用同一个变量。不需要使用解引用操作符,因为引用本身就是变量的别名
指针可以有多级,引用只能一级 - 空值和空引用
指针可以为空(nullptr)表示不指向任何有效的地址。
引用必须在声明时初始化,并且不能在后续改变引用的绑定对象。因此,没有空引用的概念
不存在指向空值的引用,但是存在指向空值的指针 - 可变性
指针可以改变指针的指向,使其指向不同的内存地址。
引用⼀旦引用被初始化,它将⼀直引用同一个对象,不能改变绑定。 - 用途
需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引⽤是没有意义的
对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式
函数指针
什么是函数指针,如何定义和使用场景
函数指针是指向函数的指针变量。它可以用于存储函数的地址,允许在运行时动态选择要调用的函数。
格式为:返回类型 (*指针变量名)(参数列表)
幅值方法: 指针名 = 函数名; 指针名 = &函数名
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
int main() {
// 定义⼀个函数指针,指向⼀个接受两个int参数、返回int的函数
int (*operationPtr)(int, int);
// 初始化函数指针,使其指向 add 函数
operationPtr = &add;
// 通过函数指针调⽤函数
int result = operationPtr(10, 5);
cout << "Result: " << result << endl;
// 将函数指针切换到 subtract 函数
operationPtr = &subtract;
// 再次通过函数指针调⽤函数
result = operationPtr(10, 5);
cout << "Result: " << result << endl;
return 0;
}
使用场景:
- 回调函数: 函数指针常用于实现回调机制,允许将函数的地址传递给其他函数,以便在适当的时候调用。
- 函数指针数组: 可以使用函数指针数组实现类似于状态机的逻辑,根据不同的输入调用不同的函数。
- 动态加载库: 函数指针可用于在运行时动态加载库中的函数,实现动态链接库的调用。
- 多态实现: 在C++中,虚函数和函数指针结合使用,可以实现类似于多态的效果。
- 函数指针作为参数: 可以将函数指针作为参数传递给其他函数,实现⼀种可插拔的函数行为。
- 实现函数映射表: 在⼀些需要根据某些条件调用不同函数的情况下,可以使用函数指针来实现函数映射表。
函数指针和指针函数的区别
函数指针是指向函数的指针变量。可以存储特定函数的地址,并在运行时动态选择要调用的函数。通常用于回调函数、动态加载库时的函数调用等场景。
int add(int a, int b) {
return a + b;
}
int (*ptr)(int, int) = &add; // 函数指针指向 add 函数
int result = (*ptr)(3, 4); // 通过函数指针调⽤函数
指针函数是⼀个返回指针类型的函数,⽤于返回指向某种类型的数据的指针。
int* getPointer() {
int x = 10;
return &x; // 返回局部变ᰁ地址,不建议这样做
}
2.数据类型
整型 short int long 和 long long
C++ 整型数据长度标准:
short 至少 16 位
int 至少与 short ⼀样长
long 至少 32 位,且至少与 int ⼀样长
long long 至少 64 位,且至少与 long ⼀样长
在使用8位字节的系统中,1 byte = 8 bit。
很多系统都使用最小长度,short 为 16 位即 2 个字节,long 为 32 位即 4 个字节,long long 为 64 位即 8 个字节,int 的长度较为灵活,⼀般认为 int 的长度为 4 个字节,与 long 等长。
可以通过运算符 sizeof 判断数据类型的长度。例如sizeof (int)
头文件climits定义了符号常量:例如:INT_MAX 表示 int 的最大值,INT_MIN 表示 int 的最小值
无符号类型
即为不存储负数值的整型,可以增大变量能够存储的最大值,数据长度不变。
int 被设置为自然长度,即为计算机处理起来效率最高的长度,所以选择类型时⼀般选用 int 类型。
强制类型转换
关键字:static_cast、dynamic_cast、reinterpret_cast和 const_cast
static_cast
- 没有运行时类型检查来保证转换的安全性
- 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的
- 进行下行转换(把基类的指针或引用转换为派生类表示)由于没有动态类型检查,所以是不安全的。
- 使用:
用于基本数据类型之间的转换,如把int转换成char。
把任何类型的表达式转换成void类型。
dynamic_cast
- 在进行下行转换时,dynamic_cast具有类型检查(信息在虚函数中)的功能,比static_cast更安全。
- 转换后必须是类的指针、引用或者void*,基类要有虚函数,可以交叉转换。
- dynamic本身只能用于存在虚函数的父子关系的强制类型转换;对于指针,转换失败则返回nullptr,对于引用,转换失败会抛出异常。
reinterpret_cast
- 可以将整型转换为指针,也可以把指针转换为数组;可以在指针和引用里进行肆无忌惮的转换,平台移植性比较差。
const_cast
- 常量指针转换为非常量指针,并且仍然指向原来的对象。常量引用被转换为非常量引用,并且仍然指向原来的对象。去掉类型的const或volatile属性。
怎样判断两个浮点数是否相等?
不能直接用==来判断,会出错!
对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!
浮点数与0的比较也应该注意。与浮点数的表示方式有关。
3.关键字
const
const的作用
const 关键字主要用于指定变量、指针、引用、成员函数等的性质
- 常量变量:声明常量,使变量的值不能被修改。在定义时必须初始化,之后无法更改
- 成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化
- 成员函数:用于声明常量成员函数,表示该函数不会修改对象的成员变量(对于成员变量是非静态的情况)。const对象不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值
- 常量对象:声明对象为常量,使得对象的成员变量不能被修改。
- const形参:const形参可以接收const和非const类型的实参,表示函数不会修改传入的参数。
常量指针(底层const)
是指定义了一个指针,这个指针指向⼀个只读的对象,不能通过常量指针来改变这个对象的值。常量指针强调的是指针对其所指对象的不可改变性。
特点:靠近变量名。
形式:
(1)const 数据类型 * 指针变量 = 变量名
(2)数据类型 const * 指针变量 = 变量名
int temp = 10;
const int* a = &temp;
int const *a = &temp;
// 更改:
*a = 9; // 错误:只读对象
temp = 9; // 正确
指针常量(顶层const)
指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。指针常量强调的是指针的不可改变性。
特点:靠近变量类型。
形式:数据类型 * const 指针变量 = 变量名
int temp = 10;
int temp1 = 12;
int* const p = &temp;
// 更改:
p = &temp2; // 错误
*p = 9; // 正确
拓展:
顶层const:指针本身是常量;
底层const:指针所指的对象是常量;
左定值,右定向:指的是const在*的左还是右边
const在*左边,表示不能改变指向对象的值,常量指针;
const在*右边,表示不能更换指向的对象,指针常量
若要修改const修饰的变量的值,需要加上关键字volatile;
若想要修改const成员函数中某些与类状态无关的数据成员,可以使用mutable关键字来修饰这个数据成员;
static
static关键字主要用于控制变量和函数的生命周期、作用域以及访问权限。
实现多个对象之间的数据共享 + 隐藏,并且使用静态成员还不会破坏隐藏原则;
- 静态变量
在函数内部使用 static 关键字修饰的变量称为静态变量。
静态变量在程序的整个生命周期内存在,不会因为离开作用域而被销毁。
静态变量默认初始化为零(对于基本数据类型)。
静态局部变量和全局变量一样,数据都存放在全局区域,所以在主程序之前,编译器已经为其分配好了内存
void exampleFunction() {
static int count = 0; // 静态变量
count++;
cout << "Count: " << count << endl;
}
- 静态成员变量
在类中使用 static 关键字修饰的成员变量是静态成员变量。
所有类的对象共享同一个静态成员变量的副本。
静态成员变量必须在类外部单独定义,以便为其分配存储空间。
可以被非static成员函数任意访问。
class ExampleClass {
public:
static int staticVar; // 静态成员变量声明
};
// 静态成员变量定义
int ExampleClass::staticVar = 0;
- 静态成员函数
在类中使用 static 关键字修饰的成员函数是静态成员函数。
静态成员函数不能直接访问非静态成员变量或非静态成员函数。但可以被非static成员函数任意访问
静态成员函数可以通过类名调用,而不需要创建类的实例。
不能被声明为const、虚函数和volatile;
class ExampleClass {
public:
static void staticMethod() {
cout << "Static method" << endl;
}
};
- 静态局部变量
在函数内部使用 static 关键字修饰的局部变量是静态局部变量。
静态局部变量的生命周期延长到整个程序的执行过程,但只在声明它的函数内可见。
void exampleFunction() {
static int localVar = 0; // 静态局部变量
localVar++;
cout << "LocalVar: " << localVar << endl;
}
const和static的区别
define 和 typedef 的区别
define
- 只是简单的字符串替换,没有类型检查
- 是在编译的预处理阶段起作用
- 可以用来防止头文件重复引用
- 不分配内存,给出的是立即数,有多少次使用就进行多少次替换
typedef - 有对应的数据类型,是要进行判断的
- 是在编译、运行的时候起作用
- 在静态存储区中分配空间,在程序运行过程中内存中只有一个拷贝
define 和 inline 的区别
define:
定义预编译时处理的宏,只是简单的字符串替换,没有类型检查,不安全。
inline:
inline是先将内联函数编译完成生成了函数体,直接插入被调用的地方,减少了压栈,跳转和返回的操作。没有普通函数调用时的额外开销;
内联函数是一种特殊的函数,会进行参数类型检查;
对编译器的一种请求,编译器有可能拒绝这种请求;
C++中inline编译限制:
- 不能存在任何形式的循环语句
- 不能存在过多的条件判断语句
- 函数体不能过于庞大
- 内联函数声明必须在调用语句之前
define和const的区别
const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的区别有:
- const生效于编译的阶段;define生效于预处理阶段。
- const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接地操作数,并不会存放在内存中。
- const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
constexpr
const 表示“只读”的语义,constexpr 表示“常量”的语义
constexpr 只能定义编译期常量,而const 可以定义编译期常量,也可以定义运行期常量。
你将一个成员函数标记为constexpr,则顺带也将它标记为了const。如果你将⼀个变量标记为constexpr,则同样它是const的。但相反并不成立,一个const的变量或函数,并不是constexpr的。
constexpr变量
复杂系统中很难分辨一个初始值是不是常量表达式,可以将变量声明为constexpr类型,由编译器来验证变量的值是否是一个常量表达式。
必须使用常量初始化:
constexpr int n = 20;
constexpr int m = n + 1;
static constexpr int MOD = 1000000007;
如果constexpr声明中定义了一个指针,constexpr仅对指针有效,和所指对象无关。
constexpr int *p = nullptr; //常量指针 顶层const
const int *q = nullptr; //指向常量的指针, 底层const
int *const q = nullptr; //顶层const
constexpr函数:
constexpr函数是指能用于常量表达式的函数。
函数的返回类型和所有形参类型都是字面值类型,函数体有且只有一条return语句。
constexpr int new() {return 42;}
为了可以在编译过程展开,constexpr函数被隐式转换成了内联函数。
constexpr和内联函数可以在程序中多次定义,一般定义在头文件。
constexpr 构造函数:
构造函数不能说const,但字面值常量类的构造函数可以是constexpr。
constexpr构造函数必须有一个空的函数体,即所有成员变量的初始化都放到初始化列表中。对象调用的成员函数必须使用 constexpr 修饰
constexpr的好处
- 为一些不能修改数据提供保障,写成变量则就有被意外修改的风险。
- 有些场景,编译器可以在编译期对constexpr的代码进行优化,提高效率。
- 相比宏来说,没有额外的开销,但更安全可靠。
volatile
影响编译器编译的结果,用该关键字声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化;
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,而不是直接从寄存器拷贝内容。
多线程中被几个任务共享的变量需要定义为volatile类型。
volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
mutable
在C++中,mutable也是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。
我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里修改一些跟类状态无关的数据成员,那么这个成员就应该被mutable来修饰。
class person
{
int m_A;
mutable int m_B;//特殊变量 在常函数⾥值也可以被修改
public:
void add() const//在函数⾥不可修改this指针指向的值 常量指针
{
m_A=10;//错误 不可修改值,this已经被修饰为常量指针
m_B=20;//正确
}
}
class person
{
int m_A;
mutable int m_B;//特殊变量 在常函数⾥值也可以被修改
}
int main()
{
const person p;//修饰常对象 不可修改类成员的值
p.m_A=10;//错误,被修饰了指针常量
p.m_B=200;//正确,特殊变量,修饰了mutable
}
explicit
explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显式的方式进行类型转换,注意:
- explicit 关键字只能用于类内部的构造函数声明上
- explicit 关键字作用于单个参数的构造函数
- 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换
extern
定义:声明外部变量(在函数或者文件外部定义的全局变量)
对于**extern"C"**的使用场景
(1)C++代码中调用C语言代码;
(2)在C++中的头文件中使用;
(3)在多个人协同开发时,可能有人擅长C语言,而有人擅长C++;
前置++与后置++
self &operator++() { //前置++
node = (linktype)((node).next);
return *this;
}
const self operator++(int) { //后置++
self tmp = *this;
++*this;
return tmp;
}
为了区分前后置,重载函数是以参数类型来区分,在调用的时候,编译器默默给int指定为⼀个0
- 为什么后置返回对象,而不是引用
因为后置为了返回旧值创建了⼀个临时对象,在函数结束的时候这个对象就会被销毁,如果返回引用,那么我请问你?你的对象对象都被销毁了,你引用啥呢? - 为什么后置前面也要加const
其实也可以不加,但是为了防止你使⽤i++++,连续两次的调用后置++重载符,为什么呢?
原因:
它与内置类型行为不一致;你无法获得你所期望的结果,因为第一次返回的是旧值,而不是原对象,你调用两次后置++,结果只累加了一次,所以我们必须手动禁止其合法化,就要在前面加上const。 - 处理用户的自定义类型
最好使用前置++,因为他不会创建临时对象,进而不会带来构造和析构而造成的格外开销。
std::atomic
问题:a++ 和 int a = b 在C++中是否是线程安全的?
答案:不是!
a++:
从C/C++语法的级别来看,这是一条语句,应该是原子的;但从编译器得到的汇编指令来看,其实不是原子的。
其一般对应三条指令,首先将变量a对应的内存值搬运到某个寄存器(如eax)中,然后将该寄存器中的值自增1,再将该寄存器中的值搬运回a代表的内存中
mov eax, dword ptr [a] # (1)
inc eax # (2)
mov dword ptr [a], eax # (3)
现在假设a的值是0,有两个线程,每个线程对变量a的值都递增1,预想⼀下,其结果应该是2,可实际运行结构可能是1!是不是很奇怪?
int a = 0;
// 线程1(执⾏过程对应上⽂汇编指令(1)(2)(3))
void thread_func1() {a++;}
// 线程2(执⾏过程对应上⽂汇编指令(4)(5)(6))
void thread_func2() {a++;}
我们预想的结果是线程1和线程2的三条指令各自执行,最终a的值变为2,但是由于操作系统线程调度的不确定性,线程1执行完指令(1)和(2)后,eax寄存器中的值变为1,此时操作系统切换到线程2执行,执行指令(3)(4)(5),此时eax的值变为1;接着操作系统切回线程1继续执⾏,执行指令(6),得到a的最终结果1。
int a = b
从C/C++语法的级别来看,这是条语句应该是原子的;但从编译器得到的汇编指令来看,由于现在计算机CPU架构体系的限制,数据不能直接从内存某处搬运到内存另外一处,必须借助寄存器中转,因此这条语句一般对应两条计算机指令,即将变量b的值搬运到某个寄存器(如eax)中,再从该寄存器搬运到变量a的内存地址
中:
mov eax, dword ptr [b]
mov dword prt [a], eax
既然是两条指令,那么多个线程在执行这两条指令时,某个线程可能会在第一条指令执行完毕后被剥夺CPU时间片,切换到另一个线程而出现不确定的情况。
解决办法
C++11新标准发布后改变了这种困境,新标准提供了对整形变量原子操作的相关库,即std::atomic,这是⼀个模板类型:
template<class T>
struct atomic:
我们可以传⼊具体的整型类型对模板进行实例化,实际上stl库也提供了这些实例化的模板类型
// 初始化1
std::atomic<int> value;
value = 99;
// 初始化2
// 下⾯代码在Linux平台上无法编译通过(指在gcc编译器)
std::atomic<int> value = 99;
// 出错的原因是这⾏代码调⽤的是std::atomic的拷贝构造函数
// ⽽根据C++11语⾔规范,std::atomic的拷贝构造函数使⽤=delete标记禁止编译器⾃动⽣成
// g++在这条规则上遵循了C++11语言规范。
4. struct和class的区别
- 通常, struct 用于表示一组相关的数据,而 class 用于表示一个封装了数据和操作的对象。
- struct结构体中的成员默认是公有的(public)。类中的成员默认是私有的(private)。
- struct 继承时默认使用公有继承。class 继承时默认使用私有继承。
- class 可以用于定义模板函数,而 struct 不行。
- 在实际使用中,可以根据具体的需求选择使用 struct 或 class 。如果只是用来组织一些数据,而不涉及复杂的封装和继承关系, struct 可能更直观;如果需要进行封装、继承等面向对象编程的特性,可以选择使用 class 。
5. 静态局部变量\全局变量\局部变量
静态局部变量\全局变量\局部变量
- 静态局部变量
作用域: 限定在定义它的函数内。
生命周期: 与程序的生命周期相同,但只能在定义它的函数内部访问。
关键字: 使用 static 关键字修饰。
初始化: 仅在第一次调用函数时初始化,之后保持其值。
当希望在函数调用之间保留变量的值,并且不希望其他函数访问这个变量时,可以使用静态局部变量
void exampleFunction() {
static int count = 0; // 静态局部变ᰁ
count++;
cout << "Count: " << count << endl;
}
- 全局变量
作用域: 整个程序。
生命周期: 与程序的生命周期相同。
关键字: 定义在全局作用域,不使用特定关键字。
当多个函数需要共享相同的数据时,可以使用全局变量。
int globalVar = 10; // 全局变量
void function1() {
globalVar++;
}
void function2() {
globalVar--;
}
- 局部变量
作用域: 限定在定义它的块(大括号内)。
生命周期: 在块结束时销毁。
关键字: 定义在函数、语句块或类的成员函数中。
当变量只在某个特定作用域内有效,并且不需要其他作用域访问时,可以使用局部变量。 - 总结
静态局部变量用于在函数调用之间保留变量的值。
全局变量适用于多个函数需要共享的数据。
局部变量适用于仅在特定作用域内有效的情况。
全局变量和局部变量有什么区别?
生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。
操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。
全局变量和static变量的区别
全局变量(外部变量)的说明之前再冠以static就构成了静态的全局变量。
全局变量本身就是静态存储方式,静态全局变量当然也是静态存储方式。
这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的。
而静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
static全局变量与普通的全局变量的区别是static全局变量只初始化一次,防止在其他文件单元被引用。
static函数与普通函数有什么区别?
static函数与普通的函数作用域不同。尽在本文件中。只在当前源文件中使用的函数应该说明为内部函数(static),内部函数应该在当前源文件中说明和定义。
对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。
static函数与普通函数最主要区别是static函数在内存中只有一份,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于(堆栈)中,全局变量存在于(静态区)中,动态申请数据存在于(堆)
6. 在main执行之前和之后执行的代码可能是什么?
- main函数执行之前,主要就是初始化系统相关资源:
- 设置栈指针
- 初始化静态 static 变量和 global 全局变量,即 .data 段的内容
- 将未初始化部分的全局变量赋初值:数值型 short,int,long 等为 0,bool 为 FALSE ,指针为 NULL 等,即 .bss 段的内容
- 全局对象初始化,在 main 之前调用构造函数,这是可能会执行前的⼀些代码
- 将main函数的参数 argc , argv 等传递给 main 函数,然后才真正运行 main 函数
- attribute((constructor)) (__attribute__是GNU编译器提供的一种机制,允许开发者为函数、变量或类型指定特殊的属性)
- main函数执行之后:
- 全局对象的析构函数会在main函数之后执行;
- 可以⽤ atexit 注册⼀个函数,它会在main 之后执行;
- attribute((destructor))
7. C++的异常处理的方法
常见异常有:数组下标越界、除法计算时除数为0、动态分配空间时空间不⾜
如果不及时对这些异常进行处理,程序多数情况下都会崩溃。
try、throw和catch关键字
C++中的异常处理机制主要使用try、throw和catch三个关键字,其在程序中的用法如下:
#include <iostream>
using namespace std;
int main()
{
double m = 1, n = 0;
try {
cout << "before dividing." << endl;
if (n == 0)
throw - 1; //抛出int型异常
else if (m == 0)
throw - 1.0; //拋出 double 型异常
else
cout << m / n << endl;
cout << "after dividing." << endl;
}
catch (double d) {
cout << "catch (double)" << d << endl;
}
catch (...) {
cout << "catch (...)" << endl;
}
cout << "finished" << endl;
return 0;
}
//运⾏结果
//before dividing.
//catch (...)
//finished
代码中,对两个数进行除法计算,其中除数为0。可以看到以上三个关键字,程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,代码中使用的是数字,也可以自定义异常class。
catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(…)的方式捕获任何异常(不推荐)。当然,如果catch了异常,当前函数如果不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。
- 函数的异常声明列表
有时候,在定义函数的时候知道函数可能发生的异常,可以在函数声明和定义时,指出所能抛出异常的列表,写法如下:
int fun() throw(int,double,A,B,C){...};
这种写法表名函数可能会抛出int,double型或者A、B、C三种类型的异常,如果throw中为空,表明不会抛出任何异常,如果没有throw则可能抛出任何异常
- C++标准异常类 exception
C++ 标准库中有⼀些类代表异常,这些类都是从 exception 类派生而来的,如下图所示
bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常。
bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常
bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常
out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常
8.函数参数
形参与实参的区别?
- 形参变量只有在被调用时才分配内存,在调用结束时, 即刻释放所分配的内存。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
- 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。
- 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。
- 当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变
值传递、指针传递、引用传递的区别和效率
- 值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间。(传值)
- 指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)
- 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
- 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。如果是数组则选择指针;如果是较大的结构,选择指针或者引用效率高;如果是类,最好选择引用。
指针参数传递和引用参数传递
- 指针参数传递本质上是值传递,它所传递的是一个地址值。
值传递过程中,被调函数的形式参数作为被调函数的局部变量处理,会在栈中开辟内存空间以存放由主调函数传递进来的实参值,从而形成了实参的一个副本(替身)。
值传递的特点是,被调函数对形式参数的任何操作都是作为局部变量进行的,不会影响主调函数的实参变量的值(形参指针变了,实参指针不会变)。 - 引用参数传递过程中,被调函数的形式参数也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。
被调函数对形参(本体)的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量(根据别名找到主调函数中的本体)。
因此,被调函数对形参的任何操作都会影响主调函数中的实参变量。 - 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
而对于指针传递的参数,如果改变被调函数中的指针地址,它将应用不到主调函数的相关变量。如果想通过指针参数传递来改变主调函数中的相关变量(地址),那就得使用指向指针的指针或者指针引用。
9. 程序的编译过程
- 预处理
读取c源程序,对其中的伪指令(以#开头的指令)和特殊符号进行处理。包括宏定义替换、条件编译指令、头文件包含指令、特殊符号。 预编译程序所完成的基本上是对源程序的“替代”工作。经过此种替代,生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。.i预处理后的c文件,.ii预处理后的C++文件。 - 编译阶段
编译程序所要作得工作就是通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。.s文件 - 汇编过程
汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这⼀处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。.o目标文件 - 链接阶段
链接程序的主要工作就是将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够诶操作系统装入执行的统一整体。
10.静态链接和动态链接
静态链接
静态链接器以一组可重定位目标文件为输入,生成一个完全链接的可执行目标文件作为输出。链接器主要完成以下两个任务:
符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。
目标文件
可执行目标文件:可以直接在内存中执行;
可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件;
共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接;
动态链接
静态库有以下两个问题:
当静态库更新时那么整个程序都要重新进行链接;
对于 printf 这种标准函数库,如果每个程序都要有代码,这会极大浪费资源。
共享库是为了解决静态库的这两个问题而设计的,在 Linux 系统中通常用 .so 后缀来表示,Windows 系统上它们被称为 DLL。它具有以下特点:
在给定的文件系统中一个库只有一个文件,所有引用该库的可执行目标文件都共享这个文件,它不会被复制到引用它的可执行文件中;
在内存中,⼀个共享库的 .text 节(已编译程序的机器代码)的一个副本可以被不同的正在运行的进程共享。
11. 将字符串“hello world”从开始到打印到屏幕上的全过程?
- 用户告诉操作系统执行HelloWorld程序(通过键盘输入等)
- 操作系统:找到helloworld程序的相关信息,检查其类型是否是可执行文件;并通过程序首部信息,确定代码和数据在可执行文件中的位置并计算出对应的磁盘块地址。
- 操作系统:创建一个新进程,将HelloWorld可执行文件映射到该进程结构,表示由该进程执行helloworld程序。
- 操作系统:为helloworld程序设置cpu上下文环境,并跳到程序开始处。
- 执行helloworld程序的第一条指令,发生缺页异常
- 操作系统:分配一页物理内存,并将代码从磁盘读入内存,然后继续执行helloworld程序
- helloword程序执行puts函数(系统调用),在显示器上写入字符串
- 操作系统:找到要将字符串送往的显示设备,通常设备是由一个进程控制的,所以,操作系统将要写的字符串送给该进程
- 操作系统:控制设备的进程告诉设备的窗口系统,它要显示该字符串,窗口系统确定这是一个合法的操作,然后将字符串转换成像素,将像素写入设备的存储映像区
- 视频硬件将像素转换成显示器可接收和一组控制数据信号
- 显示器解释信号,激发液晶屏
- OK,我们在屏幕上看到了HelloWorld
12. 写C++代码时有一类错误是 coredump
coredump是程序由于异常或者bug在运行时异常退出或者终止,在一定的条件下生成的一个叫做core的文件,这个core文件会记录程序在运行时的内存,寄存器状态,内存指针和函数堆栈信息等等。对这个文件进行分析可以定位到程序异常的时候对应的堆栈调用信息。
使用gdb命令对core文件进行调试
二、C++内存管理
1. C++内存分区
五个区域
C++程序运行时,内存被分为几个不同的区域,每个区域负责不同的任务。
- 栈:用于存储函数的局部变量、函数参数和函数调用信息的区域。函数的调用和返回通过栈来管理。
- 堆:用于存储动态分配的内存的区域,由程序员手动分配和释放。使用 new 和 delete 或 malloc 和 free 来进行堆内存的分配和释放。
- 全局/静态区:存储全局变量和静态变量。生命周期是整个程序运行期间。在程序启动时分配,程序结束时释放。
- 常量区:也称为只读区。存储常量数据,如字符串常量。
- 代码区:存储程序的代码。 (类的成员函数也放在代码区,静态函数也是放在代码区,所以类的大小等于成员变量大小)
堆和栈的区别
栈和堆都是用于存储程序数据的内存区域。
栈是一种有限的内存区域,用于存储局部变量、函数调用信息等。堆是一种动态分配的内存区域,用于存储程序运行时动态分配的数据。
栈上的变量生命周期与其所在函数的执行周期相同,而堆上的变量生命周期由程序员显式控制,可以(使用 new 或 malloc )和释放(使用 delete 或 free )。
栈上的内存分配和释放是自动的,速度较快。而堆上的内存分配和释放需要手动操作,速度相对较慢。
\ | 堆 | 栈 |
---|---|---|
管理方式 | 堆中资源由程序员控制(容易产生memory leak) | 栈资源由编译器自动管理,无需手工控制 |
内存管理机制 | 系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放入空闲链表中) | 只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。(理解一下连续空间和不连续空间的区别、链表和队列的区别,从而理解两种机制不同) |
空间大小 | 堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存(32bit 系统理论上是4G),所以堆的空间比较灵活,比较大 | 栈是一块连续的内存区域,大小是操作系统预定好的,windows下栈大小是2M(也有是1M,在编译时确定,VC中可设置) |
碎片问题 | 对于堆,频繁的new/delete会造成大量碎片,使程序效率降低 | 对于栈,它是有点类似于数据结构上的一个先进后出的栈,进出一一对应,不会产生碎片。 |
生长方向 | 堆向上,向高地址方向增长。 | 栈向下,向低地址方向增长。 |
分配地址 | 堆都是动态分配(没有静态分配的堆) | 栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloca函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。 |
分配效率 | 堆由C/C++函数库提供,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,机制很复杂。所以堆的效率比栈低很多。 | 栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。效率比较高。 |
2. 内存泄漏
- 什么是内存泄露
内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
Linux下可以使用Valgrind、Windows下可以使用CRT库进行内存泄漏检查 - 内存泄漏的分类
- 堆内存泄漏 (Heap leak)
堆内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者 delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生 Heap Leak. - 系统资源泄露(Resource Leak)
主要指程序使用系统分配的资源比如 Bitmap,handle,SOCKET 等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。 - 没有将基类的析构函数定义为虚函数
当基类指针指向子类对象时,如果基类的析构函数不是 virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。
- 堆内存泄漏 (Heap leak)
- 什么操作会导致内存泄露?
指针指向改变,未释放动态分配内存。 - 如何防止内存泄露?
将内存的分配封装在类中,构造函数分配内存,析构函数释放内存;使用智能指针 - 智能指针有了解哪些?
智能指针是为了解决动态分配内存导致内存泄露和多次释放同一内存所提出的,C11标准中放在头文件。包括:共享指针,独占指针,弱指针 - 构造函数,析构函数要设为虚函数吗,为什么?
- 析构函数
析构函数需要。当派生类对象中有内存需要回收时,如果析构函数不是虚函数,不会触发动态绑定,只会调用基类析构函数,导致派生类资源无法释放,造成内存泄漏。 - 构造函数
构造函数不需要,没有意义。虚函数调用是在部分信息下完成工作的机制,允许我们只知道接口而不知道对象的确切类型。
要创建⼀个对象,你需要知道对象的完整信息。 特别是,你需要知道你想要创建的确切类型。 因此,构造函数不应该被定义为虚函数。
- 析构函数
3. 智能指针
智能指针用于管理动态内存的对象,其主要目的是在避免内存泄漏和方便资源管理。
- std::unique_ptr 独占智能指针
std::unique_ptr 提供对动态分配的单一对象所有权的独占管理。通过独占所有权,确保只有⼀个std::unique_ptr 可以拥有指定的内存资源。移动语义和右值引用允许 std::unique_ptr 在所有权转移时高效地进行转移。
#include <memory>
std::unique_ptr<int> ptr = std::make_unique<int>(42);
- std::shared_ptr (共享智能指针):
std::shared_ptr 允许多个智能指针共享同一块内存资源。内部使用引用计数来跟踪对象被共享的次数,当计数为零时,资源被释放。提供更灵活的内存共享,但可能存在循环引用的问题。
#include <memory>
std::shared_ptr<int> ptr1 = std::make_shared<int>(42);
std::shared_ptr<int> ptr2 = ptr1;
- std::weak_ptr (弱引用智能指针):
std::weak_ptr 用于解决 std::shared_ptr 可能导致的循环引用问题。
std::weak_ptr 可以从 std::shared_ptr 创建,但不会增加引用计数,不会影响资源的释放。通过 std::weak_ptr::lock() 可以获取一个 std::shared_ptr 来访问资源。
#include <memory>
std::shared_ptr<int> sharedPtr = std::make_shared<int>(42);
std::weak_ptr<int> weakPtr = sharedPtr;
4. new / delete 和 malloc / free
new / delete 和 malloc / free 的区别
1、new内存分配失败时,会抛出bac_alloc异常,它不会返回NULL;malloc分配内存失败时返回NULL。(只对于plain new,nothrow new会返回NULL)
2、使用new操作符申请内存分配时无须指定内存块的大小,而malloc则需要显式地指出所需内存的尺寸。
3、opeartor new /operator delete可以被重载,而malloc/free并不允许重载。
4、new/delete会调用对象的构造函数/析构函数以完成对象的构造/析构。而malloc则不会,只是分配和释放内存块
5、malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符
6、new操作符从自由存储区上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。
7、delete 释放的内存块的指针值会被设置为 nullptr ,以避免野指针。free 不会修改指针的值,可能导致野指针问题。
8、delete 可以正确释放通过 new[] 分配的数组。free 不了解数组的大小,不适用于释放通过 malloc 分配的数组。
被free回收的内存是立即返还给操作系统吗?
不是,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片
C++中有几种类型的new
- plain new:就是我们常用的new,在C++中定义如下:
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void *) throw();
因此plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的。
- nothrow new在空间分配失败的情况下是不抛出异常,而是返回NULL,定义如下:
void * operator new(std::size_t,const std::nothrow_t&) throw();
void operator delete(void*) throw();
- placement new允许在⼀块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配失败,因为它根本不分配内存,它做的唯一一件事情就是调用对象的构造函数。定义如下:
void* operator new(size_t,void*);
void operator delete(void*,void*);
palcement new注意两点:
palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组
placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不⼀定等于原来分配的内存大小,使⽤delete会造成内存泄漏或者之后释放内存时出现运行时错误。
new [] 和delete []
- 动态数组管理new一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
- new动态数组返回的并不是数组类型,而是一个元素类型的指针;
- delete[]时,数组中的元素按逆序的顺序进行销毁;
- new简单类型直接调用operator new分配内存;而对于复杂结构,先调用operator new分配内存,然后在分配的内存上调用构造函数;
对于简单类型,new[]计算好大小后调用operator new;对于复杂数据结构,new[]先调用operator new[]分配内存,然后在p的前四个字节写⼊数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小 - delete简单数据类型默认只是调用free函数;复杂数据类型先调用析构函数再调用operator delete;针对简单类型,delete和delete[]等同。假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。所以必须用delete[]释放new[]出来的。
- 需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4 个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
5. 野指针
野指针是指指向已被释放的或无效的内存地址的指针。使⽤野指针可能导致程序崩溃、数据损坏或其他不可预测的行为。
野指针产生原因
- 释放后没有置空指针
int* ptr = new int;
delete ptr;
// 此时 ptr 成为野指针,因为它仍然指向已经被释放的内存
ptr = nullptr; // 避免野指针,应该将指针置为 nullptr 或赋予新的有效地址
- 返回局部变量的指针
int* createInt() {
int x = 10;
return &x; // x 是局部变量,函数结束后 x 被销毁,返回的指针成为野指针
}
// 在使用返回值时可能引发未定义⾏为
- 函数参数指针被释放
void foo(int* ptr) {
// 操作 ptr
delete ptr;
}
int main() {
int* ptr = new int;
foo(ptr);
// 在 foo 函数中 ptr 被释放,但在 main 函数中仍然可⽤,成为野指针
// 避免:在 foo 函数中不要释放调用方传递的指针
}
野指针避免措施
- 在释放内存后将指针置为 nullptr
- 避免返回局部变量的指针
- 使用智能指针(如 std::unique_ptr 和 std::shared_ptr )
- 注意函数参数的生命周期, 避免在函数内释放调用方传递的指针,或者通过引用传递指针。
野指针和悬空指针的区别
都是是指向无效内存区域(这里的无效指的是"不安全不可控")的指针,访问行为将会导致未定义行为。
野指针,指的是没有被初始化过的指针
int main(void) {
int* p; // 未初始化
std::cout<< *p << std::endl; // 未初始化就被使⽤
return 0;
}
因此,为了防止出错,对于指针初始化时都是赋值为 nullptr ,这样在使用时编译器就会直接报错,产生的非法内存访问。
悬浮指针指的是指针最初指向的内存已经被释放了的一种指针。
int main(void) {
int * p = nullptr;
int* p2 = new int;
p = p2;
delete p2;
}
此时 p和p2就是悬空指针,指向的内存已经被释放。继续使用这两个指针,行为不可预料。需要设置为 p=p2=nullptr 。此时再使用,编译器会直接保错。
如何避免
避免野指针比较简单,但悬空指针比较麻烦。c++引入了智能指针,C++智能指针的本质就是避免悬空指针的产生。
野指针:指针变量未及时初始化 => 定义指针变量及时初始化,要么置空。
悬空指针:指针free或delete之后没有及时置空 => 释放操作后立即置空。
6. 内存对齐
什么是内存对齐
内存对齐是指数据在内存中的存储起始地址是某个值的倍数。
在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是⼀些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果⼀个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
比如在32位cpu下,假设⼀个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
c++11以后引入两个关键字 alignas与 alignof。其中 alignof 可以计算出类型的对齐方式, alignas 可以指定结构体的对齐方式。
为什么需要考虑内存对齐
需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的⼀个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果⼀个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。
大多数计算机硬件要求基本数据类型的变量在内存中的地址是它们大小的倍数。例如,⼀个 32 位整数通常需要在内存中对齐到 4 字节边界。
内存对齐可以提高访问内存的速度。当数据按照硬件要求的对齐方式存储时,CPU可以更高效地访问内存,减少因为不对齐而引起的性能损失。
许多计算机体系结构使用缓存行(cache line)来从内存中加载数据到缓存中。如果数据是对齐的,那么一个缓存行可以装载更多的数据,提高缓存的命中率。
有些计算机架构要求原子性操作(比如原子性读写)必须在特定的内存地址上执行。如果数据不对齐,可能导致无法执行原子性操作,进而引发竞态条件。
对内存对齐的理解
1、 分配内存的顺序是按照声明的顺序。
2、 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
3、 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
添加了#pragma pack(n)后规则就变成了下面这样:
1、 偏移量要是n和当前变量大小中较小值的整数倍
2、 整体大小要是n和最大变量大小中较小值的整数倍
3、 n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
7. 计算机中的乱序执行
- ⼀定会按正常顺序执行的情况:
(1)对同⼀块内存进行访问,此时访问的顺序不会被编译器修改
(2)新定义的变量的值依赖于之前定义的变量,此时两个变量定义的顺序不会被编译器修改 - 其他情况计算机会进行乱序执行
单线程的情况下允许,但是多线程情况下就会产生问题 - C++中的库中提供了六种内存模型
用于在多线程的情况下防止编译器的乱序执行- memory_order_relaxed:最放松的
- memory_order_consume:当客户使用,搭配release使用,被release进行赋值的变量y,获取的时候如果写成consume,那么所有与y有关的变量的赋值⼀定会被按顺序进行
- memory_order_acquire:用于获取资源
- memory_order_release:⼀般用于生产者,当给⼀个变量y进行赋值的时候,只有自己将这个变量释放了,别人才可以去读,读的时候如果使用acquire来读,编译器会保证在y之前被赋值的变量的赋值都在y之前被执行,相当于设置了内存屏障
- memory_order_acq_rel(acquire/release)
- memory_order_seq_cst(squentially consistent) 好处:不需要编译器设置内存屏障,morden c++开始就会有底层汇编的能力
副作用
- 无副作用编程
存在一个函数,传一个参数x进去,里面进行一系列的运算,返回一个y。中间的所有过程都是在栈中进行修改 - 有副作用编程
比如在一个函数运行的过程中对全局变量进行了修改或在屏幕上输出了一些东西。此函数还有可能是类的成员方法,在此方法中如果对成员变量进行了修改,类的状态就会发生改变 - 在多线程情况下的有副作用编程
在线程1运行的时候对成员变量进行了修改,此时如果再继续运行线程2,此时线程2拥有的就不是这个类的初始状态,运行出来的结果会收到线程1的影响。 解决办法:将成员方法设为const,此时就可以放心进行调用
信号量
- binary_semaphore
定义:可以当事件来用,只有有信号和无信号两种状态,一次只能被一个线程所持有。
使用步骤:
(1)初始创建信号量,并且一开始将其置位成无信号状态 std::binary_semaphore sem(0)
(2)线程使用acquire()方法等待被唤醒
(3)主线程中使用release()方法,将信号量变成有信号状态 - counting_semaphore
定义:一次可以被很多线程所持有,线程的数量由自己指定
使用步骤:
(1)创建信号量:指定一次可以进入的线程的最大数量,并在最开始将其置位成无信号状态:std::biinary_semaphore<8> sem(0);
(2)主线程中创建10个线程:并且这些线程全部调用acquire()方法等待被唤醒。但是主线程使用release(6)方法就只能随机启用6个线程。
future库
用于任务链(即任务A的执行必须依赖于任务B的返回值)
- 例子:生产者消费者问题
(1)子线程作为消费者,参数是一个future,用这个future等待一个int型的产品:std::future& fut
(2)子线程中使用get()方法等待一个未来的future,返回一个result
(3)主线程作为生产者,做出一个承诺:std::promise prom
(4)用此承诺中的get_future()方法获取一个future
(5)主线程中将子线程创建出来,并将刚刚获取到的future作为参数传入
(6)主线程做一系列的生产工作,最后生产完后使用承诺中的set_value()方法,参数为刚刚生产出的产品
(7)此时产品就会被传到子线程中,子线程就可以使用此产品做一系列动作
(8)最后使用join()方法等待子线程停止,但是join只适用于等待没有返回值的线程的情况 - 如果线程有返回值
(1)使⽤async方法可以进行异步执行
参数一: 可以选择是马上执行还是等一会执行(即当消费者线程调用get()方法时才开始执行)
参数二: 执行的内容(可以放一个函数对象或lambda表达式)
(2)生产者使用async方法做生产工作并返回一个future
(3)消费者使用future中的get()方法可以获取产品
8. 字符串操作函数
常见的字符串函数实现
- strcpy()
把从strsrc地址开始且含有’\0’结束符的字符串复制到以strdest开始的地址空间,返回值的类型为char*
- strlen()
计算给定字符串的⻓度。
- strcat()
作用是把src所指字符串添加到dest结尾处。
- strcmp()
比较两个字符串设这两个字符串为str1,str2,
若str1 == str2,则返回零
若str1 < str2,则返回负数
若str1 > str2,则返回正数
strlen和sizeof区别
sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。
因为sizeof值在编译时确定,所以不能⽤来得到动态分配(运⾏时分配)存储空间的大小。
int main(int argc, char const *argv[]){
const char* str = "name";
sizeof(str); // 取的是指针str的⻓度
strlen(str); // 取的是这个字符串的⻓度,不包含结尾的 \0。⼤⼩是4
return 0;
}
9. 变量声明和定义区别?
声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间;
定义要在定义的地方为其分配存储空间。
相同变量可以在多处声明(外部变量extern),但只能在一处定义。
10. 零拷贝
零拷贝就是⼀种避免 CPU 将数据从一块存储拷贝到另外一块存储的技术。
零拷贝技术可以减少数据拷贝和共享总线操作的次数。
在C++中,vector的一个成员函数emplace_back()很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和转移构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和转移构造
11. C++函数调用的压栈过程
#include <iostream>
using namespace std;
int f(int n)
{
cout << n << endl;
return n;
}
void func(int param1, int param2)
{
int var1 = param1;
int var2 = param2;
printf("var1=%d,var2=%d", f(var1), f(var2));//如果将printf换为cout进⾏输出,输出结果则刚好
相反
}
int main(int argc, char* argv[])
{
func(1, 2);
return 0;
}
//输出结果
//2
//1
//var1=1,var2=2
当函数从入口函数main函数开始执行时,编译器会将我们操作系统的运行状态,main函数的返回地址、main的参数、main函数中的变量、进行依次压栈;
当main函数开始调用func()函数时,编译器此时会将main函数的运行状态进行压栈,再将func()函数的返回地址、func()函数的参数从右到左、func()定义变量依次压栈;
当func()调用f()的时候,编译器此时会将func()函数的运行状态进行压栈,再将的返回地址、f()函数的参数从右到左、f()定义变量依次压栈
从代码的输出结果可以看出,函数f(var1)、f(var2)依次入栈,而后先执行f(var2),再执行f(var1),最后打印整个字符串,将栈中的变量依次弹出,最后主函数返回。
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
12. 成员函数里memset(this,0,sizeof(*this))会发生什么
- 有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。但是下面几种情形是不可以这么使用
- 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;
- 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么做就破坏了list对象的内存。
13. 测试题目
以下为WindowsNT 32位C++程序,请计算下面sizeof的值
char str[] = "hello";
char* p = str;
int n = 10;
// 请计算
sizeof(str) = ?
sizeof(p) = ?
sizeof(n) = ?
void Func(char str[100]){
// 请计算
sizeof(str) = ?
}
void* p = malloc(100);
// 请计算
sizeof(p) = ?
参考答案:
sizeof(str) = 6;
sizeof()计算的是数组的所占内存的大小包括末尾的 ‘\0’
sizeof(p) = 4;
p为指针变量,32位系统下大小为 4 bytes
sizeof(n) = 4;
n 是整型变量,占用内存空间4个字节
void Func(char str[100]){
sizeof(str) = 4;
}
函数的参数为字符数组名,即数组首元素的地址,大小为指针的大小
void* p = malloc(100);
sizeof(p) = 4;
p指向malloc分配的大小为100 byte的内存的起始地址,sizeof§为指针的大小,而不是它指向内存的大小
分析运行下面的Test函数会有什么样的结果
void GetMemory1(char* p){
p = (char*)malloc(100);
}
void Test1(void){
char* str = NULL;
GetMemory1(str);
strcpy(str, "hello world");
printf(str);
}
char *GetMemory2(void){
char p[] = "hello world";
return p;
}
void Test2(void){
char *str = NULL;
str = GetMemory2();
printf(str);
}
void GetMemory3(char** p, int num){
*p = (char*)malloc(num);
}
void Test3(void){
char* str = NULL;
GetMemory3(&str, 100);
strcpy(str, "hello");
printf(str);
}
void Test4(void){
char *str = (char*)malloc(100);
strcpy(str, "hello");
free(str);
if(str != NULL) {
strcpy(str, "world");
cout << str << endl;
}
}
参考答案:
Test1(void):
程序崩溃。 因为GetMemory1并不能传递动态内存,Test1函数中的 str一直都是NULL。strcpy(str, “hello world”)将使程序奔溃
Test2(void):
可能是乱码。 因为GetMemory2返回的是指向“栈内存”的指针,该指针的地址不是NULL,使其原现的内容已经被清除,新内容不可知。
Test3(void):
能够输出hello, 内存泄露。GetMemory3申请的内存没有释放
Test4(void):
篡改动态内存区的内容,后果难以预料。非常危险。因为 free(str);之后,str成为野指针,if(str != NULL)语句不起作用。
实现内存拷贝函数
char* strcpy(char* strDest, const char* strSrc);
参考答案:(函数实现)
char* strcpy(char *dst,const char *src) {// [1]
assert(dst != NULL && src != NULL); // [2]
char *ret = dst; // [3]
while ((*dst++=*src++)!='\0'); // [4]
return ret;
}
- const修饰:
源字符串参数用const修饰,防止修改源字符串。 - 空指针检查:
(1)不检查指针的有效性,说明答题者不注重代码的健壮性。
(2)检查指针的有效性时使用 assert(!dst && !src):char *转换为 bool 即是类型隐式转换,这种功能虽然灵活,但更多的是导致出错概率增大和维护成本升高。
(3)检查指针的有效性时使用 assert(dst != 0 && src != 0):直接使用常量(如本例中的0)会减少程序的可维护性。而使用NULL代替0,如果出现拼写错误,编译器就会检查出来。 - 返回目标地址:
忘记保存原始的strdst值。 - ‘\0’:
(1)循环写成 while (*dst++=*src++); 明显是错误的。
(2)循环写成 while (*src!=‘\0’) *dst++ = *src++:循环体结束后,dst字符串的末尾没有正确地加上’\0’。
(3)为什么要返回char *? :返回dst的原始值使函数能够支持链式表达式 ,链式表达式的形式如: int l=strlen(strcpy(strA,strB)); char * strA=strcpy(new char[10],strB); - 返回strSrc的原始值是错误的。理由:
(1)源字符串肯定是已知的,返回它没有意义
(2) 不能支持形如第二例的表达式
(3)把 const char *作为char * 返回,类型不符,编译报错
假如考虑dst和src内存重叠的情况,strcpy该怎么实现
char s[10]="hello";
strcpy(s, s+1);
// 应返回 ello
strcpy(s+1, s);
// 应返回 hhello 但实际会报错
// 因为dst与src重叠了,把'\0'覆盖了
所谓重叠,就是src未处理的部分已经被dst给覆盖了,只有一种情况: src<=dst<=src+strlen(src)
C函数 memcpy 自带内存重叠检测功能,下面给出 memcpy 的实现my_memcpy
char * strcpy(char *dst,const char *src)
{
assert(dst != NULL && src != NULL);
char *ret = dst;
my_memcpy(dst, src, strlen(src)+1);
return ret;
}
/* my_memcpy的实现如下 */
char *my_memcpy(char *dst, const char* src, int cnt)
{
assert(dst != NULL && src != NULL);
char *ret = dst;
/*内存重叠,从⾼地址开始复制*/
if (dst >= src && dst <= src+cnt-1){
dst = dst+cnt-1;
src = src+cnt-1;
while (cnt--){
*dst-- = *src--;
}
}
else {//正常情况,从低地址开始复制
while (cnt--){
*dst++ = *src++;
}
}
return ret;
}
按照下面描述的要求写程序
已知String的原型为:
class String
{
public:
String(const char *str = NULL);
String(const String &other);
~ String(void);
String & operate =(const String &other);
private:
char *m_data;
};
请编写上述四个函数
参考答案:
此题考察对构造函数赋值运算符实现的理解。实际考察类内含有指针的构造函数赋值运算符函数写法。
// 构造函数
String::String(const char *str)
{
if(str==NULL){
m_data = new char[1]; //对空字符串自动申请存放结束标志'\0'
*m_data = '\0';
}
else{
int length = strlen(str);
m_data = new char[length + 1];
strcpy(m_data, str);
}
}
// 析构函数
String::~String(void)
{
delete [] m_data; // 或delete m_data;
}
//拷⻉构造函数
String::String(const String &other)
{
int length = strlen(other.m_data);
m_data = new char[length + 1];
strcpy(m_data, other.m_data);
}
//赋值函数
String &String::operate =(const String &other)
{
if(this == &other){
return *this; // 检查自赋值
}
delete []m_data; // 释放原有的内存资源
int length = strlen(other.m_data);
m_data = new char[length + 1]; //对m_data加NULL判断
strcpy(m_data, other.m_data);
return *this; //返回本对象的引⽤
}
说一说进程的地址空间分布
参考答案:
对于一个进程,其空间分布如下图所示:
如上图,从高地址到低地址,一个程序由命令行参数和环境变量、栈、文件映射区、堆、BSS段、数据段、代码段组成。
- 命令行参数和环境变量:命令行参数是指从命令行执行程序的时候,给程序的参数。
- 栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。
- 文件映射区:位于堆和栈之间。
- 堆区:动态申请内存用。堆从低地址向高地址增长。
- BSS 段:存放程序中未初始化的 全局变量和静态变量 的一块内存区域。
- 数据段:存放程序中已初始化的 全局变量和静态变量 的一块内存区域。
- 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。
说⼀说C与C++的内存分配方式
- 从静态存储区域分配
内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在,如全局变量,static变量。 - 在栈上创建
在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。 - 从堆上分配(动态内存分配)
程序在运行的时候用malloc或new申请任意多少的内存,程序员负责在何时用free或delete释放内存。动态内存的生存期自己决定,使⽤非常灵活。
new[]和delete
参考答案:
如果是带有自定义析构函数的类类型,用new[]来创建类对象数组,而用delete来释放会发生什么?用例子来说明:
class A {};
A* pAa = new A[3];
delete pAa;
那么 delete pAa; 做了两件事:
- 调用一次 pAa 指向的对象的析构函数
- 调用 operator delete(pAa);释放内存
显然,这里只对数组的第一个类对象调用了析构函数,后面的两个对象均没调用析构函数,如果类对象中申请了大量的内存需要在析构函数中释放,而你却在销毁数组对象时少调用了析构函数,这会造成内存泄漏。
上面的问题你如果说没关系的话,那么第二点就是致命的了!直接释放pAa指向的内存空间,这个总是会造成严重的段错误,程序必然会崩溃!因为分配的空间的起始地址是 pAa 指向的地方减去 4 个字节的地方。你应该传入参数设为那个地址!
三、C++ 面向对象
1. 面向对象的三大特性
1. 继承
定义:让某种类型对象获得另一个类型对象的属性和方法
功能:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展
常见的继承有三种方式:
1、实现继承:指使用基类的属性和方法而无需额外编码的能力
2、接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
3、可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力
例如:
将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法。
**注意:**派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。所以基类必须定义而非声明。
2. 封装
定义:数据和代码捆绑在⼀起,避免外界干扰和不确定性访问;
功能:把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。
3. 多态
定义:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)
功能:多态性是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作; 简单一句话:允许将子类类型的指针赋值给父类类型的指针。
实现多态有两种方式
- 覆盖(override): 是指子类重新定义父类的虚函数的做法
- 重载(overload): 是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)
例如:基类是一个抽象对象——人,那学生、运动员也是人,而使用这个抽象对象既可以表示学生、也可以表示运动员。
2. public、protected和private访问和继承权限
public的变量和函数在类的内部外部都可以访问。
protected的变量和函数只能在类的内部和其派生类中访问。
private修饰的元素只能在类内访问。
- 访问权限
派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,三种派生方式的访问权限如下表所示:
派生类对基类成员的访问形象有如下两种:
内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问
外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问 - 继承权限
派生类继承自基类的成员权限有四种状态:public、protected、private、不可见
派生类对基类成员的访问权限取决于两点:一、继承方式;二、基类成员在基类中的访问权限
3. 什么是多重继承?
一个类可以从多个基类(父类)继承属性和行为。在C++等支持多重继承的语言中,一个派生类可以同时拥有多个基类。
多重继承可能引入一些问题,如菱形继承问题, 比如当一个类同时继承了两个拥有相同基类的类,而最终的派生类又同时继承了这两个类时, 可能导致二义性和代码设计上的复杂性。为了解决这些问题,C++ 提供了虚继承, 通过在继承声明中使用 virtual 关键字,可以避免在派生类中生成多个基类的实例,从而解决了菱形继承带来的二义性。
#include <iostream>
class Animal {
public:
void eat() {
std::cout << "Animal is eating." << std::endl;
}
};
class Mammal : public Animal {
public:
void breathe() {
std::cout << "Mammal is breathing." << std::endl;
}
};
class Bird : public Animal {
public:
void fly() {
std::cout << "Bird is flying." << std::endl;
}
};
// 菱形继承,同时从 Mammal 和 Bird 继承
class Bat : public Mammal, public Bird {
public:
void navigate() {
// 这⾥可能会引起⼆义性,因为 Bat 继承了两个 Animal
// navigate ⽅法中尝试调⽤ eat ⽅法,但不明确应该调⽤ Animal 的哪⼀个实现
eat();
}
};
int main() {
Bat bat;
bat.navigate();
return 0;
}
虚继承:
#include <iostream>
class Animal {
public:
void eat() {
std::cout << "Animal is eating." << std::endl;
}
};
class Mammal : virtual public Animal {
public:
void breathe() {
std::cout << "Mammal is breathing." << std::endl;
}
};
class Bird : virtual public Animal {
public:
void fly() {
std::cout << "Bird is flying." << std::endl;
}
};
class Bat : public Mammal, public Bird {
public:
void navigate() {
// 不再存在二义性,eat ⽅法来自于共享的 Animal 基类
eat();
}
};
int main() {
Bat bat;
bat.navigate();
return 0;
}
虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格;表格中存放的不是虚基类子对象的地址,就是其偏移量,此类指针被称为bptr,如上图所示。如果既存在vptr又存在bptr,某些编译器会将其优化,合并为一个指针。
4. 简述一下 C++ 的重载和重写,以及它们的区别
- 重载是指在同一作用域内,使用相同的函数名但具有不同的参数列表或类型,使得同⼀个函数名可以有多个版本。
overload是重载,这些方法的名称相同而参数形式不同;一个方法有不同的版本,存在于一个作用域中。
规则:- 不能通过访问权限、返回类型、抛出的异常进行重载
- 不同的参数类型可以是不同的参数类型,不同的参数个数,不同的参数顺序(参数类型必须不⼀样)
- 方法的异常类型和数目不会对重载造成影响
int add(int a, int b) {
return a + b;
}
double add(double a, double b) {
return a + b;
}
- 重写是指派生类(子类)重新实现(覆盖)基类(父类)中的虚函数,以提供特定于派生类的实现。重写是面向对象编程中的多态性的一种体现,主要涉及基类和派生类之间的关系,用于实现运行时多态。
override是重写(覆盖)了一个方法,以实现不同的功能,⼀般是用于子类在继承父类时,重写父类方法。
规则:- 重写方法的参数列表,返回值,所抛出的异常与被重写方法⼀致
- 被重写的方法不能为private
- 静态方法不能被重写为非静态的方法
- 重写方法的访问修饰符⼀定要大于被重写方法的访问修饰符(public>protected>default>private)
class Base {
public:
virtual void print() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void print() override {
cout << "Derived class" << endl;
}
};
使用多态是为了避免在父类里大量重载引起代码臃肿且难于维护。
重载与重写的区别:
重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系
重写要求参数列表相同,重载则要求参数列表不同,返回值不要求
重写关系中,调用方法根据对象类型决定,重载根据调用时实参表与形参表的对应关系来选择函数体
补充,当子类实现了和父类相同名字的函数(参数列表可以不同),且不是虚函数,这种行为叫隐藏,可以通过指定作用域来调用不同函数
B b;
b.fun(2); //调用的是B中的fun函数
b.A::fun(2);
并且基类指针指向派生类对象时,只能调用基类的被隐藏函数,无法识别派生类中的隐藏函数。
5. c++的多态如何实现
C++中的多态性是通过虚函数(virtual function)和虚函数表(vtable)来实现的。多态性允许在基类类型的指针或引用上调用派生类对象的函数,以便在运行时选择正确的函数实现。
- 基类声明虚函数:在基类中声明虚函数,使用 virtual 关键字,以便派生类可以重写(override)这些函数。
class Shape {
public:
virtual void draw() const {
// 基类的默认实现
}
};
- 派生类重写虚函数:在派生类中重写基类中声明的虚函数,使用 override 关键字(对应的,如果不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字)
class Circle : public Shape {
public:
void draw() const override {
// 派⽣类的实现
}
};
- 使用基类类型的指针或引用指向派生类对象。
Shape* shapePtr = new Circle();
- 调用虚函数:通过基类指针或引用调用虚函数。在运行时,系统会根据对象的实际类型来选择调用正确的函数实现。
shapePtr->draw(); // 调⽤的是 Circle 类的 draw() 函数
- 虚函数表:编译器在对象的内存布局中维护了一个虚函数表,其中存储了指向实际函数的指针。这个表在运行时用于动态查找调用的函数。
6. 成员函数/成员变量/静态成员函数/静态成员变量
- 成员函数
- 成员函数是属于类的函数,它们可以访问类的成员变量和其他成员函数。
- 成员函数可以分为普通成员函数和静态成员函数。
- 普通成员函数使用对象调用,可以访问对象的成员变量。
- 普通成员函数的声明和定义通常在类的内部,但定义时需要使用类名作为限定符。
- 成员变量
- 成员变量是属于类的变量,存储在类的每个对象中。
- 每个对象拥有一份成员变量的副本,它们在对象创建时分配,并在对象销毁时释放。
- 成员变量的访问权限可以是 public 、 private 或 protected 。
class MyClass {
public:
int memberVariable; // 成员变量的声明
void memberFunction() {
// 成员函数的实现
}
};
- 静态成员函数
- 静态成员函数属于类而不是对象,因此可以直接通过类名调用,而不需要创建类的实例。
- 静态成员函数不能直接访问普通成员变量,因为它们没有隐含的 this 指针。
- 静态成员函数的声明和定义也通常在类的内部,但在定义时需要使用类名作为限定符。
- 静态成员变量
- 静态成员变量是属于类而不是对象的变量,它们在所有对象之间共享。
- 静态成员变量通常在类的声明中进行声明,但在类的定义外进行定义和初始化。
- 静态成员变量可以通过类名或对象访问。
class MyClass {
public:
static int staticMemberVariable; // 静态成员变量的声明
static void staticMemberFunction() {
// 静态成员函数的实现
}
};
int MyClass::staticMemberVariable = 0; // 静态成员变量的定义和初始化
7. 构造函数和析构函数
定义
- 构造函数
构造函数是在创建对象时自动调用的特殊成员函数。它的主要目的是初始化对象的成员变量,为对象分配资源,执行必要的初始化操作。构造函数的特点包括:- 函数名与类名相同: 构造函数的函数名必须与类名相同,且没有返回类型,包括 void。
- 可以有多个构造函数:一个类可以有多个构造函数,它们可以根据参数的类型和数量不同而重载。
- 默认构造函数: 如果没有为类定义任何构造函数,编译器会自动生成一个默认构造函数。默认构造函数没有参数,也可能执行一些默认的初始化操作。
- 析构函数
析构函数是在对象生命周期结束时自动调用的特殊成员函数。它的主要目的是释放对象占用的资源、执行必要的清理操作。析构函数的特点包括:- 函数名与类名相同,前面加上波浪号 ~ : 析构函数的函数名为 ~ClassName ,其中 ClassName 是类名。
- 没有参数: 析构函数没有参数,不能重载,每个类只能有一个析构函数。
- 默认析构函数: 如果没有为类定义任何析构函数,编译器会自动生成一个默认析构函数,执行简单的清理操作。
C++构造函数种类
- 默认构造函数:没有任何参数的构造函数。如果用户没有为类定义构造函数,编译器会自动生成一个默认构造函数。默认构造函数用于创建对象时的初始化,当用户不提供初始化值时,编译器将调用默认构造函数。
class MyClass {
public:
// 默认构造函数
MyClass() {
// 初始化操作
}
};
- 带参数的构造函数:接受一个或多个参数,用于在创建对象时传递初始化值。可以定义多个带参数的构造函数,以支持不同的初始化方式。
class MyClass {
public:
// 带参数的构造函数
MyClass(int value) {
// 根据参数进⾏初始化操作
}
};
- 拷贝构造函数:用于通过已存在的对象创建一个新对象,新对象是原对象的副本。参数通常是对同类型对象的引用。
不能传值,因为传值的话会调用拷贝构造,会导致一直递归。
class MyClass {
public:
// 拷⻉构造函数
MyClass(const MyClass &other) {
// 进⾏深拷贝或浅拷贝,根据实际情况
}
};
发生拷贝构造的情况:
- 用类的一个实例化对象去初始化另一个对象的时候
- 函数的参数是类的对象时(非引用传递)
- 函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数
- 委托构造函数:在一个构造函数中调用同类的另一个构造函数,减少代码重复。通过成员初始化列表或构造函数体内部调用其他构造函数。
class MyClass {
public:
// 委托构造函数
MyClass() : MyClass(42) {
// 委托给带参数的构造函数
}
MyClass(int value) {
// 进⾏初始化操作
}
};
-
移动构造函数(move和右值引用)
拷贝构造函数中,对于指针,我们采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。
浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。
所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a->value指向的空间; -
转换构造函数:形参是其他类型变量,且只有一个形参,用于将其他类型的变量,隐式转换为本类对象
派生类构造函数执行顺序
① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
③ 类类型的成员对象的构造函数(按照初始化顺序)
④ 派生类自己的构造函数
8. 虚函数
虚函数
C++中的虚函数的作用主要是实现了多态的机制。虚函数允许在派生类中重新定义基类中定义的函数,使得通过基类指针或引用调用的函数在运行时根据实际对象类型来确定。这样的机制被称为动态绑定或运行时多态。
在基类中,通过在函数声明前面加上 virtual 关键字,可以将其声明为虚函数。派生类可以重新定义虚函数,如果派生类不重新定义,则会使用基类中的实现。(注意缺省参数和virtual函数一起使用的时候一定要谨慎)
class Base {
public:
virtual void virtualFunction() {
// 虚函数的实现
}
};
class Derived : public Base {
public:
void virtualFunction() override {
// 派⽣类中对虚函数的重新定义
}
};
虚函数表
虚函数的实现通常依赖于一个被称为虚函数表(虚表)的数据结构。虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成
每个类(包括抽象类)都有一个虚表,其中包含了该类的虚函数的地址。每个对象都包含一个指向其类的虚表的指针,这个指针被称为虚指针(vptr)。
当调用一个虚函数时,编译器会使用对象的虚指针查找虚表,并通过虚表中的函数地址来执行相应的虚函数。这就是为什么在运行时可以根据实际对象类型来确定调用哪个函数的原因。
C++中虚函数表位于只读数据段,也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
虚函数和纯虚函数的区别
- 虚函数
有实现: 虚函数有函数声明和实现,即在基类中可以提供默认实现。
可选实现: 派生类可以选择是否覆盖虚函数。如果派生类没有提供实现,将使用基类的默认实现。
允许实例化: 虚函数的类可以被实例化。即你可以创建一个虚函数的类的对象。
调用靠对象类型决定: 在运行时,根据对象的实际类型来决定调用哪个版本的虚函数。
用 virtual 关键字声明: 虚函数使用virtual 关键字声明,但不包含 = 0 。
class Base {
public:
// 虚函数有实现
virtual void virtualFunction() {
// 具体实现
}
};
- 纯虚函数
没有实现: 纯虚函数没有函数体,只有函数声明,即没有提供默认的实现。
强制覆盖: 派生类必须提供纯虚函数的具体实现,否则它们也会成为抽象类。
禁止实例化: 包含纯虚函数的类无法被实例化,只能用于派生其他类。
用 = 0 声明: 纯虚函数使用 = 0 在函数声明末尾进行声明。
为接口提供规范: 通过纯虚函数,抽象类提供一种接口规范,要求派生类提供相关实现。
class AbstractBase {
public:
// 纯虚函数,没有具体实现
virtual void pureVirtualFunction() = 0;
// 普通成员函数可以有具体实现
void commonFunction() {
// 具体实现
}
};
什么是抽象类和纯虚函数?
抽象类是不能被实例化的类,它存在的主要目的是为了提供一个接口,供派生类继承和实现。抽象类中可以包含普通的成员函数、数据成员和构造函数,但它必须包含至少一个纯虚函数。即在声明中使用 virtual 关键字并赋予函数一个 = 0 的纯虚函数。
纯虚函数是在抽象类中声明的虚函数,它没有具体的实现,只有函数的声明。通过在函数声明的末尾使用 = 0 ,可以将虚函数声明为纯虚函数。派生类必须实现抽象类中的纯虚函数,否则它们也会成为抽象类。
class AbstractShape {
public:
// 纯虚函数,提供接⼝
virtual void draw() const = 0;
// 普通成员函数
void commonFunction() {
// 具体实现
}
};
虚析构函数
虚析构函数是一个带有 virtual 关键字的析构函数。 主要作用是确保在通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,从而释放对象所占用的资源。
通常,如果一个类可能被继承,且在其派生类中有可能使用 delete 运算符来删除通过基类指针指向的对象,那么该基类的析构函数应该声明为虚析构函数。
class Base {
public:
// 虚析构函数
virtual ~Base() {
// 基类析构函数的实现
}
};
class Derived : public Base {
public:
// 派⽣类析构函数,可以覆盖基类的虚析构函数
~Derived() override {
// 派⽣类析构函数的实现
}
};
为什么要虚析构,为什么不能虚构造
虚析构函数允许在运行时根据对象的实际类型调用正确的析构函数,从而实现多态性。
如果基类的析构函数不是虚的,当通过基类指针删除指向派生类对象的对象时,只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类的资源未被正确释放,造成内存泄漏。
构造函数在对象的创建阶段被调用,对象的类型在构造函数中已经确定。因此,构造函数调用不涉及多态性,也就是说,在对象的构造期间无法实现动态绑定。虚构造函数没有意义,因为对象的类型在构造过程中就已经确定,不需要动态地选择构造函数。
- 创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型
- 虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了
- 虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数
class Base {
public:
// 错误!不能声明虚构造函数
virtual Base() {
// 虚构造函数的实现
}
virtual ~Base() {
// 基类析构函数的实现
}
};
构造函数和析构函数可以调用虚函数吗,为什么
- 在C++中,提倡不在构造函数和析构函数中调用虚函数;
- 构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
- 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
- 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
哪些函数不能被声明为虚函数?
常见的不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。
- 构造函数:
构造函数在对象的创建期间调用,对象的类型在构造期间已经确定。因此,构造函数不能是虚函数,因为虚函数的动态绑定是在运行时实现的,而构造函数在对象还未创建完全时就会被调用。 - 普通函数
普通函数(非成员函数)只能被overload,不能被override,声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。 - 静态成员函数
静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,没有要动态绑定的必要性。并且静态成员函数没有this指针,无法访问vptr - 友元函数
因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。 - 内联成员函数
inline函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数。
9. 深拷贝和浅拷贝
主要区别在于如何处理对象内部的动态分配的资源。
- 深拷贝
深拷贝是对对象的完全独立复制,不仅复制对象的值,还会复制对象内部动态分配的资源、所指向的堆上的数据。
即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。
深拷贝通常涉及到手动分配内存,并在拷贝构造函数或赋值操作符中进行资源的复制。
class DeepCopyExample {
public:
int *data;
DeepCopyExample(const DeepCopyExample &other) {
// ⼿动分配内存并复制数据
data = new int(*(other.data));
}
~DeepCopyExample() {
// 释放动态分配的资源
delete data;
}
DeepCopyExample& operator=(const DeepCopyExample &other) {
// 复制数据
if (this != &other) {
delete data;
data = new int(*(other.data));
}
return *this;
}
};
- 浅拷贝
浅拷贝仅复制对象的值,而不涉及对象内部动态分配的资源。在浅拷贝中,新对象和原对象共享相同的资源,而不是复制一份新的资源。
主要特点:- 复制对象及其所有成员变量的值。
- 对象内部动态分配的资源不会被复制,新对象和原对象共享同一份资源。
浅拷贝通常使用默认的拷贝构造函数和赋值操作符,因为它们会逐成员地复制原对象的值。
class ShallowCopyExample {
public:
int *data;
// 使⽤默认拷⻉构造函数和赋值操作符
};
10. 运算符重载
重载运算符函数,本质还是函数调用,所以重载后:
可以是和调用运算符的方式调用,data1+data2
也可以是调用函数的方式,operator+(data1, data2),这就要注意运算符函数的名字是“operator运算符”
在可以重载的运算符里有逗号、取地址、逻辑与、逻辑或
不建议重载:
逗号、取地址,本身就对类类型有特殊定义;逻辑与、逻辑或,有短路求值属性;逗号、逻辑与、或,定义了求值顺序。
运算符重载应该是作为类的成员函数or非成员函数
注意:
重载运算符,它本身是几元就有几个参数,对于二元的,第一个参数对应左侧运算对象,第二个参数对应右侧运算对象。而类的成员函数的第一个参数隐式绑定了this指针,所以重载运算符如果是类的成员函数,左侧运算对象就相当于固定了是this。
⼀些规则:
- 算术和关系运算符建议非成员:
因为这些运算符是对称性的,形参都是常量引用 - 赋值运算符必须成员。
复合赋值运算符建议成员 - 下标运算符必须成员:返回访问元素的引用 ,建议两版本(常量、非常量)
- 递增递减运算符,建议成员:
因其会改变对象状态,后置与前置的区分——接受⼀个额外的不被使用的int类型形参,前置返回变后的对象引用 ,后置返回对象的原值(非引用 ); - 解引用 (*)建议成员,因其与给定类型关系密切,箭头(->)必须成员。
函数调用运算符:
lambda是函数对象。编译器是将lambda表达式翻译为一个未命名类的未命名对象,‘[’捕获列表‘]’(参数列表){函数体} 对应类中重载调用运算符的参数列表、函数体,捕获列表的内容就对应类中的数据成员。所以捕获列表,值传递时,要拷贝并初始化那些数据成员,引用传递就是直接用。
11. 初始化方式
有两种初始化方式:
- 赋值初始化,通过在函数体内进行赋值初始化
对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。 - 列表初始化,在冒号后使用初始化列表进行初始化
列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员时函数体还未执行。
赋值初始化是在构造函数当中做赋值的操作,而列表初始化是做纯粹的初始化操作。
而赋值初始化对于一些成员类,会先调用一次默认构造函数,进入构造函数后所做的事其实是一次赋值操作,降低程序的效率
必须使用列表初始化的四种情况
① 当初始化一个引用成员时;
② 当初始化一个常量成员时;
③ 当调用一个基类的构造函数,而它拥有一组参数时;
④ 当调用一个成员类的构造函数,而它拥有一组参数时;
12. 静态类型和动态类型,静态绑定和动态绑定
静态类型:对象在声明时采用的类型,在编译期既已确定;
动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
虚函数才具有动态绑定,引用和指针都可以实现动态绑定
13.继承和组合的缺点
一:继承
继承是Is a 的关系,比如说Student继承Person,则说明Student is a Person。继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
继承的缺点有以下几点:
①:父类的内部细节对子类是可见的。
②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。
③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
二:组合
组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象时不可见的。
②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
③:当前对象可以在运行时动态的绑定所包含的对象。可以通过set方法给所包含对象赋值。
组合的缺点:①:容易产生过多的对象。②:为了能组合多个对象,必须仔细对接口进行定义。
14.如果有⼀个空类,它会默认添加哪些函数?
1) Empty(); // 缺省构造函数//
2) Empty( const Empty& ); // 拷贝构造函数//
3) ~Empty(); // 析构函数//
4) Empty& operator=( const Empty& ); // 赋值运算符//
15.关于this指针你知道什么?
this指针是类的指针,指向对象的首地址。
this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
this指针只有在成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。
this指针的用处
一个对象的this指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行
this指针的使用
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;
另外一种情况是当形参数与成员变量名相同时用于区分,如this->n = n (不能写成n = n)
this指针特点
(1)this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数为T * const this
class A{public: int func(int p){}};
//其中,func的原型在编译器看来应该是:
int func(A * const this,int p);
(2)由此可见,this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去。如:
A a;a.func(10);//此处,编译器将会编译成:A::func(&a,10);
和静态函数的区别还是有的。编译器通常会对this指针做一些优化,因此,this指针的传递效率比较高
this指针调用成员变量时,堆栈会发生什么变化?
当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。
即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this的。
例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。
This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
16.在成员函数中调用delete this
在成员函数中调用delete this会出现什么问题?
在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。
当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能
够正常运行。一旦涉及到this指针,如操作数据成员,调⽤虚函数等,就会出现不可预期的问题。
为什么是不可预期的问题?
delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,你可以加上100,加上200,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃
如果在类的析构函数中调用delete this,会发⽣什么?
会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。
17.请说以下几种情况下,下面几个类的大小各是多少?
class A {};
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
空类的大小是1, 在C++中空类会占一个字节,这是为了让对象的实例能够相互区别。
具体来说,空类同样可以被实例化,并且每个实例在内存中都有独一无二的地址,因此,编译器会给空类隐含加上一个字节,这样空类实例化之后就会拥有独一无二的内存地址。当该空白类作为基类时,该类的大小就优化为0了,子类的大小就是子类本身的大小。这就是所谓的空白基类最优化。
空类的实例大小就是类的大小,所以sizeof(a)=1字节,如果a是指针,则sizeof(a)就是指针的大小,即4字节。
class A { virtual Fun(){} };
int main(){
cout<<sizeof(A)<<endl;// 输出 4(32位机器)/8(64位机器);
A a;
cout<<sizeof(a)<<endl;// 输出 4(32位机器)/8(64位机器);
return 0;
}
因为有虚函数的类对象中都有一个虚函数表指针 __vptr,其大小是4字节
class A { static int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 1;
A a;
cout<<sizeof(a)<<endl;// 输出 1;
return 0;
}
静态成员存放在静态存储区,不占用类的大小, 普通函数也不占用类的大小
class A { int a; };
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
class A { static int a; int b; };;
int main(){
cout<<sizeof(A)<<endl;// 输出 4;
A a;
cout<<sizeof(a)<<endl;// 输出 4;
return 0;
}
静态成员a不占用类的大小,所以类的大小就是b变量的大小,即4个字节
类对象的大小受哪些因素影响?
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
- 虚函数的话,会在类对象插入vptr指针,加上指针大小;
- 当该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。
18.多态的流程
- 编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址
- 编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数
- 所谓的合适时机,在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
4 当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面
这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。
四、C++STL
1.什么是STL,包含哪些组件
广义上讲,STL分为3类:Algorithm(算法)、Container(容器)、Iterator(迭代器),容器和算法通过迭代器可以进行无缝地连接。
详细的说,STL由6部分组成,即六大组件::容器、算法、迭代器、仿函数、适配器(配接器)、空间配置器 。彼此之间可以组合套用、
六大组件
- 容器(Container)
各种数据结构,如vector、list、deque、set、map等,用来存放数据,从实现角度来看,STL容器是一种class template。 - 算法(Algorithm)
各种常用的算法,如sort、find、copy、for_each。从实现的角度来看,STL算法是一种function tempalte. - 迭代器(Iterator)
扮演了容器与算法之间的胶合剂,共有五种类型,从实现角度来看,迭代器是一种将operator* , operator-> , operator++,operator–等指针相关操作予以重载的class template。
所有STL容器都附带有自己专属的迭代器,只有容器的设计者才知道如何遍历自己的元素。 原生指针(native pointer)也是一种迭代器。 - 仿函数(Function object)
行为类似函数,可作为算法的某种策略。从实现角度来看,仿函数是一种重载了operator()的class 或者class template - 适配器(Adaptor)
一种用来修饰容器或者仿函数或迭代器接口的东西。
STL提供的queue 和 stack,虽然看似容器,但其实只能算是一种容器配接器,因为它们的底部完全借助deque,所有操作都由底层的deque供应。 - 空间配制器(Allocator)
负责空间的配置与管理。从实现角度看,配置器是一个实现了动态空间配置、空间管理、空间释放的class template。
一般的分配器的std:alloctor都含有两个函数allocate与deallocte,这两个函数分别调用operator new()与delete(),这两个函数的底层又分别是malloc()and free();但是每次malloc会带来格外开销(因为每次malloc一个元素都要带有附加信息)
STL六大组件的交互关系
- 容器通过空间配置器取得数据存储空间
- 算法通过迭代器存储容器中的内容
- 仿函数可以协助算法完成不同的策略的变化
- 适配器可以修饰仿函数。
STL的优点
STL 具有高可重用性,高性能,高移植性,跨平台的优点。
1.高可重用性:
STL 中几乎所有的代码都采用了模板类和模版函数的方式实现,这相比于传统的由函数和类组成的库来说提供了更好的代码重用机会。
2.高性能:
如 map 可以高效地从十万条记录里面查找出指定的记录,因为 map 是采用红黑树的变体实现的。
3.高移植性:
如在项目 A 上用 STL 编写的模块,可以直接移植到项目 B 上。
STL 的⼀个重要特性是将数据和操作分离
数据由容器类别加以管理,操作则由可定制的算法定义。迭代器在两者之间充当“粘合剂”,以使算法可以和容器交互运作。
2. STL容器
常见容器
- 序列容器
vector(向量): std::vector 是一个动态数组实现,提供高效的随机访问和在尾部进行插入/删除操作。
list(链表): std::list 是一个双向链表实现,支持在任意位置进行插入/删除操作,但不支持随机访问。
deque(双端队列): std::deque 是一个双端队列实现,允许在两端进行高效插入/删除操作。
array(数组): std::array 是一个固定大小的数组实现,提供对数组元素的高效随机访问。
forward_list(前向链表): std::forward_list 是一个单向链表实现,只能从头到尾进行遍历,不支持双向访问。 - 关联容器
set(集合): std::set 是一个有序的集合,不允许重复元素,支持快速查找、插入和删除。
multiset(多重集合): std::multiset 是一个有序的多重集合,允许重复元素。
map(映射): std::map 是⼀个有序的键值对集合,不允许重复的键,支持快速查找、插入和删除。
multimap(多重映射): std::multimap 是一个有序的多重映射,允许重复的键。
unordered_set(⽆序集合): std::unordered_set 是一个无序的集合,不允许重复元素,支持快速查找、插入和删除。
unordered_multiset(⽆序多重集合): std::unordered_multiset 是一个无序的多重集合,允许重复元素。
unordered_map(⽆序映射): std::unordered_map 是一个无序的键值对集合,不允许重复的键,支持快速查找、插入和删除。
unordered_multimap(⽆序多重映射): std::unordered_multimap 是一个无序的多重映射,允许重复的键。 - 容器适配器
stack(栈): std::stack 是一个基于底层容器的栈实现,默认使用 std::deque 。
queue(队列): std::queue 是一个基于底层容器的队列实现,默认使用 std::deque 。
priority_queue(优先队列): std::priority_queue 是一个基于底层容器的优先队列实现,默认使用std::vector 。
pair容器
保存两个数据成员,用来生成特定类型的模板。
使用: pair<T1, T2>p;
内部定义
namespace std {
template <typename T1, typename T2>
struct pair {
T1 first;
T2 second;
};
}
pair在底层被定义为⼀个struct,其所有成员默认是public,两个成员分别是first和second
其中map的元素是pair,pair<const key_type,mapped_type>,可以用来遍历关联容器
map<string,int>p;
auto map1 = p.cbegin();
while(map1 != p.cend())
{
cout<<map1->first<<map1->second<<endl;
++map1;
}
对map进行插入,元素类型是pair:
p.insert({word, 1});
p.insert(pair<string, int>(word, 1));
insert对不包含重复关键字的容器,插入成功返回pair<迭代器,bool>迭代器指向给定关键字元素,bool指出插入是否成功。
vector<pair<char, int>>result(val.begin(), val.end());
sort(result.begin(), result.end(),[](auto &a, auto &b){
return a.second > b.second;
});
vector容器实现与扩充
- 底层实现
std::vector 是C++标准库中的一个动态数组实现,它的实现原理基于数组数据结构。实现通常包含一个指向数组起始位置的指针、数组的⼤大小和容量等信息。
在尾部进行插入和删除操作时,只需调整尾部指针,不需要移动整个数据块。
当元素数量达到当前内存块的容量时, std::vector 会申请⼀个更大的内存块,将元素从旧的内存块复制到新的内存块,并释放旧的内存块。
由于数组的连续内存结构,通过索引进行访问时可以通过指针运算实现常数时间复杂度的随机访问。
template <class T, class Allocator = std::allocator<T>>
class vector {
private:
T* elements; // 指向数组起始位置的指针
size_t size; // 当前元素数量
size_t capacity; // 当前分配的内存块容量
};
有三个迭代器
(1)first : 指向的是vector中对象的起始字节位置
(2)last : 指向当前最后一个元素的末尾字节
(3)end : 指向整个vector容器所占用内存空间的末尾字节
(1)last - first : 表示 vector 容器中目前已被使用的内存空间
(2)end - last : 表示 vector 容器目前空闲的内存空间
(3)end - first : 表示 vector 容器的容量
2. 扩容过程
如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,再插入新增的元素
所以对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了
(1)堆中分配内存,元素连续存放,内存空间只会增长不会减少
vector有两个函数,一个是capacity(),在不分配新内存下最多可以保存的元素个数,另一个size(),返回当前已经存储数据的个数
(2)对于vector来说,capacity是永远大于等于size的,capacity和size相等时,vector就会扩容,capacity变大(翻倍)
这里涉及到了vector扩容方式的选择,新增的容量选择多少才适宜呢?
1、固定扩容
机制:每次扩容的时候在原 capacity 的基础上加上固定的容量,比如初始 capacity 为100,扩容一次为 capacity + 20,再扩容仍然为 capacity + 20;
缺点:考虑一种极端的情况,vector每次添加的元素数量刚好等于每次扩容固定增加的容量 + 1,就会造成一种情况,每添加一次元素就需要扩容一次,扩容的时间花费十分高昂。所以固定扩容可能会面临多次扩容的情况,时间复杂度较高;
优点:固定扩容方式空间利用率比较高。
2、加倍扩容
机制:每次扩容的时候原 capacity 翻倍,比如初始capcity = 100, 扩容一次变为 200, 再扩容变为 400;
优点:一次扩容 capacity 翻倍的方式使得正常情况下添加元素需要扩容的次数大大减少(预留空间较多),时间复杂度较低;
缺点:因为每次扩容空间翻倍,而很多空间没有利用上,空间利用率不如固定扩容。
在实际应用中,⼀般采用空间换时间的策略。
简单来说, 空间分配的多,平摊时间复杂度低,但浪费空间也多。
加倍扩容的问题在于,每次扩展的新尺寸必然刚好大于之前分配的总和,也就是说,之前分配的内存空间不可能被使用。这样对内存不友好,最好把增长因子设为(1, 2),也就是1-2之间的某个数值。
对比可以发现采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。
3、resize()和reserve()
resize():改变当前容器内含有元素的数量(size()),而不是容器的容量
当resize(len)中len>v.capacity(),则数组中的size和capacity均设置为len;
当resize(len)中len<=v.capacity(),则数组中的size设置为len,而capacity不变;
reserve():改变当前容器的最大容量(capacity)
如果reserve(len)的值 > 当前的capacity(),那么会重新分配一块能存len个对象的空间,然后把之前的对象通过copy construtor复制过来,销毁之前的内存;
当reserve(len)中len<=当前的capacity(),则数组中的capacity不变,size不变,即不对容器做任何改变。
3. vector迭代器:
由于vector维护的是一个线性区间,所以普通指针具备作为vector迭代器的所有条件,就不需要重载operator+,operator*之类的东西
4. vector的数据结构:线性空间。为了降低配置空间的成本,我们必须让其容量大于其大小。
list容器
- 容器介绍
每个元素都是放在一块内存中,他的内存空间可以是不连续的,通过指针来进行数据的访问
在哪里添加删除元素性能都很高,不需要移动内存,当然也不需要对每个元素都进行构造与析构了,所以常用来做随机插入和删除操作容器
list属于双向链表,其结点与list本身是分开设计的
每个节点包含两个指针,分别指向前⼀个节点和后⼀个节点, 以及存储实际数据的部分
std::list 维护⼀个头指针和⼀个尾指针,它们分别指向链表的第⼀个和最后⼀个节点。在插⼊和删除操作时,只需调整相邻节点的指针,不需要移动整个数据块。
template <class T>
struct Node {
T data;
Node* prev;
Node* next;
};
list是一个环状的双向链表,同时它也满足STL对于“前闭后开”的原则,即在链表尾端可以加上空白节点
vector和list的区别
- 实现上
vector 使用动态数组实现。连续的内存块,支持随机访问。
list 使用双向链表实现。不连续的内存块,不支持随机访问。 - 访问上
vector支持通过索引进行快速随机访问。使用迭代器进行访问时,效率较⾼。
List不支持通过索引进行快速随机访问。迭代器在访问时需要遍历链表,效率相对较低。 - 插入和删除操作
vector:在尾部进行插入/删除操作是常数时间复杂度。在中间或头部进行插入/删除可能涉及大量元素的移动,时间复杂度为线性。
List: 在任意位置进行插入/删除操作都是常数时间复杂度,因为只需调整相邻节点的指针。
vector在中间节点进行插入删除会导致内存拷贝,list不会 - 内存管理
Vector使用动态数组,需要在预估元素数量时分配一块较大的内存空间。
list由于采用链表结构,动态分配的内存比较灵活。每个元素都有自己的内存块,避免了预分配的问题。
vector一次性分配好内存,不够时才进行翻倍扩容;list每次插⼊新节点都会进行内存申请 - 适用场景
Vector适用于需要频繁随机访问、在尾部进行插入/删除操作的场景。
list适用于需要频繁在中间或头部进行插入/删除操作、不要求随机访问的场景。
vector随机访问性能好,插入删除性能差;list随机访问性能差,插入删除性能好
deque(双端数组)
支持快速随机访问,由于deque需要处理内部跳转,因此速度上没有vector快。
- deque概述:
deque是一个双端开口的连续线性空间,其内部为分段连续的空间组成,随时可以增加一段新的空间并链接
注意:
由于deque的迭代器比vector要复杂,这影响了各个运算层面,所以除非必要尽量使用vector;为了提高效率,在对deque进行排序操作的时候,我们可以先把deque复制到vector中再进行排序最后再复制回deque - deque中控器:
deque是由一段一段的定量连续空间构成。一旦有必要在其头端或者尾端增加新的空间,便配置一段定量连续空间,串接在整个deque的头端或者尾端
好处:避免“vector的重新配置,复制,释放”的轮回,维护连整体连续的假象,并提供随机访问的接口;
坏处:其迭代器变得很复杂
deque采用一块map作为主控,其中的每个元素都是指针,指向另一片连续线性空间,称之为缓存区,这个区才是用来储存数据的。 - deque数据结构:
deque除了维护一个map指针以外,还维护了start与finish迭代器分别指向第一缓冲区的第一个元素,和最后一个缓冲区的最后一个元素的下一个元素,同时它还必须记住当前map的大小。 - vector和deque
- | deque | vector |
---|---|---|
组织方式 | 按页或块来分配存储器的,每页包含固定数目的元素 | 分配一段连续的内存来存储内容 |
效率 | 即使在容器的前端也可以提供常数时间的insert和erase操作,而且在体积增长方面也比vector更具有效率 | 只是在序列的尾端插入元素时才有效率,但是随机访问速度要比deque快 |
stack && queue
栈与队列被称之为duque的配接器,其底层是以deque为底部架构。通过deque执行具体操作
heap && priority_queue
heap(堆):
建立在完全二叉树上,分为两种,大根堆,小根堆,其在STL中做priority_queue的助手,即,以任何顺序将元素推入容器中,然后取出时一定是从优先权最高的元素开始取,完全二叉树具有这样的性质,适合做priority_queue的底层
priority_queue:
优先队列,也是配接器。其内的元素不是按照被推入的顺序排列,而是自动取元素的权值排列,缺省情况下利用一个max-heap完成,或者是以vector表现的完全二叉树。
map
-
map&&set
map内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此map内部所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来。
共同点: 都是C++的关联容器,只是通过它提供的接口对里面的元素进行访问,底层都是采用红黑树实现。
不同点:
set:用来判断某一个元素是不是在一个组里面。
map:映射,相当于字典,把一个值映射成另一个值,可以创建字典。
优点: 查找某一个数的时间为O(logn);遍历时采用iterator,效果不错。
缺点: 每次插入值的时候,都需要调整红黑树,效率有一定影响。 -
map && unordered_map的区别
这两者中元素是一些key-value对,关键字起索引作用,值表示和索引相关的数据。
底层实现:
map底层是基于红黑树实现的,因此map内部元素排列是有序的。
而unordered_map底层则是基于哈希表实现的,因此其元素的排列顺序是杂乱无序的。
map:
优点:有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作。 map的查找、删除、增加等一系列操作时间复杂度稳定,都为O(logn )。
缺点:查找、删除、增加等操作平均时间复杂度较慢,与n相关。
unordered_map:
优点:查找、删除、添加的速度快,时间复杂度为常数级O(1)。
缺点:因为unordered_map内部基于哈希表,以(key,value)对的形式存储,因此空间占用率高。 unordered_map的查找、删除、添加的时间复杂度不稳定,平均为O(1),取决于哈希函数。极端情况下可能为O(n)。 -
问题:
为什么insert之后,以前保存的iterator不会失效:
因为 map 和 set 存储的是结点,不需要内存拷贝和内存移动。但是像 vector 在插入数据时如果内存不够会重新开辟一块内存。map 和 set 的 iterator 指向的是节点的指针,vector 指向的是内存的某个位置
为何map和set的插⼊删除效率比其他序列容器高:
因为 map 和 set 底部使用红黑树实现,插入和删除的时间复杂度是 O(logn),而 vector 这样的序列容器插入和删除的时间复杂度是 O(N)
push_back 和 emplace_back 的区别
- push_back 用于在容器的尾部添加一个元素。
container.push_back(value);
container 是一个支持 push_back 操作的容器,例如 std::vector 、 std::list 等,而 value 是要添加的元素的值。 - emplace_back 用于在容器的尾部直接构造一个元素。
container.emplace_back(args);
其中 container 是一个支持 emplace_back 操作的容器,而 args 是传递给元素类型的构造函数的参数。与
push_back 不同的是, emplace_back 不需要创建临时对象,⽽是直接在容器中构造新的元素。
区别:
push_back 接受一个已存在的对象或一个可转换为容器元素类型的对象,并将其复制或移动到容器中。 emplace_back 直接在容器中构造元素,不需要创建临时对象。
emplace_back 通常比 push_back 更⾼效,因为它避免了创建和销毁临时对象的开销。
emplace_back 的参数是传递给元素类型的构造函数的参数,而 push_back 直接接受一个元素。
3. 迭代器有什么作用?什么时候迭代器会失效
迭代器为不同类型的容器提供了统一的访问接口, 隐藏了底层容器的具体实现细节, 允许开发者使用一致的语法来操作不同类型的容器。
对于序列容器vector,deque来说,使用erase后,后边的每个元素的迭代器都会失效,后边每个元素都往前移动一位,erase返回下一个有效的迭代器。
对于关联容器map,set来说,使用了erase后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素,不会影响下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可。
对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的迭代器,因此上面两种方法都可以使用。
4.你知道const char* 与string之间的关系是什么吗?
- string 是c++标准库里面其中一个,封装了对字符串的操作,实际操作过程我们可以⽤const char*给string类初始化
- 三者的转化关系如下所示:
a) string转const char*
string s = “abc”;
const char* c_s = s.c_str();
b) const char* 转string,直接赋值即可
const char* c_s = “abc”;
string s(c_s);
c) string 转char*
string s = “abc”;
char* c;
const int len = s.length();
c = new char[len+1];
strcpy(c,s.c_str());
d) char* 转string
char* c = “abc”;
string s(c);
e) const char* 转char*
const char* cpc = “abc”;
char* pc = new char[strlen(cpc)+1];
strcpy(pc,cpc);
f) char* 转const char*,直接赋值即可
char* pc = “abc”;
const char* cpc = pc;
5.简单说一下traits技法
traits技法利用“内嵌型别“的编程技巧与编译器的template参数推导功能,增强C++未能提供的关于型别认证方面的能力。常用的有iterator_traits和type_traits。
- iterator_traits
被称为特性萃取机,能够方便的让外界获取以下5种型别:- value_type:迭代器所指对象的型别
- difference_type:两个迭代器之间的距离
- pointer:迭代器所指向的型别
- reference:迭代器所引用的型别
- iterator_category:三两句说不清楚,建议看书
- type_traits
关注的是型别的特性,例如这个型别是否具备non-trivial defalt ctor(默认构造函数)、non-trivial copy ctor(拷贝构造函数)、non-trivial assignment operator(赋值运算符) 和non-trivial dtor(析构函数),如果答案是否定的,可以采取直接操作内存的方式提高效率。
6.解释一下什么是trivial destructor
“trivial destructor”一般是指用户没有自定义析构函数,而由系统生成的,这种析构函数在《STL源码解析》中称为“无关痛痒”的析构函数。
反之,用户自定义了析构函数,则称之为“non-trivial destructor”,这种析构函数如果申请了新的空间一定要显式的释放,否则会造成内存泄露
对于trivial destructor,如果每次都进行调用,显然对效率是一种伤害,如何进行判断呢?
《STL源码解析》中给出的说明是:
⾸先利⽤value_type()获取所指对象的型别,再利用__type_traits判断该型别的析构函数是否trivial,若是(__true_type),则什么也不做,若为(__false_type),则去调用destory()函数
也就是说,在实际的应用当中,STL库提供了相关的判断方法__type_traits,感兴趣的读者可以自行查阅使用方式。除了trivial destructor,还有trivial construct、trivial copy construct等,如果能够对是否trivial进行区分,可以采用内存处理函数memcpy()、malloc()等更加高效的完成相关操作,提升效率。
7.RAII是怎么回事?
- RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。
因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。 - 智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。
8.STL的两级空间配置器
为什么需要二级空间配置器?
动态开辟内存时,要在堆上申请,但若是我们需要频繁的在堆开辟释放内存,则就会在堆上造成很多外部碎片,浪费了内存空间;
每次都要进行调用malloc、free函数等操作,使空间就会增加一些附加信息,降低了空间利用率;
随着外部碎片增多,内存分配器在找不到合适内存情况下需要合并空闲块,浪费了时间,大大降低了效率。
于是就设置了二级空间配置器,当开辟内存<=128bytes时,即视为开辟小块内存,则调用二级空间配置器。
关于STL中一级空间配置器和二级空间配置器的选择上,一般默认选择的为二级空间配置器。 如果大于128字节再转去一级配置器器。
一级配置器
一级空间配置器中重要的函数就是allocate、deallocate、reallocate 。 一级空间配置器是以malloc(),free(),realloc()等C函数执行实际的内存配置 。大致过程是:
1、直接allocate分配内存,其实就是malloc来分配内存,成功则直接返回,失败就调用处理函数
2、如果用户自定义了内存分配失败的处理函数就调用,没有的话就返回异常
3、如果自定义了处理函数就进行处理,完事再继续分配试试
二级配置器
- 维护16条链表,分别是0-15号链表,最小8字节,以8字节逐渐递增,最大128字节,你传入一个字节参数,表示你需要多大的内存,会自动帮你校对到第几号链表(如需要13bytes空间,我们会给它分配16bytes大小),在找到第n个链表后查看链表是否为空,如果不为空直接从对应的free_list中拔出,将已经拨出的指针向后移动一位。
- 对应的free_list为空,先看其内存池是不是空时,如果内存池不为空:
(1)先检验它剩余空间是否够20个节点大小(即所需内存大小(提升后) * 20),若足够则直接从内存池中拿出20个节点大小空间,将其中⼀个分配给用户使用,另外19个当作自由链表中的区块挂在相应的free_list下,这样下次再有相同大小的内存需求时,可直接拨出。
(2)如果不够20个节点大小,则看它是否能满足1个节点大小,如果够的话则直接拿出一个分配给用户,然后从剩余的空间中分配尽可能多的节点挂在相应的free_list中。
(3)如果连一个节点内存都不能满足的话,则将内存池中剩余的空间挂在相应的free_list中(找到相应的free_list),然后再给内存池申请内存,转到3。 - 内存池为空,申请内存
此时二级空间配置器会使用malloc()从heap上申请内存,(一次所申请的内存大小为2 * 所需节点内存大小(提升后)*\ 20 + 一段额外空间),申请40块,一半拿来用,一半放内存池中。 - malloc没有成功
在3情况下,如果malloc()失败了,说明heap上没有足够空间分配给我们了,这时,二级空间配置器会从比所需节点空间大的free_list中一一搜索,从比它所需节点空间大的free_list中拔除一个节点来使用。如果这也没找到,说明比其大的free_list中都没有自由区块了,那就要调用一级适配器了。
释放时调用deallocate()函数,若释放的n>128,则调用一级空间配置器,否则就直接将内存块挂上自由链表的合适位置。
STL二级空间配置器虽然解决了外部碎片与提高了效率,但它同时增加了一些缺点:
- 因为自由链表的管理问题,它会把我们需求的内存块自动提升为8的倍数,这时若你需要1个字节,它会给你8个字节,即浪费了7个字节,所以它又引入了内部碎片的问题,若相似情况出现很多次,就会造成很多内部碎片;
- 当不断的开辟小块内存,最后整个堆上的空间都被挂在自由链表上,若想开辟大块内存就会失败;
- 若自由链表上挂很多内存块没有被使用,当前进程又占着内存不释放,这时别的进程在堆上申请不到空间,也不可以使用当前进程的空闲内存。
总结:STL中的allocator、deallocator
- 第一级配置器直接使用malloc()、free()和relloc(),第二级配置器视情况采用不同的策略:当配置区块超过128bytes时,视之为足够大,便调用第一级配置器;当配置器区块小于128bytes时,为了降低额外负担,使用复杂的内存池整理方式,而不再用一级配置器;
- 第二级配置器主动将任何小额区块的内存需求量上调至8的倍数,并维护16个free-list,各自管理大小为8~128bytes的小额区块;
- 空间配置函数allocate(),首先判断区块大小,大于128就直接调用第一级配置器,小于128时就检查对应的free-list。如果free-list之内有可一区块,就直接拿来用,如果没有可用区块,就将区块大小调整⾄8的倍数,然后调用refill(),为free-list重新分配空间;
- 空间释放函数deallocate(),该函数首先判断区块大小,大于128bytes时,直接调用一级配置器,小于128bytes就找到对应的free-list然后释放内存。
9.hashtable中解决冲突有哪些方法?
记住前三个:
- 线性探测
使用hash函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位 - 开链
每个表格维护一个list,如果hash函数计算出的格子相同,则按顺序存在这个list中 - 再散列
发生冲突时使用另一种hash函数再计算一个地址,直到不冲突 - 二次探测
使用hash函数计算出的位置如果已经有元素占用了,按照 1 2 1^2 12、 2 2 2^2 22、 3 2 3^2 32…的步长依次寻找,如果步长是随机数序列,则称之为伪随机探测 - 公共溢出区
一旦hash函数计算的结果相同,就放入公共溢出区
五、C++ 泛型编程
1. C++模板全特化和偏特化
模板分为类模板与函数模板,特化分为特例化(全特化)和部分特例化(偏特化)。
对模板特例化是因为对特定类型,可以利用某些特定知识来提高效率,而不是使用通用模板。
对函数模板:
- 模板和特例化版本应该声明在同一头文件,所有同名模板的声明应放在前面,接着是特例化版本。
- 一个模板被称为全特化的条件:1.必须有一个主模板类 2.模板类型被全部明确化。
模板函数:
template<typename T1, typename T2>
void fun(T1 a, T2 b)
{
cout<<"模板函数"<<endl;
}
template<>
void fun<int , char >(int a, char b)
{
cout<<"全特化"<<endl;
}
函数模板,只有全特化,偏特化的功能可以通过函数的重载完成。
对类模板:
template<typename T1, typename T2>
class Test
{
public:
Test(T1 i,T2 j):a(i),b(j){cout<<"模板类"<<endl;}
private:
T1 a;
T2 b;
};
template<>
class Test<int , char>
{
public:
Test(int i, char j):a(i),b(j){cout<<"全特化"<<endl;}
private:
int a;
char b;
};
template <typename T2>
class Test<char, T2>
{
public:
Test(char i, T2 j):a(i),b(j){cout<<"偏特化"<<endl;}
private:
char a;
T2 b;
}
对主版本模板类、全特化类、偏特化类的调用优先级从高到低进行排序是:全特化类>偏特化类>主版本模板类。
2. 模板是什么,底层怎么实现的?
- 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
- 这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
六、C++ 新特性
1. C++11的新特性有哪些
- 语法的改进
(1)统一的初始化方法
(2)成员变量默认初始化
(3)auto关键字: 允许编译器自动推断变量的类型,减少类型声明的冗余。
(4)decltype 求表达式的类型
(5)智能指针 std::shared_ptr 和 std::unique_ptr
(6)空指针 nullptr: 提供了明确表示空指针的关键字,替代了传统的 NULL 。
(7)基于范围的for循环: 简化遍历容器元素的语法
(8)右值引用和move语义 引入右值引用和移动构造函数,允许高效地将资源从一个对象移动到另一个对象,提高性能。 - 标准库扩充(往STL里新加进一些模板类)
(9)无序容器(哈希表) 用法和功能同map一模一样,区别在于哈希表的效率更⾼
(10)正则表达式 可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串
(11)Lambda表达式: 允许在代码中定义匿名函数
2. 智能指针
- shared_ptr
shared_ptr的实现机制是在拷贝构造时使用同一份引用计数
(1)一个模板指针T* ptr:指向实际的对象
(2)一个引用次数:必须new出来的,不然会多个shared_ptr里面会有不同的引用次数而导致多次delete,不new的话也做不到多线程访问 参考
(3)重载operator*和operator->:使得能像指针一样使用shared_ptr
(4)重载copy constructor:使其引用次数加一(拷贝构造函数)
(5)重载operator=(赋值运算符):如果原来的shared_ptr已经有对象,则让其引用次数减一并判断引用是否为零(是否调用delete),然后将新的对象引用次数加一
(6)重载析构函数:使引用次数减一并判断引用是否为零; (是否调用delete)
线程安全问题
(1)同一个shared_ptr被多个线程“读”是安全的;
(2)同一个shared_ptr被多个线程“写”是不安全的;
证明:在多个线程中同时对一个shared_ptr循环执行两遍swap。 shared_ptr的swap函数的作用就是和另外一个shared_ptr交换引用对象和引用计数,是写操作。执行两遍swap之后, shared_ptr引用的对象的值应该不变)
(3)共享引用计数的不同的shared_ptr被多个线程”写“ 是安全的。 - unique_ptr
unique_ptr”唯一”拥有其所指对象:同一时刻只能有一个unique_ptr指向给定对象,离开作用域时,若其指向对象,则将其所指对象销毁(默认delete)。
定义unique_ptr时需要将其绑定到一个new返回的指针上。
unique_ptr不支持普通的拷贝和赋值(因为拥有指向的对象):但是可以拷贝和赋值一个将要被销毁的unique_ptr;可以通过release或者reset将指针所有权从一个(非const)unique_ptr转移到另一个unique。 - weak_ptr
weak_ptr是为了配合shared_ptr而引入的一种智能指针:它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况,但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。
和shared_ptr指向相同内存:shared_ptr析构之后内存释放,在使用之前使用函数lock()检查weak_ptr是否为空指针。
用途:比如说一个链表节点用了智能指针指向前后节点,作用域结束后不会销毁,因为两个节点互相有智能指针指向对面。需要用weakptr,不增加计数,过了作用域自动销毁。
3. 类型推导
- auto:
auto可以让编译器在编译期就推导出变量的类型
(1)auto的使用必须马上初始化,否则无法推导出类型
(2)auto在一行定义多个变量时,各个变量的推导不能产生二义性,否则编译失败
(3)auto不能用作函数参数
(4)在类中auto不能用作非静态成员变量
(5)auto不能定义数组,可以定义指针
(6)auto无法推导出模板参数
(7)在不声明为引用或指针时,auto会忽略等号右边的引用类型和cv(const和volatile)限定 - decltype:
decltype则用于推导表达式类型,这里只用于编译器分析表达式的类型,表达式实际不会进行运算
decltype不会像auto一样忽略引用和cv属性,decltype会保留表达式的引用和cv属性
对于decltype(exp)有:
(1)exp是表达式,decltype(exp)和exp类型相同
(2)exp是函数调用,decltype(exp)和函数返回值类型相同
(3)其它情况,若exp是左值,decltype(exp)是exp类型的左值引用
int func() {return 0};
//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用函数
func()
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int
//不论是顶层const还是底层const, decltype都会保留
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const
//引⽤与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&
//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型
//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起
//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起
- auto和decltype的配合使⽤:
template<typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
4. 右值引用
参考-右值引用、移动语义和完美转发
左值右值:
左值: 可以放在等号左边,可以取地址并有名字
右值: 不可以放在等号左边,不能取地址,没有名字
字符串字面值"abcd"也是左值,不是右值
++i、–i是左值,i++、i–是右值
- 将亡值
将亡值是指C++11新增的和右值引用相关的表达式
将亡值可以理解为即将要销毁的值,通过“盗取”其它变量内存空间方式获取的值,在确保其它变量不再被使用或者即将被销毁时,可以避免内存空间的释放和分配,延长变量值的生命周期,常用来完成移动构造或者移动赋值的特殊任务 - 左值引用
左值引用就是对左值进行引用的类型,是对象的一个别名
并不拥有所绑定对象的堆存,所以必须立即初始化。 对于左值引用,等号右边的值必须可以取地址,如果不能取地址,则会编译失败,或者可以使用const引用形式 - 右值引用
右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置
右值引用的特点:
特点1:通过右值引用的声明,右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样长,只要该变量还活着,该右值临时量将会⼀直存活下去
特点2:右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值
特点3:T&& t在发生自动类型推断的时候,它是左值还是右值取决于它的初始化。
#include <bits/stdc++.h>
using namespace std;
template<typename T>
void fun(T&& t)
{
cout << t << endl;
}
int getInt()
{
return 5;
}
int main() {
int a = 10;
int& b = a; //b是左值引⽤
int& c = 10; //错误,c是左值不能使⽤右值初始化
int&& d = 10; //正确,右值引⽤⽤右值初始化
int&& e = a; //错误,e是右值引⽤不能使⽤左值初始化
const int& f = a; //正确,左值常引⽤相当于是万能型,可以⽤左值或者右值初始化
const int& g = 10;//正确,左值常引⽤相当于是万能型,可以⽤左值或者右值初始化
const int&& h = 10; //正确,右值常引⽤
const int& aa = h;//正确
int& i = getInt(); //错误,i是左值引⽤不能使⽤临时变ᰁ(右值)初始化
int&& j = getInt(); //正确,函数返回值是右值
fun(10); //此时fun函数的参数t是右值
fun(a); //此时fun函数的参数t是左值
return 0;
}
- 移动语义
可以理解为转移所有权,对于移动语义,类似于转让或者资源窃取的意思,对于那块资源,转为自己所拥有,别人不再拥有也不会再使用。
通过移动构造函数使用移动语义,也就是std::move;移动语义仅针对于那些实现了移动构造函数的类的对象,对于那种基本类型int、float等没有任何优化作用,还是会拷贝,因为它们实现没有对应的移动构造函数 - 完美转发
写⼀个接受任意实参的函数模板,并转发到其它函数,目标函数会收到与转发函数完全相同的实参,通过std::forward()实现
5. nullptr
nullptr是用来代替NULL,一般C++会把NULL、0视为同一种东西,这取决于编译器如何定义NULL,有的定义为((void*)0),有的定义为0
C++不允许直接将void* 隐式转换到其他类型,在进行C++重载时会发生混乱
例如:
void foo(char *);
void foo(int );
如果NULL被定义为 ((void*)0),那么当编译char *ch = NULL时,NULL被定义为 0
当foo(NULL)时,此时NULL为0,会去调用foo(int ),从而发生混乱
为解决这个问题,从而需要使用NULL时,用nullptr代替:
C++11引入nullptr关键字来区分空指针和0。nullptr 的类型为 nullptr_t,能够转换为任何指针或成员指针的类型,也可以进行相等或不等的比较。
6. 范围for循环
基于范围的迭代写法,for(变量:对象)表达式
例如对vector中的元素进行遍历:
std::vector<int> arr(5, 100);
for (std::vector<int>::iterator i = arr.begin(); i != arr.end(); i ++) {
std::cout << *i << std::endl;
}
// 范围for循环
for (auto &i : arr) {
std::cout << i << std::endl;
}
7. 列表初始化
C++定义了几种初始化方式,例如对一个int变量 x初始化为0:
int x = 0; // method1
int x = {0}; // method2
int x{0}; // method3
int x(0); // method4
采⽤花括号来进行初始化称为列表初始化,无论是初始化对象还是为对象赋新值。
⽤于对内置类型变量时,如果使用列表初始化,且初始值存在丢失信息风险时,编译器会报错。
long double d = 3.1415926536;
int a = {d}; //存在丢失信息⻛险,转换未执⾏。
int a = d; //确实丢失信息,转换执⾏。
8. lambda表达式
lambda表达式表示一个可调用的代码单元,没有命名的内联函数,不需要函数名因为我们直接(一次性的)用它,不需要其他地方调用它。
每当你定义⼀个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包。
- 语法:
[capture list] (parameter list) -> return type {function body }
// [捕获列表] (参数列表) -> 返回类型 {函数体 }
// 只有 [capture list] 捕获列表和 {function body } 函数体是必选的
auto lam =[]() -> int { cout << "Hello, World!"; return 88; };
auto ret = lam();
cout<<ret<<endl; // 输出88
-> int :代表此匿名函数返回int,大多数情况下lambda表达式的返回值可由编译器猜测得出,因此不需要我们指定返回值类型。
- 特点:
变量捕获才是成就lambda卓越的秘方
(1) [] 不捕获任何变量,这种情况下lambda表达式内部不能访问外部的变量
(2) [&] 以引用方式捕获所有变量(保证lambda执行时变量存在)
(3)[=] 用值的方式捕获所有变量(创建时拷贝,修改对lambda内对象无影响)
(4)[=, &foo] 以引用捕获变量foo, 但其余变量都靠值捕获
(5)[&, foo] 以值捕获foo, 但其余变量都靠引用捕获
(6)[bar] 以值方式捕获bar; 不捕获其它变量
(7)[this] 捕获所在类的this指针
int a = 1, b = 2, c = 3;
auto lam2 = [&, a](){ //b,c以引⽤捕获, a以值捕获
b = 5;
c = 6; //a = 1,a不能赋值
cout << a << b << c << endl; //输出 1 5 6
};
lam2();
void fcn() { //值捕获
size_t v1 = 42;
auto f = [v1] {return v1;};
v1 = 0;
auto j = f(); //j = 42,创建时拷⻉,修改对lambda内对象⽆影响
}
void fcn() { //可变lambda
size_t v1 = 42;
auto f = [v1] () mutable {return ++v1;}; //修改值捕获可加mutable
v1 = 0;
auto j = f(); //j = 43
}
void fcn() { //引⽤捕获
size_t v1 = 42; //⾮const
auto f = [&v1] () {return ++v1;};
v1 = 0;
auto j = f(); //注意此时 j = 1
}
lambda最大的一个优势是在使用STL中的算法(algorithms)库
例如:数组排序
int arr[] = {6, 4, 3, 2, 1, 5};
bool compare(int& a, int& b) { //谓词函数
return a > b;
}
std::sort(arr, arr + 6, compare);
//lambda形式
std::sort(arr, arr + 6, [](const int& a, const int& b){return a > b;}); //降序
std::for_each(begin(arr), end(arr), [](const int& e){cout << "After:" << e << endl;});
//6, 5, 4, 3, 2, 1
9. 并发
- std::thread
default (1) | thread () noexcept; |
---|---|
initialization (2) | template <class Fn, class… Args> explicit thread (Fn&& fn, Args&&… args); |
copy [deleted] (3) | thread (const thread&) = delete; |
move (4) | thread (thread&& x) noexcept; |
1、默认构造函数,创建一个空的 thread 执行对象。
2、初始化构造函数,创建一个 thread对象,该 thread对象可被 joinable,新产生的线程会调用 fn 函数,该函数的参数由 args 给出。
3、拷贝构造函数(被禁用),意味着 thread 不可被拷贝构造。
4、move 构造函数,调用成功之后 x 不代表任何 thread 执行对象。
注意:
可被 joinable 的 thread 对象必须在他们销毁之前被主线程 join 或者将其设置为 detached.
std::thread在使用上容易出错,即std::thread对象在线程函数运行期间必须是有效的。什么意思呢?
#include <iostream>
#include <thread>
void threadproc() {
while(true) {
std::cout << "I am New Thread!" << std::endl;
}
}
void func() {
std::thread t(threadproc);
}
int main() {
func();
while(true) {} //让主线程不要退出
return 0;
}
以上代码再main函数中调用了func函数,在func函数中创建了一个线程,乍一看好像没有什么问题,但在实际运行时会崩溃。
崩溃的原因:
在func函数调用结束后,func中局部变量t(线程对象)被销毁,而此时线程函数仍在运行。所以在使用std::thread类时,必须保证线程函数运行期间其线程对象有效。
std::thread对象提供了一个detach方法,通过这个方法可以让线程对象与线程函数脱离关系,这样即使线程对象被销毁,也不影响线程函数的运行。
只需要在func函数中调用detach方法即可,代码如下:
// 其他代码保持不变
void func() {
std::thread t(threadproc);
t.detach();
}
- lock_guard
lock_guard是一个互斥量包装程序,它提供了一种方便的RAII(Resource acquisition is initialization )风格的机制来在作用域块的持续时间内拥有一个互斥量。
创建lockguard对象时,它将尝试获取提供给它的互斥锁的所有权。当控制流离开lockguard对象的作用域时,lock_guard析构并释放互斥量。
特点如下:
(1)创建即加锁,作用域结束自动析构并解锁,无需手动解锁
(2)不能中途解锁,必须等作用域结束才解锁
(3)不能复制 - unique_lock
unique_lock是一个通用的互斥量锁定包装器,它允许延迟锁定,限时深度锁定,递归锁定,锁定所有权的转移以及与条件变量一起使用。
简单地讲,uniquelock 是 lockguard 的升级加强版,它具有 lock_guard 的所有功能,同时又具有其他很多方法,使用起来更强灵活方便,能够应对更复杂的锁定需要。
特点如下:
(1)创建时可以不锁定(通过指定第二个参数为std::defer_lock),而在需要时再锁定
(2)可以随时加锁解锁
(3)作用域规则同 lock_grard,析构时自动释放锁
(4)不可复制,可移动
(5)条件变量需要该类型的锁作为参数(此时必须使用unique_lock)
10.介绍⼀下几种典型的锁
- 读写锁
多个读者可以同时进行读
写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者) - 互斥锁
一次只能一个线程拥有互斥锁,其他线程只有等待
互斥锁是在抢锁失败的情况下主动放弃CPU进⼊睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁 - 条件变量
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。 - 自旋锁
如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。