C++总结

1、C++和C的区别

  • 设计思想上
    • C++是面向对象的语言,而C是面向过程的结构化编程语言
  • 语法上
    • C++具有封装、继承、多态三种特性
    • C++相比C,增加了许多类型的安全功能,比如强制类型转换
    • C++支持范式编程,比如模板类、函数模板等

2、C/C++编译与执行的几个阶段

参考资料

1)预编译:根据文件中的预处理指令来修改源文件的内容

2)编译:编译成汇编代码

3)汇编:把汇编代码翻译成目标机器指令

4)链接:链接目标代码生成可执行程序(源文件中可能包含另一个源文件包含的函数或变量等,因此需要通过链接使这些目标连接起来)

总结起来编译过程就上面的四个过程:预编译处理(.c) --> 编译、优化程序(.s、.asm)--> 汇编程序(.obj、.o、.a、.ko) --> 链接程序(.exe、.elf、.axf等)。

3、静态链接和动态链接

静态链接: 即在程序运行之前,将库函数全部载入到可执行的目标文件之中,这样子程序运行就脱离了库函数,优点是可移植性强,缺点也很明显,将不需要的库函数导入进去,消耗了太多内存

动态链接: 即在程序运行之前,到需要调用的库函数的时候,去看其他执行的文件有没有,如果存在的话,共享这个库函数,直接调用,等于库函数只有一份内存拷贝,没有的话再进行动态链接,这样会耗费一些时间,并且可移植性差,但是与静态链接相比占用内存少,并且如果修改了库函数的话,静态链接需要重新编译,而动态链接则不需要,因为调用的是库函数的接口。

4、C++中的struct和class有什么区别

  • 默认继承权限

如果不明确确定,struct中的默认继承权限是public,而class的默认继承权限是private

  • 成员的默认访问权限

class的默认访问权限是private,struct的默认访问权限是public

5、union是什么

union即为联合,他是一种特殊的类。通过关键字union进行定义,一个union可以有多个数据成员。

union U {
    int x;
    char y;
    double z;
};

在任意时刻,联合中只能有一个数据成员可以有值。当给联合中某个成员赋值之后,该联合中的其它成员就变成未定义状态了。联合可以为其成员指定public、protected和private等访问权限,默认情况下,其成员的访问权限为public。

sizeof(U) = max{sizeof(x),sizeof(y),sizeof(z)} = sizeof(z) = 8

6、堆和栈的区别

  • 栈(stack):存放的是函数的参数值、局部变量等,由编译器自动分配释放

​ 堆(heap):由new分配的内存块,由应用程序控制,需要程序员手动利用delete释放,如果没有,程序结束后,操作系统自动回收。

  • 因为堆得分配需要频繁使用new/delete,造成内存空间不连续,因此会有大量的碎片
  • 栈在内存中是一块连续的地址,向低地址扩展,栈的大小通常为2M(Window,Linux下为8M,可以更改),超出则会报错,即内存溢出。而堆在内存的空间是不连续的,向高地址扩展,堆的大小由引用类型的大小直接决定,引用类型的大小的变化直接影响到堆的变化。

为什么栈比堆要快?

栈是操作系统提供的数据结构,计算机底层对栈提供了一系列支持:分配专门的寄存器存储栈的地址,压栈和入栈有专门的指令执行;而堆是由C/C++函数库提供的,机制复杂,需要一系列分配内存、合并内存和释放内存的算法,因此效率较低。

7、什么是大端小端?各自的优势是什么?

参考资料

  • 大端与小端时用来描述多字节数据在内存中的存放顺序,即字节序。大端(Big Endian) 指低地址端放高位字节,小端(Little Endian) 是指低地址端存放低位字节。
  • 计算机是以字节为存储单位

各自的优势:

Big Endian:符号位的判定固定为第一个字节,容易判断正负。

Little Endian:长度为1,2,4字节的数排列方式都一样,数据类型装换方便

示例:

如果我们将0x12345678写入到以0x0000开始的内存中,则结果为:

                  big-endian            little-endian
  0x0000          0x12                  0x78
  0x0001          0x34                  0x56
  0x0002          0x56                  0x34
  0x0003          0x78                  0x12

  其中:
  0x12345678,0x12就是高位,0x78就是低位;
  0x0000是低地址,0x0003是高地址

8、C++内存分区

​ 存放函数的局部变量、函数参数、返回地址等,由编译器自动分配和释放。

​ 动态申请内存,就是由malloc分配的内存块,由程序员对它进行分配和释放,如果程序结束后还没释放,操作系统会自动回收。

  • 全局区/静态存储区(.bss段和.data段)

​ 存放全局变量和静态变量,程序运行结束操作系统自动释放,在 C 语言中,未初始化的放在 .bss 段中,初始化的放在 .data 段中,C++ 中不再区分了。

  • 常量存储区(.data段)

​ 存放的是常量,不允许修改,程序运行结束自动释放。

  • 代码区(.text)

​ 存放代码,不允许修改,但可以执行。编译后的二进制文件存放在这里。

9、自由存储区和堆之间的问题

参考资料1

参考资料2

在概念上我们是这样区分两者的:

  • malloc 在堆上分配的内存块,使用 free 释放内存。
  • new 所申请的内存则是在自由存储区上,使用 delete 来释放。

那么物理上,自由存储区与堆是两块不同的内存区域吗?它们有可能相同吗?

基本上,所有的 C++编译器 默认使用堆来实现自由存储,也即是缺省的全局运算符 new 和 delete 也许会按照 malloc 和 free 的方式来被实现,这时藉由 new 运算符 分配的对象,说它在堆上也对,说它在自由存储区上也正确。 但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。

总结:

  • 自由存储 是 C++ 中通过 new 与 delete 动态分配和释放对象的抽象概念,而 堆(heap)是 C语言 和 操作系统 的术语,是操作系统维护的一块动态分配内存。
  • new 所申请的内存区域在 C++ 中称为自由存储区。藉由堆实现的自由存储,可以说 new 所申请的内存区域在堆上。
  • 堆与自由存储区的运作方式不同、访问方式不同,所以应该被当成不一样的东西来使用。

10、内存对齐

什么是内存对齐?

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

为什么要内存对齐?

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

假如说没有内存对齐机制,那么数据就可以任意存放,假设要存储一个char和一个int,那么char将会存储在地址0,int将会存储在地址1~4。当存储粒度为4个字节的处理器开始读取数据的时候,他只能从4的倍数开始读取,当他要读取int变量时,就得先从0开始读取第一个4字节块,提出不想要的字节(0地址),然后从地址4开始再读取下一个4字节块,同样剔除不要的数据(5,6,7地址),最后留下两块数据合并当如寄存器,这需要做很多工作。

内存对齐的原则

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

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

内存对齐需要遵循的规则:

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

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

内存对齐的好处?

  • 在硬件上来说,cpu读取内存不是一个字节一个字节读的,而是一块一块的读取,所以内存对齐有利于减少读取时间,本质上就是空间换时间
  • 从平台上来说,在有些平台上只能在特定位置读取一定的字节块,内存对齐就统一这样的规范,而不会使得在不同平台上的读取结果不同

11、深拷贝与浅拷贝

浅拷贝就是两个对象同时指向了同一块内存,这样会导致析构的时候,进行两次析构,即内存释放两次,会导致程序崩溃。
深拷贝就是手写拷贝构造函数,为即将被赋值的对象新开辟一个内存。
对于类中默认的拷贝构造函数都是浅拷贝

12、new/delete和malloc/free的区别

new/delete的实现

区别:

  • 属性: new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持。

  • 参数: 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显示地指出所需内存的尺寸。

  • 返回类型: new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无需进行类型转换,因此new是符合类型安全的操作符。而malloc内存分配成功则是返回void *,需要通过强制类型转换将void * 指针转换成我们需要的类型。

  • 分配失败: new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。

  • 自定义类型: new会调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构函数。

  • 重载: 关键字new/delete是不能被重载的,重载实际上是重载new/delete调用的operator new/delete,而不是new/delete。malloc/free是库函数,因此不能进行重载

  • 内存区域: new操作符从自由存储区(free store)上为对象动态分配内存空间,而malloc函数从堆上动态分配内存。自由存储区是C++基于new操作符的一个抽象概念,凡是通过new操作符进行内存申请,该内存即为自由存储区。而堆是操作系统中的术语,是操作系统所维护的一块特殊内存,用于程序的内存动态分配。

new的原理: 先调用operator new申请空间,然后调用类的构造函数

delete的原理: 先调用类的析构函数完成对象的资源清理,然后调用operator delete释放空间

new T[N]的原理: 先调用operator new[](实际上是调用operator new N次进行内存申请)申请一段连续的空间,然后调用N次类的构造函数

delete []的原理: 先调用N次析构函数完成对象的资源清理,然后调用operator delete[](实际上是调用operator delete)释放空间

为什么有了malloc/free之后还要有new/delete?

因为对于非内部数据结构而言,malloc/free无法满足动态对象的要求。对象在创建的时候需要自动执行构造函数,在消亡的时候自动执行析构函数。对于malloc/free是库函数而不是操作符,不在编译器控制权限之内,不能把执行的构造函数和析构函数强加于malloc/free,所以就有了new/delete。

13、什么是强制类型转换

参考资料

C语言的强制类型装换

TypeName b = (TypeName) a;

C语言的强制类型转换又分为显示类型转换和隐式类型转换。

  • 显示类型转换:强制转换. 在被转换的表达式前加(类型)
  • 隐式类型转换:不需要加强制转换, 系统会自动做这个操作

C风格的强制类型转换容易理解,但是C风格的强制类型转换容易带来一些隐患,让一些问题难以察觉。所以C++提供了一组可以用在不同场合强制转换的函数。

C++ 的强制类型转换

C++中四种类型转换是:static_cast, dynamic_cast, const_cast, reinterpret_cast

  • const_cast

1)用于将const变量转为非const
2)*const_cast一般用于修改指针。如const char p形式。

e.g.

#include

int main() {
    int a[] = {1,2};    

    // 常量化数组指针
    const int * c_ptr = a;
    //c_ptr[0] = 12;   //error: assignment of read-only location '* c_ptr'

    // 通过const_cast 去常量
    //其中Ty的类型只能是指针或者引用,因为我们const_cast主要作用是将const变量去const,而去const之后你还要指向该变量,只能使用指针或者引用
    int *ptr = const_cast(c_ptr);

    // 修改数据
    ptr[0] = 2 , ptr[1] = 3;

    std::cout << c_ptr[0] << " " << c_ptr[1] << std::endl;

    return 0;
}
  • static_cast

1)static_cast 作用和C语言风格强制转换的效果基本一样,由于没有运行时类型检查来保证转换的安全性,所以这类型的强制转换和C语言风格的强制转换都有安全隐患。
2)用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换。注意:进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
3)用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性需要开发者来维护。
4)static_cast不能转换掉原有类型的const、volatile、或者 __unaligned属性。(前两种可以使用const_cast 来去除)
5)在c++ primer 中说道:c++ 的任何的隐式转换都是使用 static_cast 来实现。

e.g.

#include

int main() {
    int a = 10;
    int b = 3;
    double result = static_cast(a) / static_cast(b);
    std::cout << result << std::endl;
    return 0;
}
int main() {
    int a = 10;
    int *b = &a;
    //指针不能直接进行类型转换
    //double* c = static_cast(b); //error: invalid static_cast from type 'int*' to type 'double*'
    //但是可以通过万能指针void*作为中间件进行转换
    void* p = static_cast(b);
    double* c = static_cast(p);
    std::cout << *c << std::endl;

    return 0;
}
  • dynamic_cast

用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用,若对指针进行dynamic_cast,失败返回null,成功返回正常cast后的对象指针;若对引用进行dynamic_cast,失败抛出一个异常,成功返回正常cast后的对象引用。

向上转换:指的是子类向基类的转换

向下转换:指的是基类向子类的转换

它通过判断在执行到该语句的时候变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。

【注意】:

dynamic_cast在将父类cast到子类时,父类必须要有虚函数,否则编译器会报错。

dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。

在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的;
在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。

  • reinterpret_cast

几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用;

14、引用和指针

引用:

引用是某一变量的一个别名,对引用的操作与对变量的直接操作是完全一样的。引用的声明方法:类型标识符 &引用名 = 目标变量名。引用的本质在C++内部实现是一个指针常量,因此引用不能更改指针的指向,但是可以更改引用的值。

指针:

指针利用地址,它的值直接指向内存中一块地址的值。由于可以通过地址找到所需的变量单元,所以说,地址指向该变量单元。因此,将地址形象化的称为“指针“。意思是通过它能找到以它为地址的内存单元。

区别:

  • 指针有自己的一块空间,而引用只是变量的一个别名。
  • 指针的大小为4个字节,而引用的大小为被引用对象的大小。
  • 指针可以初始化为NULL,而引用必须被初始化且必须是一个已有对象的引用。
  • 指针可以有多级指针,而引用只能是一级。
  • 指针可以指向其他对象,而引用只能是一个对象的引用。
  • 可以有常量指针,但是没有常量引用
  • 作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象

15、extern关键字的作用

extern置于变量或函数前,用于标示变量或函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模板中寻找其定义。

它主要有两个作用:

  • 当它与“C”一起连用的时候,如:extern “C” void fun(int a,int b);则告诉编译器在编译fun这个函数时候按着C的规矩去翻译,而不是C++的(由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。比如说你用C 开发了一个DLL 库,为了能够让C ++语言也能够调用你的DLL输出(Export)的函数,你需要用extern "C"来强制编译器不要修改你的函数名。)
  • 当extern不与“C”在一起修饰变量或函数时,如:extern int g_Int;它的作用就是声明函数或全局变量的作用范围的关键字,其声明的函数和变量可以在本模块或其他模块中使用。记住它是一个声明不是定义!也就是说B模块(编译单元)要是引用模块(编译单元)A中定义的全局变量或函数时,它只要包含A模块的头文件即可,在编译阶段,模块B虽然找不到该函数或变量,但它不会报错,它会在连接时从模块A生成的目标代码中找到此函数。

16、static关键字的作用

  • 全局静态变量

​ 保存在静态存储区,整个程序运行期间一直存在。如果没有初始化则会被初始化为0,作用范围为定义该变量开始,一直到程序尾。全局静态变量在声明它的文件之外是不可见的。

  • 局部静态变量

​ 保存在静态存储区,整个程序运行期间一直存在,改变了局部变量的生命周期。如果没有初始化则会被初始化为0,作用域为定义该变量开始到包含该变量的函数结束,因为该变量是保存在自由存储区并不是在栈区,所以并不会在函数结束后被回收,每次调用的函数时都是这一个变量。

  • 静态函数

​ 普通函数默认是extern类型的,可以被其他cpp文件所调用,当把普通函数声明为静态函数,他就只能被自己的cpp文件所调用,并且不会和其他cpp文件中的同名函数起冲突。

  • 类静态成员

​ 保存在静态存储区,类的静态成员被所有对象共享,类的静态成员在类内定义,类外初始化。对于多个对象的静态成员开始,它只存储在一处,多个对象共享。使用静态数据成员还不会破坏隐藏的原则,保证了安全性

  • 类静态函数

​ 类静态函数同类静态成员一样,都为类的静态成员,类静态函数不能调用非静态成员和非静态函数,因为他没有this指针。类静态函数不能声明为const,virtual,volatile。如果静态成员函数中要引用非静态成员时,可通过对象来引用。

17、const关键字的作用

  • 欲阻止一个变量被改变,可以使用const关键字。在定义该const变量时,通常需要对它进行初始化,因为以后就没有机会再去改变它了

  • 对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const

  • 在一个函数声明中,const可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值

  • 对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的 成员变量

  • 对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”

18、面向对象的三大特性

  • 封装

将把客观事物抽象为类,包含自己的属性和方法。

  • 继承

使用现有类的所有功能,在无需重新编写原有类的情况下对类的功能进行拓展。被继承的类成为父类或基类,继承的类成为子类或派生类。

  • 多态

一种形式,多种状态,分为静态多态和动态多态。静态多态指编译时多态,如函数重载、模板;动态多态指运行时多态,特指virtual虚函数机制形成的多态。

19、野指针与悬空指针

  • 悬空指针:当所指向的对象被释放或者收回,但是没有让指针指向NULL;
{
   char *dp = NULL;
   {
       char c;
       dp = &c;
   } 
  //变量c释放,dp变成空悬指针
}
  • 野指针:那些未初始化的指针;
int func()
{
    char *dp;//野指针,没有初始化
    static char *sdp;//非野指针,因为静态变量会默认初始化为0
}

20、内联函数与宏定义

内联函数

内联函数把函数编译好的二进制指令直接复制到函数的调用位置。

优点:能够提高程序的运行速度,因为没有这样就少去了函数调用的时间
缺点:会导致可执行文件冗余,因为是牺牲空间来换取时间

内联函数分为显示内联和隐式内联:
显示:在函数前加inline
隐式:在结构、类中直接定义的成员函数,则该函数也被自动优化成内联函数

宏函数

宏函数是直接在预处理时直接进行字符串的替换

优点:节省空间(给形参节省)

缺点:浪费时间(主要浪费在编译时);没有语法检查,不安全。

内联函数与宏定义的区别:

1)宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率。

内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。如果内联函数的函数体过大,编译器会自动    的把这个内联函数变成普通函数。

2)宏定义是在预处理的时候把所有的宏名用宏体来替换,简单的说就是字符串替换

内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率

3)宏定义是没有类型检查的,无论对还是错都是直接替换

内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等

4)宏定义和内联函数使用的时候都是进行代码展开。不同的是宏定义是在预编译的时候把所有的宏名替换,内联函数则是在编译阶段把所有调用内联函数的地方把内联函数插入。这样可以省去函数压栈退栈,提高了效率

21、什么是智能指针

参考资料

智能指针有没有内存泄露的情况?

当两个对象相互使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄漏。

智能指针的内存泄漏如何解决?

为了解决循环引用导致的内存泄漏,引入了weak_ptr弱指针,weak_ptr的构造函数不会修改引用计数的值,从而不会对对象的内存进行管理,其类似一个普通指针,但不指向引用计数的共享内存,但是其可以检测到所管理的对象是否已经被释放,从而避免非法访问。

22、C++ 重载和重写的区别

  • 重载:是指同一可访问区内被声明的几个具有不同参数列的同名函数,根据参数列表确定调用那个函数,重载不关心函数的返回类型。
  • 重写:指派生类中存在定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同,派生类调用时才会调用派生类的重写函数,不会调用被重写的函数。重写的基类中被重写的函数必须有virtual修饰。

23、虚函数

相关博客:

虚函数表在内存中的位置

虚函数表详解

什么是虚函数?

虚函数是C++中用于实现多态的机制,核心理念是通过基类访问派生类的函数。

虚函数的实现原理:

虚函数是通过虚函数表来实现的。虚函数表是一个保存着虚函数的地址的表,而在每一个实例对象的内存空间里都有一个虚函数指针,这个虚函数指针指向虚函数表的地址,这个虚函数指针放在实例对象内存的最前面(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)

虚函数表的存储位置:

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

虚函数表是针对类的还是针对对象的?

编译器为每一个类维护一个虚函数表(本质是一个函数指针数组,数组里面存放了一系列函数地址 ),每个对象的首地址保存着该虚函数表的指针,同一个类的不同对象实际上指向同一张虚函数表。所以同一个类的不同对象的虚函数指针是相同的。

24、多态的基本概念

多态分为两类:

  • 静态多态:函数重载和运算符重载属于静态多态,复用函数名

  • 动态多态:派生类和虚函数实现运行时多态

静态多态和动态多态的区别:

  • 动态多态的函数早绑定 编译阶段确定函数的地址

  • 动态多态的函数地址晚绑定 运行阶段确定函数地址

多态的实现原理:

  • 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数。

  • 存在虚函数的类都有一个一维的虚函数表叫做虚表。当类中声明虚函数时,编译器会在类中生成一个虚函数表。

  • 类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。

  • 虚函数表是一个存储类成员函数指针的数据结构。

  • 虚函数表是由编译器自动生成与维护的。

  • virtual成员函数会被编译器放入虚函数表中。

  • 当存在虚函数时,每个对象中都有一个指向虚函数的指针(C++编译器给父类对象,子类对象提前布局vptr指针),当进行test(parent *base)函数的时候,C++编译器不需要区分子类或者父类对象,只需要再base指针中,找到vptr指针即可)。

  • vptr一般作为类对象的第一个成员。

25、为什么构造函数一般不定义为虚函数?

1)因为创建一个对象时需要确定对象的类型,而虚函数是在运行时确定类型的。而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型,是类本身还是派生类。

2)虚函数的调用需要通过虚函数表指针来找到虚函数表所在的地址,从而从虚函数表中找到虚函数的地址来调用虚函数,但是虚函数表指针是调用构造函数的时候初始化的,所以两者就造成了冲突。

26、为什么析构函数一般定义为虚函数?

参考资料

多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码,这时候就得将析构函数改为虚析构或者纯虚析构。定义为虚析构或纯虚析构之后就会先调用派生类的析构函数,然后调用基类的派生函数,防止内存泄漏。

27、vector

是动态空间,随着元素的加入,它的内部机制会自行扩充空间以容纳新元素,vector维护的是一个连续的线性空间。

vector的数据结构中其实就是三个迭代器构成的,一个指向目前使用的空间头iterator(first),一个指向目前使用空间尾的iterator(last),一个指向目前可用空间尾的iterator(end)。当有新的元素插入时,会把当前的size与capacity进行比较,如果当时size等于capacity的话,那么会先新开辟一片两倍的capacity的空间,然后把原来的元素迁移到新的空间,释放掉原来的空间。需要注意的是,每次扩充是重新开辟的空间,所以扩充后,原有的迭代器会失效。

resize: 调整vector中元素的个数,那么会使size增加,如果size会超过capacity的话,capacity也会增加

reserve: 会调整vector中容器预留的容量,也就是capacity的值,但是他不会改变size的值,也就是他只是增加了预留空间的大小,没有新增元素对象。

使用注意点:

注意插入和删除后迭代器失效的问题

清空vector数据时,如果保存的数据项是指针类型,需要逐项delete,

频繁调用push_back的影响:

向vector的尾部添加元素,很有可能引起整个对象存储空间重新分配,重新分配更大的内存,再将原数据拷贝到新空间中,再释放原有内存,这个过程是耗时耗力的,频繁对vector调用push_back()会导致性能下降。

在C++11之后引入了emplace_back(),和push_back()一样都是在容器末尾添加一个新元素,但是emplace_back()在效率上有了一定的提升。

emplace_back为什么比push_back快:

参考资料

底层的实现机制不同。push_back()向容器尾添加元素时,首先会创建这个元素,然后再将这个元素拷贝或移动到(如果实现了移动构造就移动,没有的话就拷贝,且拷贝之后会销毁先前创建的这个元素)到容器中;而emplace_back()是直接在容器尾部创建这个元素,省去了拷贝或移动的过程。

为什么有了emplace_back还要push_back:

为了兼顾之前的版本。

28、list

list底层实现是双向链表,是非连续存储结构,每个元素维护一对前后向指针,所以支持正序和逆序遍历。
支持高效的插入和删除,但是随机访问的效率低。由于每个元素需要维护额外的两个指针,空间开销较大。
相较于vector而言,vector是当capacity==size时候,申请一个二倍的空间,而list是每次插入都会新开辟一个元素单位的空间存放。

29、deque

vector 是单向开口的连续线性空间,deque 则是⼀种双向开⼝的连续线性空间。所谓双向开⼝,就是说 deque ⽀

持从头尾两端进⾏元素的插⼊和删除操作。相⽐于 vector 的扩充空间的⽅式,deque 实际上更加贴切的实现了动

态空间的概念。deque 没有容量的概念,因为它是动态地以分段连续空间组合⽽成,随时可以增加⼀段新的空间并

连接起来。

vector、list、deque三者使用场景:

  • 如果要求随机存取,不在乎首部/尾部插入和删除的效率,用vector
  • 如果要大量的增删,不要求随机存取,用list
  • 要求随机存取,并且会在首部和尾部进行大量插入和删除的操作,用deque

30、heap

堆并不属于 STL 容器组件,它是个幕后英雄,扮演 priority_queue 的助⼿,priority_queue 允许⽤户以任何次序

将任何元素推⼊容器内,但取出时⼀定是从优先权最⾼(数值最⾼)的元素开始取。⼤根堆(binary max heap)

正具有这样的性质,适合作为 priority_queue 的底层机制。

⼤根堆,是⼀个满足每个节点的键值都⼤于或等于其⼦节点键值的⼆叉树(具体实现是⼀个 vector,⼀块连续空

间,通过维护某种顺序来实现这个⼆叉树),新加⼊元素时,新加⼊的元素要放在最下⼀层为叶节点,即具体实现

是填补在由左⾄右的第⼀个空格(即把新元素插⼊在底层 vector 的 end()),然后执⾏⼀个所谓上溯的程序:将新

节点拿来与 ⽗节点⽐较,如果其键值⽐⽗节点⼤,就⽗⼦对换位置,如此⼀直上溯,直到不需要对换或直到根节点

为⽌。当取出⼀个元素时,最⼤值在根节点,取⾛根节点,要割舍最下层最右边的右节点,并将其值᯿新安插⾄最

⼤堆,最末节点放⼊根节点后,进⾏⼀个下溯程序:将空间节点和其较⼤的节点对调,并持续下方,直到叶节点为

⽌。

31、priority_queue

底层时⼀个 vector,使⽤ heap 形成的算法,插⼊,获取 heap 中元素的算法,维护这个 vector,以达到允许⽤户

以任何次序将任何元素插⼊容器内,但取出时⼀定是从优先权最⾼(数值最⾼)的元素开始取的⽬的。

32、map 和 set 有什么区别,分别⼜是怎么实现的?

map 和 set 都是 C++ 的关联容器,其底层实现都是红⿊树(RB-Tree)。

由于 map 和 set 所开放的各种操作接⼝,RB-tree 也都提供了,所以⼏乎所有的 map 和 set 的操作⾏为,都只是

转调 RB-tree 的操作⾏为。

map 和 set 区别在于:

(1)map 中的元素是 key-value(关键字—值)对:关键字起到索引的作⽤,值则表示与索引相关联的数据;Set

与之相对就是关键字的简单集合,set 中每个元素只包含⼀个关键字。

(2)set 的迭代器是 const 的,不允许修改元素的值;map允许修改value,但不允许修改key。其原因是因为

map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么⾸先需要删除该键,然后调节平衡,

再插⼊修改后的键值,调节平衡,如此⼀来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改

变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;⽽map的

迭代器则不允许修改key值,允许修改value值。

(3)map⽀持下标操作,set不⽀持下标操作。map可以⽤key做下标,map的下标运算符[ ]将关键码作为下标去

执⾏查找,如果关键码不存在,则插⼊⼀个具有该关键码和mapped_type类型默认值的元素⾄map中,因此下标

运算符[ ]在map应⽤中需要慎⽤,const_map不能⽤,只希望确定某⼀个关键值是否存在⽽不希望插⼊元素时也不

应该使⽤,mapped_type类型没有默认值也不应该使⽤。如果find能解决需要,尽可能⽤find。

33、map和unordered_map区别

map:底层实现是红黑树,红黑树是一种弱平衡的二叉搜索树,是关于黑节点的平衡。对于红黑树而言,它的中序遍历是从小到大有序的。增删改查的复杂度均为logn,插入一个结点最多旋转两次,删除一个结点最多旋转三次,旋转次数远小于二叉搜索树。但是红黑树比较占用空间,它每个节点不仅保留了左右儿子指针,还保留了父节点指针和结点的颜色。

unordered_map:底层实现是哈希表。相对于map而已,他的数据插入并不是有序的,对于查询而言,平均复杂度为O(1),最差会被卡成O(n)

map相较于unordered_map各种操作的复杂度都是稳定的logn,而unordered_map查找的平均复杂度优于map,但是不够稳定,map在对于要求数据有序的时候是非常适用的。

34、sort

​ STL的sort算法,当数据量大时使用快速排序,分段递归后的数据量小于某个阈值,为避免递归调用带来的过大额外开销,就改用插入排序。如果递归层次过深,改用堆排序。

​ 快速排序最关键的地方在于枢轴的选择,最坏的情况发生在分割时产生了一个空的区间,这样就完全没有达到分割的效果。STL采用的做法称为median-of-three,即取整个序列的首、尾、中央三个地方的元素,以其中值作为枢轴。

35、STL中迭代器的作用

可以通过迭代器访问顺序容器和关联容器中的元素,需要通过迭代器进行,迭代器是一个变量,相当于容器和操作容器算法之间的中介。迭代器可以指向容器中的某个元素,使得我们可以在不知道对象内部表示的情况下,按照一定顺序访问容器中的各个元素。

迭代器和指针的区别

迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的⼀些功能,通过重载了指针的⼀些操作符,->、

*、++、–等。迭代器封装了指针,是⼀个“可遍历STL( Standard Template Library)容器内全部或部分元素”的

对象, 本质是封装了原⽣指针,是指针概念的⼀种提升(lift),提供了比指针更⾼级的⾏为,相当于⼀种智能指

针,他可以根据不同类型的数据结构来实现不同的++,–等操作。

迭代器返回的是对象引⽤⽽不是对象的值,所以cout只能输出迭代器使⽤*取值后的值⽽不能直接输出其自身。

C++的设计思想之一就是尽量屏蔽指针 对指针的封装——iteraotr能达到这个目的 再者 iterator还是container与algorithm的桥梁 仅仅依靠C的指针是达不到这个功能的

36、移动语义和完美转发

参考资料1

参考资料2

参考资料3

什么是左值,右值?

每个表达式包含两个属性:类型值类型。而值类型包括3个基本类型:lvalue(左值)、prvalue、xrvalue。prvalue和xrvalue统称为rvalue()右值。C++中所有的值都必然属于左值、右值二者之一。左值是指表达式结束后依然存在的持久化对象,右值是指表达式结束时就不再存在的临时对象。所有的具名变量或者对象都是左值,而右值不具名。很难得到左值和右值的真正定义,但是有一个可以区分左值和右值的便捷方法:看能不能对表达式取地址,如果能,则为左值,否则为右值

示例:

int a = 10; //是持久对象(可以对其取地址),是左值
/*
对于 a++ 和 ++a:
a++是先取出持久对象a的一份拷贝,再使持久对象a的值加1,最后返回那份拷贝,而那份拷贝是临时对象(不可以对其取地址),故其是右值
++a则是使持久对象a的值加1,并返回那个持久对象a本身(可以对其取地址),故其是左值
*/
int *pFlag = &a; //pFlag和*pFlag都是持久对象(可以对其取地址),是左值
vector vctTemp; //vctTemp[0]调用了重载的[]操作符,而[]操作符返回的是一个int &,为持久对象(可以对其取地址),是左值
vctTemp.push_back(1);
string str1 = "hello "; //string("hello")是临时对象(不可以对其取地址),是右值,而str1是变量,是左值
string str2 = "world";
string str = str1 + str2; //str1+str2是调用了+操作符,而+操作符返回的是一个string(不可以对其取地址),故其为右值
const int &m = 1; //m是一个常量引用,引用到一个右值,但引用本身是一个持久对象(可以对其取地址),为左值

左值引用:

在C++11之前就已经有了左值引用,有时候简称为引用,即:

int x = 20;
int& rx = x;

左值引用又称为const引用和non-const引用,对于non-const引用,只能使用non-const左值来初始化:

int x = 20;
int& rx1 = x;   // non-const引用可以被non-const左值初始化
const int y = 10;
int& rx2 = y;  // 非法:non-const引用不能被const左值初始化
int& rx3 = 10;      // 非法:non-const引用不能被右值初始化

对于const引用:

int x = 10;
const int cx = 20;
const int& rx1 = x;   // const引用可以被non-const左值初始化
const int& rx2 = cx;  // const引用可以被const左值初始化
const int& rx3 = 9;   // const引用可以被右值初始化

右值引用:

定义右值引用需要使用&&,右值引用一定不能被左值所初始化,只能用右值初始化:

int x = 20;    // 左值
int&& rrx1 = x;   // 非法:右值引用无法被左值初始化
const int&& rrx2 = x;  // 非法:右值引用无法被左值初始化

对于C++11引入的移动语义和完美转发都是基于右值引用的

移动语义:

移动语义:将内存的所有权从一个对象转移到另外一个对象,高效的移动用来替换效率低下的复制,对象的移动语义需要实现移动构造函数(move constructor)和移动赋值运算符(move assignment operator)。

示例1:

class Person {

};

Person get() {
    Person p;
    return p;
}
void test() {
    Person p2 = get(); 
}
int main() {
    test();
}

移动语义就是为了减少资源开销和提高效率引入的,在示例1中创建对象p时对调用一次构造函数,返回值调用拷贝构造函数给临时对象,test中p2又通过拷贝构造函数获得临时变量的值。整个过程进行了两次拷贝,如果类比较复杂,那么会消耗很多时间的,所以引入了移动语义,移动语义通过移动构造函数,将资源所有权移动给了临时对象,临时对象移动给了主函数部分。

示例2:

vector v1{1, 2, 3, 4};
vector v2 = v1;             // 此时调用复制构造函数,v2是v1的副本
vector v3 = std::move(v1);  // 此时调用移动构造函数,v3与v1交换:v1为空,v3为{1, 2, 3, 4}
std::cout << v1.size() << std::endl;
std::cout << v2.size() << std::endl;
std::cout << v3.size() << std::endl;

output:
0
4
4

在移动语义中,用移动而不是复制来避免无必要的资源浪费,从而提升程序的运行效率。其实在C++11中,STL的容器都实现了移动构造函数与移动赋值运算符,这将大大优化STL容器。

完美转发:

完美转发:定义一个函数模板,该函数模板可以接收任意类型参数,然后将参数转发给其它目标函数,且保证目标函数接受的参数其类型与传递给模板函数的类型相同

// 目标函数
void foo(const string& str);   // 接收左值
void foo(string&& str);        // 接收右值

template 
void wrapper(T&& param)
{
    foo(std::forward(param));  // 完美转发
}

首先要有一点要明确,不论传入wrapper的参数是左值还是右值,一旦传入之后,param一定是左值,然后我们来具体分析这个函数:

  • 当一个类型为string类型的右值传递给wrapper时,T被推导为string,param为右值引用类型,但是一旦传入后,param就变成了左值,所以你直接转发给foo函数,将丢失param的右值属性,那么std::forward就确保传入foo的值还是一个右值;
  • 当类型为const string的左值传递给wrapper时,T被推导为const string&,param为const左值引用类型,传入后,param仍为const左值类型,所以你直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;
  • 当类型为string的左值传递给wrapper时,T被推导为string&,param为左值引用类型,传入后,param仍为左值类型,所以你直接转发给foo函数,没有问题,此时应用std::forward函数可以看成什么也没有做;

所以wrapper函数可以实现完美转发,其关键点在于使用了std::forward函数确保传入的右值依然转发为右值,而对左值传入不做处理。

37、哈希表

哈希表的构造方法:

1)直接定址法:例如H(Key)= a*Key + b

2)数字分析法:分析Key中的全体数据,并从中提取分析均匀的若干位或他们的组合构成全体。适用于位数较长,且有一定规律,如身份证

3)平方取中法:求关键字平方,然后取这个平方数的中间几位。适用于某些数字出现频率高的情况

4)折叠法:将数字分割成几部分,然后将其相加。适用于位数很多的情况

5)除留余数法:H(Key) = Key mod p(p<=m m为表长)

哈希冲突的解决方法:

1)开放定制法: 如Hi = (H(Key) + di) % m 。di可以通过线性探测再散列,平方探测再散列,随机探测再散列获得

2)链地址法: 每个相同key通过链将他连起来

3)公共溢出区法:建立一个特殊存储空间,专门存放冲突的数据。此种方法适用于数据和冲突较少的情况。

4)再散列法:准备若干个hash函数,如果使用第一个hash函数发生了冲突,就使用第二个hash函数,第二个也冲突,使用第三个……

38、C++类大小的计算

类的大小与内存对齐的对齐单位有关(概念见上文的内存对齐部分),不同的编译器对齐单位不同,可以通过#pragma pack(n)开设置对其单位。

e.g.

#pragma pack(n) n为对齐单位
class A {
public:
    int c; 
    char a;
	double b;
};

当内存对其单位为 1 时,则sizeof(A) = 13 ,1~4存int , 5 存 char ,6~13存double

当内存对其单位为 2 时,则sizeof(A) = 14 ,1~4存int , 5~6 存 char ,7~14存double

当内存对其单位为 4 时,则sizeof(A) = 16 ,1~4存int , 5~8 存 char ,9~16存double

39、虚继承

虚继承就是用来解决菱形继承问题,即B继承A,C继承A,D继承B和C,这时候D中就会有两份A的数据,就会造成了命名冲突和数据冗余问题,因此就引入了虚继承。虚继承就是在继承时添加virtual,例如Public B : virtual Public A,将B和C定义为虚继承,此时D继承B和C就只会在内存中只有一份数据。

虚继承的目的就是让类声明,愿意共享基类,这个基类就是虚基类

40、构造函数和析构函数为什么不能调用虚函数

可以调用但没必要

A x = new B(),其中B为派生类,A为基类,定义的这个过程他会先调用A的构造函数,然后再调用B的构造函数,所有在A的构造函数中调用虚函数时只能调用A自己的虚函数,因为B的构造函数还没有被构造,所以这就没有必要了,你将虚函数定义成普通函数他也能调用的。

析构函数也是同理,他会先析构派生类,然后析构基类,当基类调用虚函数时,派生类已经被析构了,只剩下自己的虚函数可以调用。

41、const常量和define的区别

1)编译器处理方式不同: define是在预处理阶段展开,而const是在编译阶段使用。

2)类型和安全检查不同: define宏没有类型,不做安全检查,仅仅是展开,而const有具体的类型,在编译阶段会进行检查

3)存储方式不同: define不分配内存,给出的是立即数,有多少次使用就进行多少次替换,在内存中会有多个拷贝,消耗内存大。而const在静态存储区中分配空间,在程序运行过程中内存中只有一个拷贝。

#define PI 3.14159 //常量宏 
const doulbe Pi=3.14159; //此时并未将Pi放入ROM中 ...... 
double i=Pi; //此时为Pi分配内存,以后不再分配! 
double I=PI; //编译期间进行宏替换,分配内存 
double j=Pi; //没有内存分配 
double J=PI; //再进行宏替换,又一次分配内存! 

4)效率方面: 在编译时, 编译器通常不为const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。宏替换只作替换,不做计算,不做表达式求解。

42、this指针

this在构造函数初始化列表中不能使用this,因为this是指向对象,在构造函数初始化列表时this还没有构造完成,编译器无法识别。

43、友元函数与友元类

友元机制允许类外的函数或者其他类访问当前类的私有成员,将函数或者其他类声明在当前类中,就可以实现。通常,将友元声明成组地放在类定义的开始或结尾是个好主意。友元的正确使用能够提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序的可维护性变差。

友元类的注意事项:

1)友元关系不能被继承

2)友元关系是单向的,不具有交换性。若A是B的友元类,则B不一定是A的友元类。

3)友元关系不具有传递性。若B是A的友元,C是B的友元,C不一定是A的友元。

44、构造函数成员初始化列表会快一些?

成员初始化列表是在类或者结构体中,在参数后以冒号开头,都好进行分割的一系列初始化字段。

对于int,char等内置类型性能并没有明显的提示,而对于类来说,使用初始化列表会少调用一次构造函数,见下面例子。

e.g.

#include
using namespace std;
struct Test1
{
    Test1() // 无参构造函数
    { 
        cout << "Construct Test1" << endl ;
    }
    Test1(const Test1& t1) // 拷贝构造函数
    {
        cout << "Copy constructor for Test1" << endl ;
        this->a = t1.a ;
    }
    Test1& operator = (const Test1& t1) // 赋值运算符
    {
        cout << "assignment for Test1" << endl ;
        this->a = t1.a ;
        return *this;
    }
    int a ;
};
struct Test2
{
    Test1 test1 ;
    Test2(Test1 &t1)
    {
        test1 = t1 ;
    }
};
struct Test3
{
    Test1 test1 ;
    Test3(Test1 &t1):test1(t1){}
};
int main() {
    Test1 t1;
    cout << "---------------" << endl;
    Test2 t2(t1);
    cout << "---------------" << endl;
    Test3 t3(t1);
}

输出:
Construct Test1
---------------
Construct Test1
assignment for Test1
---------------
Copy constructor for Test1

根据输出结果可以看到通过列表初始化可以少调用一次默认构造函数,对于一个数据密集型的类来说,性能的提升还是很高的。至于为什么不会调用默认构造函数,参考:参考资料

构造函数的执行可以分成两个阶段,初始化阶段和计算阶段,初始化阶段先于计算阶段。构造函数后面跟着的冒号就是初始化阶段,就是说如果构造函数后面你没有显示的初始化的话,编译器还是会为你默认的初始化(就好比Test2中他会调用无参构造函数初始化test1),

就好比:

A a;
A b;
b = a;
和
A a;
A b=a;
显然,第一种就是调用了两次普通构造和一次赋值构造
     而第二种就只调用了一次普通构造和一直拷贝构造

除了性能问题之外,有些时场合初始化列表是不可或缺的,以下几种情况时必须使用初始化列表

  • 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
  • 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
  • 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。

45、vector怎么缩减内存空间

众所周知,vector中的capacity总是会比size大一点,那么如果我们不想要在添加元素,那么我们应该要怎么减少capacity呢,让size==capacity。我们可以通过swap来缩减空间,见下面例子

int main() {
    vectorve;
    for(int i=0;i<10000;i++) ve.push_back(i);
    cout << ve.size() << endl;
    cout << ve.capacity() << endl;
    vector(ve).swap(ve);
    cout << ve.size() << endl;
    cout << ve.capacity() << endl;
}

输出:
10000
16384
10000
10000

基本流程: (1)用ve初始化一个临时对象,临时对象会根据v1的元素个数进行初始化;
(2)交换临时对象和ve;
(3)临时对象交换后销毁,ve原来的空间也销毁了;ve就指向现在的空间,明显占用空间减少。

也可以使用 shrink_to_fit(); 将capacity减少到和size一样大

46、C++、Java的联系与区别,包括语言特性、垃圾回收、应用场景等

  • C++和Java都是面向对象的语言,C++是编译成可执行文件执行的,而Java是编译之后在JAVA虚拟机上运行的,因此Java有良好的跨平台性,但是执行效率没有C++高。
  • C++的内存管理由程序员手动管理,Java的内存管理由Java虚拟机完成的,它的垃圾回收使用的是标记-回收算法。
  • C++有指针,Java没有指针,只有引用。
  • Java和C++都有构造函数,但是C++有析构函数,Java没有。

为什么C++没有实现垃圾回收?

首先,实现一个垃圾回收会带来额外的空间和时间的开销。需要开辟一定的空间保存指针的引用计数和对他们进行标记。然后需要单独开辟一个线程在空闲的时候进行free操作,垃圾回收会使得C++不适合进行很多的底层操作。

47、C++和Python的区别

1、python是一种脚本语言,是解释执行的,而C++是编译语言,是需要编译后在特定平台运行的。python可以很方便的跨平台,但是效率没有C++高

2、python使用缩进来区分不同代码块,而C++用花括号

3、C++需要自定义变量类型,而Python不需要

4、Python库比C++多,调用起来方便

48、声明和定义

声明只是告诉编译器他的存在而已,并不会分配内存空间

定义就是对这个变量或函数进行分配空间和初始化

49、typedef和define的区别

define是在预处理时进行处理的

typedef是在编译时处理的,它是在自己的作用域内给一个已存在的类型起一个别名

50、什么是符号表

符号表是编译期产生的一个hash列表,随着可执行文件在一起,符号表包括了变量和函数的信息,以及调试信息。详见:参考资料

51、linux下brk、mmap、malloc和new的区别

  • brk是系统调用,主要工作是实现虚拟内存到内存的映射,可以让进程的堆指针增长一定的大小,逻辑上消耗掉一块虚拟地址空间,malloc向OS获取的内存大小比较小时,将直接通过brk调用获取虚拟地址。
  • mmap是系统调用,也是实现虚拟内存到内存的映射,可以让进程的虚拟地址区间切分出一块指定大小的虚拟地址空间vma_struct,一个进程的所有动态库文件.so的加载,都需要通过mmap系统调用映射指定大小的虚拟地址区间,被mmap映射返回的虚拟地址,逻辑上被消耗了,直到用户进程调用unmap,会回收回来。malloc向系统获取比较大的内存时,会通过mmap直接映射一块虚拟地址区间。
  • malloc是C语言标准库中的函数,主要用于申请动态内存的分配,其原理是当堆内存不够时,通过brk/mmap等系统调用向内核申请进程的虚拟地址区间,如果堆内部的内存能满足malloc调用,则直接从堆里获取地址块返回。
  • new是C++内置操作符,用于申请动态内存的分配,并同时进行初始化操作。其实现会调用malloc,对于基本类型变量,它只是增加了一个cookie结构, 比如需要new的对象大小是 object_size, 则事实上调用 malloc 的参数是 object_size + cookie, 这个cookie 结构存放的信息包括对象大小,对象前后会包含两个用于检测内存溢出的变量,所有new申请的cookie块会链接成双向链表。 对于自定义类型,new会先申请上述的大小空间,然后调用自定义类型的构造函数,对object所在空间进行构造。

52、被free回收的内存会立即返回给操作系统吗

不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。

53、引用作为函数参数以及返回值的好处

作为函数参数:

1)在函数内部可以对此参数进行修改

2)提高函数调用和运行效率(因为没有了传值和生成副本的时间和空间消耗)

作为返回值:

1)不产生返回值的副本

作为返回值的限制:不能返回局部变量的引用。不能返回函数内部new的引用,因为如果返回的引用只是作为一个临时变量出现,没有赋予一个实际的变量,那么引用的这个空间将无法释放,导致内存泄露。可以返回类成员的引用,但是最好是const。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。

54、执行malloc申请内存时,操作系统是怎么做的

当开辟的空间小于128k时,调用brk()函数,malloc的底层实现时系统调用函数brk(),主要移动指针_enddata(此时的 _enddata指的是Linux地址空间中堆段的末尾地址,不是数据段的末尾地址),当开辟的空间大于128k时,调用mmap()函数,mmap()系统调用函数是在虚拟地址空间中(堆和栈中间,称为“文件映射区域”的地方)找一块空间来开辟。

进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才算完成。

malloc中是如何管理内存块的

malloc图文解析

55、STL迭代器失效的情况

56、volatile、mutable、explicit关键字

(1)volatile关键字是一种类型修饰符,使用volatile修饰的变量通常不会被编译器优化掉,即当使用volatile声明的变量的值的时候,系统总是重新从它所在的内存中读取数据,即时它前面的指令刚刚从该处读取过数据。

volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。

(2)mutable

如果在类中将函数声明为const常函数,那么类将不能对类中的成员变量进行更改,如果想要对这个变量进行更改,可以将这个变量声明为mutable类型。

(3)explicit

explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换

  • explicit 关键字只能用于类内部的构造函数声明上
  • explicit 关键字作用于单个参数的构造函数
  • 被explicit修饰的构造函数的类,不能发生相应的隐式类型转换

57、RAII机制

​ RAII是Resource Acquisition Is Initialization(wiki上面翻译成 “资源获取就是初始化”)的简称,是C++语言的一种管理资源、避免泄漏的惯用法。利用的就是C++构造的对象最终会被销毁的原则。RAII的做法是使用一个对象,在其构造时获取对应的资源,在对象生命期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源。

RAII机制就是让系统内部去管理资源,例如类的构造时分配资源析构时回收资源就是典型的RAII机制

小结:
但是如果程序很复杂的时候,需要为所有的new 分配的内存delete掉,导致极度臃肿,效率下降,更可怕的是,程序的可理解性和可维护性明显降低了,当操作增多时,处理资源释放的代码就会越来越多,越来越乱。如果某一个操作发生了异常而导致释放资源的语句没有被调用,怎么办?这个时候,RAII机制就可以派上用场了。

58、为什么拷贝构造函数的参数要用左值引用,不用值传递

  • 如果用值传递会陷入无限的死循环,因为如果是值传递,那么从实参拷贝到给形参,这里又拷贝了。。
  • 不考虑死循环,在效率上用引用也高于值传递,用值传递还需要拷贝一份

59、placement new()

参考:https://blog.csdn.net/zhangxinrun/article/details/5940019

通常我们要new一个对象的时候会实际上是有两步的,第一步先通过operator new申请一段内存,第二步调用对象的构造函数。并且我们可以在类内部重载operator new。placement new实际上就是operator new的重载的一个版本,placement new就是在一个已经分配的内存中创建一个对象(相比与new来说少了申请内存这一步)。使用new操作符分配内存需要在堆中查找足够大的剩余空间,这个操作速度是很慢的,而且有可能出现无法分配内存的异常(空间不够)。placement new就可以解决这个问题。placement new就可以解决这个问题。我们构造对象都是在一个预先准备好了的内存缓冲区中进行,不需要查找内存,内存分配的时间是常数;而且不会出现在程序运行中途出现内存不足的异常。所以,placement new非常适合那些对时间要求比较高,长时间运行不希望被打断的应用程序。

60、memcpy 和 memmove

memcpy函数的功能是从源src所指的内存地址的起始位置开始拷贝N个字节到目标dst所指的内存地址的起始位置中。

memmove函数的功能同memcpy基本一致,但是当src区域和dst内存区域重叠时,memcpy可能会出现错误,而memmove能正确进行拷贝。

二者与strcpy区别:

memcpy与memmove都是对内存进行拷贝可以拷贝任何内容,而strcpy仅是对字符串进行操作。

memcpy与memmove拷贝多少是通过其第三个参数进行控制而strcpy是当拷贝至’\0’停止。

61、内存管理

内存管理主要通过malloc/free申请内存释放内存。为了减少内存碎片以及系统调用。malloc采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区中取出一块合适的空闲块。malloc采用隐式链表结构将堆区所有已分配的块以及未分配的块连接起来。同时采用显示结构来管理所有的空闲块,即使用一个双向链表将所有空闲块连接起来。当进行内存分配时,malloc会通过隐式链表遍历所有的空闲块,选择满足的块进行分配,将满足的这一块切成两块,一块拿去分配,另一块插入到空闲链表中。在内存释放时,首先搜索空闲链表,通过地址找到可以插入的位置,如果空闲链表中有和这一块地址连续的空闲块,则将两块空闲块进行合并,减少内存碎片。

如果申请内存时,堆中已经找不到内存,则在虚拟地址空间(堆和栈中间的那一块空间)中调用brk或mmap系统调用在虚拟空间中找一块空间来开辟。

62、函数传递参数的几种方式

值传递: 形参是实参的拷贝,函数内部对形参的操作并不会对外部实参的影响。

指针传递: 指针传递也算是值传递的一种,形参是指向实参的地址,当对形参的指向的地址进行操作时,就相当与对实参本身进行操作。但是如果改变形参的地址,并不会对外部的实参产生影响。

引用传递: 实际上就是把引用对象的地址放在了开辟的栈空间中,函数内部对形参的任何操作可以直接映射到外部的实参上面。

63、返回值

值作为返回值:

使用值作为返回值,会先创建一个临时变量,然后将该临时变量赋值给新的对象

指针作为返回值:

引用作为返回值:

64、静态变量什么时候初始化

静态变量存储在虚拟地址空间的数据段和bss段。

  • C语言中其在代码执行之前初始化,属于编译期初始化。
  • C++中,由于对象的引入,对象的生成必须要调用构造函数,因此,C++规定全局或者局部静态对象当且仅当对象首次用到时进行构造

65、 数组中arr和&arr区别

arr表示数组首个元素的起始地址,而&arr表示arr数组中所有元素的起始地址,所以arr &arr是相同的

arr+1表示数组第二个元素起始地址,&arr+1是arr数组中全部元素再加一个元素的起始地址

示例:

int arr[6] = {1, 2, 3, 4, 5, 6}; 
printf("%d\n",  arr);
printf("%d\n", &arr);
printf("%d\n",  (arr+1));
printf("%d\n", (&arr+1));

输出:
6422016
6422016
6422020
6422040

拓展:

int *arr[4]表示存储4个int型指针

int *arr[4] [4]表示存储16个int型指针

66、虚函数底层原理

https://blog.csdn.net/haolexiao/article/details/63690188

示例:

#include 
class Base
{
public:
    Base() { PrintBase(); }
    virtual ~Base() { PrintBase(); }
    void PrintBase()
    {
        Foo();
    }
    virtual void  Foo() { std::cout << "Base" << std::endl; }
};

class Derive : public Base
{
public:
    Derive() : Base() { PrintDerive(); }
    virtual ~Derive() { PrintDerive(); }

    void PrintDerive()
    {
        Foo();
    }

    virtual void Foo() { std::cout << "Derive" << std::endl; }
};

int main()
{
    Base* p = new Derive();
    p->Foo();
    delete p;
   // return 0;
}

输出:
Base
Derive
Derive
Derive
Base

调用哪一个虚函数取决于虚指针指向哪一个对象(我的理解)

​ 分析一下上面代码的输出:实例化一个派生类,首先会调用基类的构造函数,因为派生类还没有被实例化,所以此时虚指针指向派生类的虚函数表,所以调用基类的虚函数表中的虚函数,第二步就会调用派生类的构造函数,此时虚指针就指向派生类的虚函数表,调用的就是派生类中虚函数表的虚函数。在还没有析构之前,对象的虚指针都指向派生类的虚函数表,因此调用的都是派生类中的虚函数。将对象进行回收,会先调用派生类的析构函数,所以调用的还是派生类的虚函数表中的虚指针,然后可以理解为派生类的析构函数结束之后,由于派生类被释放掉了,虚指针就重新指向基类的虚函数表,所以基类的析构函数指向的还是本身的虚函数。

虚函数表什么时候生成?

虚函数表存放在类定义模块的数据段中,模块的数据段通常存放定义在该模块的全局数据或静态数据,可以理解为存放在内存分区中的全局区。

虚函数表在程序一开始运行的时候就初始化好的,可以理解为在编译阶段就生成的。

虚指针什么时候初始化的?

类实例中的虚表指针是在类调用构造函数的时候完成初始化的,只不过是在进入构造函数函数体之前就完成了初始化。所以new一个派生类对象时,会先调用基类的构造函数,然后虚指针指向基类的虚函数表(因为派生类的虚指针还没有生成),接着调用派生类的构造函数,然后再将虚指针指向派生类的虚函数表。

对于下面这个程序:

#include 
class Base
{
public:
    Base() { PrintBase(); }
    ~Base() { PrintBase(); }
    void PrintBase()
    {
        Foo();
    }
    virtual void  Foo() { std::cout << "Base" << std::endl; }
};

class Derive : public Base
{
public:
    Derive()  { PrintDerive(); }
    ~Derive() { PrintDerive(); }

    void PrintDerive()
    {
        Foo();
    }

    virtual void Foo() { std::cout << "Derive" << std::endl; }
};
int main()
{
    Base* p = new Derive();
    delete p;
    std::cout << "-----" << std::endl;
    Derive p2;
   return 0;
}

输出:
Base
Derive
Base
-----
Base
Derive
Derive
Base

为什么new一个对象的话不将析构函数定义为虚函数的话就不会调用派生类的虚函数呢?而实例化对象时却会调用呢?

这涉及到了静态绑定和动态绑定。查看:https://blog.csdn.net/iicy266/article/details/11906509

静态绑定: 编译时绑定,通过对象调用

动态绑定: 运行时绑定,通过地址实现

对于上诉代码:p是一个基类指针,它指向了一个派生类对象,基类Base中PrintDerive为虚函数,析构函数为非虚函数。因此,对于PrintDerive就表现为动态绑定,实际调用的是派生类对象中的PrintDerie,而析构函数为非虚函数,因此它表现为静态绑定,也就是说指针类型是什么,就会调用该类型相应的函数。

而对于p2而言,他就是指的一个p2对象,所以他的所有操作都是静态绑定。

67、为什么空指针能够调用类的部分成员函数?

详细分析参考:https://blog.csdn.net/chenzrcd/article/details/60472616

#include
#include
using namespace std;
class A
{
public:
    static void f1(){ cout<<"f1"<f1();	//正常
	pa->f2();   //正常
	pa->f3();   //错误,提示段错误
	pa->f4();   //错误,提示段错误
    return 0;
}

类的成员函数并不是与具体的对象绑定,所有的对象共用一份成员函数,当程序被编译后,成员函数的地址已经确定,这份成员函数被所有对象共享。通过隐式传递给成员函数的this指针,成员函数中对成员变量的访问转化为this->数据成员。因此成员函数可以看成时普通函数一样,只不过多传递了一个隐式参数this指针。所以当你对象为NULL时,只要成员函数中不调用对象的变量,那么还是可以执行成功的。对于虚函数的话,因为你要调用虚函数得通过虚指针来找虚函数表从而找到虚函数,但是你对象是空的话肯定就没有虚指针,从而就找不到虚函数地址。

68、C++模板是什么,底层怎么实现的

​ 编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。

69、拷贝构造函数和赋值运算符重载的区别

  • 拷贝构造函数是函数,赋值运算符是运算符重载。
  • 拷贝构造函数会生成新的类对象,赋值运算符不能。
  • 拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。
  • 形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现"="的地方都是使用赋值运算符

你可能感兴趣的:(C++,面试,c++,面试,开发语言)