八股文-0829

知识点列表

  1. 堆与栈的区别
  2. 栈溢出问题
  3. 如何避免栈溢出
  4. c++内存泄漏检测工具
  5. RAII思想
  6. 友元函数和友元类
  7. 虚函数
  8. 构造函数和析构函数能不能为虚函数,说明原因
  9. 虚函数表在哪个阶段被分配
  10. 没有虚函数如何实现多态
  11. 基类的虚函数表存储在内存的什么区,虚表指针vptr的初始化时间
  12. 虚函数调用非虚函数
  13. 纯虚函数的实现原理
  14. 虚继承解决的问题
  15. 野指针和悬空指针

1.堆与栈的区别

  1. 内存管理:栈由编译器自动管理,使用后自动释放;堆由程序员手动管理,容易出现内存泄漏
  2. 存储内容:栈存储局部变量,函数参数和返回地址;堆可以存储动态分配的对象和数据和不确定大小的数据
  3. 控制方式:栈的控制由编译器管理,无法直接控制栈的内存分配;堆可以自由分配
  4. 大小和扩展性:栈的大小受限于系统的栈大小配置,过大可能导致栈溢出;堆理论上可以使用大量内存,受限于系统的可用内存,扩展性较好
  5. 访问速度:栈的访问速度快,因为栈内存的分配和释放是通过指针的简单加减操作实现的;堆的访问速度相对栈来说慢,因为堆内存需要通过复杂的管理机制来分配和释放
  6. 管理成本:栈低,自动释放;堆高,程序员需要负责内存的分配和释放,容易出错
  7. 安全性:栈相对安全,自动管理减少了内存泄漏和错误的可能性;堆安全性较低,容易出现内存泄漏、悬挂指针和双重释放等问题。
  8. 生命周期:栈的生命周期与其所在的作用域相关,超出作用域自动释放;堆生命周期由程序员控制,直到显式释放内存之前一直存在。
  9. 使用场景:栈适合存储小型局部变量和函数调用的上下文信息;堆适合存储大型数据结构、对象和不确定大小的数据,如动态数组、链表、树等。

2.栈溢出问题

  1. 栈的结构:栈是一个先进后出的数据结构,通常用于存储函数调用的局部变量,参数和返回地址。每当一个函数被调用时,系统会为该函数分配一个栈帧(stack frame),每当函数返回时,该栈帧会被释放
  2. 原因:
    1. 深度递归,当一个函数递归调用自身时,每次调用都会在栈上分配一个新的栈帧,如果递归的深度过大,最终会耗尽栈空间,导致栈溢出
    2. 大量的局部变量
    3. 循环调用。如果两个或多个函数相互调用,且没有适当的终止条件,也可能导致栈溢出。
  3. 栈溢出的后果栈溢出通常会导致程序崩溃,操作系统会抛出一个异常(如 std::bad_allocSIGSEGV),并且可能会产生未定义行为。程序可能会在运行时突然终止,或者出现其他错误。

3.如何避免栈溢出

  • 限制递归深度:在递归函数中,确保有适当的终止条件,并且递归的深度不会过大。
  • 使用尾递归:如果可能,将递归函数改写为尾递归,这样编译器可以优化栈帧的使用。
  • 使用堆内存:对于大型数据结构,使用动态内存分配(newstd::vector)而不是在栈上分配。
  • 使用引用:对于大的对象使用引用传递而不是值传递。

4.c++内存泄漏检测工具

  1. Valgrind;Valgrind 是一个强大的开源工具,专门用于内存调试、内存泄漏检测和性能分析。它可以检测到内存泄漏、未初始化的内存读取、使用后释放等问题。
  2. AddressSanitizer 是一个快速的内存错误检测工具,集成在 GCC 和 Clang 编译器中。它可以检测内存泄漏、缓冲区溢出、使用后释放等问题。
  3. 如果你在使用 Microsoft Visual Studio 开发 C++ 程序,可以利用其内置的内存检测工具。Visual Studio 提供了一些调试工具,可以帮助检测内存泄漏。
  4. LeakSanitizer 是 AddressSanitizer 的一部分,专门用于检测内存泄漏。它可以与 GCC 和 Clang 一起使用。

5.RAII思想

RAII的全名是Resource Acquisition Is Initialization,也就是自愿获取即初始化,核心理念是将资源的管理与对象的生命周期绑定在一起 。在RAII中资源可以是内存,网络连接,文件句柄,数据库连接等任何需要手动管理的资源,当一个对象被创建时,它会在构造函数中获取所需的资源,当对象被销毁时,它会在析构函数中释放这些资源。

自动释放:由于 C++ 的对象在作用域结束时会自动调用析构函数,因此 RAII 可以确保资源在不再需要时被自动释放。这种机制大大减少了内存泄漏和资源泄漏的风险。

6.友元

友元函数:在类外的函数,能够访问类的私有和保护成员;使用friend关键字来声明

友元类:一个类可以访问另一个类的保护和私有成员;使用friend关键字来声明

7.虚函数

  1. 核心概念
    1. 动态绑定:虚函数的调用不是在编译时决定,而是允许程序在运行时根据对象的实际类型来调用适当的函数。
    2. 虚函数表:每个包含虚函数的都有一个虚函数表,这个表是一个函数指针数组,每个元素对应着类中的一个虚函数,对象通过这个表来查找正确的函数调用。每个包含虚函数的类都会有一个虚函数表(vtable),但并不是每个实例对象都有一个单独的虚函数表。相反,所有同一类的实例对象共享同一个虚函数表。当一个类被继承时,派生类会创建自己的虚函数表,覆盖基类中虚函数的条目,以指向派生类中重写的函数
    3. 虚指针:每个含有虚函数的对象都自动包含一个指向基类的虚函数表指针。
  2. 好处
    1. 提高灵活性。允许在不修改现有代码的情况下添加新的行为和功能
    2. 代码重用。通过继承和覆盖虚函数来扩展类功能,而不需要重写已有代码
    3. 接口一致性。基类可以定义接口,派生类实现具体的功能,使得代码更加模块化
  3. 什么函数不能声明为虚函数构造函数,因为对象在构造时还没有建立虚函数表静态函数,静态函数没有this指针,与对象无关,不能声明为虚函数内联函数,(可以)内联函数如果声明为虚函数,那么内联的特性会被编译器忽略
  4. 编译阶段:当编译器遇到一个虚函数的声明时,它会将其标记为虚函数。虚函数通常是在基类中声明的,并且在派生类中重写。编译器在处理虚函数时,会为每个包含虚函数的类生成一个“虚函数表”(vtable)。这个表包含了该类的所有虚函数的地址。每个类(如果有虚函数)会有一个对应的虚函数表。这个表是在编译时生成的,包含指向类中各个虚函数的指针。当类被实例化时,编译器会为每个对象生成一个指向其虚函数表的指针,称为“虚指针”(vptr)。每个对象的 vptr 指向其类的虚函数表。
  5. 链接阶段:在链接阶段,链接器会处理不同的翻译单元(源文件)之间的符号引用。如果一个虚函数在不同的源文件中被定义,链接器会确保所有引用都能正确解析到对应的实现。
  6. 动态链接:对于虚函数的调用,链接器在生成最终的可执行文件时不会直接将虚函数的地址嵌入到调用代码中。相反,调用虚函数的代码会通过 vptr 和 vtable 来查找实际的函数地址。这种方式允许在运行时根据对象的实际类型动态调用正确的函数。

8.构造函数和析构函数能不能为虚函数,说明原因

  1. 当创建一个包含虚函数的类的实例时,过程是先创建虚函数指针(vptr),然后调用构造函数:当你创建一个类的实例时,首先会为该实例分配足够的内存来存储该对象的所有成员变量,包括指向虚函数表的虚指针(vptr)。在调用构造函数之前,编译器会将该实例的 vptr 初始化为指向该类的虚函数表(vtable)。这个过程确保在构造函数执行时,虚指针已经设置为正确的类类型,以便在构造函数中可以正确调用虚函数。一旦 vptr 被初始化,接下来会调用构造函数。构造函数可以是基类的构造函数(如果是派生类的实例)或派生类的构造函数。在构造函数的执行过程中,虚指针已经指向了正确的虚函数表,因此如果在构造函数中调用虚函数,调用的是派生类中的实现(如果存在重写),而不是基类的实现。
  2. 构造函数不能声明为虚函数。构造函数用于初始化对象。当你创建一个对象时,首先会调用基类的构造函数,然后是派生类的构造函数。如果构造函数是虚函数,那么在基类构造函数中调用虚函数时,实际上会调用派生类的实现,而这在对象尚未完全构造时是不可行的。对象的完整性和状态在构造过程中是非常重要的。在构造函数执行时,对象的类型并未完全确定。虽然在派生类的构造函数中可以访问到虚指针(vptr),但在基类的构造函数中,虚指针指向的仍然是基类的虚函数表。因此,如果在基类构造函数中调用虚函数,会导致调用基类的实现,而不是派生类的实现,这与虚函数的设计初衷(实现多态性)相悖。
  3. 析构可以。此时虚指针已经指向了正确的虚函数表,如果基类的析构函数是虚函数,那么当通过基类指针删除派生类对象时,会调用派生类对象的析构函数,确保正确释放资源

9.虚函数表在哪个阶段被分配

1. 编译阶段

在编译阶段,编译器会进行以下操作:

  • 生成虚函数表
    • 编译器为每个包含虚函数的类生成一个虚函数表。这个表包含了该类的所有虚函数的地址。
    • 如果一个类继承了基类并重写了其中的虚函数,派生类的虚函数表将会更新,指向重写后的函数。
  • 添加虚函数指针
    • 对于每个包含虚函数的类,编译器会在类的内部添加一个隐含的成员变量(虚函数指针,通常称为 vptr),用于指向该类的虚函数表。
    • 这个 vptr 并不在类的定义中显式出现,而是由编译器自动插入。
  • 静态分析
    • 编译器会进行静态分析,确保所有虚函数的调用都是合法的,并且正确地处理了虚函数的重写和覆盖。

2. 链接阶段

在链接阶段,链接器会进行以下操作:

  • 合并虚函数表
    • 链接器会将不同翻译单元中的虚函数表合并,确保每个类的虚函数表是唯一的。
    • 如果有多个源文件定义了同一个类,链接器会确保只生成一个虚函数表。
  • 解析符号
    • 链接器会解析所有符号,包括虚函数的地址,确保它们在最终的可执行文件中是可用的。
  • 生成最终的可执行文件
    • 在链接阶段,虚函数表的地址和相关信息会被嵌入到最终的可执行文件中,以便在运行时可以访问。

3. 运行时

在运行时,虚函数表的工作机制如下:

  • 对象创建
    • 当创建一个对象时,内存会被分配给该对象,包括隐含的 vptr
    • 在对象的构造函数中,vptr会被初始化为指向该对象所属类的虚函数表。例如:
      • 如果构造的是基类对象,vptr 会指向基类的虚函数表。
      • 如果构造的是派生类对象,vptr 会指向派生类的虚函数表。
  • 虚函数调用
    • 当通过对象调用虚函数时,程序会使用对象的 vptr 查找相应的虚函数表,并调用表中对应的函数地址。这种机制实现了动态绑定和多态性。
    • 例如,通过基类指针调用派生类的虚函数时,程序会根据 vptr 找到派生类的虚函数表,从而调用正确的函数实现。
  • 编译阶段:生成虚函数表和虚函数指针,进行静态分析。
  • 链接阶段:合并虚函数表,解析符号,生成可执行文件。
  • 运行时:初始化对象的虚函数指针,进行虚函数调用,实现动态绑定

10.没有虚函数如何实现多态

使用函数指针来实现运行时的多态,使用std::function进行函数对象的存储,手动类型擦除,使用模板实现编译是多态

11.基类的虚函数表存储在内存的什么区,虚表指针vptr的初始化时间

虚函数表是全局共享的,每个类型只有一个虚函数表,在编译时就已经构造完成

虚函数表存储的是虚函数的地址,每个类对象都有一个指向其虚函数表的指针vptr

虚函数表的大小在编译的时候就已经确定,无需在运行时动态分配内存,因此不存储在堆区。在Linux/Unix系统中,虚函数表通常存放在可执行文件的只读数据段(.rodata),在Windows系统中,存放在常量段

虚函数表指针vptr存储在对象内存布局的最前面,如果是堆上的对象,vptr就存储在堆上,如果是栈上的对象,就存储在栈上

vptr在类的构造函数执行前进行初始化

12.虚函数调用非虚函数

当一个虚函数在调用时,调用的是实际对象的虚函数的实现,这是通过虚函数表(vtable)来实现的。然而,非虚函数的调用是静态绑定的,即在编译时就已经确定了调用哪个函数,因此,当虚函数内部调用非虚函数时,调用的是当前对象所属类的非虚函数

13.纯虚函数的实现原理

纯虚函数用于定义接口类,没有具体实现。含有纯虚函数的类被称为抽象类,不能实例化。

纯虚函数的实现依赖于虚函数表(vtable)和虚函数指针(vptr)。

每个包含虚函数的类都有一个虚函数表,表中包含指向虚函数的指针。

运行时通过虚函数指针和虚函数表实现动态绑定

14.虚继承解决的问题

在多继承结构中,不使用虚继承会导致基类成员的重复继承,通过虚继承,基类成员在继承链中只存在一份,避免了冗余和潜在的二义性

class Base; class ABase : virtual public Base ; class BBase : virtual public Base ; class C : public ABase , public BBase;

15.野指针和悬空指针

野指针是指向了一个随机的内存地址的指针变量。野指针的产生往往由于未初始化指针变量,使用野指针可能会读写到不属于程序的内存区域,从而导致数据损坏或程序崩溃

悬空指针是指向了一块已经被释放的内存区域。释放内存后指针未被置为空,再次使用该指针会出现未定义行为