八股文整理

132 阅读41分钟

1. C++

1.1 四种强制类型转换

C++的强制类型转换包括四类:static_cast,dynamic_cast, const_cast, reinterpret_cast。

  1. static_cast : 主要用于有明确定义的转换,比如编译器自带的类型转换,或者是自定义类的转换构造函数。主要适用于非多态的转换操作。
  2. dynamic_cast : RTTI(Runtime Type Identification, 运行时类型识别)功能实现之一,主要用于基类与派生类之间的转换,常用于将基类指针/引用,转换成派生类的指针/引用。此外,dynamic_cast还具有类型检查功能,当转换失败时,如果传入类型是指针,则会返回空指针;如果传入类型是引用,则会抛出异常。
  3. const_cast : 只能改变运算对象的底层const属性,常用于去掉底层const操作(使所指对象可变),即去掉指针/引用所指对象的常量属性。
  4. reinterpret_cast : 从底层角度对数据进行重新解释,重新解释数据的位模式,依赖于具体的平台,可移植性较差。

1.2 智能指针

智能指针是用于管理原始指针的模版类,其设计是基于C++的RAII(Resource Acquisition Is Initialization,资源获取即初始化)理念,用对象的生命周期来管理指针,当对象析构时,会自动释放资源,无需手动释放。
智能指针有以下几类:

  1. unique_ptr
    unique_ptr实现独占式拥有或严格拥有概念,其只支持移动操作,保证同一时间内只有一个智能指针可以指向该对象。
  2. shared_ptr
    shared_ptr实现共享式拥有概念,多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个指针被销毁”时候释放。其实现方式是通过引用计数的方式来实现,当对shared_ptr进行初始化或拷贝时,会对保留的引用数+1,当shared_ptr销毁或release()时,引用计数-1。当引用计数为0时,资源才会被释放。
  3. weak_ptr
    weak_ptr实现的是弱引用概念,其不涉及所指对象的生命周期,主要是为了配合shared_ptr使用,解决shared_ptr可能存在的环型引用所导致的资源无法释放问题。
    当使用weak_ptr时,需要先调用lock()接口,来获取指针对应的shared_ptr, 然后才对所指对象进行访问。

shared_ptr的循环引用问题
如下图中,有两个shared_ptr智能指针ptrA,ptrB各自指向不同的对象A和B, 而其中A和B对象中又各自有shared_ptr指向对方,则对于A,B对象,每一个的引用计数都为2,当ptrA和ptrB都释放后,A,B的引用计数仍都为1,不会被释放,出现内存泄漏。 image.png

1.3 C++程序的内存分布

程序的地址空间内存布局如下图所示,由低到高分别是:

  1. .text段
    .text段又称为文本段,代码段,主要存放程序的机器指令,以及程序中的字面值,字符串字面值,这是一个只读段。
    (这里也可以将.text拆分为.text代码段和.rodata只读数据段。
    .rodata段存储常量数据,比如程序中定义为const的全局变量,#define定义的常量,以及诸如“Hello World”的字符串常量。只读数据,存储在ROM中。)
  2. .data段
    .data段又被称为数据段,其中保存了程序中已经明确初始化了的全局变量或静态变量。(类比全局变量,这个段并不只读,也可修改。)
  3. .bss段
    .bss段(block started by symbol),未初始化的数据段,保存着程序开始前,没有被初始化的全局变量或静态变量。程序开始前,会将该段内的所有数据初始化为0。
  4. Heap 堆
    程序运行过程中,用于动态分配内存的区域,大小不固定,由低地址向高地址增长。
  5. Stack 栈
    进程栈,主要保存函数调用时的现场信息,函数里定义的局部变量,临时变量等。函数调用时,其参数会被压入发起调用的进程栈中,在调用结束后,返回值也会被存放回栈中。
  6. OS Kernel 内核空间
    高地址段有一片内核的预留空间,进程不能读写。

内存分布.png

1.4 C++指针参数传递、引用参数传递

指针和引用都含有地址的概念,指针是一个变量,他的值是一个地址,指向另一个变量;引用则是某块内存的别名
具体区别:
(1) 程序需要为指针变量分配内存,而引用不需要分配内存。
(2) 引用在定义时就需要被初始化,之后无法改变。指针可以发生改变。
(3) sizeof指针,得到的是指针变量的大小,sizeof引用,得到的是所指变量的大小。
(4) 作为参数传递时,指针实质上还是值传递,相当于是在被调函数上开辟的一个局部变量,拷贝原指针内容,这个局部变量也可以修改。对于引用,在参数传递时是直接传入所指实参的地址,任何对于引用参数的处理,都会通过一个间接寻址的方式操作到实参对象上。
(5) 从编译角度来讲,程序在编译时都会将指针和引用添加到符号表上。对于指针,符号表上记录的是指针变量名,以及指针变量的地址。而对于引用,符号表记录的是引用名,以及所引用对象的地址

1.5 static 和 const 的区别

static和const都是C++的关键字,但他们的含义不同,static一般用于控制变量的存储方式生命周期作用域。而const一般用于控制变量的可变性

static.png static关键字用法:
(1) 修饰局部变量,变量的生命周期会从程序开始持续到程序结束。作用域没有改变,只有语句块内可以访问得到,变量会存放在程序的静态数据区(.data/.bss,根据初始化情况确定)。
(2) 修饰全局变量或全局函数,会成为静态变量/静态函数,相当于修改作用域,其只在本文件中可见。
(3) 作用于类中,修饰类中成员/类中的方法,表示被修饰对象/方法属于整个类所有,所有该类对象均访问同一个成员。static类成员需要在类外初始化。static方法不需要this指针,不能为虚函数。

const关键字用法:
(1) 用于对象声明时,修饰变量类型,表示对象为常量,不可变,顶层const。
(2) 用于指针/引用声明时,修饰所引用对象的类型,表示所引用对象的不可变,底层const。
(3) 在函数中修饰形参的可变性,或指针/引用参数所指对象的可变性,具体类似于1,2的作用。
(4) 作用于类中,修饰成员变量,表示对象成员不可变;修饰成员函数,const放在成员函数末尾,表示该函数为常量成员函数,仅可被const对象访问。

1.6 C和C++的区别

首先,C和C++在基本语句(顺序、条件、循环等)上没有过大的区别。
主要的区别有:
(1) C++有新增的语法和关键字。namespace、引用、new/delete
语法上,C++中有命名空间的概念,而C中依赖全局的头文件导入,可能会有重名的变量或函数出现导致冲突。另外,除指针外,C++中还加入了引用的概念,也是一种间接寻址访问变量的手段。
关键字方面,如new和delete关键字。C和C++动态管理内存的方式不同,C++中在malloc和free的基础上增加了new和delete关键字用于管理动态申请的内存,出错时可以抛出异常。
(2) 函数方面,C++的函数支持重载
在符号表中,C++函数命名与C的函数命名是不相同的,C++的函数名会带上函数的参数类型(例如 int func(int, double),在C++中是_func_int_double,在C中是_func)。
在重载基础上,C++强调强类型,不允许C中的一些隐式转换,如C中的void*可以转换成任意类型的指针。因此NULL宏在C++和C中的定义是不同的,在C++中是0(有明确的类型int),而在C中,则是(void *)0(因为C的NULL在重载函数场景,如int func(char *)int func(int)时,会出现不明确),C++的NULL,有明确的类型int。当然,对于空指针的概念,C++中更推荐使用nullptr关键字。
(3) 类方面
C中的struct只能表示结构体,是内存空间上的集合。而C++中的class/struct实际上是提供了完整的面向对象OOP的能力,包括封装、继承、多态。C++提供了不同的成员访问权限,和继承时访问权限控制,使用虚函数的方式来实现多态。此外,C++中的提供了明确的面向对象的生命周期管理,会在对象定义时自动调用构造函数,销毁时调用析构函数。
(4) C++加入了模版的概念来重用代码,提供了强大的STL标准库。
C是一种结构化的语言,其设计时考虑数据结构和对输入数据的运算处理。而C++是一种面向对象的语言,其设计时考虑如何构建一个对象模型,并使得模型与需要解决的问题相契合。

1.7 C++和JAVA的区别

  1. 编译产物 java是解释性语言,其编译产物是字节码,需要在java虚拟机上运行,跨平台执行,具有良好的移植性。而C++的编译产物是在原生平台下的机器码,运行效率更高。
  2. 指针 C++中有指针的概念,Java语言中无法直接使用指针来访问内存,没有指针的概念,并有内存的自动管理功能。
  3. 多重继承
    C++中支持多重继承,而Java中不支持,取而代之的是Java中拥有接口的概念,支持一个类实现不同的接口。
  4. 数据结构和类
    Java是完全面向对象的语言,所有函数和变量必须定义是类的一部分,除了基本的数据类型之外,其余的都作为类对象,需要通过方法访问。而C++为了兼容C,是可以存在类外定义的全局变量和函数,或者面向过程来实现程序。
  5. 自动内存管理
    Java中所有对象都是使用new操作符构建在内存堆上(引用变量和基本数据类型变量放在栈上),Java虚拟机自动对无用的内存进行回收操作,不需要程序员进行手动删除,而C++中必须由程序员手动释放内存。(Java 中当一个对象不再被用到时, 无用内存回收器将给他们加上标签。Java 里无用内存回收程序是以线程方式在后台运行的,利用空闲时间工作来删除。)
  6. 操作符重载
    操作符重载被认为是C++的突出性质,java不支持。
  7. 对于异常的使用 Java中更倾向于使用异常来抛出和捕获例外事件,增强系统容错能力。而C++中虽然也有异常,但编程时一般更倾向于就地处理异常并返回。(异常机制的使用,需要耗费额外的性能)

1.8 C++中常量

根据定义方式和位置的不同有所区别。

  1. 基础类型的字面值常量,以右值方式使用, 不进行取地址、extern等操作。如const int a = 10;(局部常量和全局常量都适用)
    编译器会在编译前直接替换数值,不需要分配内存。(也相当于在.text代码段中,立即数使用)
  2. 定义在函数中的局部常量
    内存分配在栈中,函数内不可修改,退出函数后释放。
  3. 全局常量
    内存分配在.rodata段,并会加入到符号表中。

1.9 C++中重载、重写、重定义的区别

  1. 重载(overload)
    重载,是C++支持指同一作用域内定义多个具有多个不同参数列表的同名函数。其实现是基于C++编译器在编译时,会在函数的命名加上每个参数具体的参数类型,加入到符号表中(命名解析可以使用C++filt工具)。重载可以是基于参数列表中类型,个数,顺序不同,但与函数返回类型不相关(仅仅返回类型不同无法重载)。
  2. 重写(override)
    重写,是指在派生类中重新定义父类中对应的虚函数(注意虚函数是不支持类static函数的)。重写函数的访问修饰符是可以不同的(如基类中是private,派生类中可以修改为public的)。重写基于C++虚函数表实现,是面向对象中多态的一环。
  3. 重定义(隐藏,Hiding)
    重定义,是指在派生类中定义和基类函数具有相同函数签名的非虚函数(名称,参数类型,重定义也可以不同返回类型)。此时派生类中的函数隐藏了基类中的同名函数。(由于不是虚函数,使用指针来访问时,具体调用由指针类型来确定)

1.10 C++中的所有构造函数

构造函数的作用:初始化对象的数据成员。

  1. 默认构造函数
    如果没有明确写出无参数构造函数,编译器会自动生成默认的无参数构造函数,函数为空。当有任意其他带参的构造函数定义时,编译器会便不会生成默认构造函数。
  2. 一般构造函数(带参)
    也称重载构造函数,一般构造函数可以有各种参数形式,一个类可以有多个一般构造函数,前提是参数的个数或者类型不同,创建对象时根据传入参数不同调用不同的构造函数。
  3. 拷贝构造函数
    拷⻉构造函数的函数参数为对象本身的引用,用于根据一个已存在的对象复制出一个新的该类的对象,一般在函数中会将已存在的对象的数据成员的值一一复制到新创建的对象中。如果没有显式定义拷⻉构造函数,则系统会默认创建一个拷⻉构造函数,但当类中有 指针成员时,最好不要使用编译器提供的默认的拷⻉构造函数,因为它只会对指针变量内容进行一个拷贝,最好自己定义并且在函数中执行深拷⻉。
  4. 类型转换构造函数
    类型转换构造函数的参数只有一个,且是另外的一种数据类型,用于从一个指定类型的对象创建一个本类的对象,也可以算是一般构造函数的一种。
    类型转换构造函数,可以在复制、函数传参时被使用,进行隐式转换,如果不允许默认转换的话,可以用explict关键字修饰。
  5. 移动构造函数
    移动构造函数的输入是一个同类型的右值引用对象。其能够帮助我们从一个“将亡”的对象中取走数据,用于构造新的对象,无须进行深拷贝。

1.11 野指针和空悬指针

野指针(wild pointer):没有被初始化的指针。
空悬指针(dangling pointer):指针最初指向的内容已经被释放的指针。

1.12 RVO(Return Value Optimization)

RVO是一种编译器优化技术,它避免了从函数返回时创建临时对象。当函数返回一个临时对象(通常是由构造函数直接初始化的匿名对象)时,RVO允许编译器省略创建和销毁临时对象的过程,而是直接在接收对象的位置构造返回值。

当编译器确定可以进行RVO时,它会:

  1. 调用者的栈帧上为返回值分配空间,而不是在被调用函数的栈帧上。
  2. 将返回值对象的地址传递给被调用的函数,这样被调用的函数就可以直接在该地址上构造对象
  3. 允许函数直接在预分配的内存位置构造返回值,从而避免了额外的拷贝构造和析构调用。

1.13 NRVO(Named Return Value Optimization)

NRVO与RVO类似,但适用于返回函数内部已命名的局部变量。编译器优化这个过程,允许在调用者的栈帧上直接构造局部变量,避免了将局部变量拷贝到返回值的过程。

在应用NRVO时,编译器会:

  1. 识别函数中将被返回的命名局部变量。
  2. 在调用者的栈帧上为该局部变量预留空间。
  3. 直接在该空间上构造局部变量,当函数返回时不需要移动或拷贝对象。

1.14 new/delete,malloc/free区别

  1. 两者的标准不同,new/delete是C++中的关键字,而malloc/free是C语言标准库中的函数。
  2. 两者都主要用来在堆上动态分配内存空间。
  3. new关键字实际上执行了两个过程:
    (1)在堆上分配未初始化的内存空间(也是malloc实现);
    (2)使用对象的构造函数对所申请空间进行初始化。
  4. delete关键字实际上也执行两个过程:
    (1)使用对象的析构函数对对象进行析构; (2)回收内存空间(free实现)
  5. new关键字,还可能会抛出异常,如果在第一步分配空间中出现问题,则抛出std::bad_alloc异常;如果在第二步构造对象时出现 异常,则自动调用delete释放内存。(如果不需要异常,new关键字可以传入std::no_throw,此时失败会返回空指针)

1.15 volatile和extern关键字

volatile关键字作用:

  1. 强调变量的易变性。在汇编层面反映出来,就是两条语句,下一条语句不会直接使用上一条语句对应的volatile变量的存放在寄存器中内容,而是重新从内存中读取
  2. 强调变量的不可优化性:volatile 告诉编译器,不要对这个变量进行各种激进的优化,甚至将变量直接消除,保证程序员写在代码中的指令,一定会被执行。
  3. 强调变量的顺序性:能够保证volatile变量之间的顺序性,编译器不会进行乱序优化。

extern关键字作用:

  1. 强调声明而非定义。修饰符 extern 用在变量或者函数的声明前,用来说明 “此变量/函数是在别处定义的,要在此处引用”。
  2. extern "C"修饰函数,告诉编译器在编译和链接时用C函数规范来生成和使用函数名。

1.16 define和const的区别

define:
1.define是在预处理阶段进行,没有类型的概念,也就不会做类型检查。 2.define的作用仅仅是遇到宏定义时,在代码上下文进行字符串展开,展开后的代码容易受到上下文作用域的影响,出现非预期的问题。 3.define的特点是直接在上下文展开,不是类似“inline内联函数”的对编译器“建议优化”。 4.由于在预处理阶段,所以define定义的“宏常量”不分配内存,在内存中有若干个拷贝,从汇编代码的角度,每次都是以立即数的形式来寻址。

const:

  1. const是在编译期间进行处理的,const常量有类型,也有类型检查,程序运行时系统会为const常量分配内存。
  2. 而且从汇编的角度讲,const常量在出现的地方保留的是真正数据的内存地址,只保留了一份数据的拷⻉,省去了不必要的内存空间。
  3. 有时编译器不会为普通的const常量分配内存,而是直接将const常量添加到符号表中(变量名和数据),省去了读取和写入内存的操作,效率更高。

1.17 计算类的大小

class A{}; sizeof(A) = 1; //编译器会为空类隐含地加上一个字节的大小
class A{virtual Fun(){} }; sizeof(A) = 48 (32bit/64bit机器) //当 C++ 类中有虚函数的时候,会有一个指向虚函数表的指针(vptr),32为机器指针大小为4字节,64位机器指针大小为8字节  
class A{static int a; }; sizeof(A) = 1;  // 类静态变量不存放在对象中
class A{int a; }; sizeof(A) = 4;  
class A{static int a; int b; }; sizeof(A) = 4;

1.18 面向对象的三大特性

封装、继承、多态

封装:

  • 将一些属性和相关方法封装在类中,对外隐藏内部具体实现细节。
  • 对象的数据仅可以通过类提供方法进行访问,外界不需要关心方法的内部实现,只需要根据”内部提供的接口“去使用就可以。
  • 数据访问和处理更安全,代码更内聚。

继承:

  • 让某个类型的对象获得另一个类型的对象的属性的方法。
  • 被继承的类称为“基类”、 “父类”或“超类”。
  • 继承的过程,就是从一般到特殊的过程。

多态:

  • 就是用不同的对象调用同一个方法,会产生不同的行为(或功能)。
  • 反映的是“接口的共性,类/对象的特性”。

1.19 多态的分类

多态一般指“运行时多态”,当然也有“静态多态”的说法,分别来讨论。

静态多态其实就是重载,因为静态多态是指在编译时期就决定了调用哪个函数,根据参数列表 来决定;对于重载来说,实际上基于的原理是,编译器为函数生成符号表时,不同参数的同名函数生成不同的函数名,调用时会根据类型确定其中具体的函数实现。重载只是一种语言特性,与多态无关,与面向对象也无关,

动态多态是指通过子类重写父类的虚函数来实现的,因为是在运行期间决定调用的函数,所以 称为动态多态。动态多态的实现与虚函数表,虚函数指针相关。

1.20 虚函数的实现原理

  1. 虚函数的声明,在基类的函数前加上virtual关键字,声明其为虚函数。
  2. 虚函数的使用,使用基类指针/基类引用,在运行时调用虚函数方法时,会调用到对象具体类型的函数实现。

实现原理:

  1. 当一个类中包含虚函数时,编译器会为该类生成一个虚函数表,保存该类中虚函数的 地址。
  2. 同样,派生类继承带有虚函数的基类,编译器也会生成一个对应的虚函数表。
  3. 在基类对象中,会生成一个虚函数指针来指向虚函数表。而在子类对象中,对于其继承的每一个带有虚函数的基类,都会有一个虚函数指针指向虚函数表(子类中多继承虚函数,会有多张)。
  4. 在子类中重写虚函数时,会将对应的基类部分虚函数表中的对应方法进行替换,指向所覆盖的函数地址。对于子类中新加入的虚函数,会在其加入到第一张虚函数表中(所继承的第一个具有虚函数的类部分,主基类)。
  5. 调用时会根据虚函数指针,在对应的虚函数表中,调用对应的函数实现。 image.png

1.21 析构函数一般是虚函数的原因

主要原因:在多态使用的场景下,降低内存泄漏的可能性。 比如在多态的场景下,使用基类指针来使用一个派生类对象,在使用完毕准备销毁时,如果析构函数没有定义成虚函数,那么就会调用基类的析构函数,而不是派生类的析构函数,那么仅在派生类中的内容可能不会被释放,出现内存泄漏。

1.22 构造函数为什么不能是虚函数

  1. 虚函数的目的是实现运行时多态,允许通过基类指针调用派生类实现,不要求知道对象的具体类型。但构造函数的作用是初始化对象,此时对象的具体类型已经明确(正在构造的类型),不需要多态行为。
  2. 从虚函数的实现上来讲,虚函数的调用,依赖实例化后的对象中的虚函数指针,来找到类对应的虚函数表,在表中找到对应的函数地址进行调用。构造函数的调用在对象实例化之前,此时没有对应的虚函数指针,无从找到虚函数表,也就无法调用。
  3. 设计原则上,对象的初始化和调用应该分开,构造函数应专注于对象初始化,对象完全实例化之后,通过普通虚函数实现多态行为,进行访问和操作。

1.23 在构造函数/析构函数中调用虚函数会怎么样?

在构造函数和析构函数中调用虚函数时,虚函数的动态绑定机制并不会生效,而是会执行当前类中定义的版本。C++标准明确规定,在构造/析构期间,对象的动态类型被视为当前正在构造/析构的类类型。
构造函数:

  1. 构造函数的执行顺序是从基类到派生类。
  2. 当基类构造函数执行时,派生类部分的尚未构造完成,此时虚函数表只包含当前类的虚函数实现。
class Base {
public:
    Base() { foo(); }  // 调用Base::foo()
    virtual void foo() { cout << "Base::foo\n"; }
};

class Derived : public Base {
public:
    Derived() : Base() {}
    void foo() override { cout << "Derived::foo\n"; }
};

Derived d;  // 输出"Base::foo"而非"Derived::foo"

析构函数:

  1. 析构函数的顺序是从派生类到基类。
  2. 当基类析构函数执行时,此时派生类部分已经被销毁,虚函数表此时已经退回到基类的版本了。
class Base {
public:
    virtual ~Base() { foo(); }  // 调用Base::foo()
    virtual void foo() { cout << "Base::foo\n"; }
};

class Derived : public Base {
public:
    ~Derived() {}
    void foo() override { cout << "Derived::foo\n"; }
};

Base* b = new Derived();
delete b;  // 输出"Base::foo"而非"Derived::foo"

1.24 构造函数/析构函数的执行顺序

构造函数:

  1. ​虚基类构造函数​​(如果有多个虚基类,按类声明时继承顺序从左到右)
  2. ​非虚基类构造函数​​(按派生类声明时的继承顺序)
  3. ​派生类的成员变量初始化​​(按类中声明顺序,而不是按成员初始化表)
  4. ​派生类的构造函数体​

析构函数,与构造函数相反:

  1. ​派生类的析构函数体​​ 先执行
  2. ​派生类的成员变量析构​​(按声明顺序的​​逆序​​)
  3. ​非虚基类的析构函数​​(按继承顺序的​​逆序​​)
  4. ​虚基类的析构函数​​(按继承顺序的​​逆序​​)

1.25 为什么拷⻉构造函数的参数必须引用传递,不能是值传递?

为了防止递归调用。当一个对象需要以值方式进行传递时,编译器会生成代码调用它的拷⻉构造函数生成一个副本,如果类的拷⻉构造函数的参数不是引用传递,而是采用值传递,那么就又需要为了创建传递给拷⻉构造函数的参数的临时对象,而又一次调用类的拷⻉构造函数,这就是一个无限递归。

1.26 纯虚函数

纯虚函数用于定义抽象基类,当我们想为类定义接口,而不给出具体的实现时使用。

  1. 包含纯虚函数的类是抽象类,不能实例化。
  2. 派生类必须实现所有纯虚函数才能成为具体类(否则仍然是抽象类)
  3. 一般纯虚函数没有函数体(但C++11后可以给出实现,需要通过类名限定,显式调用)

对于上面第三点中C++11中的情况

// 抽象基类
class AbstractBase {
public:
    virtual void pureVirtualFunc() = 0;
};

// 提供默认实现
void AbstractBase::pureVirtualFunc() {
    cout << "Default implementation" << endl;
}

class Derived : public AbstractBase {
    // 没有重写 pureVirtualFunc()
};

class Derived1 : public AbstractBase {
public:
    void pureVirtualFunc() override {
        AbstractBase::pureVirtualFunc();  // 必须显式调用
    }
};

int main() {
    Derived d;  // 错误:Derived 仍然是抽象类,不能实例化
    
    Derived1 d1;
    d1.pureVirtualFunc();  // 正确
    
    return 0;
}

1.27 内存对齐的规则

内存对齐的规则是:结构体(类)中的成员地址偏移量,以及结构体(类)的大小,要是所指定的“对齐数”的倍数。

默认情况下,不指定“对齐数”

  1. 结构体中第一个成员的偏移地址(相对于结构地址)为0。
  2. 成员变量的地址按照“自身长度”进行对齐。
  3. 结构体本身的大小要按照“最⻓的成员变量的⻓度”进行对齐。
  4. 结构体嵌套,被嵌套的结构体要按照“自身的长度最长的数据成员长度”来进行对齐。整体结构大小按照3中的规则对齐。(结构体拆开来看偏移)
struct S1 {
    char c1;    // offsetof(S1, c1) = 0
    int i;      // offsetof(S1, i)  = 4
    char c2;    // offsetof(S1, c2) = 8
};

struct S2 {
    char c3;    // offsetof(S2, c3) = 0
    S1 s1;      // offsetof(S2, s1) = 4
    char c4;    // offsetof(S2, c4) = 16
    int *i2;    // offsetof(S2, i2) = 24,指针大小按照64位8字节算。
};

sizeof(S1)      // 12
sizeof(S2)      // 32

指定“对齐数”可以用

#pragma pack(1)
struct S1 {
    char c1;    // offsetof(c1) = 0
    int i;      // offsetof(i)  = 1
    char c2;    // offsetof(c2) = 5
};

struct S2 {
    char c3;    // offsetof(c3) = 0
    S1 s1;      // offsetof(s1) = 1
    char c4;    // offsetof(c4) = 7
    int *i2;    // offsetof(i2) = 8
};

#pragma pack()

sizeof(S1) = 6
sizeof(S2) = 16

1.27.1 虚函数对于默认内存对齐规则的影响

当类中包含虚函数时,编译器会添加一个隐式的虚函数指针(vptr)。

  1. 通常vptr是类中的第一个成员。
  2. 64位系统中,vptr的大小是8个字节。
  3. vptr需要按8个字节对齐。
// 没有虚函数的类
class Base {
    int a;      // 偏移 0,大小 4
    char b;     // 偏移 4,大小 1
                // 填充 3 字节
};  // 总大小 8,对齐要求 4

// 有虚函数的类
class Base {
    // 编译器添加的 vptr(通常在最前面)
    // void* __vptr;   // 偏移 0,大小 8,对齐 8
    
    int a;              // 偏移 8,大小 4
    char b;             // 偏移 12,大小 1
                        // 填充 3 字节(为了对齐到 8 的倍数)
};  // 总大小 16,对齐要求 8

1.28 内存对齐的作用

  1. 硬件兼容性
    不是所有的硬件平台都能访问任意地址上的数据,如早期的RISC架构强制要求对齐访问,未对齐的内存访问会触发异常或程序崩溃。对齐确保了代码在不同平台上的可移植性。
  2. 性能原因
    大多数处理器(如CPU)在访问内存时,会以字长(如4字节或8字节)为单位进行读写。
    若数据未对齐,处理器可能需要作两次内存访问,并额外进行位操作拼接数据,这会显著降低效率;而对齐的内存访问仅需要一次访问。

1.29 动态绑定和静态绑定

绑定(Binding)是指将“函数调用”和“具体的函数实现”关联起来的过程。根据绑定时机的不同,可以分为 静态绑定(编译时绑定)动态绑定(运行时绑定)

  1. 静态绑定,基于变量的静态类型,效率高,无需运行时确定调用的函数。
  • 普通函数
  • 类的静态函数
  • 构造函数、析构函数(即使声明为virtual)
  • 运算符重载
  • 缺省参数值也是静态绑定的(即使为虚函数)

p.s. 我们不应该重新定义继承而来的缺省参数,因为即使我们重定义了,也不会起到效果。因为一个基类的指针指向一个派生类对象,在派生类的对象中针对虚函数的参数缺省值进行了重定义,但是缺省参数值是静态绑定的,静态绑定绑定的是静态类型相关的内容,所以会出现一种派生类的虚函数实现方式结合了基类的缺省参数值的调用效果,这个与所期望的效果不同。

  1. 动态绑定,需要在运行时确定具体调用的函数,基于对象的实际类型(动态类型)。灵活性高,依赖虚函数表。
  • 虚函数
  • 通过基类指针/引用调用虚函数

1.30 深拷贝和浅拷贝的区别

  • 深拷贝和浅拷贝的区别在于拷贝时对“指针所指对象的内存空间”是否会在堆中申请额外的内存空间来存储。
  • 在未显式定义类的拷贝构造函数/拷贝赋值操作符时,系统会调用默认的拷贝构造函数,此时是浅拷贝操作,对于指针类数据成员仅是值拷贝,两个操作对象的指针引用的对象还是同一个。
  • 浅拷贝可能的安全性问题:两个操作对象拥有的指针指向同一块内存空间,对象释放时,可能会对同一片内存空间进行二次释放。
  • 类中数据成员有指针时,一般深拷贝更安全。

1.31 右值引用

右值引用是C++11中引入的一种新的语法,(区别于左值引用,需要引用的是一个具名对象),右值引用可以引用一个:

  • 纯右值(prvalue):字面量、表达式结果
  • 将亡值(xvalue):即将被移动的对象 右值引用的作用是为了引入移动语义和转发功能。

1.32 移动

移动语义可以让编译器用开销更小的移动操作,代替昂贵的拷贝操作。一般是希望将对象的生命周期,转移给另外的一个具体对象,被移动后的对象我们认为将不可用,会被正常析构。
移动语义主要通过函数中的标记为右值引用参数(一般是类的移动构造函数、移动赋值操作符)和std::move()来实现。

如下的移动构造函数和移动赋值操作的定义:

class DataA {
public:
    DataA() {
        ptr = new int[10];
    };

    DataA(DataA &&rhs) {
        swap(ptr, rhs.ptr);
    }

    DataA& operator=(DataA &&rhs) {
        delete []ptr;
        ptr = nullptr;
        swap(ptr, rhs.ptr);
    }
private:
    int *ptr = nullptr;
};

也可以是成员函数中使用右值引用的参数,如

vector<DataA> vec;
vec.push_back(std::move(a));

将对象a移动到vector中。

1.32.1 std::move()的实现

template <typename T>
typename remove_reference<T>::type&& move(T&& param)
{
    using ReturnType = typename remove_reference<T>::type&&;

    return static_cast<ReturnType>(param);
}

可以看到std::move()可以接收左右值,一定返回一个右值引用回去。

1.33 转发

转发的场景:在一个模版函数中,我们希望其能够接受任意一种参数(左右值)类型,并将参数传递给其他函数,使得此函数收到的参数和我们传给完美转发函数模板的参数一模一样。

转发的实现依赖:

  1. 在模版函数中
  2. 引用折叠规则(T&& && -> T&&, 其余情况如T&& & -> T&都是左值引用)
  3. std::forward<T>()
// 目标函数
void foo(const string& str);   // 接收左值
void foo(string&& str);        // 接收右值

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

1.33.1 std::forward<T>()的实现

// 第一个函数特化模版是,左值作为输入,_Tp根据模版参数来确定返回的左右值引用
template <class _Tp>
_Tp&&
forward(__libcpp_remove_reference_t<_Tp>& __t) {
  return static_cast<_Tp&&>(__t);
}

// 第二个函数特化模版是,右值作为输入,
// 这里如果_Tp传入的是左值引用,会static_assert失败,会防止防止将右值转发为左值引用
// 返回引用情况根据_Tp类型的左右值情况
template <class _Tp>
_Tp&&
forward(__libcpp_remove_reference_t<_Tp>&& __t) {
  static_assert(!is_lvalue_reference<_Tp>::value, "cannot forward an rvalue as an lvalue");
  return static_cast<_Tp&&>(__t);
}

一般在函数转发的场景下,左值引用走第一个模版特化,返回左值引用。右值引用走第二个模版特化,返回右值引用。 另外,使用类似DataA b(std::forward<DataA>(a));确实也可以触发移动语义,因为按照模版实例化的结果std::forward<DataA>(a)会将a转化为DataA &&类型返回。

1.34 虚继承

虚继承是C++中解决菱形继承问题的一种机制。它确保在多重继承中,当派生类通过多个路径继承同一个基类时,该基类在派生类中只存在一个实例,避免了公共基类部分的数据冗余、数据访问冲突问题。

问题背景:

class Animal {
public:
    int age;
    void eat() {}
};

class Bird : public Animal {
public:
    void fly() {}
};

class Horse : public Animal {
public:
    void run() {}
};

class Pegasus : public Bird, public Horse {
    // 问题:这里有两个 Animal 子对象!
};

// 数据冗余:两份age
// 访问冲突:peg.eat() 访问不明确

使用虚继承解决:

class Animal {
public:
    int age;
    void eat() {}
};

class Bird : virtual public Animal {  // 虚继承
public:
    void fly() {}
};

class Horse : virtual public Animal {  // 虚继承
public:
    void run() {}
};

class Pegasus : public Bird, public Horse {
    int color;
    // 现在只有一个 Animal 实例!
};

虚继承的实现机制

虚继承底层实现,与具体的编译器相关,一般有:

  1. 虚基类指针(virtual base pointer)来实现。
  2. 通过虚基类表指针(vbptr, virtual base table pointer)和虚基类表(virtual base table)来实现。
1. 虚基类指针直接指向虚基类子对象

进行继承的子类中隐式存放直接指向虚继承类对象的指针。(如果有多继承的情况,就会有多个)

class Bird : virtual public Animal {
    int wingSpan;
    // Animal* __vbp_Animal;  // 直接指向 Animal 子对象的指针
};

// 对象布局:
// [wingSpan] [指向Animal的指针] [Animal子对象]
//                 ↓
//           直接指向这里的 Animal 部分
2. 虚基类表指针指向虚基类表

虚基类表中记录了虚基类与本类的偏移地址,通过偏移地址,可以找到虚基类对象,访问其中成员。(如果有多继承的情况,也会只有一个指向虚基类的指针)

class Bird : virtual public Animal {
    int wingSpan;
    // void* __vbtable_ptr;  // 指向虚基类表的指针
};

// 虚基类表(全局,在程序的.rodata段):
// Bird_vbtable { offset_to_Animal = 16 }

// 对象布局:
// [wingSpan] [指向虚基类表的指针] [Animal子对象]
//                 ↓
//           指向全局的虚基类表

普通继承的内存布局

普通继承:

// 普通继承
class Animal {
    int age;
    int weight;
};  // 8字节

class Bird : public Animal {  // 普通继承
    int wingSpan;
};  // 12字节 (Animal8 + wingSpan4)

class Horse : public Animal {  // 普通继承
    int hoofSize;
};  // 12字节 (Animal8 + hoofSize4)

class Pegasus : public Bird, public Horse {
    int color;
};
[Bird部分]           偏移0-11
  [Animal子对象1]     0-7
    age               0-3
    weight            4-7
  wingSpan            8-11

[Horse部分]           偏移12-23
  [Animal子对象2]     12-19
    age              12-15
    weight           16-19
  hoofSize           20-23

[Pegasus自己的部分]    偏移24-27
  color              24-27
总大小:28字节(可能填充到32,对齐到8的倍数)
对齐要求:4(最大成员对齐)

虚继承内存布局

虚继承情况:

class Animal {
    int age;
    int weight;
};  // 8字节

class Bird : virtual public Animal {  // 虚继承
    int wingSpan;
};  // 4(wingSpan)+8(虚基类表指针)+8(Animal对象)+4(填充)字节

class Horse : virtual public Animal {  // 虚继承
    int hoofSize;
};  // 4(hoofSize)+8(虚基类表指针)+8(Animal对象)+4(填充)字节

class Pegasus : public Bird, public Horse {
    int color;
};

对于Bird和Horse的内存布局:

Bird对象(假设GCC风格):
[0-3]   wingSpan
[4-11]  虚基类指针(指向Animal的指针/表)
[12-19] Animal子对象(age + weight)
[20-23] 填充,对齐到8
总大小:24字节

Horse对象:
[0-3]   hoofSize
[4-11]  虚基类指针(指向Animal的指针/表)
[12-19] Animal子对象(age + weight)
[20-23] 填充,对齐到8
总大小:24字节

对于Pegasus的内存布局:

Pegasus对象(64位系统,GCC风格):

[Pegasus自己的部分]
0-3:     color

[Bird子对象部分]
4-7:     wingSpan
8-15:    虚基类(对象/表)指针1(指向共享的Animal)

[Horse子对象部分]
16-19:   hoofSize
20-27:   虚基类(对象/表)指针2(指向同一个共享的Animal)

[共享的Animal子对象]
28-35:   Animal部分(age + weight)
         age(28-31) + weight(32-35)

[可能的填充]
36-39:   填充(为了对齐整个对象到8字节)

总大小:40字节
对齐要求:8(因为有8字节指针)

一般虚继承下对象布局的规则:

1. 非虚基类子对象      (普通继承)
2. [派生类自己的成员]   (如color)
3. [间接虚继承的基类子对象部分]   (如Bird::wingSpan)
   [虚基类指针/偏移信息] (如Bird::vbptr)
   ...                 (多个则按声明顺序重复,如Horse)
4. [虚基类子对象]      (所有虚基类共享)

1.34 C++多线程

image.png

1.35 进程和线程

基本区别:进程是操作系统进行资源分配的基本单位,线程是操作系统调度的基本单元。每个进程都有独立的地址空间,文件描述符等。同一进程的线程共享进程的资源。
切换开销:进程之间切换开销比较大,速度慢;线程之间切换开销比较小,速度比较快。
包含关系:进程可以包含多个线程。线程是轻量级进程。
健壮性:一个进程崩溃并不会影响其他进程,一个线程崩溃会导致整个进程挂掉。所以多进程比多线程健壮。

2. 数据结构

2.1 C++容器类

image.png

C++容器类分为顺序容器,和关联容器。 其中顺序容器有array、vector、list、forward_list、deque、string。
array的实现是固定长度的数组,支持快速随机访问。
vector则是支持动态扩展的数组,尾部插入和删除较快,支持快速随机访问。
stringvector类似,但用于字符串存放。 list是双向链表,支持双向顺序访问。
forward_list是单链表,只能单向顺序访问。
deque是双端队列,也是动态数组,提供了快速的随机访问能力,但允许在两端进行插入和删除操作。

此外还有容器适配器stackqueuepriority_queue,这些是操作受限的容器结构。
stack是存取位置受限的容器适配器,默认只能栈顶存入和取出。
queue是先进先出结构的容器适配器。
priority_queue是优先队列,堆结构,默认大根堆,其中每个节点的数据大于子节点的。

关联容器包括set、map、multiset、multimap、unordered_set、unordered_map、unordered_multiset、unordered_multimap。 其中set系列表示的是集合,map系列表示的是字典结构,用于映射关系。 map中存放的是键值对。(set也可以看作存放的kv相同的键值对)

multiset/unordered_multiset中可以存放多组同样的数据,而multimap/unordered_multimap中可以存放多组key相同的键值对。

set/map系列的底层实现结构是红黑树,unordered_set/unordered_map系列的底层实现是哈希表。

2.2 红黑树

image.png

红黑树是一种特殊的二叉查找树,它的每个节点上都有一种颜色,可以是红 (Red) 或黑 (Black)。

红黑树的特性:

  1. (颜色属性)每个节点或者是黑色,或者是红色。
  2. (根属性)根节点是黑色。
  3. (叶子属性)每个叶子节点(NIL)是黑色的。(这里叶子节点是指为空或NULL的节点
  4. (红色属性)如果一个节点是红色的,则他的子节点一定都是黑色的。(但是黑节点的子节点可以是黑的,也可以是红色的,如图)
  5. (黑色属性)从一个节点到该节点的叶子节点(NIL)的所有路径上包含相同数目的黑节点。

Q&A

  1. 有了二叉搜索树,为什么还需要平衡二叉树?
    二叉搜索树容易退化成一条链。这时,查找的时间复杂度从O(log n)也将退化成O(n)。 而引入对左右子树高度差有限制的平衡二叉树 AVL,保证查找操作的最坏时间复杂度也为O (logn)
  2. 有了平衡二叉树,为什么还需要红黑树?
    AVL的左右子树高度差不能超过1,每次进行插入/删除操作时,几乎都需要通过旋转操作保持平衡。
    在频繁进行插入/删除的场景中,频繁的旋转操作使得AVL的性能大打折扣,红黑树通过牺牲严格的平衡要求,换取插入/删除时少量的旋转操作,整体性能优于AVL。
    红黑树插入时的不平衡,不超过两次旋转就可以解决;
    删除时的不平衡,不超过三次旋转就能解决。
    红黑树的红黑规则,保证最坏的情况下,也能在O(logn)时间内完成查找操作。

2.3 哈希表

哈希表,也被称为散列表,是一种常用的数据结构,这种结构在插入、删除、查找等操作上也具有”常数平均时间“的表现O(1)。哈希表使用哈希函数来确定元素在表中的索引位置。

  • 散列函数:使用某种映射函数,将任意长度的输入数据映射为固定大小的哈希值。负责将某一个元素映射为一个“大小可接受内的索引”,这样的函数称为hash function(散列函数)。
  • 碰撞问题:使用散列函数时,可能会有不同的元素被映射到相同的位置上,这就是所谓的“碰撞问题”(这无法避免,因为元素个数有可能大于分配的array容量)。解决碰撞问题的方法一般有:开放地址法、二次探测、开链法等。

解决哈希冲突的方法:

  1. 开放地址法:在碰撞发生时,在哈希表中寻找下一个“空闲的位置”来存放元素。
  • 线性探测法:从当前碰撞位置开始,逐步往后寻找空位。(线性探测法可能会引入“元素”在哈希表中聚集的问题)
  • 二次探测法:指采用前后指数位置跳跃方式探测的方法,会在x - 1, x + 1, x - 2^2, x + 2^2, x - 3^2, x + 3^2的位置去进行探测。

开放地址法可能会占用其他元素存放位置,所有映射到同一位置的元素都要依次寻找,查找效率较低。

  1. 再散列法:准备多个哈希函数,在碰撞发生时,使用下一个函数重新计算哈希值,直到找不到冲突的位置为止。
    优点:减少聚集问题。 缺点:每次碰撞都要计算新的哈希值,增加计算成本。

  2. 链地址法:哈希表中的每个位置相当于一个“桶”,维护一个链表,当多个元素碰撞到同一个桶时,将他们按顺序串起来。(最常用)

image.png

  1. 公共溢出区:将哈希表分成两部分,主表存放正常的元素,溢出区存放所有碰撞的元素。(缺点:查找时先查主表,再查溢出区,效率较低)

2.4 堆、优先队列

priority_queue

priority_queue<int, vector<int>, less<int>> mQueue;// 大根堆,默认
priority_queue<int, vector<int>, greater<int>> mQueue;// 小根堆

// 自定义比较方式
auto cmp = [](int lhs, int rhs) {return lhs < rhs;}
priority_queue<int, vector<int>, decltype(cmp)> mQueue(cmp);