C/C++ 高频八股文面试题1000题(二)
在准备技术岗位的求职过程中,C/C++始终是绕不开的核心考察点。无论是互联网大厂的笔试面试,还是嵌入式、后台开发、系统编程等方向的岗位,C/C++ 都扮演着举足轻重的角色。
本系列文章将围绕 “大厂C/C++ 笔试面试中出现频率最高的 1000 道题目” 进行深入剖析与讲解。由于篇幅限制,整个系列将分为多篇陆续发布,每篇50道题,本文为 第二篇。
第一篇(0~50题):C/C++ 高频八股文面试题1000题(一)
通过系统梳理这些高频考点,帮助你在面对各大厂笔试、技术面试时真正做到 心中有数、下笔有神、对答如流!
面试题51:const、static作用?
const最主要的作用就是声明一个变量为常量,即这个变量的值在初始化之后就不能被修改。但const不仅可以用作普通常量,还可以用于指针、引用、成员函数、成员变量等。
具体作用如下:
1)定义普通常量:当修饰基本数据类型的变量时,表示常量含义,对应的值不能被修改。
2)修饰指针:这里分多种情况,比如指针本身是常量,指针指向的数据是常量,或者指针本身和其指向的数据都是常量。
3)修饰引用:const修饰引用时,一般用作函数参数,表示函数不会修改传递的参数值。
4)修饰类成员函数:const修饰成员函数,表示函数不会修改类的任何成员变量,除非这些成员变量被声明为mutable。
5)修饰类成员变量:const修饰成员变量,表示生命期内不可改动此值。
static是C++中很常用的修饰符,它被用来控制变量的存储方式和可见性。
可以重点回答以下几个方面的作用:
1)修饰局部变量:当static用于修饰局部变量时,这个变量的存储位置会在程序执行期间保持不变,且只在程序执行到该变量的声明处时初始化一次。即使函数被多次调用,static局部变量也只在第一次调用时初始化,之后的调用将不会重新初始化它。
2)修饰全局变量或函数:当static用于修饰全局变量或函数时,限制了这些变量或函数的作用域,它们只能在定义它们的文件内部访问。有助于避免在不同文件之间的命名冲突。
3)修饰类的成员变量或函数:在类内部,static成员变量或函数属于类本身,而不是类的任何特定对象。这意味着所有对象共享同一个static成员变量,无需每个对象都存储一份拷贝。static成员函数可以在没有类实例的情况下调用。
面试题52:C++面向对象三大特征及对他们的理解
在 C++ 中,面向对象编程的核心是三大特征:封装、继承和多态。这三个特性共同构成了 OOP(面向对象编程)的基础,帮助我们写出结构清晰、易于维护和扩展的代码。
1. 封装
封装是把数据和操作数据的方法绑定在一起,形成一个"类",同时对外隐藏内部实现细节。
- 封装就像给数据加了一个保护壳,只暴露必要的接口给外界
- 通过public、private、protected等访问控制实现信息隐藏
- 好处是提高了代码的安全性和可维护性,使用者不需要知道内部实现
- 典型的例子就是类的设计,数据成员通常私有,通过公有方法访问
2. 继承
继承允许我们基于已有类创建新类,实现代码重用和层次关系。
- 子类继承父类的特征和行为,可以添加新功能或修改现有功能
- 实现了"是一个"的关系(is-a关系)
- 支持单继承和多继承(C++特有)
- 通过继承可以构建类层次结构,提高代码复用性
- 需要注意合理设计继承层次,避免过度继承带来的复杂性
3. 多态
多态是指同一操作作用于不同对象可以产生不同的行为。
- 主要通过虚函数和继承机制实现
- 分为编译时多态(函数重载、运算符重载)和运行时多态(虚函数)
- 运行时多态通过虚函数表和动态绑定实现
- 提高了代码的扩展性和灵活性,是面向对象设计的重要特性
- 典型应用是通过基类指针或引用调用派生类方法
面试题53:说说虚析构函数的必要性
当基类指针指向派生类对象时,如果基类析构函数不是虚函数,那么通过基类指针删除对象时,只会调用基类的析构函数,导致派生类部分资源泄漏。这就是为什么基类析构函数必须声明为虚函数 - -它能确保正确调用整个对象继承链上的所有析构函数。
这个问题直接关系到内存泄漏。如果没有虚析构,派生类特有的资源就无法释放,比如派生类中动态分配的内存、文件句柄等资源就会泄漏。在实际项目中,这种泄漏可能累积成严重问题。
面试题54:malloc、free和new、delete区别
这个问题其实是在考察你对 C++ 中内存管理机制的理解。
malloc 和 free 是 C 语言中的内存分配函数,而 new 和 delete 是 C++ 中的关键字,它们虽然都能用来申请和释放内存,但有几点非常关键的区别:
1. 面向对象的支持
malloc 和 free 只是负责分配和释放原始内存,不会调用构造函数和析构函数。
而 new 和 delete 在分配和释放对象时,会自动调用构造函数和析构函数,这才是真正支持面向对象的语言应该做的事。
比如你 new 一个对象的时候,它不仅分配了内存,还会初始化这个对象;delete 的时候也会先析构对象,再释放内存。
2. 类型安全
malloc 返回的是一个 void* 指针,使用时要手动强转成你需要的类型,容易出错。
而 new 是类型相关的,返回的就是你申请的类型的指针,不需要强制转换,更安全。
3. 操作方式不同
malloc 只是分配一块内存,不做任何初始化;
new 不仅分配内存,还会构造对象,完成初始化。
同理:
free 只是把内存还给系统;
delete 会先调用析构函数清理对象资源,然后再释放内存。
4. 异常处理
如果 new 分配失败,默认情况下会抛出异常(也可以使用 nothrow 版本);
而 malloc 分配失败只会返回 NULL,需要手动判断。
面试题55:STL容器线程安全吗?
这个问题实际上是在考察你对 STL 容器及其在并发编程中的使用理解
STL 容器本身不是线程安全的,但在多线程环境中可以通过外部同步机制(如互斥锁)来保证线程安全。
为什么STL容器不是线程安全的?
- 性能考虑:如果 STL 容器内置了线程安全机制,比如每次访问都需要加锁,这会带来显著的性能开销。C++ 标准库选择不强制实现这些开销,以保持容器的高效性。
- 灵活性:不同的应用场景对线程安全的需求不同,标准库允许开发者根据具体需求选择合适的同步策略,而不是提供一种“一刀切”的解决方案。
那多线程环境下如何确保线程安全?
- 外部同步:
- 当多个线程需要访问同一个容器时,你需要自己实现同步机制。常用的方法包括使用互斥锁(如 std::mutex)来保护对容器的操作,确保同一时间只有一个线程可以修改或读取容器内容。
- 只读操作:
- 如果多个线程只是读取容器而不进行写操作,通常不需要加锁,因为读操作是无冲突的。不过,一旦有写操作存在,所有读操作也需要同步,以避免数据不一致的问题。
- 使用线程安全的替代品:
- 对于某些特定的应用场景,你可以使用第三方库提供的线程安全容器,或者自己封装一个线程安全版本的容器。例如,Intel 的 TBB(Thread Building Blocks)库就提供了线程安全的容器。
面试题56:说说快排、堆排的原理
1、快速排序(Quick Sort)
快速排序的核心思想是“分治法”。
它的基本思路就是:
找一个基准数(比如选中间的数或者第一个数),然后把整个数组分成两部分:一部分比基准数小,另一部分比基准数大。接着对这两部分继续递归地进行同样的操作,直到每一部分只剩下一个元素为止。
这个过程就像是在不断地“分区”,每一轮都能把一个元素放到它最终应该在的位置上,然后处理它左右两边的数据。
- 时间复杂度
- :平均是 O(n log n),最坏情况下是 O(n²)(比如数据已经有序的时候)。
- 空间复杂度
- :因为用到了递归调用栈,所以是 O(log n) 左右。
- 稳定性
- :快排是不稳定的排序算法(即相同元素的相对顺序可能被打乱)。
2、堆排序(Heap Sort)
堆排序的核心是利用了“堆结构”来进行排序。
堆是一种特殊的完全二叉树结构,分为最大堆和最小堆:
- 最大堆:父节点的值总是大于等于子节点;
- 最小堆:父节点的值总是小于等于子节点。
堆排序的过程大致分为两个阶段:
- 构建最大堆
- :把无序数组构建成一个最大堆,这样最大的元素就在堆顶;
- 不断取最大值并调整堆
- :把堆顶的最大元素放到数组末尾,然后重新调整堆,重复这个过程,直到所有元素都有序。
- 时间复杂度
- :始终是 O(n log n),不会像快排那样退化到 O(n²)。
- 空间复杂度
- :O(1),不需要额外空间,属于原地排序。
- 稳定性
- :也是不稳定的排序算法。
面试题57:说一说c++中四种cast转换
C++ 的四种 cast 各司其职:static_cast 用于常规转换,dynamic_cast 用于安全的多态转换,const_cast 用于去除常量性,reinterpret_cast 则是底层的强制类型转换,慎用。
1. static_cast
这是最常用、最安全的一种类型转换。
它用于有明确转换关系的类型之间,比如基本数据类型之间的转换(如 int 转 float),或者有继承关系的类指针之间(比如子类转父类)。
但它不会做运行时检查,所以如果你把一个父类指针强制转成子类类型,而这个指针其实不是那个子类的对象,那就会出错。
2. dynamic_cast
这种转换主要用于多态类型的指针或引用之间的转换,它会在运行时进行类型检查,确保转换是安全的。
比如你有一个基类指针指向派生类对象,你想把它转成派生类指针,用 dynamic_cast 就会自动判断是否合法。如果不行,返回空指针(如果是引用则抛异常)。
所以它是安全但有性能开销的一种转换方式。
3. const_cast
顾名思义,它用来去掉变量的 const 或 volatile 属性。
比如说你有一个 const int*,你想把它传给一个不接受 const 的函数,就可以用 const_cast 去掉 const。
不过要注意:如果你对原本定义为 const 的变量进行修改,行为是未定义的,可能会导致程序崩溃。
4. reinterpret_cast
这是最危险、也最底层的一种转换。
它可以将一种类型的指针直接转换成另一种类型的指针,甚至可以把指针转成整数。
它几乎不做任何检查,只是“重新解释”内存中的二进制数据,所以使用时要非常小心,通常只在底层开发或系统编程中使用。
面试题58:tcp和udp区别?
1、连接方面
TCP 是面向连接的协议,而 UDP 是无连接的协议。在 TCP 中,发送方和接收方必须先建立连接,然后才能传输数据。UDP 则不需要建立连接,直接发送数据即可。
2、可靠性
TCP 保证数据传输的可靠性,通过序列号、确认应答和重传机制等方式来保证数据的完整性和正确性。UDP 则不保证数据传输的可靠性,因为它不提供确认和重传机制。
3、传输速度
因为 TCP 要保证数据传输的可靠性,所以在传输速度方面相对较慢。而 UDP 则不需要进行复杂的传输控制,因此传输速度更快。
4、传输内容
TCP 是一种面向字节流的协议,将数据看作是一连串的字节流,没有明确的消息边界。UDP 则是面向报文的协议,将数据看作是一系列的报文,每个报文是一个独立的单元,具有明确的消息边界。
基于以上的特点,TCP 和 UDP 适用于不同的场景。TCP 适用于对传输可靠性要求比较高的场景,例如网页浏览、文件传输、邮件等。而 UDP 则适用于对传输可靠性要求较低、传输速度要求较高的场景,例如在线游戏、视频直播等。
面试题59:进程和线程区别?
进程:是资源分配的基本单位,每个进程都有自己独立的内存空间(代码段、数据段、堆栈等),可以看作是一个正在运行的程序实例。进程之间是相互独立的。
线程:是 CPU 调度的基本单位,属于进程,一个进程中可以包含多个线程。线程共享进程的内存空间和资源(如文件句柄、数据段),但每个线程有自己独立的栈和寄存器。
其它区别:
- 资源消耗不同
- :进程时需要为其分配独立的内存空间和系统资源,创建和切换进程的开销较大。线程间共享进程的资源,创建线程所需的开销较小,线程切换的开销也远小于进程切换。
- 通信方式
- :因为各自独立的内存空间,进程间通信(IPC)较为复杂,需要使用管道、消息队列、共享内存、套接字等方式。同一进程内的线程共享内存空间,因此线程直接读写内存即可,但注意需要使用同步机制避免数据错误。
面试题60:指针和引用作用以及区别?
这个问题在C++面试中几乎必问
指针本质上就是一个变量。不过这个变量有点特殊,它里面存放的不是实际的数据值,而是另一个变量(或对象)在内存中的地址。
引用本质上是一个别名。它不是一个独立的新变量,而是给一个已经存在的变量(或对象)起的另一个名字。
指针提供了强大的内存操控能力,但使用时需要小心管理;而引用则像是变量的别名,更加简单安全,尤其适合参数传递和返回值优化。
核心区别总结:
身份不同:
指针:是一个独立变量,存储的是地址。
引用:只是一个别名,本身不占存储空间(编译器通常用指针实现,但对程序员透明)。
初始化要求:
指针:可以声明时不初始化(危险!),也可以初始化为 nullptr。
引用:必须声明时初始化,且绑定后不能改变(终身绑定)。
空值 (Nullability):
指针:可以指向空 (nullptr),表示不指向任何对象。
引用:不能为空,必须绑定有效对象。
操作方式:
指针:需要 * 操作符进行解引用才能操作指向的数据。有指针运算(如 ++, --, +)。
引用:直接使用,就像操作普通变量一样。没有引用运算。
重绑定 (Rebinding):
指针:可以改变指向,指向不同的对象。
引用:不能重新绑定,一生只绑定一次。
安全性和用途倾向:
指针:更灵活,功能更强大(如多级指针、指向指针的指针),但也更“危险”(空指针、野指针、内存泄漏风险)。
引用:更安全(无空引用、无显式解引用)、语法更简洁清晰。在函数参数传递(尤其 const T&)和创建别名时是首选。在需要“可能为空”、“需要改变指向”或操作动态内存/复杂数据结构时,必须用指针(或智能指针)。
面试题61:C++11用过哪些特性,auto作为返回值和模板一起怎么用,函数指针能和auto混用吗?
C++11 新特性几乎是面试必问的一个话题,可以主要回答以下几个特性:
- auto 类型推导
- 智能指针
- RAII lock
- std::thread
- 左值右值
- std::function 和 lambda 表达式
auto 和函数指针混用非常好!它能极大简化函数指针变量的声明,让代码更干净。存储无捕获 Lambda 时,auto 是最通用方便的方式(存储闭包对象),也可以配合 + 或显式转换获得函数指针。
函数指针与 auto 混用可以帮助简化复杂类型的声明,但在 C++11 中,auto 主要用于局部变量的自动类型推导。
面试题62:boost用过哪些类?
Boost 提供了一系列强大的工具来解决各种编程挑战。例如,Boost.Thread 让我们能够轻松地编写多线程应用;Boost.Asio 则是处理网络通信的理想选择,特别是对于需要高性能的服务端开发;Boost.Signal 实现了类似事件驱动的架构,非常适合构建反应式系统;Boost.Bind 和 Boost.Function 提升了函数调用的灵活性和通用性,特别是在与 STL 结合使用时显得尤为重要。
面试题63:说出五种常见设计模式的应用场景
1、单例模式:适合于需要全局唯一实例的情况,如配置管理器或日志记录器。
2、工厂模式:适用于需要灵活创建不同对象的情形,特别是在创建逻辑较为复杂时。
3、代理模式:用于控制对某个对象的访问,增强功能而不改变原有接口。
4、适配器模式:当两个不兼容的接口需要共同工作时,可以通过适配器进行桥梁搭建。
5、模板方法模式:在需要定义固定流程但允许某些步骤可变的情况下非常有用,常见于框架设计中。
面试题64:QT信号槽优点
Qt信号和槽的本质是回调函数。信号或是传递值,或是传递动作变化;槽函数响应信号或是接收值,或者根据动作变化来做出对应操作。
优点: ①类型安全。需要关联的信号槽的签名必须是等同的。即信号的参数类型和参数个数同接受该信号的槽的参数类型和参数个数相同。若信号和槽签名不一致,编译器会报错。
②松散耦合。信号和槽机制减弱了Qt对象的耦合度。激发信号的Qt对象无需知道是那个对象的那个信号槽接收它发出的信号,它只需在适当的时间发送适当的信号即可,而不需要关心是否被接受和那个对象接受了。Qt就保证了适当的槽得到了调用,即使关联的对象在运行时被删除。程序也不会崩溃。
③灵活性。一个信号可以关联多个槽,或多个信号关联同一个槽。
不足:速度较慢。与回调函数相比,信号和槽机制运行速度比直接调用非虚函数慢10倍。
原因:①需要定位接收信号的对象。②安全地遍历所有关联槽。③编组、解组传递参数。④多线程的时候,信号需要排队等待。
面试题65:说说进程间通信
在操作系统中,进程是相互独立的运行单元,它们有各自的地址空间和资源。但有时候我们希望多个进程之间能够交换数据或者协同工作,这就需要使用到进程间通信(IPC)机制。
进程间通信的方式有很多,像管道适用于父子进程之间的简单通信,共享内存速度快但需要同步控制,而套接字则适合跨网络的复杂通信需求。
面试题66:多线程,锁和信号量,互斥和同步
锁是实现互斥的主要工具;信号量和条件变量是实现同步的主要工具;互斥是同步的一种特殊情况;条件变量依赖互斥锁保护共享状态。
1. 互斥锁(Mutex)
- 互斥锁是最常用的同步工具之一。
- 它的作用是:一次只允许一个线程访问共享资源,其他线程必须等待锁释放后才能继续执行。
- 比如多个线程同时写一个日志文件,就可以用互斥锁来保护这个文件的写入过程。
不过要注意死锁问题 —— 如果两个线程各自拿着对方需要的锁不放,就会卡住。
2. 锁(Lock)
- 锁其实是广义的说法,通常指的就是互斥锁,也包括读写锁、递归锁等变种。
- 在 C++ 中我们经常使用 std::lock_guard 或 std::unique_lock 来自动加锁和解锁,避免手动管理带来的风险。
3. 信号量(Semaphore)
- 信号量是一种更通用的同步机制,它可以控制多个线程同时访问的资源数量。
- 它有两个基本操作:wait()(或叫 acquire()) 和 post()(或叫 release())。
- 举个例子:如果有三个线程可以同时访问某个资源池,就可以初始化一个初始值为 3 的信号量,每次有线程进入就减一,离开时加一。
信号量比互斥锁更灵活,既可以做互斥(初始值为1),也可以做资源计数。
4. 同步(Synchronization)
- 同步是指多个线程之间按照某种顺序来执行,确保某些操作不会乱序执行。
- 实现同步的方式有很多,除了上面说的互斥锁和信号量之外,还可以使用条件变量(condition variable)、原子操作(atomic)、事件(event)等方式。
比如生产者-消费者模型中,我们常用条件变量配合互斥锁来做同步:当队列为空时消费者等待,当生产者放入数据后再通知消费者继续处理。
面试题67:动态库和静态库的区别
这个问题实际上是在考察你对编译链接过程的理解,特别是关于如何管理和使用代码库的不同方式。
静态库将所有依赖直接打包进可执行文件,便于部署但会导致文件较大;动态库则通过外部引用,在运行时加载,节省了空间和便于更新,但增加了部署复杂度。
关键点区别总结 (对比表):
特性 | 静态库 | 动态库 |
链接时机 | 编译链接时 | 运行时 (或启动时) |
代码位置 | 库代码拷贝到最终可执行文件中 | 库代码在独立文件 (.dll/.so) 中 |
可执行文件大小 | 大 (包含所有库代码) | 小 (只包含调用信息) |
运行时依赖 | 无 (单文件即可运行) | 有 (必须能找到对应的 .dll/.so) |
内存占用 | 高 (每个程序独享一份库代码拷贝) | 低 (多个程序共享内存中的同一份库代码) |
磁盘空间占用 | 高 (库代码在每个程序里重复存储) | 低 (库文件在磁盘上只有一份) |
启动速度 | 快 (无需加载库) | 稍慢 (需要加载、链接库) |
库更新/升级 | 困难 (需重新编译链接整个程序并重新分发) | 方便 (替换库文件即可,程序无需重新编译) |
版本冲突问题 | 无 (库代码已嵌入,自给自足) | 有 (著名的 "DLL Hell",需管理版本和依赖) |
适用场景 | 程序简单、独立部署要求高、对磁盘/内存不敏感 | 大型程序、多个程序共享库、需要热更新、节省资源 |
面试题68:提高C++性能,你用过哪些方式去提升
主要通过减少拷贝、使用 move 语义、选择合适的数据结构、优化内存分配、减少锁竞争以及善用编译器优化等方式来提升 C++ 性能。
面试题69:尝试自己写过语言或语言编译器吗?
这是一个在中高级 C++ 或系统编程面试中可能出现的问题,尤其是在考察你对语言设计、编译原理、底层机制的理解时。
可以这样回答:虽然我没有完整实现过一门编程语言,但我在学习过程中尝试过写小型解析器和解释器原型,对编译流程有一定理解。
比如:
- 我用过 Flex 和 Bison(或 Lex/Yacc)写过一些小型的解析器,用于解析配置文件、表达式计算等;
- 也尝试过写一个简单的脚本解释器原型,可以识别变量、基本运算、条件判断等语法结构;
- 对词法分析、语法分析、抽象语法树(AST)、中间代码生成这些编译流程有比较清晰的认识;
- 同时也了解过 LLVM 的基本使用,知道如何通过 IR 来生成目标代码。
面试题70:模板和泛型编程有什么好处
这个问题其实是在考察你对 C++ 模板机制的理解,以及你是否具备抽象思维和代码复用意识。
简单来说,模板是 C++ 实现泛型编程的手段,而泛型编程的核心思想是:写出一套通用的代码,可以适用于多种类型。
它的主要好处包括:
1. 提高代码复用性
- 写一个函数或类模板,就可以支持各种数据类型,避免为每种类型重复写相同的逻辑。
- 比如 std::vector 就是一个模板类,它可以用 int、string、自定义结构体等任何类型。
2. 提升类型安全性
- 模板在编译时进行类型检查,比宏或者 void* 更安全。
- 它不是简单地绕过类型系统,而是根据具体类型生成对应的代码,既灵活又安全。
3. 性能无损失
- 模板在编译阶段展开,最终生成的是针对具体类型的普通代码,没有运行时的额外开销。
- 相比之下,像 Java 的泛型是“擦除式”的,在运行时无法知道具体类型。
4. 增强代码灵活性和可扩展性
- 使用模板可以实现一些高级技巧,比如策略模式、SFINAE(替换失败并非错误)、模板特化、类型萃取等。
- 这些技术广泛应用于 STL 和现代 C++ 编程中,也使得库的设计更灵活、更强大。
面试题71:你对多种计算机语言熟悉吗?
我的主力语言是 C++
我对 C++ 有比较深入的理解,尤其是在面向对象、模板元编程、STL 和多线程方面有实际项目经验。也了解现代 C++(C++11 及以后标准)的一些新特性,比如 auto、智能指针、lambda 表达式等。
面试题72:Git项目了解多少
Git 是我最常用的版本控制工具,它不仅帮助团队成员协作开发,还提供了强大的分支管理功能,使得代码管理和版本追踪变得非常高效。
在日常工作中,我会经常使用 Git 来:
- 管理代码的不同版本;
- 协同团队成员共同开发,避免冲突;
- 使用 GitHub/GitLab 等平台进行代码审查(Code Review),确保代码质量;
- 设置钩子(Hooks),自动化一些测试或部署流程;
- 解决合并冲突,保证代码的一致性和稳定性。
面试题73:熟悉哪些网络框架
我熟悉多种网络框架,包括 Boost.Asio、libevent、gRPC、Netty 和 POCO 等,了解它们在不同场景下的优势与适用方式,并在实际项目中使用过 Boost.Asio 和 gRPC 来构建高性能、可扩展的网络通信模块,能够根据项目需求选择合适的框架并快速上手开发
面试题74:如何选择使用引用还是指针?
- 安全性 vs 灵活性
- :如果追求安全性和代码的简洁性,优先考虑引用;如果需要更大的灵活性,特别是涉及动态内存管理和多态性,则选择指针。
- 是否需要修改原对象
- :如果你想要函数能够修改传入的对象,而不想承担指针带来的额外复杂性,使用引用作为参数是一个好选择。
- 是否允许空值
- :如果有可能需要表示“没有值”的情况,比如在查找失败时返回 nullptr,则应使用指针。
- 性能考量
- :虽然两者在大多数情况下性能差异不大,但在某些特定情况下(如频繁的指针运算),可能会有细微差别。不过,通常这不是主要的考虑因素。
面试题75:从汇编层去解释一下引用
虽然我们在写 C++ 代码时觉得引用和指针是两个不同的概念,但从汇编层或编译器实现的角度来看,引用其实在底层通常就是用指针来实现的。
那么从汇编层面来看,引用到底是什么?
简单来说:引用本质上是通过指针实现的一种语法糖。
举个例子:
int a = 10;
int& ref = a;
ref = 20;
这段代码在编译之后,ref 在底层其实就是 int* const 类型的指针(常量指针),它指向变量 a 的地址。
也就是说,上面的代码可以理解为:
int a = 10;
int* const ref = &a;
*ref = 20;
所以你在 C++ 中使用 ref = 20; 这种不需要解引用的方式操作引用,在汇编层面其实是自动帮你加了一个 *,也就是做了间接访问。
面试题76:C++中的指针参数传递和引用参数传递
在 C++ 中,函数参数的传递方式主要有三种:值传递、指针传递、引用传递。
重点来看指针参数传递和引用参数传递的区别与使用场景。
(1)、指针参数传递
- 指针传递就是把变量的地址传给函数,函数内部通过这个地址去访问和修改原始数据。
- 函数形参是一个指针类型,调用时需要取地址(&)或传入已有的指针。
- 在函数内部操作的是指针指向的内容,所以可以修改外部变量。
特点:
- 可以为空(nullptr),表示“不指向任何对象”;
- 可以重新指向其他对象;
- 调用者需要显式传地址,语法上略显繁琐;
- 常用于动态内存管理、多态行为(如基类指针)、数组传递等。
(2)、引用参数传递
- 引用传递实际上是变量的别名,它在函数内部直接操作的就是外部变量本身。
- 不需要解引用,也不允许为空,必须绑定到一个有效的对象上。
- 更加安全简洁,推荐用于大多数需要修改实参的场景。
特点:
- 必须初始化,不能为 null;
- 一旦绑定就不能再改变目标;
- 使用方便,语法更自然;
- 推荐用于函数参数传递中避免拷贝大对象,或者希望修改外部变量的情况。
面试题77:形参与实参的区别?
形参是函数定义中的占位符,用于接收调用时传入的实参;实参则是调用函数时传递的具体值或表达式,两者通过不同的传递方式(值传递、指针传递、引用传递)实现数据交互。
- 角色: 实参是调用时实际塞进去的东西,形参是函数定义时占坑的代号。
- 位置: 实参在调用处,形参在函数定义/声明处。
- 要求: 实参要有具体值/变量且类型匹配,形参规定接收类型和名字。
- 生命周期: 实参的命自己管,形参的命函数管(函数结束形参就没了)。
- 传递方式关键: 值传递(改形参不影响实参),引用/指针传递(改形参直接影响实参)。
面试题78:static的用法和作用?
static 的核心作用主要有三个:一是改变局部变量的生命周期;二是限制全局变量和函数的作用域;三是实现类级别的数据共享和操作。
局部变量 + static: 让变量活得更久(函数结束不销毁),只初始化一次,作用域不变(还是局部)。
全局变量/函数 + static: 让它们只在当前文件有效(对外隐藏),避免命名冲突,封装细节。
类成员 + static:
- 变量: 所有对象共享一份,属于类本身,生命周期长,需类外初始化。
- 函数: 没有 this 指针,只能访问 static 成员,可通过类名直接调用。
面试题79:静态变量什么时候初始化
静态变量的初始化有以下特点:
1、初始化次数:静态变量的初始化仅发生一次,但可以多次赋值。其内存空间在程序运行前(编译阶段)就已由编译器分配完成。
2、内存区域:静态局部变量与全局变量一样,均存储在程序的全局数据区,因此它们的内存分配都在主程序启动前完成。不过,C 和 C++ 在静态局部变量的初始化时机上存在差异:
3、C 语言的初始化规则:在 C 语言中,静态局部变量的初始化发生在代码执行前。具体来说,编译器在分配内存后立即进行初始化,因此初始化值必须是编译期常量,无法使用运行时变量。静态变量的生命周期持续到程序结束,此时全局内存区域会被统一回收。
4、C++ 的初始化规则:C++ 引入对象概念后,静态局部变量的初始化推迟到首次执行相关代码时进行。这是因为对象初始化可能涉及构造函数的执行,而构造函数可能包含依赖于程序运行状态的逻辑(如资源分配、动态计算等)。C++ 标准规定,全局或静态对象将在首次使用时构造,并通过atexit()注册析构函数,确保在程序结束时按构造顺序的逆序自动析构。因此,C++ 允许使用变量对静态局部变量进行初始化。
面试题80:聊聊你对const的理解?
const 是 C++ 中非常重要且强大的关键字之一,它的核心作用是定义“只读”语义,帮助我们写出更安全、更健壮的代码
1. 修饰变量:防止修改
- 最基本的用法就是用来声明一个常量,一旦初始化后就不能再改变。
- 必须在定义时就进行初始化,否则以后就没有机会改了。
- 这种方式可以避免一些不希望被修改的数据被意外更改,提高程序的安全性和可维护性。
2. 修饰指针:灵活控制权限
- 对于指针来说,const 可以修饰指针本身(指针不能指向其他地方),也可以修饰指针所指向的内容(内容不可修改),或者两者都加 const。
- 比如:
- const int* p;
- :指向的内容不能改;
- int* const p;
- :指针本身不能改;
- const int* const p;
- :都不能改。
- 这种细粒度控制在编写接口或处理底层数据时非常有用。
3. 修饰函数参数:明确输入输出语义
- 在函数参数中使用 const 表示这个参数是只读的,不会在函数内部被修改。
- 特别是在引用或指针传递中,加上 const 能保护实参不被修改,同时也允许传入临时对象或常量。
- 值得注意的是,在值传递中 const 并没有实际意义,因为传进去的是副本。
4. 修饰成员函数:常函数
- 如果一个类的成员函数被声明为 const,表示它是一个“常函数”,不能修改类的成员变量(除非变量是 mutable)。
- 这样做的最大好处是:const 对象只能调用 const 成员函数,保证了对象状态的稳定性。
- 同时也支持函数重载:可以有两个同名函数,一个带 const,一个不带,根据调用者的对象是否为 const 来选择调用哪一个。
5. 修饰返回值:防止误操作
- 有时我们需要将函数的返回值设为 const,特别是当返回的是一个对象时,这样可以防止返回值被当作左值进行赋值。
- 比如像 operator[] 或者某些访问器函数,如果返回的是非 const 引用,外部可以直接修改私有数据,破坏封装性。
6. 与 const_cast配合使用
- 虽然 const 通常用于限制修改,但 C++ 提供了 const_cast 允许我们在必要时去掉 const 属性。
- 不过要小心使用,只有在确实需要修改一个原本不是 const 的对象时才合法,否则行为未定义。
7. 类中 const 成员变量的初始化
- 类中的 const 成员变量必须在构造函数的初始化列表中完成初始化,不能在构造函数体内赋值。
- 因为 const 成员变量一旦进入构造函数体就已经不能再修改了。
8. const 与引用/指针参数的函数重载
- 只有引用或指针类型的形参,才能通过是否带有 const 实现函数重载。
- 因为顶层 const(如 int const)在值传递中会被忽略,无法区分两个函数。
面试题81:const成员函数的理解和应用?
const Stock & Stock::topval (②const Stock & s) ③const
①处const:确保返回的Stock对象在以后的使用中不能被修改
②处const:确保此方法不修改传递的参数 S
③处const:保证此方法不修改调用它的对象,const对象只能调用const成员函数,不能调用非const函数
面试题82:指针和const的用法
const 和指针的结合非常灵活,关键看它修饰的是指针本身还是指针指向的内容。顶层 const 限制指针不能重新指向,底层 const 限制不能通过指针修改数据,两者结合则提供了更严格的只读保护
1)当const修饰指针时,由于const的位置不同,它的修饰对象会有所不同。
2)int const p2中const修饰p2的值,所以理解为p2的值不可以改变,即p2只能指向固定的一个变量地址,但可以通过p2读写这个变量的值。顶层指针表示指针本身是一个常量
3)int const p1或者const int p1两种情况中const修饰p1,所以理解为p1的值不可以改变,即不可以给*p1赋值改变p1指向变量的值,但可以通过给p赋值不同的地址改变这个指针指向。底层指针表示指针所指向的变量是一个常量。
4)int const *const p;
面试题83:mutable
1)如果需要在const成员方法中修改一个成员变量的值,那么需要将这个成员变量修饰为mutable。即用mutable修饰的成员变量不受const成员方法的限制;
2)可以认为mutable的变量是类的辅助状态,但是只是起到类的一些方面表述的功能,修改他的内容我们可以认为对象的状态本身并没有改变的。实际上由于const_cast的存在,这个概念很多时候用处不是很到了。
面试题84:extern用法?
在 C/C++ 中,extern 是一个关键字,主要用于声明外部变量或函数,表示该变量或函数是在其他文件中定义的,当前文件只需要进行声明即可使用。
1)extern修饰变量的声明 如果文件a.c需要引用b.c中变量int v,就可以在a.c中声明extern int v,然后就可以引用变量v。
2)extern修饰函数的声明 如果文件a.c需要引用b.c中的函数,比如在b.c中原型是int fun(int mu),那么就可以在a.c中声明extern int fun(int mu),然后就能使用fun来做任何事情。就像变量的声明一样,extern int fun(int mu)可以放在a.c中任何地方,而不一定非要放在a.c的文件作用域的范围中。
3)extern修饰符可用于指示C或者C++函数的调用规范。 比如在C++中调用C库函数,就需要在C++程序中用extern “C”声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用C函数规范来链接。主要原因是C++和C程序编译完成后在目标代码中命名规则不同。
面试题85:将字符串转换为整型
方法 | 适用场景 | 安全性 | 扩展性 |
atoi | 简单场景,无需进制转换 | 低(无溢出检查) | 差 |
strtol | C 语言,需进制或溢出处理 | 中(需手动处理 errno) | 中 |
stoi | C++11,字符串类场景 | 高(异常机制) | 好(支持 string) |
手动实现 | 面试、自定义需求 | 高(可完全控制) | 高(可添加自定义逻辑) |
面试题86:深拷贝与浅拷贝?
1)浅拷贝——仅复制基本数据类型的值,而对于引用类型的数据,并不会创建新的对象,而是让新对象的引用指向原始被复制对象的内存地址。因此,这种拷贝方式被称为“浅拷贝”。换句话说,浅拷贝只是对原对象的一种引用指向,若原对象中的内容发生了变化,那么拷贝后的对象也会随之改变。
深拷贝——则会在内存中开辟一块全新的空间,并将原对象中的所有内容完整地复制到新的内存区域中。这样,拷贝后的新对象与原对象之间完全独立,互不影响,即使原对象发生修改,也不会影响深拷贝得到的对象。
2)当类中包含需要动态申请堆内存的成员变量时,如果采用默认的位拷贝(即浅拷贝)方式进行对象复制,例如执行 A = B,会出现潜在问题。假设对象 B 中有一个指针成员已经分配了堆内存,那么 A 中对应的指针会指向同一块内存区域。当 B 析构并释放该内存后,A 中的指针就变成了野指针,再次访问或析构时就会导致未定义行为,引发程序崩溃或其他运行时错误。
面试题87:C++模板是什么,底层怎么实现的?
1)模板的工作机制
C++ 编译器并不会将函数模板视为能够处理任意类型的通用函数。相反,它依据模板定义,在编译过程中根据实际使用的类型生成具体的函数版本。这意味着每当函数模板被调用时,编译器会基于传递的具体参数类型来实例化出一个特定版本的函数。这一过程涉及两次编译:第一次是在声明模板的地方对模板本身的代码进行检查;第二次则是在调用点,根据实际传入的类型替换模板参数后,再对生成的具体函数代码进行编译。
2)实例化的重要性
由于函数模板必须先经过实例化才能成为可执行的函数,所以在使用函数模板的源文件中,必须包含其定义或至少是完整的声明。如果仅在头文件中提供了模板的声明而缺少定义部分,当尝试实例化该模板以生成具体函数时,编译器将无法找到所需的定义,从而导致链接阶段出现错误。这是因为模板的定义需要在编译期间可见,以便针对每个不同的类型正确地生成相应的函数版本。因此,通常建议将模板的定义放置于头文件中,确保它们能够在任何引用模板的地方被访问到并顺利实例化。
面试题88:C语言struct和C++struct区别
1)在 C语言中,struct 是一种用户自定义数据类型(UDT),主要用于将不同类型的数据组合在一起。而在 C++ 中,struct 被提升为抽象数据类型(ADT),不仅支持成员变量的定义,还允许定义成员函数。此外,C++ 的 struct 支持继承机制,并能够实现多态行为,具备面向对象的特性。
2)在 C 中,struct 没有访问权限的概念,其内部只能包含变量,不能封装函数逻辑。虽然可以将多个变量组织在一起,但不具备数据隐藏能力,也无法提供操作数据的方法。因此,它只是数据的集合体。
3)在 C++ 中,struct 的成员默认访问权限为 public,这是为了兼容 C 的语法风格;而 class 的默认访问权限是 private。C++ 的 struct 增加了对访问控制的支持,如 public、protected、private 等修饰符,并且可以像类一样拥有构造函数、析构函数以及各种成员函数,功能更加完整。
4)尽管 struct 可以看作是类的一种特例,通常用于定义简单的数据结构,但在 C 语言中使用时有一些限制。例如,在定义一个结构体类型时,如果只使用了结构标签(tag),那么在后续引用该类型时必须加上 struct 关键字作为前缀,才能正确表示其类型名。
面试题89:虚函数可以声明为inline吗?
1)虚函数的核心作用是实现运行时多态,也称为动态绑定或晚绑定。它允许程序在运行期间根据对象的实际类型来调用相应的函数。而 inline 函数则主要用于提升程序执行效率,其机制是在编译阶段将函数调用处直接替换为函数体代码。因此,inline 函数特别适用于那些被频繁调用的小型函数,以减少函数调用带来的开销。
2)虚函数依赖于运行时的动态绑定机制,无法在编译期确定具体调用哪个函数;而 inline 函数要求在编译阶段就完成代码展开。这两者在实现机制上存在冲突:编译器通常无法对虚函数进行内联优化,因为实际调用的函数体直到运行时才能确定。即使将虚函数声明为 inline,大多数编译器也会忽略该内联请求。
面试题90:类成员初始化方式?构造函数的执行顺序?为什么用成员初始化列表会快一些?
1)成员初始化的方式
- 赋值初始化:通过在构造函数体内直接对成员变量进行赋值操作来完成初始化。这种方式是在所有数据成员分配内存之后才开始执行的。
- 列表初始化:利用初始化列表(即在构造函数参数列表后的冒号部分),可以在分配内存的同时直接初始化数据成员。这意味着一旦为某个数据成员分配了内存,如果在初始化列表中有对应的赋值表达式(该表达式必须是括号或大括号形式),则会在进入构造函数体之前完成对该成员的初始化。
这两种方法的主要区别在于:赋值初始化发生在构造函数体内部,而列表初始化则是在对象内存分配时立即执行。
2)构造函数的执行顺序
当创建一个派生类的对象时,其构造函数的执行顺序如下:
- 首先调用虚拟基类的构造函数(如果有多个虚拟基类,则按照它们被继承的顺序依次调用)。
- 接着调用普通基类的构造函数(同样按照继承声明的顺序)。
- 然后是类中定义的其他类类型的成员对象的构造函数(根据它们在类定义中的声明顺序而非初始化列表中的顺序)。
- 最后才是派生类自身的构造函数体被执行。
3)为何成员初始化列表更高效
- 赋值初始化:在构造函数体内对成员变量进行赋值,这通常涉及生成临时对象并将其值复制到目标成员变量中,增加了额外的操作开销。
- 成员初始化列表:直接在对象创建时初始化成员变量,避免了临时对象的生成和后续的赋值过程。因此,它是一种纯粹的初始化操作,减少了不必要的计算步骤,从而提高了程序的执行效率。
简而言之,使用成员初始化列表可以减少由于赋值操作带来的性能损耗,因为它避免了临时对象的创建和销毁过程,使得初始化过程更加直接高效。
面试题91:成员列表初始化?
1)必须使用成员初始化列表的四种情况
- ① 当类中包含一个引用类型的成员变量时,由于引用必须在定义时绑定到一个对象,因此不能通过赋值进行初始化,只能在构造函数的初始化列表中完成。
- ② 当类中包含一个常量(const)成员变量时,因为常量一旦初始化后就不能再修改,所以也必须在构造函数初始化列表中完成初始化。
- ③ 当派生类需要调用基类的构造函数,并且该构造函数带有参数时,必须通过初始化列表显式指定调用哪一个基类构造函数。
- ④ 当类中含有一个其他类类型的成员对象,且该成员对象的构造函数需要传递参数时,也需要通过初始化列表来调用其对应的构造函数。
2)成员初始化列表在底层做了什么?
- ① 在编译阶段,编译器会根据构造函数中的初始化列表生成相应的初始化代码,并按照正确的顺序插入到构造函数体内。这些初始化操作会在任何用户自定义的构造函数逻辑执行之前完成。
- ② 初始化列表中各个成员的初始化顺序不是由初始化列表本身的书写顺序决定的,而是严格遵循它们在类定义中被声明的顺序。这一点非常重要,避免因初始化顺序引发的潜在错误。
面试题92:构造函数为什么不能为虚函数?析构函数为什么要虚函数?
1、构造函数为何不能是虚函数?
1)从实现机制角度分析:
虚函数依赖于虚函数表(vtable)以及指向该表的指针(vptr)。而 vptr 是在对象构造过程中由编译器自动设置的。也就是说,在构造函数执行之前,对象的 vptr 还未初始化,也就无法通过虚函数机制来调用对应的函数。如果构造函数被声明为虚函数,则会试图通过尚未建立的 vtable 来调用它,这将导致不可预测的行为。
2)从面向对象行为角度分析:
构造函数的作用是创建并初始化一个具体的对象。在调用构造函数时,对象的类型已经明确指定,因此不存在运行时多态的需求。虚函数的核心价值在于支持运行时动态绑定,而在构造期间,对象的真实类型是已知的,不需要也不适合通过基类指针或引用去调用构造函数。
3)从对象生命周期角度分析:
构造函数是在对象内存分配之后立即调用的。此时对象尚未完全构造完成,其子类部分可能还未初始化。若允许构造函数为虚函数,那么可能会出现调用派生类中重写的构造函数的情况,这显然违背了构造函数的执行顺序(基类先于派生类),从而引发逻辑混乱。
2、析构函数为什么要为虚函数?
1)防止内存泄漏:
当通过基类指针删除一个派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会触发派生类的析构逻辑。这会导致派生类中申请的资源(如堆内存、文件句柄等)没有被释放,造成资源泄露。
2)实现多态析构:
将析构函数声明为虚函数后,就可以确保在通过基类指针删除对象时,能够正确地调用到派生类的析构函数,实现动态绑定。这样保证了整个对象(包括所有子对象)都能被完整地销毁。
3)对象销毁顺序的保障:
虚析构函数不仅解决了派生类析构函数的调用问题,还确保了析构过程遵循正确的顺序:派生类析构函数先执行,然后依次向上调用基类的析构函数,从而避免因资源释放顺序不当而导致的问题。
面试题93:析构函数的作用,如何起作用?
1)构造函数的核心作用是初始化
构造函数的主要职责是在对象创建时对其进行初始化。当实例化一个对象时,可以通过构造函数将参数传递进去,从而为对象内部的数据成员赋予初始值。这些值可以进一步用于类中其他成员函数的逻辑处理。
系统规定:一旦实例化一个对象,编译器会自动调用对应的构造函数。即使用户没有显式定义构造函数,编译器也会生成一个默认的构造函数来完成基本的初始化操作。
2)析构函数的功能与特点
析构函数的作用正好与构造函数相反,它负责在对象生命周期结束时执行清理工作,比如释放对象所占用的动态内存、关闭文件句柄或断开网络连接等资源。
其主要特征包括:
- 函数名与类名相同,但在前面加上波浪号 ~,表示“销毁”语义;
- 没有返回值,也不接受任何参数
- ;
- 不能被重载
- ,因此每个类只能拥有一个析构函数;
- 在对象生命周期结束时(如离开作用域、delete 操作或程序结束),析构函数会被自动调用;
- 如果用户没有显式定义析构函数,编译器会自动生成一个默认的析构函数;
- 虽然析构函数通常定义为公有成员,但它的调用是由编译器自动管理的,无需手动调用。
面试题94:构造函数和析构函数可以调用虚函数吗,为什么?
1)不推荐在构造函数或析构函数中调用虚函数。
C++ 社区普遍建议避免在这两个特殊成员函数中调用虚函数。虽然语法上是允许的,但其行为并不符合我们对虚函数动态绑定机制的预期。
2)调用虚函数时不会触发动态绑定。
在构造函数或析构函数内部调用虚函数时,编译器不会执行运行时多态(即动态联编),而是直接调用当前正在执行构造或析构的那个类所定义的虚函数版本。也就是说,此时虚函数机制“退化”成了静态绑定。
3)构造阶段对象尚未完全形成,调用虚函数不安全。
构造函数的执行是从基类向派生类依次进行的。当基类构造函数执行时,派生类的成员变量还未初始化。如果此时调用了虚函数,并恰好指向派生类的实现,就可能访问到未初始化的数据,导致未定义行为。
4)析构阶段对象正在逐步销毁,调用虚函数无意义。
析构过程是先执行派生类的析构函数,再执行基类的析构函数。当基类析构函数被调用时,派生类的成员变量已经被销毁。此时若调用虚函数并进入派生类的实现,将访问已释放的资源,造成不可预料的结果。
面试题95:构造函数的执行顺序?析构函数的执行顺序?构造函数内部干了啥?拷贝构造干了啥?
1)构造函数的执行顺序
当一个派生类对象被创建时,其构造过程遵循以下顺序:
- ① 基类构造函数:
如果当前类继承自一个或多个基类,则这些基类的构造函数会按照它们在派生列表中出现的顺序依次调用(注意:不是初始化列表中的顺序)。如果存在虚基类,虚基类的构造函数会在所有非虚基类之前执行。 - ② 成员对象的构造函数:
如果该类包含其他类类型的成员变量(即成员对象),则这些成员对象的构造函数将按照它们在类中声明的顺序依次调用,而不是它们在初始化列表中的顺序。 - ③ 派生类自身的构造函数体执行:
当所有基类和成员对象都完成构造后,才进入派生类构造函数的函数体部分,执行用户定义的初始化逻辑。
2)析构函数的执行顺序
与构造顺序相反,析构函数的调用顺序如下:
- ① 派生类的析构函数体执行:
首先执行派生类自己的析构函数体中的清理代码。 - ② 成员对象的析构函数调用:
按照成员对象在类中声明的逆序依次调用它们的析构函数。 - ③ 基类的析构函数调用:
最后按继承链从派生到基类的反方向依次调用各个基类的析构函数。
3)构造函数内部主要做了什么?
构造函数的核心任务是初始化对象的状态。具体包括:
- 调用父类的构造函数;
- 初始化类中的成员变量(尤其是类类型成员);
- 执行构造函数体内的赋值或初始化操作;
- 设置虚函数表指针(vptr)指向对应的虚函数表(vtable),以支持多态;
- 确保对象处于一个可用状态。
4)拷贝构造函数的作用是什么?
拷贝构造函数用于根据已存在的对象创建一个新的对象。它默认执行的是浅拷贝,即逐字节复制原对象的数据成员。
如果类中包含动态分配的资源(如指针、文件句柄等),通常需要手动实现拷贝构造函数以完成深拷贝,确保新对象拥有独立的资源副本,避免多个对象共享同一块内存带来的管理问题。
面试题96:虚析构函数的作用,父类的析构函数是否要设置为虚函数?
1)虚析构函数的核心作用是防止资源泄漏
在 C++ 中,如果一个基类可能被继承,并且派生类中涉及了动态内存分配或其他需要清理的资源(如文件句柄、网络连接等),将基类的析构函数声明为虚函数是非常必要的。
当通过基类指针删除一个派生类对象时,只有基类析构函数为虚函数,才能确保派生类的析构函数也被调用,从而完整释放所有资源。否则,只会执行基类的析构逻辑,导致派生类中分配的资源未被释放,造成内存泄漏。
因此,只要一个类有可能作为基类被继承,就应该将其析构函数声明为虚函数。
2)纯虚析构函数也需要提供定义
有时我们会将基类设计为抽象类,并把析构函数声明为纯虚函数:
class Base {
public:virtual ~Base() = 0;
};
但需要注意的是:即使是一个纯虚析构函数,也必须提供一个函数体实现。因为编译器在生成派生类的析构函数时,会自动调用其所有基类的析构函数,包括虚基类。如果找不到纯虚析构函数的定义,链接阶段将会失败。
因此,通常建议避免将析构函数定义为纯虚函数,除非你有特殊的设计意图并清楚如何处理其实现问题。
构造函数和析构函数中可以调用虚函数吗?
1)构造函数和析构函数中不建议调用虚函数。
虽然语法上允许这样做,但由于对象处于初始化或销毁阶段,虚函数机制无法正常工作。
2)调用虚函数不会触发动态绑定。
在构造函数中,当前对象尚未完全构造完成;在析构函数中,部分成员可能已经被销毁。此时调用虚函数,实际调用的是当前正在执行构造/析构的那个类的函数版本,而不是运行时多态所期望的派生类版本。
3)行为不可控,容易引发错误。
即使代码能编译通过,在构造或析构过程中调用虚函数也可能访问到未初始化或已销毁的数据成员,导致程序行为不可预测,甚至崩溃。
面试题97:构造函数析构函数可否抛出异常
1)构造函数中抛出异常
C++ 中规定:只有当构造函数执行完毕,对象才被视为完全构造成功。 如果在构造过程中发生了异常,控制权会立即离开构造函数,此时对象尚未完全构造完成。
- 这意味着:如果在某个对象(如 b)的构造函数中抛出了异常,那么该对象的析构函数不会被调用。
- 如果构造函数中分配了资源(如内存、文件句柄等),而这些资源未在异常发生前正确释放,就会导致资源泄漏。
2)如何安全地处理构造函数中的异常?
为了防止构造函数抛出异常时造成资源泄漏,推荐使用**RAII(资源获取即初始化)**技术,例如使用智能指针(如 std::unique_ptr 或早期的 auto_ptr)或其他封装资源的对象来管理资源。
- 这些智能资源对象会在其自身析构时自动释放所持有的资源;
- 即使构造函数中途抛出异常,这些局部对象仍会被正常析构,从而避免资源泄漏。
面试题98:类如何实现只能静态分配和只能动态分配
1)实现方式概述
要限制类对象的创建方式,可以通过控制构造函数、析构函数以及 new 和 delete 运算符的访问权限来达成目的:
- 若希望类只能
- 静态分配(即在栈上创建),可以将 operator new 和 operator delete 设置为私有成员,防止外部通过 new 在堆上创建对象。
- 若希望类只能
- 动态分配(即在堆上创建),则可以将构造函数和析构函数设为 protected 或 private,并提供一个公共的静态工厂方法用于返回堆上的对象。
2)类对象的两种常见创建方式
- ① 静态创建:
即直接声明类的对象,如 A a;。此时对象由编译器自动在栈上分配内存,并调用构造函数进行初始化。 - ② 动态创建:
使用 new 表达式手动在堆上创建对象,例如:
A* p = new A();
此过程分为两个步骤:
- 调用 operator new 分配内存;
- 调用构造函数对这块内存进行初始化。
3)实现“只能静态分配”的类
为了让类只能在栈上创建,核心思路是禁止使用 new 创建对象:
- 将类中的 operator new 和 operator delete 声明为私有(或删除),这样外部就无法通过 new 来在堆上创建对象。
示例代码如下:
class StackOnly {
private:void* operator new(size_t) = delete;void operator delete(void*) = delete;
public:StackOnly() {}~StackOnly() {}
};
此时下面的操作将无法通过编译:
StackOnly* obj = new StackOnly(); // 编译错误:'operator new' is inaccessible
但可以在栈上正常创建对象:
StackOnly obj; // 合法
4)实现“只能动态分配”的类
为了让类只能在堆上创建,可以将构造函数和析构函数设为受保护或私有,并提供一个静态方法用于返回堆上的实例:
class HeapOnly {
protected:HeapOnly() {}~HeapOnly() {}
public:static HeapOnly* create() {return new HeapOnly();}void destroy() {delete this;}
};
这样可以保证:
- 对象不能在栈上直接创建:HeapOnly obj; 会报错;
- 只能通过 create() 方法在堆上创建对象;
- 提供 destroy() 方法来安全地释放资源。
面试题99:如果想将某个类用作基类,为什么该类必须定义而非声明?
当一个类被用作基类时,派生类会继承其成员变量和成员函数。为了正确地进行继承和使用这些继承而来的成员,编译器必须清楚地知道基类的完整结构和内容。
仅对基类进行前置声明(如 class Base;)只能让编译器知道该类存在,但无法得知其内部成员、大小、布局等关键信息,这会导致以下问题:
- 派生类无法确定从基类继承了哪些成员;
- 无法计算对象的大小;
- 无法正确布局内存结构;
- 无法调用基类的构造函数或析构函数;
- 如果涉及虚函数机制,也无法建立正确的虚函数表(vtable);
因此,为了让派生类能够正确继承和使用基类的功能,基类必须有完整的定义(即要有类体实现),而不能只是一个简单的声明。
面试题100:什么情况会自动生成默认构造函数?
C++ 编译器会在某些特定条件下自动为类生成一个默认构造函数。这些情况主要包括以下几种:
1)类中含有成员对象,且该成员对象有默认构造函数
如果一个类没有显式定义任何构造函数,并且它包含了一个或多个具有默认构造函数的类类型成员对象,那么编译器会合成一个默认构造函数。
- 合成构造函数会在初始化阶段调用这些成员对象的默认构造函数;
- 初始化顺序严格按照成员对象在类中声明的顺序进行;
- 此操作仅在确实需要时才会发生(即惰性生成)。
2)类继承自具有默认构造函数的基类
当一个派生类没有定义任何构造函数,并且其基类拥有默认构造函数时,编译器会为其生成一个默认构造函数。
- 该构造函数会调用基类的默认构造函数;
- 如果基类也依赖于其他基类,则依次向上递归处理。
3)类中声明了虚函数
如果一个类中定义了虚函数,为了支持运行时多态,编译器需要在对象中维护一个指向虚函数表(vtable)的指针(vptr)。
- 为此,编译器会合成默认构造函数来初始化这个 vptr;
- 确保对象在创建时能够正确绑定虚函数。
4)类中继承自虚基类
当一个类从虚基类派生时,为了确保在整个继承链中只存在一份虚基类子对象,编译器必须控制虚基类的初始化时机和方式。
- 因此,即使用户没有定义构造函数,编译器也会合成默认构造函数以完成这一任务。
5)合成默认构造函数的行为限制
需要注意的是,编译器合成的默认构造函数只会执行以下初始化工作:
- 调用基类的默认构造函数;
- 调用成员对象的默认构造函数;
而对于类中的基本数据类型成员(如 int、float、指针等),不会进行初始化。这意味着它们的值是未定义的,除非手动赋值或通过用户定义的构造函数进行初始化。
往期推荐:
C/C++ 高频八股文面试题1000题(一)
【大厂标准】Linux C/C++ 后端进阶学习路线
C++ Qt学习路线一条龙!(桌面开发&嵌入式开发)
欢迎大家,点击下方关注我~