C++98

1. 在main执行的前后需要处理什么工作?

Main函数之前

(1) 设置栈指针

(2) 全局对象初始化,在main之前调用构造函数

(3) 将main函数的参数argc,argv等传递给main函数

Main函数之后

全局对象的析构函数

详细版本:

main函数执行之前:

  • 配置堆栈
  • 初始化静态和全局变量
  • 为未初始化部分的全局变量赋值
  • 运行全局构造器
  • 将main函数的参数(argv,argc等)传递给main函数

main函数执行之后

  • 全局对象的析构函数会在main函数之后执行;
  • 可以用 atexit 注册一个函数,它会在main 之后执行;
    • atexit() 函数是C/C++标准库中的一个函数,用于注册一个函数,在程序退出时自动执行。
  • _ attribute_((destructor))
  • _ _ attribute _ _((destructor)) 是 C/C++ 编译器属性,用于定义一个对象的析构函数。
    在 C++ 中,当使用动态内存分配(如 new 或 malloc)创建对象时,需要在程序退出时手动释放这些对象所占用的内存。如果不手动释放内存,就会出现内存泄漏的问题。为了避免这种情况,可以使用 _ _attribute _ _((destructor)) 来定义一个对象的销毁函数。

补充:int main(int argc,char*argv[])

  • argc:表示命令行参数的数量,是一个整型变量。

    ./myprogram arg1 arg2 arg3

  • 那么 argc 的值为4,argv 数组中的元素分别为:

    1
    2
    3
    4
    argv[0] = "./myprogram"
    argv[1] = "arg1"
    argv[2] = "arg2"
    argv[3] = "arg3"
    • argv:是一个字符指针数组,其中每个元素都是一个字符串,表示命令行参数的值。

2.什么是内存对齐?

学习自:C/C++内存对齐详解

还是得看例子:【从例子解释概念】

还是用一个例子带出这个问题,看下面的小程序,理论上,32位系统下,int占4byte,char占一个byte,那么将它们放到一个结构体中应该占4+1=5byte;但是实际上,通过运行程序得到的结果是8 byte,这就是内存对齐所导致的。

1
2
3
4
5
6
7
8
9
10
11
12
//32位系统
#include<stdio.h>
struct{
int x;
char y;
}s;

int main()
{
printf("%d\n",sizeof(s); // 输出8
return 0;
}

现代计算机中内存空间都是按照 byte 划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但是实际的计算机系统对基本类型数据在内存中存放的位置有限制,它们会要求这些数据的首地址的值是某个数k(通常它为4或8)的倍数,这就是所谓的内存对齐。

精简:

(1) 在计算机中,内存是按字节划分的,而CPU在读取数据时,并不是一个字节一个字节的读取,实际上是按块的大小读取,块大小可以是2,4,8,16等等,称为内存访问粒度。

(2) 内存对齐则是将特定的数据类型按照一定的规则摆放在内存上,具体规则是按照变量的声明顺序,依次安排内存,其偏移量为变量大小的整数倍。

补充说法:

(1)硬件平台限制

● 内存以字节为单位,但不同硬件平台不一定支持任何内存地址的存取。例如,某些硬件平台可能以双字节、4字节等为单位存取内存。因此,为了保证处理器正确读取数据,需要进行内存对齐。

(2)提高cpu内存访问速度

● 处理器访问内存时,对齐的数据可以在较少的内存访问周期内被读取或写入。

● 如果数据没有对齐,处理器可能需要进行额外的处理来组合不同内存地址中的数据片段,这会增加处理器的工作负担,降低效率。

image-20240625182822826

3. 为什么要做内存对齐?

这个的话展开说就是:

尽管内存是以字节为单位,但是大部分处理器并不是按字节块来存取内存的.它一般会以双字节,四字节,8字节,16字节甚至32字节为单位来存取内存,我们将上述这些存取单位称为内存存取粒度.

现在考虑4字节存取粒度的处理器取int类型变量(32位系统),该处理器只能从地址为4的倍数的内存开始读取数据。

假如没有内存对齐机制,数据可以任意存放,现在一个int变量存放在从地址1开始的联系四个字节地址中,该处理器去取数据时,要先从0地址开始读取第一个4字节块,剔除不想要的字节(0地址),然后从地址4开始读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下的两块数据合并放入寄存器.这需要做很多工作.

image-20240513204345153

现在有了内存对齐的,int类型数据只能存放在按照对齐规则的内存中,比如说0地址开始的内存。那么现在该处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,提高了效率。

image-20240513204405396

精简:

(1) **平台原因(移植原因):**不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。

(2) **性能原因:**数据结构(尤其是栈)应该尽可能地在自然边界上对齐。原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。

4. 说说内存对齐规则?

每个特定平台上的编译器都有自己的默认“对齐系数”(也叫对齐模数)。gcc中默认#pragma pack(4),可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。

有效对其值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

了解了上面的概念后,我们现在可以来看看内存对齐需要遵循的规则:

(1) 结构体第一个成员的偏移量(offset)为0,以后每个成员相对于结构体首地址的 offset 都是该成员大小与有效对齐值中较小那个的整数倍,如有需要编译器会在成员之间加上填充字节。

(3) 结构体的总大小为 有效对齐值 的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

下面给出几个例子以便于理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//32位系统
#include<stdio.h>
struct
{
int i;
char c1;
char c2;
}x1;

struct{
char c1;
int i;
char c2;
}x2;

struct{
char c1;
char c2;
int i;
}x3;

int main()
{
printf("%d\n",sizeof(x1)); // 输出8
printf("%d\n",sizeof(x2)); // 输出12
printf("%d\n",sizeof(x3)); // 输出8
return 0;
}

以上测试都是在Linux环境下进行的,linux下默认#pragma pack(4),且结构体中最长的数据类型为4个字节,所以有效对齐单位为4字节,下面根据上面所说的规则以s2来分析其内存布局:

首先使用规则1,对成员变量进行对齐:

sizeof(c1) = 1 <= 4(有效对齐位),按照1字节对齐,占用第0单元;

sizeof(i) = 4 <= 4(有效对齐位),相对于结构体首地址的偏移要为4的倍数,占用第4,5,6,7单元;

sizeof(c2) = 1 <= 4(有效对齐位),相对于结构体首地址的偏移要为1的倍数,占用第8单元;

然后使用规则2,对结构体整体进行对齐:

s2中变量i占用内存最大占4字节,而有效对齐单位也为4字节,两者较小值就是4字节。因此整体也是按照4字节对齐。由规则1得到s2占9个字节,此处再按照规则2进行整体的4字节对齐,所以整个结构体占用12个字节。

根据上面的分析,不难得出上面例子三个结构体的内存布局如下:

image-20240513204725927

#pragma pack(n)

不同平台上编译器的 pragma pack 默认值不同。而我们可以通过预编译命令#pragma pack(n), n= 1,2,4,8,16来改变对齐系数。

例如,对于上个例子的三个结构体,如果前面加上#pragma pack(1),那么此时有效对齐值为1字节,此时根据对齐规则,不难看出成员是连续存放的,三个结构体的大小都是6字节。

image-20240513204744215

如果前面加上#pragma pack(2),有效对齐值为2字节,此时根据对齐规则,三个结构体的大小应为6,8,6。内存分布图如下:

image-20240513204752271

经过上面的实例分析,大家应该对内存对齐有了全面的认识和了解,在以后的编码中定义结构体时需要考虑成员变量定义的先后顺序了。

精简:

(1) 每个特定平台上的编译器都有自己的默认**“对齐系数”(也叫对齐模数)**。32位系统,gcc中默认#pragma pack(4),即对齐系数默认为4。可以通过预编译命令#pragma pack(n),n = 1,2,4,8,16来改变这一系数。

(2) 有效对齐值:是给定值#pragma pack(n)和结构体中最长数据类型长度中较小的那个。有效对齐值也叫对齐单位

(3) 结构体第一个成员的偏移量为0,以后每个成员相对于结构体首地址的偏移量都是该成员大小与有效对齐值中较小数的整数倍,如有需要编译器会在成员之间加上填充字节。

(4) 结构体的总大小为有效对齐值的整数倍,如有需要编译器会在最末一个成员之后加上填充字节。

5. 指针和引用的区别?

(1) 指针是一个变量,存储的是一个地址。引用跟原来的变量实质上是同一个东西,是原变量的别名

(2) 指针可以有多级,引用只有一级

(3) 指针可以为空,引用不能为NULL且在定义时必须初始化

(4) 指针在初始化后可以改变指向,而引用在初始化之后不可再改变

(5) sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小

6. 堆和栈的区别?

(1) 栈由系统自动分配,堆是自己申请和释放的。

(2) 堆向上,向高地址方向增长。栈向下,向低地址方向增长。

(3) 在空间连续性上,栈区的空间是连续的;但堆的空间是不连续的。

(4) 堆只能动态分配。栈有静态分配和动态分配,静态分配由编译器完成,动态分配由alloca函数分配,但栈的动态分配的资源由编译器进行释放,无需程序员实现。

(5) 因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。

(6) 堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。

7. new / delete 与 malloc / free的异同?

(1) 都可用于内存的动态申请和释放

(2) 前者是C++运算符,后者是C/C++语言标准库函数

(3) new自动计算要分配的空间大小,malloc需要手工计算

(4) new是类型安全的,malloc不是。

(5) new的实现过程是:首先调用名为operator new的标准库函数,分配足够大的原始类型的内存,接下来在该内存上调用构造函数初始化对象;最后返回该内存指针。

(6) delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存

(7) malloc/free可重载,new/delete不可重载,operator new/operator delete可重载

8. 被free回收的内存是立即返还给操作系统吗?

不会,new或者malloc申请内存是向内存管理器申请,内存管理器再向操作系统申请,这里面涉及到系统调用,如果频繁的申请释放,效率会很低,所以一般进程申请了内存后,释放资源后并不会立即将内存还给操作系统,而是放到一个类似于内存缓存池的地方,下次申请的时候首先会在内存缓存池中查找合适的内存,减少了大量的系统调用,提高速度。

9. 宏函数和普通函数有何区别?

(1) 宏函数作用在预编译期,进行文本替换,相当于直接插入了代码,运行时不存在函数调用,执行起来更快;普通函数调用在运行时需要跳转到具体调用函数。

(2) 宏函数属于在结构中插入代码,没有返回值;普通函数调用具有返回值。

(3) 宏函数参数没有类型,不进行类型检查;普通函数参数具有类型,需要检查类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 宏定义,没有类型检查
#define SQUARE(x) ((x) * (x))

// 函数定义,有类型检查
int square(int x) {
return x * x;
}

int main() {
double y = 1.5;
// 使用宏,虽然y的类型不是整数,但仍然可以计算
double r1 = SQUARE(y); // 结果是2.25

// 使用函数,如果尝试将y作为参数,编译器会报错,因为y的类型与函数期望的类型不匹配
int r2 = square(y); // 编译错误

return 0;
}

在这个例子中,SQUARE(x)是一个宏,它接受任何类型的参数x,并返回x的平方。因为宏没有类型检查,所以你可以传递任何类型的参数给宏,编译器不会报错。

但是,square(x)是一个函数,它期望一个整数类型的参数x。如果你尝试将一个非整数类型的值(如double类型的值)传递给这个函数,编译器会报错,因为函数参数的类型检查失败。

10. 宏定义和typedef有何区别?

(1) 宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。

(2) 宏替换发生在预编译期,属于文本插入替换;typedef是编译的一部分。

(3) 宏不检查类型;typedef会检查数据类型。

11. 宏定义和const的区别?

(1) 宏定义发生在预编译期。const是在编译、运行的时候起作用

(2) 宏定义只做替换,不做类型检查和计算。const常量有数据类型,编译器做类型检查。

(3) define只是将宏名称进行文本替换,占用代码段内存。const在程序运行中只有一份备份,占用数据段内存。

作用:用于定义只读的变量,即如果一个变量被const修饰,那么它的值将无法再改变。值得注意的是,const定义的是变量而不是常量。在C99标准中,const定义的变量是全局变量,存放在全局数据区。此外,用const修饰变量时,一定要给变量初始值,否则编译器会报错。

12. 内联函数和宏函数的区别?

(1) 宏函数在预处理阶段进行文本替换,inline函数在编译阶段进行替换

(2) inline函数有类型检查,相比宏函数比较安全

(3) Inline函数具有返回值,宏函数没有

13. 变量声明和定义的区别?

声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间。只有在定义处才为其分配存储空间。相同变量可以在多处声明,但只能在一处定义

1
2
3
4
5
// 声明一个变量
extern int a;

// 定义一个变量
int a = 10;

isOK!

1
2
3
4
5
6
7
8
9
10
11
#include<bits/stdc++.h>

using namespace std;

extern int a;
extern int a;

int main(){

return 0;
}

区别

1.声明/定义次数

变量/函数可以声明多次,变量/函数的定义只能一次。

2.分配内存

声明不会分配内存,定义会分配内存。

3.做了什么

声明是告诉编译器变量或函数的类型和名称等,定义是告诉编译器变量的值,函数具体干什么。

14. strlen和sizeof的区别?

(1) sizeof是C/C++操作符,并不是函数,结果在编译时得到。strlen是字符处理的库函数。

(2) sizeof参数可以是任何数据的类型或者数据。strlen的参数只能是字符指针,它指向了一个结尾是’\0’的字符串。

区别

  • sizeof 是一个运算符,而strlen()是一个函数。
  • sizeof计算的是变量或类型所占用的内存字节数,而strlen()计算的是字符串中字符的个数。
  • sizeof的语法sizeof(data type),即sizeof可用于计算任何类型的数据;而strlen()的语法strlen(const char* str),即只能用于计算字符串。
  • sizeof计算字符串的长度,包含末尾的‘\0’;stlen()计算字符串的长度,不包含字符串末尾的‘\0’。

15. 常量指针和指针常量的区别?

(1) 指针常量(底层const)是一个指针,它指向了一个只读值。它实际上只是限制了指针的拥有者不能通过指针修改它所指向的值,对这个值的属性没有限制,这个值是可以是常量也可以变量,均不影响。

(2) 常量指针(顶层const)是一个不能给改变指向的指针,即我们可以通过指针修改它所指向的值,但不能修改指针的指向。

详细说明:

指针常量

指针常量:顾名思义它就是一个常量,但是是指针修饰的。

格式为:

1
2
int * const p //指针常量
在这个例子下定义以下代码:

在这个例子下定义以下代码:

1
2
3
4
5
int a,b;
int * const p=&a //指针常量
//那么分为一下两种操作
*p=9;//操作成功
p=&b;//操作错误因为声明了指针常量,说明指针变量不允许修改。如同次指针指向一个地址该地址不能被修改,但是该地址里的内容可以被修改

常量指针

常量指针:如果在定义指针变量的时候,数据类型前用const修饰,被定义的指针变量就是指向常量的指针变量,指向常量的指针变量称为常量指针,格式如下

1
2
const int *p = &a; //常量指针
在这个例子下定义以下代码:

在这个例子下定义以下代码:

1
2
3
4
5
int a,b;
const int *p=&a //常量指针
//那么分为一下两种操作
*p=9;//操作错误
p=&b;//操作成功

因为常量指针本质是指针,并且这个指针是一个指向常量的指针,指针指向的变量的值不可通过该指针修改,但是指针指向的值可以改变。

因为常量指针本质是指针,并且这个指针是一个指向常量的指针,指针指向的变量的值不可通过该指针修改,但是指针指向的值可以改变。

16. 指针常量能不能赋值给非指针常量?

不能,指针常量限制了指针的拥有者不能通过指针修改它所指向的值,如果指针常量可以赋值给非指针常量,那就意味着拥有者可以使用这种方法获取写权限,这在语义上是冲突的。

17. C++和Python的区别?

(1) Python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。

(2) python可以很方便的跨平台,但是效率没有C++高。

(3) C++中需要事先定义变量的类型,而Python不需要

(4) Python的库函数比C++的多,调用起来很方便

18. C++中struct和class的区别?

(1) 两者都拥有成员函数、公有和私有部分

(2) 任何可以使用class完成的工作,同样可以使用struct完成

(3) 两者中如果不对成员不指定公私有,struct默认是公有的,class则默认是私有的

(4) class默认是private继承,而struct模式是public继承

补充:Union概念。

共用体是一种特殊的数据类型,允许您在相同的内存位置存储不同的数据类型。您可以定义一个带有多成员的共用体,但是任何时候只能有一个成员带有值。共用体提供了一种使用相同的内存位置的有效方式。

共用体占用的内存应足够存储共用体中最大的成员。

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

union Data
{
int i;
float f;
char str[20];
};

int main( )
{
union Data data;

printf( "Memory size occupied by data : %d\n", sizeof(data));

return 0;
}

当上面的代码被编译和执行时,它会产生下列结果:

1
Memory size occupied by data : 20

访问共用体成员

为了访问共用体的成员,我们使用成员访问运算符(.)。成员访问运算符是共用体变量名称和我们要访问的共用体成员之间的一个句号。您可以使用 union 关键字来定义共用体类型的变量。下面的实例演示了共用体的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <string.h>

union Data
{
int i;
float f;
char str[20];
};

int main( )
{
union Data data;

data.i = 10;
data.f = 220.5;
strcpy( data.str, "C Programming");

printf( "data.i : %d\n", data.i);
printf( "data.f : %f\n", data.f);
printf( "data.str : %s\n", data.str);

return 0;
}

当上面的代码被编译和执行时,它会产生下列结果:

1
2
3
data.i : 1917853763
data.f : 4122360580327794860452759994368.000000
data.str : C Programming

在这里,我们可以看到共用体的 if 成员的值有损坏,因为最后赋给变量的值占用了内存位置,这也是 str 成员能够完好输出的原因。现在让我们再来看一个相同的实例,这次我们在同一时间只使用一个变量,这也演示了使用共用体的主要目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <string.h>

union Data
{
int i;
float f;
char str[20];
};

int main( )
{
union Data data;

data.i = 10;
printf( "data.i : %d\n", data.i);

data.f = 220.5;
printf( "data.f : %f\n", data.f);

strcpy( data.str, "C Programming");
printf( "data.str : %s\n", data.str);

return 0;
}

当上面的代码被编译和执行时,它会产生下列结果:

1
2
3
data.i : 10
data.f : 220.500000
data.str : C Programming

在这里,所有的成员都能完好输出,因为同一时间只用到一个成员。

19. C++和C的struct的区别?

C语言:struct是用户自定义数据类型(UDT),不能设置权限,成员不可以是函数,不能被继承。

C++:struct是抽象数据类型(ADT)支持成员函数的定义,能设置权限,能被继承与实现多态。

20. C++中const和static的作用?

(1) Static(类外):

① 隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他模块中使用,加了之后只能在该文件所在的编译模块中使用

② 静态变量没有定义初始值时,会初始化为0。

③ 静态变量在函数内定义,生命周期跟随程序(同全局变量),且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不能使用

(2) Static(类内):

① static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化, 必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。

② static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问

(3) Const(类外):

① const常量在定义时必须初始化,之后无法更改

② const形参可以接收const和非const类型的实参

(4) Const(类内):

① const成员变量:只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化

② const成员函数:const类对象不可以调用类中的非const成员函数;非const类对象则均可调用;const成员函数不可以改变类中非mutable数据成员的值。

举例说明

在C++中,conststatic都是修饰符,它们可以用来修改变量、函数或对象的行为。

  • const: const关键字可以用来修饰变量,表示这个变量的值是常量,不能被修改。在类中,如果一个成员函数被声明为const,那么这个函数不能修改类的任何成员变量(除非这个变量是mutable的)。
1
2
3
4
5
6
7
8
9
class MyClass {
public:
int x;
mutable int y;
void setX(int a) const {
// x = a; // Error, can't modify x in a const function
y = a; // OK, y is mutable
}
};
  • static: static关键字在类中有两种用法。一种是修饰成员变量,这样这个变量就变成了一个静态成员变量,它不属于类的任何对象,而是属于类本身,所有的对象都共享这一个变量。另一种是修饰成员函数,这样这个函数就变成了一个静态成员函数,它可以在不创建对象的情况下被调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyClass {
public:
static int x;
static void setX(int a) {
x = a;
}
};

int MyClass::x = 0; // Define static member variable

int main() {
MyClass::setX(10); // Call static member function
return 0;
}

从类外部来看,conststatic修饰的成员变量或函数可以通过类名直接访问(对于static),或者通过对象访问但不能修改(对于const)。

21.数组名和指针(这里为指向数组首元素的指针)的区别?

(1) 编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。

(2) 二者均可通过增减偏移量来访问数组中的元素。

(3) 数组名不是真正意义上的指针,实际上是一个常量指针,所以不能进行自增自减操作。

(4) 当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作, 但sizeof运算符不能再得到原数组的大小了。

22. C代码使用C语言编译和C++编译有什么不同?

(1) C++虽然语法上支持和兼容C语言,但为了支持多种特性,在编译上却做了很多C语言所没有的其他处理。

(2) 比如说现在有一个C语言函数库,而我们用C++去调用该函数库,在编译链接时链接器会报错。因为C++为了支持函数重载,会在编译时给每个函数进行“改名”,但是C语言在编译时则不会改名,C函数库中的函数名都保持原样,这就会使链接器在函数库中找不到改名后的对应函数地址,然后报错。extern “C”可以很好地解决这个问题

(3) 因为C++的函数改名规则,C++代码在使用其他模块的函数前必须包含其头文件或显式声明函数,不然它无法识别该函数是C函数还是C++函数,是否需要进行改名。C语言编译器即使不提前声明函数也可以调用函数,因为C编译器没有改名规则

23. extern”C”的用法?

在程序中加上extern “C”后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++,在以下情况时会用到该语法:

(1) C++代码中调用C语言函数库

(2) 在多个人协同开发时,可能有人擅长C语言,而有人擅长C++

24. 说说野指针和悬空指针?

(1) **野指针:**指的是没有被初始化过的指针。解决方法:定义指针变量要及时初始化或者置空。(保持良好的编码规范)

(2) **悬空指针:**指针最初指向的内存已经被释放了的一种指针。解决方法:释放操作后立即置空,或者使用智能指针

野指针和空悬指针的区别是野指针是指向未知地址的指针,而空悬指针是指向已经被释放的地址的指针

1
2
void *p;
// 此时 p 是“野指针”
1
2
3
4
5
6
void *p = malloc(size);
assert(p);
free(p);
// 现在 p 是“悬空指针”
// 避免“悬空指针”
p = NULL;

25. C++中的重载、重写(覆盖)和隐藏的区别?

(1) 重载是指在同一范围定义中的同名函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。

(2) 重写指的是在派生类中重写父类的函数体,要求基类函数必须是虚函数,且重写的函数签名**(返回值、函数名、参数列表)**必须完全一致。

(3) 隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数。

(4) 重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系

(5) 隐藏和重写的区别在于基类函数是否是虚函数

26. 浅拷贝和深拷贝的区别?

(1) 浅拷贝就是将对象的指针进行简单的复制,原对象和副本指向的是相同的资源。如果原来的指针所指向的资源释放了,那么使用新指针就会出现错误。

(2) 深拷贝是新开辟一块空间,将原对象的资源复制到新的空间中,并返回该空间的地址。即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值。

来,手写个简单的深拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <bits/stdc++.h>

using namespace std;
class Test
{
private:
char *p;

public:
Test(const char *str)
{
p = new char[strlen(str) + 1];
strcpy(p, str);
printf("Test cntr.%s\n",p);
}
~Test()
{
printf("~Test cntr.%s\n",p);
delete[] p;
p = nullptr;

}
};
signed main()
{
Test t1("hello");
Test t2(t1);
return 0;
}

27. public,protected和private权限的区别?

(1) 访问权限:

① public的变量和函数在类的内部外部都可以访问。

② protected的变量和函数只能在类的内部和其派生类中访问。

③ private修饰的元素只能在类内访问。

(2) 继承权限:

① Public:基类的公有成员和保护成员作为派生类的成员时,都保持原有的访问权限,基类的私有成员任然是私有的

② Protected:基类的公有成员和保护成员都成为派生类的保护成员,基类的私有成员仍然是私有的

③ private:基类的公有成员和保护成员都成为派生类的私有成员,基类的私有成员任然是私有的

对于继承权限而言,它所针对的是类外的访问权限。对于类内来说,不管是何种继承,派生类均可访问基类的公用和保护成员,但不可访问基类的私有成员。

28. 如何用代码判断大小端存储?

(1) **大端存储:**字数据的高字节存储在低地址中

(2) **小端存储:**字数据的高字节存储在高地址中

(3) 方式一:使用强制类型转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<bits/stdc++.h>

using namespace std;

#define int long long

signed main(){
int a=0x1234;
char c=(char)(a);
if(c==0x12){
cout<<"big";
}else{
cout<<"small";
}
return 0;
}

(4) 方式二:使用union联合体

29. volatile、mutable和explicit关键字的用法?

(1) volatile 关键字是一种类型修饰符,用它声明的类型变量表示它可能被某些编译器未知的因素更改,所以系统总是重新从它所在的内存读取数据而不会去使用内存读取优化(使用寄存器),即使它前面的指令刚刚从该处读取过数据。

【防止编译的时候被优化!每次都得从内存中读取!】

(2) mutable关键字是一种类型修饰符,被mutable修饰的数据成员表示他可以被const成员函数所修改(const成员函数无法修改类中的普通数据成员),它的修改不会影响整个类对象的状态。

(3) **explicit关键字用来修饰类的构造函数,**加上该关键字,表示该类不能发生相应的隐式类型转换(函数调用传参时的类型转换),只能以显式地进行类型转换(调用构造函数) 。

(4)register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率

30. C++中有几种类型的new?

【未看】

在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new

(1) plain new:最普通的new,它在分配内存失败时会抛出std::bad_alloc异常而不是返回NULL

(2) nothrow new :不会抛出异常的new,分配内存失败时返回NULL

(3) placement new:不分配内存,只在一块分配好了的内存上调用类的构造函数

31. 形参与实参的区别?

(1) 形参在定义时不分配内存,只有在被调用时才分配内存

(2) 实参可以任意形式的表达式,但在进行函数调用时,它们都必须具有确定的值

(3) 实参和形参在数量上,类型上,顺序上应严格一致,否则会发生**“类型不匹配”**的错误

(4) 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参。

32. 值传递、指针传递、引用传递的区别和效率?

(1) 值传递:实参的值向形参进行值拷贝,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间。

(2) 指针传递:同样有实参的值向形参进行值拷贝,但拷贝的数据是一个固定大小的地址。

(3) 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。

(4) 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰

33. C++有哪几种的构造函数?

(1) 默认构造函数(无参数)

(2) 初始化构造函数(有参数)

(3) 拷贝构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include<iostream>
using namespace std;
class CExample
{
private:
int a;
public:
//构造函数
CExample(int b)
{
a=b;
printf("constructor is called\n");
}
//拷贝构造函数
CExample(const CExample & c)
{
a=c.a;
printf("copy constructor is called\n");
}
//析构函数
~CExample()
{
cout<<"destructor is called\n";
}
void Show()
{
cout<<a<<endl;
}
};
int main()
{
CExample A(100);
CExample B=A;
B.Show();
return 0;
}

34. 什么情况下会调用拷贝构造函数?

(1) 用类的一个实例对象去初始化构造一个类的新对象的时候

(2) 函数的参数是类的对象时(值传递)

(3) 函数的返回值是函数体内局部对象的类的对象时

35. 说说C++中的初始化?

C++中变量的初始化有很多种方式,如:默认初始化,值初始化,直接初始化,拷贝初始化。下面一一介绍。

(1) 默认初始化

① 默认初始化是指定义变量时 没有指定初值时进行的初始化操作。

1
int a;

② 对于内置类型变量(如int,double,bool等),如果是全局变量或静态变量,则初始化为0,如果是栈或者堆变量,则将拥有未定义的值

③ 对于类类型的变量(如string或其他自定义类型),不管定义于何处,都会执行默认构造函数。如果该类没有默认构造函数,则会引发错误。因此,建议为每个类都定义一个默认构造函数(=default)。

④ 注意:默认初始化的值并不是绝对的,在一些情况下会产生未知的错误,一般定义变量时最好都要为它设定初始值,这是一种良好的编程习惯。

(2) 值初始化

① 值初始化是指使用了初始化器(即使用了圆括号或花括号)但却没有提供初始值的情况。

② 特别的,采用动态分配内存的方式(即采用new关键字)创建的变量,不加括号时,如int *p=new int,是默认初始化,加了括号,如int *p=new int(),为值初始化。

③ 若不采用动态分配内存的方式(即不采用new运算符),写成int a();是错误的值初始化方式,因为这种方式是声明了一个函数而不是进行值初始化。如果一定要进行值初始化,必须结合拷贝初始化使用,即写成int a=int()。

④ 值初始化和默认初始化一样,对于内置类型初始化为0,对于类类型则调用其默认构造函数,如果没有默认构造函数,则不能进行初始化。

(3) 直接初始化

① 直接初始化是指采用小括号的方式进行变量初始化(小括号里一定要有初始值,如果没提供初始值,那就是值初始化了!)

② 对于类类型来说,直接初始化会直接调用与实参匹配的构造函数。

(4) 拷贝初始化

① 拷贝初始化是指采用等号(=)进行初始化的方式,编译器把等号右侧的初始值拷贝到新创建的对象中去。

② 拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。

36. 直接初始化、拷贝初始化、赋值的差别?

(1) 这三种操作非常类似,语法上也很相像,区分这三者主要看他们的最终调用的函数

(2) 直接初始化:要创建的对象不存在,主要利用**初始化器(圆括号)**使用其他的初始值进行初始化操作,一般这个初始值是别的对象或数据类型,直接初始化调用类的构造函数(调用参数类型最佳匹配的那个)

(3) 拷贝初始化:要创建的对象不存在,使用已有的对象(与创建的对象类型一致)来进行初始化,这个已有的对象既可以是临时对象也可以是其他对象。既可以使用初始化器(圆括号),也可以使用赋值号(“=”)来进行初始化操作,但他们的背后都会调用类的拷贝构造函数

(4) 赋值:要创建的对象已存在,用已存在的对象给它赋值,这属于重载“=”号运算符的范畴,他并不是一种初始化操作,背后调用类的重载“=”运算符函数

(5) 对于内置类型变量(如int,double,bool等),直接初始化与拷贝初始化差别可以忽略不计。

37. 静态变量什么时候初始化?

视编译器而定,有些可能在代码执行之前就初始化,有些则可能直到代码执行时才初始化。

38. delete、delete []的区别?

delete只会调用一次析构函数,delete [] 会根据数组元素的数量,对数组中的每个元素调用析构函数。

这个可以衍生个有意思的问题?

delete[] 如何知道要调用多少次析构函数

解答:

delete[] 需要知道数组中有多少个元素,以便对每个元素调用析构函数。这是通过在分配数组时存储数组大小来实现的。具体步骤如下:

  1. 内存分配: 当使用 new[] 分配内存时,编译器会在分配的内存块前面存储一个额外的区域,用来记录数组的大小。这个额外的区域通常位于用户数据的前面。
  2. 数组大小存储: 这个额外的区域(通常称为“头部”)存储数组的大小(即元素个数)。例如,如果分配了一个包含 N 个元素的数组,则这个头部会存储 N
  3. 析构函数调用: 当使用 delete[] 释放数组时,delete[] 会先访问这个头部,以获取数组的大小 N。然后,delete[] 会依次对数组中的每个元素调用析构函数。

39. malloc、calloc、realloc的区别?

(1) malloc函数:void* malloc(unsigned int num_size); 需要手动计算分配大小,申请的空间的值是随机初始化的

(2) calloc函数:void* calloc(size_t n,size_t size);无需手动计算分配大小,申请的空间的值是初始化为0的

(3) realloc函数 :给动态分配的空间分配额外的空间,用于扩充容量。

详细解释:

malloc, callocrealloc 都是 C 和 C++ 语言中用来动态分配内存的函数,但它们各有不同的用途和行为。

  • malloc: 这是最常用的动态内存分配函数。malloc 接受一个参数,即要分配的字节数,然后返回一个指向新分配内存的指针,或者在无法分配内存时返回 NULL。需要注意的是 malloc 不会清零新分配的内存。
1
int *arr = (int*)malloc(10 * sizeof(int)); // 分配足够存储10个整数的内存
  • calloc: 这个函数与 malloc 类似,但它接受两个参数:要分配的元素数量和每个元素的大小。calloc 会返回一个指向新分配内存的指针,或者在无法分配内存时返回 NULL。与 malloc 不同,calloc 会清零新分配的内存。
1
int *arr = (int*)calloc(10, sizeof(int)); // 分配足够存储10个整数的内存,并将内存清零
  • realloc: 这个函数用于改变已分配的内存大小。realloc 接受两个参数:一个指向已分配内存的指针和新的内存大小(以字节为单位)。realloc 会返回一个指向新内存的指针,或者在无法分配内存时返回 NULL。如果新的内存大小大于原来的大小,realloc 会保留原来内存的内容,并可能将新的内存部分初始化为零。
1
2
int *arr = (int*)malloc(10 * sizeof(int)); // 分配足够存储10个整数的内存
arr = (int*)realloc(arr, 20 * sizeof(int)); // 扩大内存以存储20个整数

在使用这些函数时,一定要记住在不再需要内存时使用 free 函数释放它,以防止内存泄漏。

40. 说说类成员的初始化方式?

(1) **构造函数初始化:**在构造函数体中初始化,在所有的数据成员被分配内存空间后,才进行赋值操作。背后会调用一次数据成员的构造函数和赋值函数。

(2) **初始化列表:**给数据成员分配内存空间时就进行初始化,相比起构造函数初始化,少了一次调用赋值函数的操作,因此效率会更高一些。

41. 构造函数的执行顺序?

(1) 基类的构造函数

(2) 派生类的数据成员的构造函数

(3) 派生类自己的构造函数

42. 析构函数的执行顺序?

(1) 调用派生类的析构函数;

(2) 调用派生类的数据成员的析构函数;

(3) 调用基类的析构函数。

43. 有哪些情况必须使用初始化列表?

要不然进入到括号里面就对象就构造完成了。

(1) 当初始化一个引用成员时

(2) 当初始化一个常量成员时

(3) 当调用一个基类的构造函数,而它拥有一组参数时

(4) 当调用一个成员类的构造函数,而它拥有一组参数时

44. 初始化列表的初始化顺序?

这和对象内存有关。

初始化列表中出现的顺序并不是真正的初始化顺序,初始化顺序只取决于成员变量在类中的声明顺序。我们应尽可能保证成员变量的声明顺序与初始化列表顺序一致,才能真正保证其效率。

45. C++中新增的string与C中的 char *有什么区别?

(1) string对char*进行了封装,包含了字符串的属性以及对外提供了通用方法。

(2) string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间(2^n),然后将原字符串拷贝过去,并加上新增的内容。

46. 什么是内存泄露,如何检测与避免?

(1) 内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的内存块,使用完后必须显式释放的内存,否则,这块内存就不能被再次使用,我们就说这块内存泄漏了。

(2) 检测方法:有专门的内存泄漏检测工具,Linux下可以使用Valgrind工具 ,Windows下可以使用CRT库。

(3) 避免方法:使用智能指针,良好的编程习惯。

47. 什么是内存溢出和内存越界?

(1) 内存溢出指的是程序在申请内存时,没有足够的内存空间供其使用。

(2) 内存越界指的是申请了一块内存,使用的时候超出了这块内存区域。

48. 介绍C++面向对象的三大特性?

(1) 继承

让某个类获得另一个类的属性和方法。它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展,是代码复用的一种机制。

常见的继承有两种方式:

**实现继承:**指使用基类的属性和方法而无需额外编码的能力

**接口继承:**指仅使用属性和方法的名称、但是子类必须提供实现的能力

(2) 封装

数据和代码捆绑在一起,避免外界干扰和不确定性访问。在C++中借以权限机制实现这一特性,利用三种访问权限控制外界对对象数据的访问。

(3) 多态

多态是同一个行为具有多个不同表现形式或形态的能力。

常见的多态有两种方式:

**编译时多态(重载):**是指允许存在多个同名函数,而这些函数的参数表不同

**运行时多态(重写):**是指子类重新定义父类的虚函数的做法

49. 说说C++的四种强制转换?

(1) **reinterpret_cast:**reinterpret_cast 用以处理互不相关的类型之间的转换,reinterpret_cast 操作执行的是比特位拷贝,即编译器不会做任何检查,截断,补齐的操作,只是把比特位拷贝过去。这种转换提供了很强的灵活性,但转换的安全性只能由程序员的细心来保证了。

(2) **const_cast:**该运算符用来修改类型的const属性,可以使指向常量的指针被转化成指向非常量的指针,并且仍然指向原来的对象,使拥有者可以通过指针修改对象。

(3) static_cast:static_cast 用于进行比较“自然”和低风险的转换,如整型和浮点型、字符型与整型之间的互相转换。static_cast **不能用于在不同类型的指针之间互相转换,也不能用于整型和指针之间的互相转换,当然也不能用于不同类型的引用之间的转换。**因为这些属于风险比较高的转换。他的安全性比起reinterpret_cast 更高,但是由于该操作符没有运行时类型检查机制,在进行下行转换(把基类指针或引用转换成派生类指针或引用)时仍然是不安全的。

(4) **dynamic_cast:**dynamic_cast主要用于类层次间的上行转换和下行转换,因为有动态类型检测,在进行下行转换时比static_cast更安全。

举例

C++中有四种类型的强制类型转换,分别是 static_castdynamic_castconst_castreinterpret_cast

  1. static_cast:这是最常用的类型转换符。它可以在任何的数据类型之间进行转换,但是转换的类型需要是相关的,否则可能会产生意外的结果。

例如,将整数转换为浮点数:

1
2
int a = 10;
float b = static_cast<float>(a); // b = 10.0
  1. dynamic_cast:这个类型转换符主要用在类的继承体系中,用于进行上行转换和安全的下行转换。当我们试图将父类指针转换为子类指针时,可以使用 dynamic_cast
1
2
3
4
5
class Base {};
class Derived : public Base {};

Base* base = new Derived();
Derived* derived = dynamic_cast<Derived*>(base); // 安全的下行转换
  1. const_cast:这个类型转换符用于移除常量性。如果我们有一个 const 变量,但我们需要修改它,可以使用 const_cast
1
2
3
const int a = 10;
int* p = const_cast<int*>(&a);
*p = 20; // a现在是20,但这样做是危险的,因为a本来是const的
  1. reinterpret_cast:这个类型转换符用于进行低级别的类型转换,它可以在任何指针或整数类型之间进行转换。但是使用这个转换符需要非常小心,因为它可能会产生意外的结果。
1
2
3
int a = 10;
int* p = &a;
char* c = reinterpret_cast<char*>(p); // 将int*转换为char*

注意:尽管 C++ 提供了这些强制类型转换,但应尽量避免使用它们,除非你非常确定你知道自己在做什么。在很多情况下,使用强制类型转换可能会导致未定义的行为。

50. 如何获得结构成员相对于结构开头的字节偏移量?

使用<stddef.h>头文件中的,offsetof宏

offsetof用法:offsetof(S, x),S为结构体对象,x为结构体数据成员之一

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#define offsetof(s, m) (reinterpret_cast<size_t>(&reinterpret_cast<const volatile char&>(static_cast<s*>(nullptr)->m)))

struct S {
char a;
double b;
char c;
};

int main(void) {
std::cout<<"the first element is at offset "<<offsetof(struct S, a)<<std::endl;
std::cout<<"the second is at offset "<<offsetof(struct S, b)<<std::endl;
std::cout<<"the third element is at offset "<<offsetof(struct S, c)<<std::endl;
}

解释

此处,通过 static_cast<s*>(nullptr),编译器相信在 nullptr 处(0x0)有一个真实存在的 s 类型的对象。

此处使用 static_cast 而非 reinterpret_cast 是因为 C++ 标准不允许将 nullptr 通过 reinterpret_cast 转换成其他类型的指针;

此类转换应用 static_cast。由于 static_cast<s*>(nullptr) 返回指向 s 类型对象的指针,因此 static_cast<s*>(nullptr)->m 就是一个虚拟但在编译器看来可用的成员变量 m

为了求得以字节为单位的 ptrdiff_t,实现中通过 &reinterpret_cast<const volatile char&>(static_cast<s*>(nullptr)->m) 获得一个 const volatile char* 类型的变量。

由于在该实现中,虚拟的变量位于 0x0 位置,故而 &reinterpret_cast<const volatile char&>(static_cast<s*>(nullptr)->m) 即是 ms 类型对象当中相对对象起始地址的偏移量。

最后,只需将它转换为 size_t 类型的值即可。

具体:理解C/C++ 中的offsetof 宏原理

51. 静态类型和动态类型,静态绑定和动态绑定的介绍?

(1) 静态类型:对象在声明时采用的类型,在编译期既已确定;

(2) 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;

(3) 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;

(4) 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;

52. 全局变量和局部变量有什么区别?

(1) 生命周期:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;

(2) 作用域:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用

53. 指针加减计算要注意什么?

指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,即内存越界。

54. 怎样判断两个浮点数是否相等?

对两个浮点数判断是否相等不能直接用==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!

55. 类如何实现只能静态分配和只能动态分配?

(1) 实现只能静态分配:把operator new运算符重载为private属性。

(2) 实现只能动态分配:把构造函数设为private属性

只能动态分配类的对象的例子

将构造函数设置成private.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <string>
#include <stdio.h>

using namespace std;

class Test
{
public:
static Test *create()
{
return new Test();
}
static void destroy(Test *p)
{
delete p;
}
void fun()
{
cout << "hhh" << endl;
}

private:
Test() {}
~Test() {}
};
signed main()
{
Test *p = Test::create();
p->fun();
p->destroy(p);
return 0;
}

只能静态分配对象的例子

直接删除new这个重载运算符

1
2
3
4
5
6
7
8
9
10
11
12
13
class StaticOnly {
public:
StaticOnly() {}
~StaticOnly() {}
private:
static void* operator new(size_t size) = delete; // 禁用new运算符
};

int main() {
StaticOnly obj; // 正确,可以在栈上创建对象
// StaticOnly* p = new StaticOnly(); // 错误,无法动态分配对象
return 0;
}

56. 知道C++中的组合吗?它与继承相比有什么优缺点吗?

继承有以下几个缺点:

(1) 父类的内部细节对子类是可见的,破坏了封装性。

(2) 子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法的行为。

(3) 子类与父类是一种高耦合,违背了面向对象思想。

使用组合的目的即为了克服这几个缺点,但也因此产生了另一些缺点,如:容易产生过多的对象、为了能组合多个对象,必须仔细对接口进行定义。

57. 为什么模板类一般都是放在一个头文件中?

模板定义很特殊,编译器在遇到任何的模板定义时不会为它分配内存空间,它一直处于等待状态直到被一个模板实例告知才会分配内存空间。如果在分离式编译环境下,编译器编译某一个源文件时并不知道另一个源文件的存在,也不会去查找(当遇到未决符号时它会寄希望于连接器),同时由于模板定义没有被分配空间,链接器也无法查找到函数的入口地址。

58. 你了解重载运算符吗?

C++预定义中的运算符的操作对象只局限于基本的内置数据类型,但是对于我们自定义的类型(类)是没有办法操作的。但是大多时候我们需要对我们定义的类型进行类似的运算,这个时候就需要我们对这么运算符进行重新定义,赋予其新的功能,以满足自身的需求。这就是运算符重载,它的实质就是函数重载。

运算符重载规则:

(1) 为了防止用户对标准类型进行运算符重载,C++规定重载后的运算符的操作对象必须至少有一个是用户定义的类型。

(2) 使用运算符不能违法运算符原来的句法规则。如不能将‘+’重载为一个操作数。

(3) 不能修改运算符原先的优先级。

(4) 不能创建一个新的运算符

(5) 不能进行重载的运算符:成员运算符,作用域运算符,条件运算符,sizeof运算符,typeid运算符,const_cast、dynamic_cast、reinterpret_cast、static_cast强制类型转换运算符。

(6) 大多数运算符可以通过成员函数和非成员函数进行重载,但是下面这四种运算符只能通过成员函数进行重载:= 赋值运算符,()函数调用运算符,[ ]下标运算符,->通过指针访问类成员的运算符。

(7) 一般来说,单目运算符重载为类的成员函数,双目运算符重载为类的友元函数

59. 前++和后++重载的区别?

(1) 前++重载函数参数列表不需要带参数,后++参数列表需要带参数,这个参数仅仅只是区分前++和后++用,没有实际意义。

(2) 前++返回一个引用,后++返回一个临时对象,后++效率比较高。

60. 当程序中有函数重载时,函数的匹配原则和顺序是什么?

(1) 名字查找

(2) 确定候选函数

(3) 寻找最佳匹配

61. 条件编译的作用?

(1) 一般情况下,源程序中所有的行都参加编译。但是有时希望对其中一部分内容只在满足一定条件才进行编译,也就是对一部分内容指定编译的条件,这就是“条件编译”

(2) 在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。在头文件中使用条件编译即可避免该错误。

62. 隐式转换是什么,如何消除类的隐式转换?

(1) C++的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换。最常见的隐式转换为函数传参。

(2) C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。

63. 如何在不使用额外空间的情况下,交换两个数?

1
2
3
4
5
6
7
8
(1)
x=x+y
y=x-y
x=x-y
(2)
x=x^y
y=x^y
x=x&y

64. 你知道strcpy和memcpy的区别是什么?

(1) **复制的内容不同。**strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类对象等。

(2) **复制的方法不同。**strcpy不需要指定长度,它遇到被复制字符串的结束符”\0”才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度,更加安全。

(3) **用途不同。**通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy。

65. 如果有一个空类,它会默认添加哪些函数?

(1) 默认构造函数

(2) 拷贝构造函数

(3) 析构函数

(4) 赋值运算符函数

66. static_cast比C语言中的转换强在哪里?

(1) 更加安全

(2) 更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误

67. 成员函数里memset(this,0,sizeof(*this))会发生什么?

有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。

对于这种情形可以很好的工作,但是下面几种情形是不可以这么使用的;

(1) 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常;

(2) 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。

68. 你知道回调函数吗?它的作用?

(1) 回调函数就是一个通过函数指针调用的函数。回调的函数的定义由程序员实现,但无需程序员调用,可将函数指针作为参数传递给某个函数库中的函数,由函数库去调用。

(2) 回调函数是在**“你想让别人的代码执行你的代码,而别人的代码你又不能动”**这种需求下产生的。

(3) 可以做回调函数的函数有两种,一种是普通函数,一种是静态成员函数。普通成员函数不能做回调函数,因为普通成员函数自带this指针参数,会导致函数声明与调用不匹配的情况发生。

(4) 回调函数是一种设计系统的思想,能够解决系统架构中的部分问题,但是系统中不能过多使用回调函数,因为回调函数会改变整个系统的运行轨迹和执行顺序,耗费资源,而且会使得代码晦涩难懂。

回调函数的本质确实是函数指针。通过函数指针,我们可以将函数作为参数传递给另一个函数,这就是所谓的“回调”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

#include <bits/stdc++.h>
#include <stdio.h>
#include <stdlib.h>
using namespace std;

//typedef int(*callback)(int); //定义回调函数的原型(接收参数、返回值、名称等)
using callback = int(*)(int);
int func(int x,callback cb) { return 2 + cb(x); } //接收回调函数指针的函数(需要使用回调功能的地方)

int callbackImpl1(int i) { return i; } //第一个回调函数实现示例
int callbackImpl2(int i) { return i + 1; } //第二个回调函数实现示例
int main()
{
int result1=func(2, &callbackImpl1); //注意传入的是第一个回调函数实现的(地址)指针
printf("result=%d\n", result1);
int result2 = func(2, &callbackImpl2); //注意传入的是第一个回调函数实现的(地址)指针
printf("result=%d\n", result2);
getchar();
return 0;
}

补充:函数指针的使用例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string>
#include <stdio.h>

using namespace std;

int fun(int a, int b)
{
return a + b;
}
/*
有一个函数,int fun(int a,int b);
它的函数指针是:int (*p)(int,int)
这个指针的类型:int (*)(int,int)
*/
using pFun = int (*)(int, int);
typedef int (*pFun1)(int, int);
signed main()
{
// 定义一个函数指针
int (*p)(int, int) = fun;
int c = p(1, 2);
cout << c << endl;
// 使用Using调用函数
pFun pp = fun;
cout << pp(2, 3) << endl;
// 使用typedef调用函数
pFun1 poo = fun;
cout << poo(2, 3) << endl;
return 0;
}

69. C++从代码到可执行程序经历了什么?

(1) 预编译

主要处理源代码文件中的以“#”开头的预编译指令。

(2) 编译

把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。

(3) 汇编

将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来。

(4) 链接

将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接。

70. 类的对象存储空间?

(1) 非静态成员的数据类型大小之和。

(2) 编译器加入的额外成员变量(如指向虚函数表的指针)。

(3) 为了内存对齐而补入的额外空间。

(4) 空类大小为1,但若是作为基类,则大小为0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
using namespace std;
class A{
};
class B:public A{
};
int main(void) {
cout<<sizeof(A)<<endl;
cout<<sizeof(B)<<endl;
}
/*
1
1
*/

71. 静态链接和动态链接的区别?

静态链接和动态链接是针对函数库来说的,现在的函数库分为两种:静态库和动态库。他们分别对应静态链接和动态链接。

(1) **静态链接:**所有的函数和数据都被编译进一个文件中。在使用静态库的情况下,在编译链接可执行文件时,链接器从函数库中复制函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。静态链接有以下特点:

① 空间浪费:因为每个可执行程序中都拥有函数库数据的一份副本,所以如果多个程序对同一个目标文件都有依赖,当他们同时运行在计算机上,会出现同一个函数库在内存中有多个副本,浪费内存空间;

② 更新困难:每当库函数的代码修改了,所有依赖该库的程序都需要重新进行编译链接。

③ 运行速度快:因为在可执行程序中已经具备了所有执行程序所需要的任何东西,所以在执行的时候运行速度快。

(2) **动态链接:**动态链接会在程序运行时才将函数库与源程序执行链接,而不是像静态链接一样把所有模块都链接成一个单独的可执行文件。动态链接有以下特点:

① 共享库:如果多个程序都依赖同一个库,该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本;

② 更新方便:更新时只需要替换原来的库文件,依赖它的程序无需重新编译。当程序下一次运行时,新库会被自动加载到内存并且与其他程序执行链接,程序就完成了升级迭代。

性能损耗:因为把链接推迟到了程序运行时,每次执行程序都需要进行链接,所以性能会有一定损失。

72. 为什么不能把所有的函数写成内联函数?

(1) 首先,不管是什么函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联

(2) 内联函数以代码复杂为代价,省去了函数调用的开销来提高执行效率。如果内联函数体内代码执行时间相比起函数调用开销更大,则没有太大的意义。一般来说若是函数体代码比较长或者内部带有循环,则不推荐使用内联函数。

(3) **将构造函数和析构函数声明为inline是没有什么意义的,**即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),致使构造函数/析构函数并不像看上去的那么精简。

(4) **将虚函数声明为inline,要分情况讨论。**当指向派生类的指针(多态性)调用声明为inline的虚函数时,由于inline是编译期决定的,而虚函数是运行期决定的,在不知道将要调用哪个函数的情况下,编译器不会内联展开;当对象本身调用虚函数时,编译器能决议出会调用哪个函数时,就会内联展开,当然前提依然是函数并不复杂的情况下。

73. 为什么C++没有垃圾回收机制?

(1) 实现一个垃圾回收器会带来额外的空间和时间开销。

(2) 垃圾回收会使得C++不适合进行很多底层的操作。

74. 说说C++的内存分区?

在C++中,内存分成5个区,他们分别是堆、栈、全局/静态存储区和常量存储区和代码区

(1) **栈:**在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈工作效率很高,但是分配的内存容量有限。

(2) **堆:**就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。

(3) **全局/静态存储区:**这块内存在编译时已经分配好,且在程序运行期间都存在。它主要存放静态数据(局部static变量,全局static变量)、全局变量。

(4) **常量存储区:**这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改。

(5) **代码区:**存放程序的二进制代码。

75. 说说友元函数和友元类的特性?

友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,外部的普通函数或者另一个类中的成员函数可以访问本类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。友元具有以下特性:

(1) 友元关系不能被继承。

(2) **友元关系是单向的,不具有交换性。**若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。

**友元关系不具有传递性。**若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的声明。

76. 关于this指针你知道什么?

(1) this指针是类的指针,指向对象的首地址。

(2) this指针只能在普通成员函数中使用,在全局函数、静态成员函数中都不能用this。

(3) this在成员函数的开始执行前构造,在成员的执行结束后清除。

(4) this指针只有在普通成员函数中才有定义,且存储位置会因编译器不同有不同存储位置。

(5) this指针主要可用于返回类对象本身的时候,直接使用 return *this。或者当形参数与成员变量名相同时用于区分,如this->n = n。

77. 在成员函数中调用delete this会出现什么问题?

当调用delete this时,类对象的内存空间被释放。因为类成员函数并没有存放在类对象的内存空间中,所以在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,因为内存被释放,所以就会出现不可预期的问题。

78. 如果在类的析构函数中调用delete this,会发生什么?

会导致堆栈溢出。因为delete会调用析构函数,导致递归。

79. C++中类的数据成员和成员函数内存分布情况?

(1) 普通数据成员存放在对象内存空间中

(2) 静态数据成员存放在全局/静态存储区中

(3) 普通成员函数和静态成员函数都存放在代码区中

80. C++的多态怎么实现?

C++的多态机制有两种,分为编译时多态和运行时多态,下面分别介绍这两种多态。

编译时多态

(1) 主要通过模板和函数重载实现,在编译期发生,由编译器进行推断决议。

(2) 优点:

① 它带来了泛型编程的概念,使得C++拥有泛型编程与STL这样的强大武器。

② 在编译器完成多态,提高运行期效率。

③ 具有很强的适配性与松耦合性,对于特殊类型可由模板偏特化、全特化来处理。

(3) 缺点:

① 程序可读性降低,代码调试带来困难。

② 无法实现模板的分离编译,当工程很大时,编译时间不可小觑。

运行时多态

(1) 运行期多态的设计思想要归结到类继承体系的设计上去。对于有相关功能的对象集合,我们总希望能够抽象出它们共有的功能集合,在基类中将这些功能声明为虚接口(虚函数),然后由子类继承基类去重写这些虚接口,以实现子类特有的具体功能。

(2) 在C++中,主要由虚表和虚表指针实现运行时多态。运行时多态实现细节如下:

① 编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。

② 编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数

③ 在派生类定义对象时,会先调用父类的构造函数,此时,编译器只“看到了父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表

④ 当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面

(3) 优点:

面向对象设计中重要的特性,对客观世界直觉认识。

(4) 缺点:

① 运行期间进行虚函数绑定,提高了程序运行开销。

② 庞大的类继承层次,对接口的修改易影响类继承层次。

③ 由于虚函数在运行期在确定,所以编译器无法对虚函数进行优化。

④ 虚表指针增大了对象体积,类也多了一张虚函数表。

81. 为什么要把析构函数写成虚函数?

由于类的多态性,基类指针可以指向派生类的对象。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。

82. 虚函数表存放在内存的什么区域?

C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;

虚函数则位于代码段(.text),也就是C++内存模型中的代码区。

83. 模板偏特化了解吗?

(1) 通过编写模板,能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化(模板偏特化)。

(2) 所谓的模板偏特化对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上。

(3) 特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。

(4) 可以特例化类中的部分成员函数而不是整个类。

举例:

模板偏特化是C++模板编程中的一个重要概念。简单来说,模板偏特化就是对模板进行部分特化,也就是将模板的部分参数具体化。模板偏特化可以让我们为特定的类型或条件提供特化的实现。

举例来说,假设我们有一个模板类用于存储数组:

1
2
3
4
5
template <typename T, int N>
class Array {
T data[N];
// ...
};

现在,我们想要对bool类型的数组进行特化,因为bool类型的数组可以通过位操作进行压缩存储,从而节省空间。我们可以通过模板偏特化来实现:

1
2
3
4
5
6
template <int N>
class Array<bool, N> {
// 使用位操作进行压缩存储
unsigned char data[(N+7)/8];
// ...
};

在这个例子中,Array<bool, N>Array<T, N>的一个偏特化版本。当我们创建一个Array<bool, 100>对象时,编译器会使用偏特化版本的模板,从而实现对bool类型数组的优化存储。

84. 哪些函数不能被定义为虚函数?

(1) **构造函数。**每一个声明了虚函数的类对象都有一个指向虚表(vtable)的指针,但是这个指向vtable的指针事实上是存储在对象的内存空间的,假设构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,也就是内存空间还没有,怎么找vtable呢?再者,虚函数主要是在调用对象不确定的情况下使用的,然而构造函数本身就是要初始化实例,那使用虚函数也没有实际意义。

(2) **静态函数。**静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。

(3) **友元函数。**友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。

(4) **普通函数。**普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。

85. 构造函数和析构函数可以调用虚函数吗,为什么?

(1) 构造函数调用虚函数没有意义,因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编。

(2) **析构函数调用虚函数没有意义,**析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。

86. 构造函数和析构函数可否抛出异常?

(1) **构造函数不可抛出异常:**C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。因此,如果某个对象的构造函数中发生异常,则该对象的析构函数不会被调用。因此会造成内存泄漏。

(2) **析构函数不可抛出异常:**如果析构函数抛出异常,则异常点之后的程序不会执行,如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。再者,通常异常发生时,C++ 的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

87. 模板类和模板函数的区别是什么?

函数模板的实例化是由编译器在处理函数调用时自动完成的,而类模板的实例化必须由程序员在代码中显式地指定。即函数模板允许隐式调用和显式调用,而类模板只能显式调用。

88. 什么是虚继承?

(1) 由于C++支持多继承,因此除了public、protected和private三种继承方式外,还支持虚拟(virtual)继承。

(2) 多继承有可能引发一直特别情况:B和C公有继承A,D又公有继承B和C,这种方式是一种菱形继承或者钻石继承,如下图所示。

image-20240513220826075

(3) 如果D调用了A的方法,则会引发数据的二义性和冗余,编译器不知道是要调用B所继承的A,还是C所继承的A。

(4) 为了解决这个问题,C++引入了虚拟继承,在虚拟继承的情况下,无论基类被继承多少次,只会存在一个实体。

89. 抽象基类为什么不能创建对象?纯虚函数又是什么?

(1) 带有纯虚函数的类为抽象类。抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的。抽象类会将所有派生类中有关的操作抽象成一个通用接口且不做具体实现(纯虚函数),具体实现由派生类实现,因此抽象基类不可以创建对象。

(2) 纯虚函数是一种特殊的虚函数,该类函数没有函数体。因为在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。

90. 说说RTTI?

运行时类型识别(Run-time type identification , RTTI),是指在只有一个指向基类的指针或引用时,确定所指对象的准确类型的操作。其常被说成是C++的四大扩展之一(其他三个为异常、模板和名字空间)。使用RTTI有两种方法:

1、typeid()

第一种就像sizeof(),它看上像一个函数,但实际上它是由编译器实现的。typeid()带有一个参数,它可以是一个对象引用或指针,返回全局typeinfo类的常量对象的一个引用。可以用运算符“= =”和“!=”来互相比较这些对象,也可以用name()来获得类型的名称。同时,我们也可以用typeid 检查基本类型和非多态类型。如果想知道一个指针所指对象的精确类型,我们必须逆向引用这个指针。比如:

2、dynamic_cast (expression)

该运算符为强制类型转换符,上文第49条有提及

91. 多继承的优缺点,作为一个开发者怎么看待多继承?

多重继承的优点很明显,就是一个对象可以调用多个基类中的接口。多继承容易导致菱形继承问题,虽然可以用虚继承解决该问题,但也会造成内存结构复杂,效率降低,继承体系过于复杂化的缺点。

92. 为什么拷贝构造函数必须传引用不能传值?

拷贝构造函数的作用就是用来拷贝对象的,使用一个已存在的对象来初始化一个新的对象。如果使用传值方式,那么在拷贝构造函数被调用时,在进行参数传递的时候就会调用拷贝构造函数,这样会导致递归溢出。

93. 为什么在C/C++中要将代码分为头文件和源文件,不能写到一起吗?

(1) 将所有代码写入一个文件中当然可以,同样可以通过编译且正常运行

(2) **分开写的目的是方便未来。**有时候我们写的代码会给别人去用,如果是非开源代码,可以将源文件封装起来并生成库文件(库文件是二进制文件,无法查阅代码)。只对外开放头文件和库文件,那么别人就无法看到代码的具体实现了。

94.数组指针和指针数组的区别

数组指针和指针数组的区别 - jack_Meng - 博客园 (cnblogs.com)

数组指针(行指针):数组指针只是一个指针变量,C语言里专门用来指向二维数组的,它占有内存中一个指针的存储空间。

指针数组:指针数组是一个数组,多个指针变量,以数组形式存在内存当中,占有多个指针的存储空间。

数组指针:定义 int ( p)[n];*

*指针数组:定义 int p[n];

优先级为 **() > [] > ***

比如int (* p)[n],因为()的优先级最高,p先于*结合,所以p是一个指针,后面是数组,于是这个就是数组指针。

在如int * p[n],[]优先级比* 高,p先与[]结合,所以p是一个数组,前边又有*,所以是指针数组。

数组指针:首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度是n,也可以说是p的步长。也就是说执行p+1时,p要跨过n个整型数据的长度。

指针数组:[]先与p结合成为一个数组,再由int * 说明这是一个整型指针数组,它有n个指针类型的数组元素。这样赋值是错误的:p=a;因为p是个不可知的表示,只存在p[0]、p[1]、p[2]…p[n-1],而且它们分别是指针变量可以用来存放变量地址。可以这样 p=a; 这里p表示指针数组第一个元素的值,a的首地址的值。

image-20240624161617374

p1为指针数组,p2为数组指针

看例子更加的通透

数组指针

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main(void) {
int arrP[5] = {1,2,3,4,5};//定义一个数组并赋值
int (*p)[5] = &arrP;//定义一个数组指针并为其赋值,把这个数组的首地址赋给了数组指针

printf("%p\n",arrP);//数组名为数组首元素的地址 与 &arrP[0] 等价
printf("%p\n",p);//p为arrP的地址 及 &arrP,注意:虽然arrP与&arrP值相同,单代表的意思却不一样,类型却不同。arrP代表首元素的地址,&arrP代表数组的地址。
printf("%p\n",*p);//*p代表arrP,所以这个表示arrP首元素的地址
printf("%d\n",**p);//既然*p代表首元素的地址,**p为求这个地址上的值
printf("%d",(*p)[1]);//*p为arrP,所以(*p)[1]就是arrP[1]的值
return 0;
}

指针数组

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
int main(void) {
int x = 100;
int y = 200;
int *p[2];//定义一个指针数组
p[0] = &x;
p[1] = &y;
printf("%p\n",p[0]);//x的地址
printf("%p\n",&x);//x的地址
printf("%d",*p[0]);//x的值
return 0;
}

C++11

1. auto、decltype的用法?

(1) auto:C++11引入了auto类型说明符,它可以让编译器通过初始值来进行类型推演,使得程序员无需知道类型名称就可以定义变量,所以auto 定义的变量必须有初始值。

(2) decltype: auto定义的变量必须初始化,如果我们不想要初始化就可以使用decltype,它会返回参数的数据类型,并定义新的变量,而且新变量的值不会被初始化。

2. C++中NULL和nullptr的区别?

(1) NULL是一个宏定义,C中NULL为(void*)0,C++中NULL为整数0。

(2) 将NULL定义为0带来的一个问题是无法与整数的0区分,因为C++中允许有函数重载,若是有个a、b两个重载函数,参数分别为整数和指针,那么在传入NULL参数时,会把NULL当做整数0来看,导致错误调用了参数为整数的函数。

(3) nullptr可以解决这一问题,nullptr可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误。

3. 说说final和override关键字?

(1) Override指定了子类的这个虚函数是对父类虚函数的重写,如果函数名不小心打错了的话,编译器会进行报错。

(2) 当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后如果被继承或重写,编译器会报错。

举例:

在C++中,finaloverride 是两个非常有用的关键字,它们主要用于类和成员函数的继承。

  1. final 关键字可以用于阻止类的进一步继承,或者阻止虚函数的进一步覆盖。例如:
1
2
3
4
5
class Base final {   // 这个类不能被继承
virtual void func() final { // 这个函数不能被子类覆盖
// ...
}
};
  1. override 关键字用于明确表示一个虚函数覆盖了基类中的虚函数。这有助于编译器检查我们的代码,如果基类中没有对应的虚函数,使用 override 将导致编译错误。例如:
1
2
3
4
5
6
7
8
9
10
11
class Base {
virtual void func() {
// ...
}
};

class Derived : public Base {
void func() override { // 明确表示这个函数覆盖了基类的虚函数
// ...
}
};

以上就是 finaloverride 在C++中的基本用法。这两个关键字都可以帮助我们编写出更清晰、更安全的代码。

4. C++中的智能指针?

(1) 智能指针会管理程序员申请的内存,在使用结束后会自动释放,防止堆内存泄漏。

(2) auto_ptr:最原始的智能指针。auto_ptr采用的是独享所有权语义,一个非空的auto_ptr总是拥有它所指向的资源,转移一个auto_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空。由于支持拷贝语义,拷贝后源对象变得无效,如果程序员忽视了这点,这可能引发很严重的问题。在C++11中该指针已被弃用

(3) unique_ptr:与auto_ptr类似,采用独享所有权语义unique_ptr提供移动语义,这在很大程度上避免了auto_ptr的错误,因为很明显必须使用std::move()进行转移,提醒程序员在这个地方发生了移动。

(4) shared_ptr:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。引用计数器的变化依据如下所示。

① 每次创建类的新对象时,初始化指针并将引用计数置为1

② 当对象作为另一对象的副本而创建时,拷贝构造函数会拷贝指针并增加与之相应的引用计数

③ 对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数

④ 调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)

(5) weak_ptr: 由于shared_ptr引用计数存在的问题,即互相引用形成环(环形引用),使得两个指针指向的内存都无法释放,如下所示。

① 为了解决这个问题,C++引入了weak_ptr(弱引用),它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数

② 如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。

③ weak_ptr不保证它指向的内存一定是有效的,在使用之前应先检查weak_ptr是否为空指针,避免访问非法内存,也因此weak_ptr并不能直接访问对象,他只能通过转化为shared_ptr来使用对象。

share_ptr手写

share_ptr-double free问题:double free 问题就是一块内存空间或者资源被释放两次。

double free 可能是下面这些原因造成的:

  • 直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 工厂函数,从而导致多个独立的引用计数。
  • 循环引用,即两个或多个 shared_ptr 互相引用,导致引用计数永远无法降为零,从而无法释放内存。

case1:举例

在C++中,std::shared_ptr 是一个智能指针,它可以自动管理对象的生命周期。它通过引用计数来确保当没有任何 shared_ptr 指向一个对象时,该对象会被自动删除。

然而,直接使用原始指针创建多个 shared_ptr,而没有使用 shared_ptr 的 make_shared 工厂函数,可能会导致多个独立的引用计数,进而导致double-free的问题。

下面是一个例子:

1
2
3
4
5
6
7
int* ptr = new int(10); // 创建一个原始指针指向一个新的int对象

// 创建两个独立的shared_ptr,它们都指向同一个对象
std::shared_ptr<int> sp1(ptr);
std::shared_ptr<int> sp2(ptr);

// 当sp1和sp2离开其作用域时,它们都会试图删除同一个对象,导致double-free的问题

在这个例子中,我们创建了两个独立的 shared_ptr,它们都指向同一个对象。当它们离开其作用域时,它们都会试图删除同一个对象,导致double-free的问题。

正确的做法是使用 make_shared 工厂函数来创建 shared_ptr,或者只使用一个原始指针来初始化一个 shared_ptr,然后使用这个 shared_ptr 来初始化其他的 shared_ptr。这样,所有的 shared_ptr 都会共享同一个引用计数。

1
2
3
4
5
6
7
std::shared_ptr<int> sp1(new int(10)); // 使用原始指针初始化一个shared_ptr
std::shared_ptr<int> sp2 = sp1; // 使用sp1初始化sp2

// 或者

std::shared_ptr<int> sp1 = std::make_shared<int>(10); // 使用make_shared创建一个shared_ptr
std::shared_ptr<int> sp2 = sp1; // 使用sp1初始化sp2

在上述正确的做法中,所有的 shared_ptr 都会共享同一个引用计数,因此不会出现double-free的问题。

case2:

循环引用问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A destructor called" << std::endl;
}
};

class B {
public:
std::shared_ptr<A> a_ptr;
~B() {
std::cout << "B destructor called" << std::endl;
}
};

int main() {
{
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b; // A 指向 B
b->a_ptr = a; // B 指向 A
} // a 和 b 离开作用域,但由于循环引用,它们的析构函数不会被调用

std::cout << "End of main" << std::endl;
return 0;
}

使用weak_ptr

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

class B; // 前向声明

class A {
public:
std::shared_ptr<B> b_ptr;
~A() {
std::cout << "A destructor called" << std::endl;
}
};

class B {
public:
std::weak_ptr<A> a_ptr; // 使用 weak_ptr 替代 shared_ptr
~B() {
std::cout << "B destructor called" << std::endl;
}
};

int main() {
{
std::shared_ptr<A> a = std::make_shared<A>();
std::shared_ptr<B> b = std::make_shared<B>();
a->b_ptr = b; // A 指向 B
b->a_ptr = a; // B 对 A 使用 weak_ptr
} // a 和 b 离开作用域,它们的析构函数会被正确调用

std::cout << "End of main" << std::endl;
return 0;
}

enable_shared_from_this
从名字可以看出几个关键词:enable: 允许 shared 指 shared_ptr, from_this 则是指从类自身this 构造 shared_ptr。

想象这样一个场景:

1
2
3
4
5
6
7
8
struct SomeData;
void SomeAPI(const std::shared_ptr<SomeData>& d) {}

struct SomeData {
void NeedCallSomeAPI() {
// 需要用this调用SomeAPI
}
};

上面这段代码需要在NeedCallSomeAPI函数中调用SomeAPI,而SomeAPI需要的是一个std::shared_ptr的实参。这个时候应该怎么做? 这样吗?

1
2
3
4
5
struct SomeData {
void NeedCallSomeAPI() {
SomeAPI(std::shared_ptr<SomeData>{this});
}
};

上面的做法是错误的,因为SomeAPI调用结束后std::shared_ptr对象的引用计数会降为0,导致 this 被意外释放。

这种情况下,我们需要使用std::enable_shared_from_this ,使用方法很简单,只需要让SomeData继承std::enable_shared_from_this,然后调用shared_from_this,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <memory>
#include <iostream>
using namespace std;
struct st;
void fun(const shared_ptr<st> &d);
struct st : enable_shared_from_this<st>
{
static shared_ptr<st> New()
{
return shared_ptr<st>(new st);
}
void f1()
{
fun(shared_from_this());
}
int num = 66;
};

void fun(const shared_ptr<st> &d)
{
cout << d->num << endl;
}
signed main()
{
auto p = st::New();
p->f1();
return 0;
}

总结一下,当下面👇这些场景用到 shared_ptr 时,需要搭配上 enable_shared_from_this:

  • 当你需要将this指针传递给其他函数或方法,而这些函数或方法需要一个std::shared_ptr,而不是裸指针。
  • 当你需要在类的成员函数内部创建指向当前对象的std::shared_ptr,例如在回调函数或事件处理中。

手敲智能指针是面试常见的题目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <iostream>

template <typename T>
class SimpleSharedPtr {
public:
// 构造函数
explicit SimpleSharedPtr(T* ptr = nullptr) : ptr_(ptr), count_(ptr ? new size_t(1) : nullptr) {}

// 拷贝构造函数
SimpleSharedPtr(const SimpleSharedPtr& other) : ptr_(other.ptr_), count_(other.count_) {
if (count_) {
++(*count_);
}
}

// 赋值操作符
SimpleSharedPtr& operator=(const SimpleSharedPtr& other) {
if (this != &other) {
release();
ptr_ = other.ptr_;
count_ = other.count_;
if (count_) {
++(*count_);
}
}
return *this;
}

// 析构函数
~SimpleSharedPtr() {
release();
}

T& operator*() const { return *ptr_; }
T* operator->() const { return ptr_; }
T* get() const { return ptr_; }
size_t use_count() const { return count_ ? *count_ : 0; }

private:
void release() {
if (count_ && --(*count_) == 0) {
delete ptr_;
delete count_;
}
}

T* ptr_;
size_t* count_;
};

class MyClass {
public:
MyClass() { std::cout << "MyClass 构造函数\n"; }
~MyClass() { std::cout << "MyClass 析构函数\n"; }
void do_something() { std::cout << "MyClass::do_something() 被调用\n"; }
};

int main() {
{
SimpleSharedPtr<MyClass> ptr1(new MyClass());
{
SimpleSharedPtr<MyClass> ptr2 = ptr1;
ptr1->do_something();
ptr2->do_something();
std::cout << "引用计数: " << ptr1.use_count() << std::endl;
}
std::cout << "引用计数: " << ptr1.use_count() << std::endl;
}

return 0;
}

5. 说说STL容器中的智能指针?

具备独占所有权语义的智能指针不能在STL的容器中使用,如auto_ptr和unique_ptr,因为STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,容易导致错误,而unique_ptr又不支持普通的拷贝和赋值操作,也不能用在STL标准容器中。

6. 说说lambda函数?

利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象。每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,该实例是一个右值。lambda的优点有以下几点:

(1) 距离:很多人认为,让定义位于使用的地方附近很有用。这样,就无需翻阅很多页的源代码,以了解函数。另外,如果需要修改代码,设计的内容就在附近,就很好修改。

(2) 简洁:函数符代码要比lambda代码更加繁琐,函数和lambda的简洁程度相当。

(3) 功能:lambda可以访问作用域内的任何动态变量,可以采用取值、引用的形式进行捕获。

7. 什么是声明时初始化?

C++11新增了类成员初始化新机制——声明时初始化,可以直接在类中声明数据成员时就进行初始化操作,而不用借助构造函数或者初始化列表。

8. C++11添加哪几种构造函数关键字?

(1) default关键字可以显式要求编译器生成默认构造函数,防止在调用时相关构造函数没有定义而报错。

(2) delete关键字可以删除构造函数、赋值运算符函数等,在使用时编译器会报错

9. 说说C++的左值和右值?

(1) 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。

(2) 在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。

(3) 纯右值的概念等同于我们在C++98标准中右值的概念,指的是临时变量和字面量值;将亡值则是C++11新增的新右值,它表示该对象的内存空间接下来会被其他对象接管。通过这种接管空间的方式可以避免内存空间的释放和分配,延长变量值的生命期。

(4) C++11对于引用了也有了新的解释。传统的C++引用被称为左值引用,符号为&,他关联的是左值。C++11中增加了右值引用,符号为&&。右值引用会关联到右值,右值被存储到特定位置,右值引用会指向该特定位置,也就是说,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置。使用std::move可以使一个左值转换为右值引用。

10. 说说移动构造函数?

(1) 移动构造是C++11标准中提供的一种新的构造方法,用来给予程序员新的构造选择,用以替换拷贝构造。

(2) 拷贝构造函数是先将传入的参数对象进行一次深拷贝,再传给新对象。这就会有一次拷贝对象的开销,拷贝的内存越大越耗费时间,并且进行了深拷贝,就需要给对象分配地址空间。

(3) 移动构造函数会直接接管源对象空间,既不会产生额外的拷贝开销,也不会给新对象分配内存空间。提高程序的执行效率,节省内存消耗。

(4) 移动构造函数的参数必须是自身类型的右值引用,也就是说能调用移动构造函数的参数必然是个右值(纯右值和将亡值)。

11. 什么是列表初始化?

列表初始化是C++ 11新引进的初始化方式,它采用一对花括号(即**{}**)进行初始化操作。能用直接初始化和拷贝初始化的地方都能用列表初始化,而且列表初始化能对容器进行方便的初始化,因此在新的C++标准中,推荐使用列表初始化的方式进行初始化。

12.初始化列表和列表初始化的区别?

(1) 初始化列表是在创建类对象时,对类对象内部的数据成员进行的一种初始化方式,具体用在类的构造函数中。

13.什么是完美转发?

完美转发是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另一个函数,即传入转发函数的是左值对象,目标函数就能获得左值对象,转发函数是右值对象,目标函数就能获得右值对象,而不产生额外的开销。

14.左值和右值引用

左值和右值的概念:

左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象

右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。

右值和左值的区别:

  1. 左值可以寻址,而右值不可以。
  2. 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
  3. 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。

而在指C++11中,右值是由两个概念构成,==将亡值==和==纯右值==。纯右值是用于识别临时变量和一些不跟对象关联的值,比如1+3产生的临时变量值,2、true等,而将亡值通常是指具有转移语义的对象,比如返回右值引用T&&的函数返回值等。

本质都是引用,就是取别名,只不过对象不一样罢了!

15.move原理

std::move() 函数原型:

1
2
3
4
5
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type &&>(t);
}

std::move() 实现原理:

  1. 利用引用折叠原理将右值经过 T&& 传递类型保持不变还是右值,而左值经过 T&& 变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变;
  2. 然后通过 remove_refrence 移除引用,得到具体的类型 T;
  3. 最后通过 static_cast<> 进行强制类型转换,返回 T&& 右值引用。

STL

1.什么是STL?

Standard Template Library(标准模板库),是C++的标准库之一,它是一套基于模板的容器类库,还包括许多常用的算法,提高了程序开发效率和复用性。STL包含6大部件:容器、迭代器、算法、仿函数、适配器和空间配置器

2.SGI的二级空间配置器了解吗?

(1) 对象构造前的空间配置和对象析构后的空间释放,由<stl_alloc.h>负责,SGI设计了双层级配置器

① 第一级空间配置器直接使用malloc和free,如果在申请动态内存时找不到足够大的内存块,将返回NULL 指针,宣告内存申请失败。

② 第二级空间配置器视情况使用不同的策略,当申请内存大于128字节时,调用第一级配置器。当申请内存小于128b字节时,采用内存池方式,维护16个(128/8)自由链表,每个链表维护8字节大小的内存块,从中进行内存分配,如果内存不足转第一级配置器处理。

(2) 二级空间配置器存在的问题

① 自由链表所挂区块都是8的整数倍,因此当我们需要非8倍数的区块,往往会导致浪费。

② 由于配置器的链表都是静态变量,他们存放在全局/静态区,其释放时机就是程序结束,这样子会导致自由链表一直占用内存。

3.traits技法?

入门案例:

在C++中,traits(特性)是一种常用的技术,主要用于在编译期间获取类型的信息。通过traits,我们可以编写出更加通用、灵活的代码。

下面是一个简单的traits的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义一个traits模板
template <typename T>
struct MyTraits {
// 默认情况下,我们假设类型T不是一个指针
static const bool is_pointer = false;
};

// 对指针类型进行特化
template <typename T>
struct MyTraits<T*> {
// 对于指针类型,我们设定is_pointer为true
static const bool is_pointer = true;
};

// 测试代码
int main() {
cout << MyTraits<int>::is_pointer << endl; // 输出0,表示int不是指针
cout << MyTraits<int*>::is_pointer << endl; // 输出1,表示int*是指针
return 0;
}

这个例子中,我们定义了一个名为 MyTraits 的模板,并对指针类型进行了特化。通过 MyTraits,我们可以在编译期间判断一个类型是否是指针类型。

除了这个简单的例子,traits还有很多其他的应用,例如获取一个类型的大小、判断一个类型是否是const类型、获取一个容器的迭代器类型等等。通过traits,我们可以更好地抽象和封装代码,提高代码的复用性和灵活性。

4.说说STL中的容器?

(1) vector

① vector底层是一个动态数组,包含三个迭代器:start、finish、end_of_storage。start和finish之间是已经被使用的空间范围,表示当前vector中有多少个元素,即有效空间size。start和end_of_storage是整块连续空间包括备用空间的大小,表示它分配的内存中可以容纳多少元素,即容量capacity。

② 当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间。之所以是1.5倍或者2倍,是因为考虑到可能产生的堆空间浪费,增长倍数不能太大,使用1.5或者2是比较合理的倍数。

③ 当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。

④ 对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器都会失效。

⑤ reserve函数的作用:将vector直接扩充到确定的大小,可以减少多次开辟和释放空间的效率问题(优化push_back),还可以减少拷贝数据的次数,它直接更改capacity

⑥ resize()函数的作用:可以改变vector有效空间的大小,即size的大小。如果size大于capacity,capacity的大小也会随着改变。

⑦ vector的底层实现要求连续的对象排列,引用并非对象,没有实际地址,因此vector的元素类型不能是引用

⑧ 当删除容器中一个元素后,该迭代器所指向的元素已经被删除,那么也造成迭代器失效。erase方法会返回下一个有效的迭代器

⑨ 释放vector内存的方法:

**vec.clear():**清空内容,但是不释放内存。

**vector().swap(vec):**清空内容,且释放内存,想得到一个全新的vector。

**vec.shrink_to_fit():**请求容器降低其capacity和size匹配。

**vec.clear();vec.shrink_to_fit();:**清空内容,且释放内存。

(2) List

① list的底层是一个双向链表,以结点为单位存放数据,结点的地址在内存中不一定连续,每次插入或删除一个元素,就配置或释放一个元素空间。

② list不支持随机存取,如果需要大量的插入和删除,而不关心随机存取,则可以使用list。

(3) Deque

① deque的是一个双向开口的连续线性空间(双端队列),在头尾两端进行元素的插入跟删除操作都有理想的时间复杂度。

② deque的底层并不是真正连续的空间,而是由一段段连续的小空间拼接而成,实际deque类似于一个动态的二维数组,由一个map(中控指针数组)和多个连续的缓冲区组成。

③ 当deque不断增加元素时,一旦map(中控指针数组)满了,那么会增容,不过map增容的代价非常低,因为只需要拷贝存储数据的buffer数组的指针,不需要拷贝buffer中的内容

④ 双端队列底层是一段假象的连续空间,实际是分段连续的,为了维护其“整体连续”以及随机访问的假象,deque的迭代器设计就比较复杂。由cur、first、last指向当前遍历buffer数组,node指向map中的元素,遍历deque的操作由这几个指针进行维护。

⑤ deque并不是从map的第一个位置就开始存放元素,而是从中间开始存放,这样在头部和尾部插入元素就会变得容易。

⑥ 与vector比较:头部插入和删除时,不需要搬移元素,效率特别高,而且在扩容时,也不需要搬移大量的元素,因此其效率是必vector高的。

⑦ 与list比较:其底层是连续空间,空间利用率比较高,不需要存储额外字段。

⑧ 不适合遍历,因为在遍历时,deque的迭代器要频繁的去检测其是否移动到某段小空间的边界,导致效率低下。不适合大量的中间插入删除,也不适合大量的随机访问。

(4) map 、set、multiset、multimap

① 底层数据结构都是红黑树,是一种自平衡的二叉搜索树

② set和multiset会根据特定的排序准则自动将元素排序,set中元素不允许重复,multiset可以重复。

③ map和multimap将key和value组成的键值对作为元素,根据key的排序准则自动将元素排序,map中元素的key不允许重复,multimap可以重复。

④ map和set的增删改查速度为都是logn,是比较高效的。

⑤ map和set的插入删除效率比序列容器高,而且每次insert之后,以前保存的iterator不会失效。因为存储的是结点,不需要内存拷贝和内存移动。

(5) unordered_map、unordered_set

① 底层数据结构是一个防冗余的哈希表(采用除留余数法)。其数据的存储和查找的效率很高,时间复杂度为O(1);而代价仅仅是消耗比较多的内存。

② 使用开链法解决哈希冲突。

③ 数据的存放是无序的

5. 什么是STL的顺序容器和关联式容器?

(1) 关联容器(Associative Container)与顺序容器(Sequential Container)的本质区别在于:关联容器是通过键(key)存储和读取元素的,而顺序容器则通过元素在容器中的位置顺序存储和访问元素

(2) 在STL中,这里的“顺序”和“关联”指的是上层接口表现出来的访问方式,并非底层存储方式。为什么这样划分呢?因为对STL的用户来说,他们并不需要知道容器的底层实现机制,只要知道如何通过上层接口访问容器元素就可以了,否则违背了泛型容器设计的初衷。

(3) 顺序容器主要采用向量和链表及其组合作为基本存储结构,如堆栈和各种队列。而关联式容器采用平衡二叉搜索树作为底层存储结构。

6. 说说STL的容器适配器?

容器适配器,其就是将不适用的序列式容器(包括 vector、deque 和 list)变得适用。容器适配器的底层实现都是通过封装某个序列式容器,并重新组合该容器中包含的成员函数,使其满足某些特定场景的需要。

(1) stack

① stack(栈)是一种**先进后出(First In Last Out)**的数据结构,只有一个出入口,那就是栈顶,除了对栈顶元素进行操作外,没有其他方法可以操作内部的其他元素。

② C++的栈是一种容器适配器,其底层数据结构一般用list或deque实现,只开放一部分的接口和方法即可完成对栈的支持。

(2) queue

① queue(队列)是一种**先进先出(First In First Out)**的数据结构,只有一个入口和一个出口,分别位于队头与队尾,只能在队尾插入元素,在队头取出元素,没有其他方法可以操作内部的其他元素。

② C++的队列是一种容器适配器,其底层数据结构一般用list或deque实现,只开放一部分的接口和方法即可完成对队列的支持。

(3) priority_queue

① priority_queue,优先级队列,是一个拥有权值观念的queue,它跟queue一样只能在队尾插入元素,在队头取出元素。在插入元素时,元素并非按照插入次序排列,它会自动根据权值(通常是元素的实值)排列,权值最高,排在最前面。

② priority queue(优先队列)的底层实现机制实际上是,因为大根堆总是最大值位于堆的根部,优先级最高。

③ C++中的堆是容器适配器,一般是vector为底层容器,以堆的处理规则来进行管理。

7. 说说STL的迭代器?

(1) 迭代器是连接容器和算法的一种重要桥梁,通过迭代器可以在不了解容器内部原理的情况下遍历容器。

(2) 在遍历容器的时候,不可避免的要对遍历的容器内部有所了解,所以,干脆把迭代器的开发工作交给容器的设计者好了,如此以来,所有实现细节反而得以封装起来不被使用者看到,这正是为什么每一种 STL 容器都提供有专属迭代器的缘故。

(3) 迭代器种类分为5类:

① **输入迭代器:**是只读迭代器,在每个被遍历的位置上只能读取一次。

② **输出迭代器:**是只写迭代器,在每个被遍历的位置上只能被写一次。

③ **前向迭代器:**兼具输入和输出迭代器的能力,但是它可以对同一个位置重复进行读和写。但它不支持operator–,所以只能向前移动。

④ **双向迭代器:**很像前向迭代器,只是它向后移动和向前移动同样容易。

⑤ **随机访问迭代器:**有双向迭代器的所有功能。而且,它还提供了“迭代器算术”,即在一步内可以向前或向后跳跃任意位置, 包含指针的所有操作,可进行随机访问,随意移动指定的步数。

(5) 通过traits技法,我们可以获取到迭代器一些特性,STL规定,每一个迭代器至少包含以下几种特性供外界获取,方便算法使用迭代器。

① **value_type:**迭代器所指对象的类型

② **difference_type:**两个迭代器之间的距离

③ **pointer:**迭代器所指对象的指针类型

④ **reference:**迭代器所指对象的引用类型

⑤ **iterator_category:**迭代器种类

(6) 迭代器失效问题

① **数组型数据结构(vector):**该数据结构的元素是分配在连续的内存中,insert和erase操作,都会使得删除点和插入点之后的元素挪位置,所以,插入点和删除掉之后的迭代器全部失效,也就是说insert( * iter)(或erase( * iter)),然后再iter++,是没有意义的。解决方法:erase( * iter)的返回值是下一个有效迭代器的值。 iter =cont.erase(iter)。

② **链表型数据结构:**对于list型的数据结构,使用了不连续分配的内存,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器.解决办法两种,erase( * iter)会返回下一个有效迭代器的值,或者erase(iter++)。

③ 树形数据结构: 使用红黑树来存储数据,插入不会使得任何迭代器失效;删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。erase迭代器只是被删元素的迭代器失效,但是返回值为void,所以要采用erase(iter++)的方式删除迭代器。

④ Deque:插入头尾会使迭代器全部失效但是引用不失效。其原因在于插入头尾可能会进行扩容,由于map的重新分配,迭代器的node失效,但是原map指向的连续数组并没有重新分配。因此,对整个迭代器来说是失效的,但对于元素的指针和引用仍然是有效的。删除头尾会使被删除的元素迭代器和引用失效,插入和删除中间会使迭代器和引用全部失效。

⑤ unodered_map/unordered_set:由于底层是哈希表,迭代器是否失效主要看哈希表的实现策略,对于使用除留余数法和开链法的哈希表来说,删除运算使指向删除位置的迭代器失效,但是不会失效其他迭代器。

8. 说说STL容器的线程安全性?

**STL只保证最低限度的线程安全性,即:**多个读者是安全的,多线程可以同时读取一个容器的内容,但如果有多个写者,则必须使用同步互斥机制保证线程安全。

9. STL容器的使用场景?

(1) vector的使用场景:只查看,而不频繁插入删除的

(2) deque的使用场景:头尾需要频繁插入删除

(3) list的使用场景:频繁的插入删除的场景,且位置不固定

(4) Set:针对单一值的增删查改操作都要有,且要求数据有序

(5) Map:针对键值对的增删查改操作都要有,且要求数据有序

(6) unordered_set:针对单一值的增删查改操作都要有,数据排列无要求。

(7) unordered_map:针对键值对的增删查改操作都要有,数据排列无要求。

C++常见面试题-进阶部分50题