C++ 高频面试题之二

大纲

基础面试题

C++ 中构造函数和析构函数可不可以是虚函数,为什么

  • 构造函数不可以是虚函数,

    • 因为在调用构造函数时,编译器尚未将对象的虚函数表(vtable)建立完整,也就是尚未将对象的虚函数表指针(vptr)设为派生类的版本,无法实现多态。
  • 析构函数可以是虚函数

    • 在有继承时应该将析构函数设为虚函数(即虚析构函数),因为这样才能确保通过基类指针删除派生类对象时,能够正确调用派生类的析构函数,防止资源泄漏。

C++ 中构造函数和析构函数可不可以抛出异常,为什么

  • 构造函数可以抛出异常,但不建议这样做

    • 因为当构造函数抛出异常时,对象构造会中止,进入未完全构造的状态。此时,编译器会自动调用已成功构造的成员对象或基类的析构函数,而未构造完成的成员对象则不会调用其析构函数。
    • 特别注意:如果在构造函数中手动分配了资源(如使用裸指针、new 分配内存等),而在抛出异常时未手动释放这些资源,就会发生内存泄漏。因此,推荐使用 RAII(如智能指针、STL 容器)来管理资源,或者使用 try-catch 在异常抛出后完成资源释放。
  • 析构函数可以抛出异常,但强烈不建议这样做

    • 因为如果析构函数在对象销毁过程中抛出异常,而此时程序正在处理另一个未捕获的异常,会导致双重异常。此时,C++ 运行时系统将调用 std::terminate(),直接终止程序,导致崩溃。
    • 因此,析构函数内部应捕获所有可能发生的异常,避免异常向外传播,可以使用 try-catch 包裹资源清理逻辑,并在 catch 块中记录日志或执行降级处理。

总结

  • 构造函数可以抛异常,用于表示构造失败,但需确保资源安全,避免内存泄漏。
  • 析构函数不应该抛异常,防止异常传播冲突(双重异常错误)导致程序崩溃,建议使用 try-catch 处理内部异常。

C++ 中局部变量存储在哪里

普通局部变量在栈上,static 局部变量在静态区,局部动态分配(new)的变量在堆上。

类型存储区域备注
普通局部变量栈(Stack)函数调用时分配,函数返回时自动回收
const 修饰的局部变量栈(Stack)只是不可修改,本质还是普通局部变量
static 修饰的局部变量静态区(Data Segment)程序整个运行期间都存在,生命周期和程序一样长
局部动态分配的变量(如 new 出来的)堆(Heap)手动分配内存,手动释放内存,不跟函数栈生命周期同步

C++ 中拷贝构造函数为什么传引用不传值

在 C++ 中,拷贝构造函数必须传引用(通常是 const 引用),而不能传值,否则会导致无限递归调用拷贝构造函数,最终造成编译不通过或者程序崩溃。

  • 传引用的话,不需要拷贝,只是引用原对象,不触发拷贝构造。
  • const 是为了支持传入 const 对象,并防止在函数内部意外修改原对象。

C++ 中内联函数的普通函数的区别(反汇编的角度)

在 C++ 中,内联函数和普通函数的主要区别体现在函数调用开销上。从反汇编层面来看,两者区别很明显:

  • 普通函数:

    • 编译器会生成真实的函数调用指令,比如在 x86 平台上通常是 CALL 指令。
    • 调用普通函数时,程序会:
      • 将参数压入栈中(或者通过寄存器传递)。
      • 保存当前执行位置(调用点地址)。
      • 跳转到函数地址执行。
      • 函数执行完后,通过 RET 指令返回调用点继续执行。
    • 在反汇编中,可以看到明显的 CALLRET 指令。
  • 内联函数:

    • 编译器在调用内联函数的地方直接展开函数体代码,不会生成 CALL 指令。
    • 也就是说,没有函数调用开销,代码像普通语句一样直接排列在调用点。
    • 在反汇编中,内联函数对应的机器码是直接展开的,看不到 CALL 指令,而是看到函数体相关的操作指令(比如加法、赋值、比较等)。

内联函数的普通函数的区别总结

特性普通函数内联函数
调用方式生成 CALL 指令跳转直接展开函数体
性能有函数调用开销(压栈、跳转、返回等)无函数调用开销(更快)
二进制体积较小(代码复用)可能变大(每次调用都复制函数体)
反汇编特征明显看到 CALL 和 RET 汇编指令没有 CALL 汇编指令,代码直接嵌入

C++ 中如何实现一个不可以被继承的类

在 C++ 中,要实现一个不能被继承的类(Final Class),有以下两种常见的方法:

  • (1) 使用 C++ 11 引入的 final 关键字

    • 在类声明后加上 final,表示该类禁止被继承。
    • 符合现代 C++ 规范,如果尝试继承,会在编译期直接报错。
  • (2) 声明构造函数或析构函数为 privateprotected,并且不给友元访问

    • 将类的构造函数或析构函数声明为 privateprotected,并且不开放友元访问。
    • 如果尝试继承,因为子类构造或析构时无法访问基类的构造函数或析构函数。
    • 这种方式会让实例化对象也变复杂(比如只能通过静态方法创建对象),适合一些单例模式或者控制构造的场景。

C++ 中什么是纯虚函数,为什么要有纯虚函数

什么是纯虚函数?

在 C++ 中,纯虚函数(Pure Virtual Function)是指在基类中声明,但不提供具体实现,强制派生类必须重写(Override)这个函数。

  • 定义形式是使用 virtual 关键字,并在函数声明后面加上 = 0,比如 virtual void func() = 0;
  • 有至少一个纯虚函数的类叫做抽象类(Abstract Class),抽象类不能直接实例化对象(只能作为基类使用)。

为什么要有纯虚函数?

纯虚函数是 C++ 中实现 “接口抽象” 和 “强制子类重写” 的关键机制,是支撑多态性的基石。

  • 强制子类去实现某些接口 / 行为,保证程序的设计一致性。
  • 为多态(运行期多态)提供基础支持。
  • 描述一种 “规范” 或者 “接口”,而不是具体实现。

提示

  • 一个类即使只有一个纯虚函数,也是抽象类,不能被实例化。
  • 子类如果不实现所有继承的纯虚函数,那么子类也是抽象类。
  • 抽象类可以有成员变量和成员函数。

C++ 中虚函数表存放在哪里

虚函数表(vtable)存放在程序的静态存储区,由编译器维护。拥有虚函数的类,其每个对象的内部都持有一个指向它的虚函数表指针(vptr),用于实现运行时多态。

虚函数表详细介绍

当一个类中有虚函数时,编译器通常会为这个类生成一张虚函数表(vtable)。vtable 本质上是存放在程序的静态存储区(.rodata 只读数据段或全局数据段)的一张表,而对象本身只保存一份指向 vtable 的指针,通常称为虚函数表指针(vptr)。

  • 虚函数表(vtable

    • 每个拥有虚函数的类都对应一张 vtable,通常是只读的,防止程序运行时被篡改。
    • vtable 是一张静态分配的表(通常存放在 .rodata 只读数据段或全局数据段)。
    • vtable 里面存放的是各个虚函数的实际函数地址(指针)。
  • 虚函数表指针(vptr

    • 拥有虚函数的类,其每个对象的内部都隐藏了一个 vptr 指针成员(占 4 个字节),指向所属类的 vtable
    • vptr 通常在构造函数中由编译器自动初始化。
  • 为什么要这么设计

    • 快速实现多态:调用虚函数时,通过 vptr 可以快速找到函数地址,效率很高(只多一次指针间接访问)。
    • 支持动态绑定:编译器在运行时根据实际对象类型,动态找到正确的函数实现。

C++ 中 const 和 static 的区别

const 保证变量或函数行为不可修改,static 控制变量或函数的存储周期和作用域,两者关注点完全不同,但可以组合使用。

特性conststatic
关键字含义表示常量,值不可修改表示静态存储周期,生命周期贯穿整个程序运行
修饰的对象变量、指针、函数参数,函数返回值、成员函数等变量、成员变量、成员函数
生命周期作用域结束时销毁(正常对象)程序开始时分配,结束时销毁
主要作用保证只读、不被意外修改保持数据共享、持久化
常用场景保护只读数据、接口规范跨函数 / 跨对象共享数据、缓存数据

C++ 中四种强制类型转换

类型转换主要用途安全性是否运行时检查典型应用场景
const_cast用于增加或去除 const / volatile 属性安全(只改修饰,不会改变对象本质)修改函数参数的常量性(如 API 不一致场景)
static_cast用于基本类型转换、类层次间的安全转换较安全(编译期类型安全的转换)intfloat、父类和子类之间的指针 / 引用转换(向上转换无风险)
dynamic_cast多态类型之间安全向下转换很安全(失败时返回 nullptr 或抛异常)运行时会判断实际类型,实现多态安全转换
reinterpret_cast最暴力,用于无关类型之间的转换不安全(可能导致未定义行为)指针和整数之间转换、 不同类型指针之间转换

C++ 中 deque 的底层原理

  • deque 叫做双端队列,它是通过多段小连续块(buffer)组织起来的,配合中央控制器(map)来管理,实现了头部和尾部快速插入和删除,并支持随机访问。
  • deque 的底层结构是由分块连续数组和一个中央控制器(map)组成
    • 元素不是存在一整块内存里,而是存在若干小块内存(称为 buffer)。
    • 每个 buffer 是一个固定大小的数组(比如 512 bytes 或 4096 bytes 之类的,根据元素大小决定)。
    • 有一个叫 map 的数组,存了所有 buffer 的指针。
    • map 自己是连续的小数组,支持随机访问和动态扩展。

提示

  • deque 一种双向开口的连续线性空间(双端队列容器),底层的数据结构是支持动态开辟内存空间的二维数组。
  • deque 本质上是由一段一段的定量连续空间(分段连续内存空间)构造而成,一旦有必要在 deque 的头端或尾端增加新空间,便会配置一段新的定量连续空间,然后串接在整个 deque 的头端或尾端。

C++ 中虚函数和多态

  • 虚函数是指在基类中使用 virtual 关键字声明的成员函数,目的是为了允许派生类重写(重新定义)它,从而实现运行时的多态性。
  • 多态指的是,当通过基类指针或引用调用虚函数时,程序能够在运行时根据实际派生类对象的类型,动态决定调用哪一个派生类中重写的函数。
  • 虚函数使得同一接口在不同对象上展现出不同的行为,C++ 通过虚函数表(vtable)和虚函数表指针(vptr)机制来支持这种动态绑定。

C++ 中异常处理机制

  • 异常处理机制

    • C++ 中的异常机制主要通过 try-catch-throw 关键字组合来实现,用于处理程序运行时出现的错误。
    • 当程序执行中遇到异常时,通过 throw 抛出一个异常对象,异常对象可以是内置类型、类对象或指针等。
    • 异常抛出后,程序会沿调用栈向上寻找匹配的 catch 语句块进行处理。
    • 如果找到了合适的 catch,就进入处理代码块;如果没有找到匹配的处理器,程序会调用 std::terminate() 终止运行。
  • 异常注意事项

    • C++ 异常是按类型匹配,可以按异常对象的类型分发到不同的 catch 语句块。
    • 异常发生时,局部对象会自动析构(保证资源释放),这叫栈展开(Stack Unwinding)。
    • 可以自定义异常类,通常继承自 std::exception
    • 不建议抛出裸指针或基本类型,应抛出类对象,利于扩展。

C++ 中为什么在性能要求高的地方不能滥用异常

在性能敏感的场景下,不建议滥用 C++ 异常机制。因为即使不抛出异常,异常机制本身也增加了代码量和隐藏开销;而一旦抛出异常,栈展开、资源清理的代价非常高,会严重影响程序性能。

  • (1) 异常会增加隐藏的运行开销

    • 即使程序正常执行、没有抛异常,编译器也要为了异常处理生成额外的隐藏代码(比如保存上下文信息、支持栈展开)。
    • 这导致了指令更多、代码膨胀、缓存命中率降低,从而影响执行性能。
  • (2) 抛出异常本身是非常昂贵的操作

    • 抛出异常(throw)时,会:
      • 搜索调用栈上所有活跃的 catch 语句块。
      • 执行栈展开(调用析构函数释放资源)。
    • 这些操作比普通的返回、跳转要慢几个数量级;在一些测试中,throw 一次比普通函数调用慢几十倍甚至上百倍。
  • (3) 异常破坏了分支预测和局部性

    • 高性能系统(比如游戏引擎、交易系统)特别依赖 CPU 的分支预测和缓存局部性。
    • 异常的流程是非线性跳转,让 CPU 很难预测,大大降低执行效率。

提示

  • 正常流程不应该依赖异常(异常是处理真正意外情况的,不是常规控制逻辑)。
  • 如果追求极致性能,常用错误码返回(比如 return false;status code),而不是抛出异常。
  • C++ 11 及以后(现代 C++)提倡在可以预测的错误路径上使用 noexcept 保证无异常,提高优化空间。

C++ 中的早绑定和晚绑定

在 C++ 中,早绑定是编译期确定函数调用,比如普通成员函数、重载函数;而晚绑定是运行期根据对象的实际类型确定调用,依赖虚函数和虚函数表。一般非虚函数使用早绑定,虚函数使用晚绑定。

  • 早绑定

    • 又叫 “静态绑定”
    • 在编译时就确定了调用哪个函数。
    • 早绑定适用于:
      • 普通成员函数(非虚函数)
      • 重载函数(函数名相同,参数不同)
      • 静态成员函数
    • 早绑定的特点是效率高,因为编译器可以直接生成调用代码,没有运行时开销。
  • 晚绑定

    • 又叫 “动态绑定”
    • 在运行时根据对象的实际类型确定调用哪个函数。
    • 晚绑定适用于:
      • 虚函数(virtual 修饰的函数)
    • 晚绑定依靠虚函数表(vtable)和虚函数表指针(vptr)来实现。

什么是绑定

绑定指的是:在程序中,把函数调用与实际的函数实现对应起来的过程。

C++ 中指针和引用的区别(反汇编的角度)

在语法层面,指针和引用有较大区别,但从底层反汇编来看,引用通常也是通过隐藏的指针实现的,二者机器指令上差异非常小;不同点在于编译器对引用自动处理了解引用操作,使得引用使用起来更接近对象本身,更安全直观。

指针和引用的深入对比

  • 语义上的区别
指针(Pointer)引用(Reference)
绑定关系可以随时指向不同对象初始化后必须绑定一个对象,不能再改
是否可以为空可以是 nullptr理论上不能为空(但野引用有风险)
语法操作*p 解引用,p-> 访问成员直接用引用名访问,像别名一样
内存开销占一个指针大小底层的实现通常也是一个指针
多级概念有多级指针没有多级引用
  • 反汇编上的区别
    • 反汇编层面,指针和引用几乎没有本质差别。
    • 主要区别是:
      • 指针:需要程序员显式操作(*->),比如需要解引用(*)才能访问实际对象。
      • 引用:在大部分编译器实现里,底层也是一个隐藏的指针,但是编译器帮程序员自动完成了解引用动作,所以语法上看起来像直接操作对象。

提示

  • 在优化后的汇编代码中,如果编译器确定引用不会为 nullptr,可以进一步做优化(比如省略空指针检查),而指针一般需要显式检查。
  • 有些平台(比如嵌入式)可能为了节省开销,对指针和引用处理得更细致些,但在常规 PC 编译器(如 GCC、Clang、MSVC)中,两者的性能开销接近。

C++ 中如何解决智能指针循环引用的问题

  • 循环引用问题发生在两个对象相互持有对方的 shared_ptr 智能指针时,导致对象无法被销毁。
  • 解决方法是:在定义对象的时候,使用强引用智能指针(shared_ptr);而在引用对象的时候,使用弱引用智能指针(weak_ptr)。
  • weak_ptr 是一种不增加引用计数的智能指针,不会干扰 shared_ptr 的生命周期控制,从而避免了循环引用问题。
  • 使用 weak_ptr 后,可以在需要时通过 lock() 将其转换为 shared_ptr,然后再安全地访问对象。

C++ 中重载函数和虚函数底层实现的区别

重载函数在编译时通过函数签名(函数名称、参数的数量和类型)来选择具体函数版本,不需要额外的运行时开销。而虚函数依赖于虚函数表(vtable)和虚函数表指针(vptr)来实现动态绑定,增加了运行时的开销,特别是在多态场景中需要查找虚函数表来决定调用哪个版本的函数。

重载函数和虚函数底层实现的区别

  • 函数解析方式:

    • 重载函数:在编译时,编译器根据函数调用的参数类型来选择具体的函数。这个过程完全是静态的,基于编译时的类型信息。
    • 虚函数:在运行时,根据对象的动态类型来决定调用哪个函数。这个过程是动态的,依赖于虚函数表(vtable)和虚函数表指针(vptr)。
  • 符号表管理:

    • 重载函数:编译器通过名称改编(Name Mangling)来区分不同的重载函数。每个重载版本都有一个唯一的符号名,且所有的解析发生在编译期。
    • 虚函数:编译器为每个包含虚函数的类生成虚函数表(vtable),对象实例包含一个指向该表的虚函数表指针(vptr)。函数解析是在运行时进行的,通过查找 vtable 来决定调用的虚函数。
  • 性能开销:

    • 重载函数:没有运行时开销,函数调用在编译时期确定。
    • 虚函数:由于依赖虚函数表(vtable)和虚函数表指针(vptr),每次调用虚函数时都会有一定的运行时开销。具体来说,每次虚函数调用需要通过对象的 vptr 查找 vtable,然后找到正确的函数指针,再进行调用。
  • 内存结构:

    • 重载函数:每个函数都有一个固定的符号,编译器通过符号表来处理不同版本的重载函数。
    • 虚函数:每个包含虚函数的类有一个虚函数表(vtable),每个对象有一个指向该表的虚函数表指针(vptr),通过该指针实现运行时多态。

重载函数和虚函数底层实现的总结

重载函数虚函数
解析方式编译时静态解析运行时动态解析
符号管理名称改编(Name Mangling)虚函数表(vtable
性能开销无运行时开销每次调用虚函数时有额外开销(查找 vtable
内存结构不需要 vtable,直接通过符号表需要 vtablevptr,增加内存开销

C++ 中 map 的底层实现,AVL 和 RBTree 有什么区别

  • map 的底层实现

    • map 是一种有序关联容器,底层是基于红黑树(RBTree)实现的。
    • 红黑树是一种自平衡的二叉查找树,它保证了树的高度在 O(log n) 的范围内,从而保证了查找、插入和删除操作的时间复杂度为 O(log n)
    • map 中,键(Key)是唯一的,而且数据会根据键值的大小进行排序。每个节点包含一个键值对,查找、插入和删除操作都利用红黑树的性质进行。
  • AVL 树和 RBTree 树的区别

    • AVL 树和红黑树都是自平衡二叉查找树,主要用于保证查找操作的时间复杂度为 O (log n)
    • AVL 树的平衡更严格,查询性能更好,但在插入和删除时需要更多的旋转。
    • 红黑树的平衡度较为宽松,插入和删除的旋转操作较少,但查询性能可能稍差于 AVL 树。

AVL 树和 RBTree 树的深入对比

  • 平衡条件

    • AVL 树:
      • AVL 树是一种严格平衡的二叉查找树。它要求树中每个节点的左右子树的高度差不超过 1,即 平衡因子(左右子树的高度差)只能是 -101
      • 由于这种严格平衡的要求,AVL 树的插入和删除操作可能需要较多的旋转来恢复平衡。
    • 红黑树:
      • 红黑树对平衡的要求较为宽松。它通过给每个节点着色(红色或黑色)来实现平衡,满足以下规则:
        • 每个节点是红色或黑色。
        • 根节点是黑色。
        • 红色节点不能有红色的子节点(即没有两个连续的红色节点)。
        • 从任一节点到其所有叶节点的路径上,黑色节点的个数相同。
      • 红黑树的平衡度不如 AVL 树严格,但它的插入和删除操作通常较为高效,尤其是在频繁修改数据的情况下。
  • 插入和删除

    • AVL 树:
      • 插入和删除操作较为复杂,因为每次操作后都要保持严格的平衡条件。通常需要通过 “单旋转” 或 “双旋转” 来调整树结构。
      • 由于要求严格平衡,AVL 树在插入和删除操作中可能会有较多的旋转。
    • 红黑树:
      • 红黑树的插入和删除操作相对简单一些。插入时,通过颜色的改变和较少的旋转来恢复平衡。删除时,也有特定的调整策略来保持树的平衡。
  • 查询效率

    • AVL 树:
      • 由于 AVL 树的平衡条件更严格,通常能保证更小的树高度,查找操作相对更快。查询操作的时间复杂度为 O(log n),但由于需要更多的旋转,性能在插入和删除时可能受到影响。
    • 红黑树:
      • 红黑树的平衡度相对较宽松,因此查找操作的效率略低于 AVL 树,但由于插入和删除操作需要的旋转较少,整体性能在一些情况下更为稳定。
  • 性能对比

    • AVL 树:
      • 在查找操作频繁的情况下,AVL 树通常会表现得更好,因为它的高度比红黑树更小。
      • 但是在插入和删除操作频繁的场景中,AVL 树的旋转操作会导致性能下降。
    • 红黑树:
      • 在插入和删除操作频繁的场景中,红黑树通常表现得更好,因为它需要较少的旋转,调整较为简单。
      • 但是在查询操作上,红黑树的性能可能会稍微逊色于 AVL 树。

C++ 中假如 map 的键是类类型,那么 map 底层是如何比较的

  • 在 C++ 中,当 map 的键(Key)是类类型时,底层红黑树需要对键进行大小比较。默认是使用键类型的 < 运算符重载函数。
  • 如果类类型没有提供 < 运算符重载函数,则可以自定义一个比较器,并在 map 模板参数中指定。比较器需要符合严格弱序性,以保证红黑树能正常维护平衡和有序性。

C++ 中设计模式知道哪些,具体讲一下

  • C++ 中设计模式分为创建型、结构型、行为型三大类,我比较熟悉的包括单例模式、工厂模式、观察者模式、策略模式、代理模式、适配器模式、装饰器模式等。
  • 以单例模式为例,它通过私有化构造函数和提供全局访问点,保证系统中只有一个实例,常用于日志记录、配置管理等场景。

C++ 中 stack 和 queue 的底层实现

  • stackqueue 都是容器适配器,默认是基于 deque 容器实现。
  • stack 主要通过 push_back()pop_back() 来实现后进先出(LIFO),queue 主要通过 push_back()pop_front() 来实现先进先出(FIFO)。
  • 底层之所以选用 deque,是因为 deque 在头部和尾部都能提供常数时间(O(1))的插入和删除操作,性能符合 stackqueue 的特点。

提示

  • 在 C++ 中,stackqueue 都允许自定义底层容器,比如可以用 vector 来作为底层容器(只要它支持需要的接口),即 stack<int, std::vector<int>> s;

C++ 中如果构造函数里面抛出了异常,会发生什么,如何解决

  • 如果 C++ 构造函数抛出异常,当前对象不会构造成功,编译器会自动析构已经成功构造的成员对象和基类对象,防止资源泄漏。
  • 为了避免内存泄漏,通常遵循 RAII 原则,比如使用智能指针等自动资源管理工具来避免手动释放资源。
  • 在 C++ 11 之后,可以使用 noexcept 明确声明构造函数不会抛出异常,这对性能优化、标准容器(如 vector)的内部移动操作有重要意义。