静态变量和全局变量、局部变量的区别、在内存上是怎么分布的
首先需要说明的是变量属性的三个维度:
- 作用域:可以分为块作用域和命名空间作用域。块作用域就是
{}中的内容;命名空间作用域则是在整个命名空间范围内有效,全局变量在全局命名空间内可见; - 生命周期:变量可以分为自动变量、静态变量、动态变量。自动变量在进入块作用域时创建,离开时销毁;静态变量在程序启动或首次执行到声明语句时初始化,随程序结束而销毁;动态变量由
new/delete或malloc/free管理,存在于堆上; - 链接性(linkage):分为外部链接、内部链接和无链接。外部链接的变量可以在其他翻译单元中通过
extern声明来访问;内部链接只能在当前翻译单元(编译单元/.cpp文件)内使用;无链接则是像局部变量这样,仅在块作用域内可见;
全局变量
- 在全局命名空间内可见,默认具有外部链接;
- 初始化发生在
main()之前,分两阶段:先进行零初始化,再进行动态初始化(如有构造函数或非常量初始化表达式); - 加上
static后唯一改变的是链接性,从外部链接变为内部链接,仅当前翻译单元可见;
局部变量
- 仅在块作用域内可见,无链接;
- 作为自动变量,随所在块的进入而在栈上创建,块结束时销毁;
- 加上
static成为静态局部变量:作用域和链接性不变,但生命周期改变——首次执行到声明语句时初始化(C++11起保证线程安全),之后不再随块结束而销毁,直到程序结束才销毁;
内存布局
内存大致分为代码段(.text)、数据段、堆、栈:
.data段:存放已初始化的全局变量和静态变量;.bss段:存放未初始化(或初始化为零)的全局变量和静态变量,在可执行文件中不占实际空间,程序加载时由操作系统清零;- 栈:局部变量(自动变量)随函数调用在栈上分配,块作用域结束时销毁;
- 堆:动态分配的内存(
new/malloc);
指针和引用的区别
简单来说,引用是一个已有变量的别名;指针是一个独立的对象,存放了一个地址。
- 初始化:引用声明时必须绑定到一个已存在的对象,不能为空;指针可以声明为空指针(
nullptr),也可以先声明后赋值; - 操作语义:引用是别名,所有对引用的操作都穿透到其绑定的对象上;指针本身是一个独立对象,可以进行
++、--等指针算术运算,改变的是指针自身指向的地址; - 换绑:引用一旦绑定就不能更换目标,对引用赋值实际上是对绑定对象赋值;指针可以随时改为指向另一个地址;
- sizeof:
sizeof(引用)得到的是绑定对象的大小;sizeof(指针)得到的是指针本身的大小(32位系统4字节,64位系统8字节); - 多级间接:没有"引用的引用"(
int& &不合法),但可以有多级指针(int**); - 底层实现:编译器通常将引用实现为一个不可变的指针(即
T* const),但这是实现细节,语言标准不要求引用占据存储空间;
C++内存分区
C++标准只规定了四种存储期(storage duration):自动、静态、动态、线程局部;
而实践中操作系统会将一个C++程序的虚拟地址空间大致分为以下几个区域:
- 代码段(.text):存放编译后的机器指令,通常为只读;
- 数据段:
.rodata:存放只读常量(如字符串字面量、const全局常量等);.data:存放已初始化(非零)的全局变量和静态变量;.bss:存放零初始化和未初始化的全局变量和静态变量,可执行文件中不占实际空间,加载时由OS清零;
- 堆(heap):存放由
new/delete、malloc/free管理的动态分配内存,向高地址增长; - 栈(stack):存放函数调用帧(返回地址、参数)和局部变量(自动变量),向低地址增长;
static关键字和const关键字的作用
static
static主要用于改变变量、函数的存储期和链接性:
- 修饰局部变量:存储期从自动变为静态,首次执行到声明时初始化,直到程序结束才销毁;作用域不变,仍为块作用域;
- 修饰全局变量:将链接性从外部链接改为内部链接,仅当前翻译单元可见;
- 修饰普通函数:效果与修饰全局变量类似,将函数的链接性改为内部链接,避免跨翻译单元的符号冲突;
- 修饰类的成员变量:该变量不属于任何实例而属于整个类,所有实例共享;必须在类外定义(C++17起可用
inline在类内初始化); - 修饰类的成员函数:没有
this指针,因此不能访问非静态成员变量和非静态成员函数,只能访问静态成员;
const
const主要用于表达"不可修改"的语义:
- 修饰普通变量:变量不可修改;修饰全局变量时,在C++中默认链接性变为内部链接(与C不同);
- 修饰指针:
const int* p(pointer to const):指向常量,不能通过该指针修改所指对象;int* const p(const pointer):指针本身为常量,不能改变指向;const int* const p:两者皆不可修改;
- 修饰函数参数:表明函数承诺不修改该参数,常用于引用参数(
const T&)避免拷贝的同时保证不修改; - 修饰函数返回值:返回值不可被修改,常用于返回引用或指针时防止调用方意外修改;
- 修饰类的成员函数(放在参数列表后面):承诺该函数不会修改对象的任何非
mutable成员,this的类型变为const T*;const对象只能调用const成员函数;
常量指针和指针常量之间有什么区别
- const pointer(指针常量):
int* const p——指针本身是常量,指向的地址不可改变,但可以通过该指针修改所指对象的值; - pointer to const(常量指针):
const int* p(等价于int const* p)——指针指向的对象被视为常量,不能通过该指针修改所指对象,但指针本身可以改为指向其他地址; - const pointer to const:
const int* const p——指针本身不可变,也不能通过该指针修改所指对象;
记忆技巧:
const修饰的是它左边的东西(如果左边没有则修饰右边)。const int*中const修饰int,所以指向的值不可变;int* const中const修饰*(即指针本身),所以指针不可变。
结构体和类之间有什么区别
在C++中,struct和class的语言层面区别只有两点:
- 默认访问权限:
struct默认为public,class默认为private; - 默认继承方式:
struct默认public继承,class默认private继承;
除此之外二者完全等价,struct同样可以有成员函数、构造函数、虚函数、模板参数等。
更多的区别来自社区惯例:struct一般用于纯数据聚合(POD风格),class用于封装行为的面向对象设计。模板参数中typename和class含义相同,但这里的class不能替换为struct。
什么是智能指针,C++有哪几种智能指针
智能指针的核心思想是RAII(Resource Acquisition Is Initialization),将动态分配的资源封装到一个栈上的对象中,利用析构函数自动释放资源,避免手动管理内存导致的泄漏和悬垂指针问题。
现代C++有三种智能指针(均在<memory>头文件中):
unique_ptr:独占所有权,同一时刻只有一个unique_ptr指向该对象。不可拷贝,只能通过std::move转移所有权。零开销抽象,性能与裸指针一致。优先使用std::make_unique<T>()创建;shared_ptr:共享所有权,内部通过引用计数管理生命周期,每次拷贝计数加1,析构时计数减1,计数归零时释放对象。引用计数的增减是原子操作(线程安全),但指向的对象本身不是线程安全的。优先使用std::make_shared<T>()创建,它只分配一次内存(控制块和对象放在一起);weak_ptr:弱引用,不增加引用计数,不拥有对象。主要用于打破shared_ptr的循环引用。使用前需调用lock()尝试提升为shared_ptr,如果对象已被释放则返回空的shared_ptr;
面试常见追问:
shared_ptr的控制块(control block)中包含强引用计数、弱引用计数和删除器。强引用归零时对象被销毁,但控制块要等弱引用也归零才释放(因为weak_ptr仍需要读取计数来判断对象是否存活)。
智能指针的实现原理是什么
智能指针的核心思想是RAII,将动态分配的资源封装到一个栈上的对象中,在构造函数中获取资源,在析构函数中释放资源,避免手动管理内存导致的泄漏和悬垂指针问题。
1. unique_ptr 的实现原理
- 核心思路是禁止拷贝,只允许移动:将拷贝构造函数与拷贝赋值运算符显式
= delete,从编译期杜绝多个指针指向同一对象; - 移动时将源指针的内部裸指针转移给目标,并将源指针置为
nullptr; - 内部只持有一个裸指针(加上可选的删除器),没有控制块,所以大小与裸指针一致(零开销);
2. shared_ptr 的实现原理
- 核心思路是引用计数 + 控制块:
shared_ptr内部持有两个指针,一个指向被管理的对象,一个指向控制块; - 控制块是在堆上分配的一块内存,包含:
- 强引用计数:记录有多少个
shared_ptr指向该对象。初始为1,每次拷贝构造/拷贝赋值+1,每次析构/reset时-1。归零时调用删除器销毁对象; - 弱引用计数:记录有多少个
weak_ptr(加上一个偏移量)。当强计数和弱计数都归零时,控制块本身才被释放; - 删除器:以类型擦除方式存储,用于销毁对象(这也是删除器不影响
shared_ptr模板类型的原因);
- 强引用计数:记录有多少个
- 强引用计数的增减是原子操作,保证多线程下引用计数本身的安全性(但不保证所指对象的线程安全);
make_shared会将对象和控制块分配在一块连续内存上,减少一次堆分配,提升缓存局部性;
3. weak_ptr 的实现原理
- 核心思路是共享控制块,不参与强计数:
weak_ptr内部结构和shared_ptr一样——两个指针分别指向对象和控制块。区别在于weak_ptr的构造/拷贝/析构只影响弱引用计数,不影响强引用计数; - 弱引用计数的作用是延长控制块的生命周期(而非对象的生命周期)。强引用计数归零时对象被析构,弱引用计数也归零时控制块才被释放;
- 使用时需调用
lock()尝试提升为shared_ptr:原子地检查强引用计数,若非零则+1并返回有效的shared_ptr,否则返回空shared_ptr;
new和malloc有什么区别
- 语言层面:
new是C++运算符(operator),编译器会对其进行特殊处理;malloc是C标准库函数,编译器视为普通函数调用; - 构造对象:
new的完整过程是:调用operator new分配内存 → 在该内存上调用构造函数;malloc只分配一块原始内存,不会调用构造函数; - 返回类型:
new返回确切类型的指针(如int*),无需类型转换;malloc返回void*,C++中需要显式转换; - 失败处理:
new默认抛出std::bad_alloc异常(也可用nothrow版本返回nullptr);malloc失败返回NULL,需手动检查; - 大小计算:
new由编译器自动计算所需大小;malloc需要手动指定字节数(sizeof);
delete 和 free 有什么区别?
- 语言层面:
delete是C++运算符;free是C标准库函数; - 析构对象:
delete的完整过程是:调用析构函数 → 调用operator delete释放内存;free只释放内存,不会调用析构函数; - 数组形式:
new[]分配的数组必须用delete[]释放(会依次析构每个元素);malloc分配的内存统一用free释放;
注意:
new/delete和malloc/free必须配对使用,不能混用。new出来的用free释放会跳过析构函数,malloc出来的用delete释放属于未定义行为。
堆区和栈区的区别
- 管理方式:栈由编译器自动管理(函数调用时分配栈帧,返回时回收);堆由程序员手动管理(
new/delete、malloc/free)或通过智能指针自动管理; - 生命周期:栈上变量绑定作用域,离开作用域自动回收;堆上内存直到显式释放前一直存在,忘记释放则内存泄漏;
- 增长方向:常见系统中,栈从高地址向低地址增长;堆从低地址向高地址增长;二者相向增长,中间是共享的虚拟地址空间;
- 内存碎片:栈是严格的LIFO结构,分配释放都在栈顶,不会产生碎片;堆上频繁的不同大小的分配与释放容易产生外部碎片;
- 分配速度:栈分配只需移动栈指针寄存器(
ESP/RSP),通常一条指令完成;堆分配需要在空闲链表中查找合适的块,可能触发系统调用(如brk/mmap)向操作系统申请新页面,开销大得多; - 大小限制:栈大小有限(Linux默认通常8MB,可通过
ulimit -s查看),递归过深会栈溢出;堆大小受限于系统可用虚拟内存,远大于栈;
什么是内存泄漏,如何检测和防止?
1. 什么是内存泄漏
- 程序在堆上分配了内存,但在不再需要时没有释放,且丢失了指向该内存的最后一个指针,导致这块内存在程序运行期间永远无法被回收;
- 内存泄漏不会导致程序立刻崩溃,但随着泄漏积累,进程内存占用持续增长,最终可能导致OOM(Out Of Memory)被操作系统杀死;
2. 常见导致内存泄漏的场景
- 忘记释放:
new了但没有delete,包括提前return、异常抛出导致delete语句被跳过等; - 基类析构函数非虚:通过基类指针
delete派生类对象时,如果基类析构函数不是virtual,只会调用基类的析构函数,派生类的资源不会被释放(同时这也是未定义行为); - 指针覆盖:指针被重新赋值或置为
nullptr前,没有先释放其原来指向的内存,导致原内存丢失; - 循环引用:两个
shared_ptr互相持有对方,强引用计数永远无法归零; - 分配释放不匹配:如
new[]配delete(缺少[]),只析构第一个元素,其余元素泄漏(同时也是未定义行为);
3. 如何检测内存泄漏
- 使用工具如Valgrind、AddressSanitizer或Visual Studio的诊断工具来检测内存泄漏;
4. 如何防止内存泄漏
- 核心原则是RAII:将所有资源管理封装到栈上对象中,利用析构函数自动释放;
- 优先使用智能指针(
unique_ptr/shared_ptr)代替裸new/delete; - 优先使用
make_unique/make_shared,避免new表达式直接出现在业务代码中; - 用
weak_ptr打破循环引用; - 继承体系中始终将基类析构函数声明为
virtual;
什么是野指针?如何避免?
1. 什么是野指针
- 野指针是指向无效内存地址的指针。对野指针解引用是未定义行为,可能导致读到垃圾数据、修改不属于自己的内存、程序崩溃,或者更隐蔽地——程序"看起来正常"但行为不可预测;
2. 常见产生野指针的场景
- 未初始化:局部指针变量未赋值,存储的是栈上的随机垃圾值,指向任意地址;
- 释放后继续使用(use-after-free):
delete/free后指针的值没有变化,仍然指向原地址,但该内存已被回收,可能已被分配给其他对象。这是最常见也最危险的野指针场景; - 返回局部变量的地址:函数返回后栈帧被销毁,返回的指针指向的内存已无效;
- 迭代器失效:对
vector等容器进行插入/删除操作后,之前保存的迭代器或指针可能已经失效(底层内存被重新分配),继续使用也属于野指针问题;
3. 如何避免野指针
- 优先使用智能指针代替裸指针,从根本上避免手动管理生命周期;
- 裸指针释放后立即置为
nullptr(对nullptr解引用会确定性地崩溃,比访问随机地址更容易排查); - 避免返回局部变量的指针或引用;
- 注意容器操作后迭代器和指针的失效问题;
C++面向对象三大特性
封装
是什么 封装是将数据和操作数据的方法绑定在一起,并通过访问控制隐藏内部实现细节,只暴露必要的接口;外部代码通过公开接口与对象交互,不能直接触碰内部状态;
为什么需要 封装的核心价值是维护不变式(invariant)。一个设计良好的类有其内部一致性约束,比如银行账户余额不能为负、月份必须在1-12之间。如果成员变量完全暴露,任何外部代码都可以随意修改,不变式就无法保证。封装让类自己负责校验和维护这些约束;
实现机制:三种访问控制
public:类内部、派生类、外部均可访问;protected:类内部和派生类可访问,外部不可访问;private:仅类内部可访问,派生类和外部均不可访问;- 另外
friend可以突破访问控制,让指定的函数或类访问私有成员,但会破坏封装性,应谨慎使用;
继承
是什么 继承允许派生类基于基类来定义,自动获得基类的成员变量和成员函数,并可以添加新成员或修改已有行为,实现代码复用和"is-a"关系的表达;
三种继承方式
public继承:基类的public成员在派生类中仍为public,protected仍为protected;语义上表达"is-a"关系;protected继承:基类的public和protected成员在派生类中都变为protected;private继承:基类的public和protected成员在派生类中都变为private;语义上表达"implemented-in-terms-of"关系;- 实践中绝大多数继承都是
public继承,private继承一般建议优先用组合(composition)替代; - 无论哪种继承方式,基类的
private成员在派生类中都不可直接访问(存在但不可见);
- 一些细节
- 构造顺序从基类到派生类,析构顺序从派生类到基类;
- 派生类中与基类同名的成员(无论参数是否相同)会**隐藏(name hiding)**基类的所有同名成员,而不是重载。如需访问被隐藏的基类成员,可用
using Base::func将其引入派生类作用域; - 如果通过基类指针
delete派生类对象,基类析构函数必须声明为virtual,否则是未定义行为;
多态
是什么 多态是用同一个接口操作不同类型的对象,调用时根据对象的实际类型执行不同的行为;
分类 C++的多态分为两大类:
- 编译期多态(静态多态):在编译阶段确定调用哪个函数,机制包括函数重载、运算符重载和模板(CRTP等);
- 运行时多态(动态多态):在程序运行时根据对象的实际类型确定调用哪个函数,机制是虚函数 + 继承;
- 运行时多态的实现原理(虚函数表)
- 当类中声明了虚函数时,编译器为该类生成一张虚函数表(vtable),表中按声明顺序存放各虚函数的实际地址;
- 每个对象的内存布局最前面会多一个虚表指针(vptr),指向所属类的vtable。派生类拥有自己独立的vtable,其中重写过的虚函数槽位存放的是派生类版本的地址,未重写的槽位仍指向基类版本;
- 通过基类指针或引用调用虚函数时:先通过对象的vptr找到vtable,再按固定偏移取出目标函数地址,最后跳转调用。这就是运行时多态的开销来源——多了一次间接寻址;
面试常见追问:虚函数的开销是什么?主要有两点:每个对象多一个vptr(通常8字节),以及每次虚函数调用多一次间接跳转(且难以被CPU内联优化)。
虚函数、虚表、虚指针
详见 虚函数与虚表详解。
简述一下C++的重载和重写,以及它们的区别和实现方式
重载(Overloading)
- 是什么
- 在同一个作用域内,多个函数使用相同的函数名但参数列表不同;编译器在编译期根据调用时传入的实参类型和个数决定调用哪个版本,这个过程叫做重载决议(overload resolution);
- 重载是编译期行为,属于静态多态;
- 构成重载的条件
- 必须满足:同一作用域、函数名相同、参数列表不同(参数类型、个数或顺序);
- 仅返回值类型不同不能构成重载,因为C++允许忽略返回值调用函数,编译器无法仅凭返回值区分版本;
const成员函数和非const成员函数可以构成重载(因为隐含的this指针类型不同);
- 底层机制
- C++编译器通过**名称修饰(name mangling)**将函数名、参数类型、所属命名空间等编码成唯一的符号名,使链接器能区分同名但参数不同的函数。这也是
extern "C"的作用——禁用名称修饰以兼容C的链接方式; - 重载决议三步:
- 候选函数:找到所有同名且可见的函数;
- 可行函数:筛选出参数个数匹配且每个实参都能隐式转换到对应形参类型的函数;
- 最佳匹配:在可行函数中选择隐式转换代价最小的。如果存在多个同样好的匹配,则产生**二义性(ambiguity)**编译错误;
重写(Overriding)
- 是什么
- 派生类重新定义基类的虚函数,提供不同的实现;通过基类指针或引用调用时,在运行时根据对象的实际类型决定调用哪个版本;
- 构成重写的条件
- 基类函数必须是
virtual; - 函数名相同,参数列表完全相同,
const修饰符一致; - 返回值类型相同(或满足协变返回类型:基类返回
Base*/Base&,派生类可以返回Derived*/Derived&); - 建议使用
override关键字显式标注,让编译器检查是否确实重写了基类虚函数,避免因签名不匹配而意外变成隐藏;
- 底层机制
- 编译器为每个含虚函数的类生成一张vtable,派生类重写虚函数时,其vtable中对应槽位的函数指针被替换为派生类版本的地址。运行时通过 vptr → vtable → 函数地址 完成动态派发;
对比总结
| 重载 | 重写 | |
|---|---|---|
| 作用域 | 同一作用域 | 基类与派生类之间 |
| 绑定时机 | 编译期(静态) | 运行时(动态) |
| 函数签名 | 函数名相同,参数列表不同 | 函数名、参数列表、const完全一致 |
| 是否需要virtual | 不需要 | 基类必须声明virtual |
| 底层机制 | 名称修饰 | 虚函数表 |
C++怎么实现多态
C++的多态分为编译期多态(静态多态)和运行时多态(动态多态)两种。
编译期多态
在编译阶段确定调用哪个函数,没有运行时开销:
- 函数重载:同一作用域内多个函数名相同但参数列表不同,编译器根据实参类型和个数通过重载决议选择对应版本;
- 运算符重载:为自定义类型定义或修改运算符的行为(如重载
+、<<等),本质上也是函数重载的一种形式; - 模板:允许编写泛型函数和类,编译器根据实际使用的类型实例化出具体版本。更高级的用法如CRTP(Curiously Recurring Template Pattern)可以在编译期实现类似虚函数的多态效果,且没有vtable开销;
运行时多态
通过虚函数和继承实现,在运行时根据对象的实际类型决定调用哪个函数:
- 虚函数:基类中用
virtual声明,派生类重写(建议加override)。通过基类指针或引用调用时,实际执行派生类的版本; - 纯虚函数与抽象类:纯虚函数(
virtual void func() = 0;)没有默认实现,包含纯虚函数的类是抽象类,不能实例化,只能作为接口由派生类继承并实现;
运行时多态的底层原理
- 编译器为每个含虚函数的类生成一张虚函数表(vtable),按声明顺序存放各虚函数的地址;
- 每个对象的内存布局起始位置有一个虚表指针(vptr),在构造时被设置为指向所属类的vtable;
- 派生类重写虚函数后,其vtable中对应槽位的地址被替换为派生类版本,未重写的槽位仍指向基类版本;
- 调用过程:
obj->vptr→ 找到vtable → 按偏移取出函数地址 → 跳转调用;
运行时多态的代价:每个对象多一个vptr(通常8字节),每次虚函数调用多一次间接寻址,且虚函数调用难以被编译器内联优化。这也是在性能敏感场景下有时选择CRTP等编译期多态替代方案的原因。
虚函数和纯虚函数的区别
虚函数
- 在基类中用
virtual关键字声明的成员函数,具有默认实现。派生类可以重写(override)它提供不同行为,也可以不重写直接继承基类的版本; - 含有虚函数的类可以被实例化。如果派生类不重写,调用时走基类的默认实现。虚函数在vtable中对应的槽位始终有一个有效的函数地址;
纯虚函数
- 在声明末尾加
= 0的虚函数,表示该函数没有默认实现(或不强制要求基类提供实现),派生类必须重写它才能实例化; - 含有至少一个纯虚函数的类自动成为抽象类,不能被实例化,只能作为基类使用,通过指针或引用实现多态;
- 如果派生类没有重写所有纯虚函数,派生类本身也成为抽象类,同样不能实例化。纯虚函数的"强制重写"义务沿继承链向下传递,直到某个派生类全部实现;
- 纯虚函数可以有函数体(在类外定义),派生类可以通过
Base::func()显式调用它。典型用途是纯虚析构函数:让一个类成为抽象类,但这个类本身没有其他适合声明为纯虚的函数。——声明为纯虚使类成为抽象类,但必须提供函数体,因为派生类析构时会沿继承链调用基类析构函数:
class Base {
public:
virtual ~Base() = 0; // 纯虚析构:使 Base 成为抽象类
};
Base::~Base() {} // 必须提供定义,否则链接报错
虚函数是怎么实现的
虚函数的实现依赖于虚函数表(vtable)机制。
编译期:编译器做了什么
- 生成vtable:当一个类包含虚函数时,编译器为该类生成一张虚函数表,本质是一个函数指针数组,按声明顺序存放各虚函数的地址。vtable是per-class的(每个类一张),存放在只读数据段中;
- 插入vptr:编译器在对象的内存布局起始位置插入一个隐式的虚表指针(vptr),占一个指针大小(64位系统8字节)。vptr是per-object的(每个对象一个);
- 处理继承:派生类会获得一份基类vtable的副本。如果派生类重写了某个虚函数,该槽位的地址被替换为派生类版本;未重写的槽位仍指向基类版本。如果派生类新增了虚函数,则在vtable末尾追加新的槽位;
运行期:一次虚函数调用的过程
通过基类指针或引用调用虚函数时,编译器生成的代码等价于:
// base->func() 实际被翻译为:
(*(base->vptr[slot_index]))(base);
具体步骤:
- 通过对象的vptr找到其所属类的vtable;
- 在vtable中按编译期确定的固定偏移(slot index)取出目标函数地址;
- 跳转到该地址执行;
这就是动态绑定(late binding)——调用哪个版本的函数不是在编译期确定的,而是在运行时由对象的实际类型(即vptr指向哪张vtable)决定的。
vptr的设置时机
- 构造时:基类构造函数将vptr指向基类vtable,派生类构造函数再将vptr更新为派生类vtable。因此在构造函数中调用虚函数不会发生多态;
- 析构时:顺序相反,派生类析构函数执行后vptr回退到基类vtable,基类析构函数中调用虚函数同样不会发生多态;
开销
- 空间:每个对象多一个vptr(8字节),每个类多一张vtable(虚函数数量 × 指针大小);
- 时间:每次调用多一次间接寻址,且虚函数调用无法被编译器内联优化;
虚函数表是什么
虚函数表(vtable)是编译器为实现运行时多态而生成的数据结构。
结构
- vtable本质是一个函数指针数组,按虚函数声明顺序存放各虚函数的地址;
- vtable是per-class的:每个含虚函数的类有且仅有一张vtable,存放在只读数据段,所有该类的对象共享同一张表;
- 每个对象的内存布局起始位置有一个vptr(虚表指针),指向所属类的vtable。vptr是per-object的;
继承时vtable的变化
- 派生类会获得一份基类vtable的副本;
- 如果派生类重写了某个虚函数,对应槽位的地址被替换为派生类版本;
- 如果未重写,槽位仍指向基类版本;
- 如果派生类新增虚函数,在vtable末尾追加新槽位;
多继承的情况
- 单继承时对象只有一个vptr;多继承时对象中会有多个vptr,分别指向对应基类的vtable。编译器通过
this指针调整(thunk)保证调用正确;
一次虚函数调用的本质
base->func();
// 等价于:
(*(base->vptr[slot_index]))(base);
先通过vptr找到vtable,再按编译期确定的固定偏移取出函数地址,最后跳转调用。整个过程多了一次间接寻址,这就是虚函数的运行时开销。
什么是构造函数和析构函数?构造函数、析构函数可以是虚函数吗
构造函数
- 是什么
- 构造函数是在对象创建时自动调用的特殊成员函数,负责初始化对象的状态。函数名与类名相同,没有返回值类型。可以重载(多个构造函数),若用户未定义任何构造函数,编译器会自动生成默认构造函数;
- 常见的构造函数类型:默认构造函数、参数化构造函数、拷贝构造函数、移动构造函数;
- 不能是虚函数
- 虚函数调用依赖对象内部的vptr,而vptr是在构造函数执行过程中才被设置的——基类构造函数执行时将vptr指向基类vtable,派生类构造函数再将vptr更新为派生类vtable;
- 如果构造函数本身是虚函数,调用时需要通过vptr查vtable来决定调用哪个版本,但此时对象尚未存在,vptr未初始化,形成逻辑矛盾;
- 由此也引出一个重要结论:在构造函数中调用虚函数不会发生多态,调用的始终是当前正在构造的类的版本(因为此时vptr指向的是当前类的vtable,派生类部分尚未构造);
析构函数
- 是什么
- 析构函数是在对象生命周期结束时自动调用的特殊成员函数,负责释放对象持有的资源。函数名是
~加类名,没有参数、没有返回值、不可重载(每个类只有一个析构函数);
- 可以且经常应该是虚函数
- 当类作为基类使用时,析构函数应该声明为
virtual。原因:通过基类指针delete派生类对象时,如果析构函数非虚,只会调用基类的析构函数,派生类的析构函数被跳过,导致资源泄漏(且属于未定义行为); - 声明为虚析构函数后,
delete基类指针时会通过vtable找到派生类的析构函数,从派生类到基类依次析构,确保完整释放; - 反过来,如果一个类不打算被继承,析构函数不需要声明为
virtual,避免不必要的vptr开销。C++11可以用final关键字显式禁止继承;
面试常见追问:构造函数和析构函数中调用虚函数会怎样?都不会发生多态。构造时vptr指向当前类的vtable(派生类未构造),析构时vptr已被回退到当前类的vtable(派生类已析构),所以调用的都是当前类自己的版本。
C++构造函数有几种,分别什么作用
1. 默认构造函数
- 无参数(或所有参数都有默认值)的构造函数。如果用户未定义任何构造函数,编译器会自动生成一个(对内置类型不做初始化,对类类型成员调用其默认构造函数);
T obj;或T obj{};时调用;
2. 参数化构造函数
- 带参数的构造函数,用于在创建对象时指定初始值;
- 可以有多个参数化构造函数(重载),编译器通过重载决议选择匹配版本;
3. 拷贝构造函数
- 签名为
T(const T& other),用一个已有的同类型对象来初始化新对象; - 触发场景:用同类型对象初始化、函数参数按值传递、函数按值返回(未被RVO优化时);
- 如果用户未定义,编译器会自动生成一个,对每个成员执行逐成员拷贝(浅拷贝)。如果类中持有裸指针等资源,需要自定义实现深拷贝;
4. 移动构造函数
- 签名为
T(T&& other) noexcept,从一个即将销毁的右值对象"窃取"资源,避免深拷贝的开销; - 实现时将源对象的内部指针/资源转移到目标对象,然后将源对象置于合法但未定义的空状态(如指针置
nullptr); - 建议标记
noexcept,否则std::vector等容器在扩容时会退化为拷贝而非移动;
5. 委托构造函数(C++11)
- 一个构造函数在初始化列表中调用同一个类的另一个构造函数,避免初始化逻辑重复;
class T {
T(int a, int b) : x(a), y(b) {}
T(int a) : T(a, 0) {} // 委托给上面的版本
};
6. 转换构造函数
- 只接受一个参数(或只有一个参数没有默认值)的构造函数,允许从该参数类型隐式转换为本类类型;
- 如果不希望隐式转换,应使用
explicit关键字修饰,阻止编译器自动调用该构造函数进行类型转换;
class T {
T(int x); // 转换构造函数,允许 int 隐式转换为 T
explicit T(double x); // 阻止 double 隐式转换为 T
};
7. 继承构造函数(C++11)
- 通过
using Base::Base;将基类的构造函数引入派生类,省去在派生类中逐个编写只是转发参数给基类的构造函数; - 继承来的构造函数不会初始化派生类新增的成员变量,新增成员需要通过类内默认初始化(
int x = 0;)来保证状态正确;
class Base {
public:
Base(int a, int b);
};
class Derived : public Base {
using Base::Base; // 继承 Base 的所有构造函数
int extra = 0; // 新增成员需要类内初始化
};
STL 容器了解哪些
序列容器
| 容器 | 底层实现 | 随机访问 | 插入/删除 | 特点 |
|---|---|---|---|---|
vector | 连续动态数组 | O(1) | 尾部O(1)摊还,中间O(n) | 最常用,扩容时容量翻倍,触发搬移 |
deque | 分段连续数组 | O(1) | 头尾均O(1)摊还 | 头部插入比vector高效,但缓存局部性不如vector |
list | 双向链表 | 不支持 | 任意位置O(1)(已知迭代器) | 插入删除不会导致迭代器失效 |
forward_list | 单向链表 | 不支持 | 头部O(1) | 比list省一个指针,不支持反向遍历 |
array | 固定大小数组 | O(1) | 不支持 | 栈上分配,大小编译期确定,替代C风格数组 |
关联容器(有序)
底层均为红黑树,元素自动排序,操作复杂度均为O(log n):
| 容器 | 特点 |
|---|---|
set | 存储唯一键,默认升序 |
multiset | 允许重复键 |
map | 存储唯一键值对,按键排序 |
multimap | 允许重复键的键值对 |
无序关联容器
底层均为哈希表(拉链法),平均O(1),最坏O(n):
| 容器 | 特点 |
|---|---|
unordered_set | 唯一键,无序 |
unordered_multiset | 允许重复键,无序 |
unordered_map | 唯一键值对,无序 |
unordered_multimap | 允许重复键的键值对,无序 |
容器适配器
不是独立容器,而是对底层容器的封装,限制接口以提供特定语义:
| 适配器 | 语义 | 默认底层容器 |
|---|---|---|
stack | LIFO | deque |
queue | FIFO | deque |
priority_queue | 按优先级出队(最大堆) | vector |
深拷贝与浅拷贝的区别
浅拷贝(Shallow Copy)
- 逐成员复制对象的每个字段的值。对于指针成员,只复制指针本身(地址值),不复制指针指向的内存;
- 编译器默认生成的拷贝构造函数和拷贝赋值运算符执行的就是浅拷贝;
- 问题:多个对象的指针指向同一块堆内存,一个对象析构释放后,其他对象的指针变成野指针,再次释放就是double free(未定义行为);
深拷贝(Deep Copy)
- 不仅复制对象本身的字段,还为指针成员指向的资源分配新的内存并复制内容,使每个对象拥有独立的资源副本;
- 需要自定义拷贝构造函数和拷贝赋值运算符;
代码对比
class String {
char* data;
size_t len;
public:
// 浅拷贝(编译器默认行为等价于此)
// String(const String& other) : data(other.data), len(other.len) {}
// → 两个对象的 data 指向同一块内存,析构时 double free
// 深拷贝
String(const String& other) : len(other.len) {
data = new char[len + 1];
memcpy(data, other.data, len + 1); // 分配新内存并复制内容
}
// 深拷贝赋值(注意自赋值检查)
String& operator=(const String& other) {
if (this != &other) {
delete[] data;
len = other.len;
data = new char[len + 1];
memcpy(data, other.data, len + 1);
}
return *this;
}
~String() { delete[] data; }
};
什么时候需要深拷贝
- 类中有指针成员指向动态分配的资源时,必须实现深拷贝;
- 判断依据就是Rule of Three/Five:如果需要自定义析构函数(说明类管理了资源),那么几乎一定也需要自定义拷贝构造函数和拷贝赋值运算符(Rule of Three),C++11后还应加上移动构造和移动赋值(Rule of Five);
现代C++的实践
- 优先用智能指针或容器(如
std::string、std::vector)管理资源,它们内部已经正确实现了深拷贝和移动语义,避免手写拷贝控制;
vector和list的区别
快速对比
vector | list | |
|---|---|---|
| 底层结构 | 连续动态数组 | 双向链表 |
| 随机访问 | O(1),start + n指针运算 | 不支持,遍历O(n),无operator[] |
| 尾部插入/删除 | O(1)摊还 | 严格O(1) |
| 头部插入/删除 | O(n)(搬移所有元素) | O(1) |
| 中间插入/删除 | 定位O(1) + 搬移O(n) | 定位O(n) + 修改指针O(1) |
| 缓存局部性 | 好(连续内存,硬件预取高效) | 差(节点分散,频繁cache miss) |
| 内存开销 | 元素本身 + 预留空间 | 每节点额外16字节(两个指针)+ 堆元数据 |
| 迭代器失效 | 扩容时全部失效,中间插删后续失效 | 仅被删除节点失效,其余不受影响 |
| 内存分配模式 | 扩容时一次大分配,频率低 | 每次插入/删除都分配/释放,频率高易碎片 |
关键细节补充
中间操作的复杂度陷阱:"list中间插入O(1)“的前提是已持有目标位置的迭代器。如果需要先查找再插入,总时间是O(n)+O(1)=O(n),和vector的O(1)+O(n)=O(n)同一量级;
缓存友好性的影响:CPU的L1缓存命中约1ns,主内存访问约100ns,差两个数量级。vector连续存储使硬件预取器高效工作,实测中即使理论复杂度list更优,vector由于缓存优势往往仍然更快,除非元素非常大或数量极多;
list独有的splice操作:splice可以在O(1)时间内将另一个list的节点转移到当前list中,只需修改指针,无需拷贝或移动元素;
排序差异:vector可用std::sort(introsort,缓存友好,性能好);list不支持随机访问迭代器,必须用自带的list::sort()(归并排序),实际性能通常不如拷贝到vector排序再拷贝回来;
实践建议:默认优先用
vector。只有在需要频繁中间插入删除且元素搬移代价高、需要迭代器稳定性、或需要splice操作时,才考虑list。
vector底层原理和扩容过程
底层结构
vector本质是一个堆上分配的连续内存块,内部维护三个指针:
T* _start; // 指向第一个元素
T* _finish; // 指向最后一个元素的下一个位置
T* _end_of_storage; // 指向分配空间的末尾
由此可得:size() = _finish - _start,capacity() = _end_of_storage - _start。
扩容过程
当size() == capacity()时再插入元素,触发扩容:
- 申请一块更大的连续内存(通常为当前容量的2倍,MSVC是1.5倍);
- 将旧内存中的元素搬移到新内存(C++11后优先使用移动构造,若移动构造非
noexcept则退化为拷贝); - 释放旧内存;
- 更新三个内部指针指向新内存;
扩容倍数的选择
- 2倍扩容(GCC/libstdc++):扩容次数少,
push_back的摊还时间复杂度为O(1),但旧内存无法被后续分配复用(因为之前释放的所有块之和 1+2+4+…+n/2 = n-1 < n); - 1.5倍扩容(MSVC):扩容稍频繁,但累计释放的内存最终可以被复用,内存碎片更少;
关键注意事项
- 迭代器失效:扩容后内存地址完全改变,之前所有的迭代器、指针、引用全部失效;
reserve():如果预知元素数量,提前调用reserve(n)预分配空间,可以完全避免扩容带来的搬移开销和迭代器失效;shrink_to_fit():大量删除元素后capacity不会自动缩小,可以调用此函数请求释放多余内存(非强制);
push_back()和emplace_back()的区别
核心区别
push_back接受一个已构造的对象,将其拷贝或移动到容器末尾;emplace_back(C++11)接受构造函数的参数,通过完美转发 + placement new直接在容器内存上原地构造,跳过临时对象的创建和搬迁;
性能差异的关键:只在传入构造参数时体现
std::vector<Widget> v;
v.reserve(10);
v.push_back(Widget(1, "Alice")); // 构造临时对象 + 移动构造 + 析构 = 3步
v.emplace_back(1, "Bob"); // 直接原地构造 = 1步
Widget w(2, "Charlie");
v.push_back(w); // 拷贝构造
v.emplace_back(w); // 也是拷贝构造,两者等价
类型安全差异
emplace_back会绕过explicit的隐式转换保护:
class Connection {
public:
explicit Connection(int port);
};
std::vector<Connection> conns;
conns.push_back(8080); // ❌ 编译错误,explicit 阻止
conns.emplace_back(8080); // ✅ 编译通过,绕过了 explicit
选用原则
- 直接传构造参数且确定参数正确时,用
emplace_back更高效; - 传入已有对象、或需要类型安全检查时,用
push_back更安全清晰;
面试延伸
- 其他emplace系列:
emplace_front(deque/list)、emplace(map/set)、C++17的try_emplace(键不存在时才构造value); - C++17起
emplace_back返回新元素的引用(之前返回void); - 异常安全:构造过程中抛异常,vector保证强异常安全(状态回退);
map、deque、list的实现原理
std::map——红黑树
- 底层是红黑树(自平衡二叉搜索树),元素以
pair<const Key, Value>存储,按Key自动排序; - 红黑树五条性质保证树高始终为O(log n),查找、插入、删除均为**严格O(log n)**最坏情况保证;
- 每个节点独立堆分配,包含三个指针(parent/left/right)+ 颜色标记,额外开销约25字节;
- 插入/删除通过旋转 + 重着色恢复平衡,插入最多2-3次旋转,删除最多3次旋转;
- 迭代器稳定性好:插入删除不影响其他迭代器;
operator[]陷阱:key不存在时会自动插入默认值,只查找应用find()或C++20的contains();
std::deque——分段连续内存
- 底层是中控数组(map array)+ 多个固定大小缓冲区(chunk),中控数组每个元素是指向一个缓冲区的指针;
- 迭代器包含四个成员:
cur(当前位置)、first/last(缓冲区边界)、node(中控数组槽位),比vector的裸指针迭代器复杂; - 随机访问O(1),但比vector多一次间接寻址(先算在哪个chunk,再算chunk内偏移);
- 头尾插入均O(1):缓冲区满了只需分配新chunk并更新中控数组,不需要搬迁已有元素;
- 中间插入O(n),但会选择移动较少的一半(靠近哪端就移哪端),最坏移动n/2个元素;
- 中控数组扩容只需拷贝指针,开销远小于vector的元素搬迁;
std::list——带哨兵的环形双向链表
- 有一个不存储数据的哨兵节点,其next指向首元素,prev指向末元素,统一了边界处理,无需特判空链表/头尾;
- 每个节点独立堆分配,包含prev指针 + next指针 + 数据,额外开销16字节;
- 插入/删除严格O(1)(已知迭代器),只需修改前后节点的指针;
splice是list独有操作:O(1)转移节点,无分配、无拷贝;list::sort()用归并排序(不能用std::sort),稳定排序O(n log n);- C++11起
size()保证O(1)(维护计数器),代价是部分splice变为O(n);
综合对比
| map | deque | list | |
|---|---|---|---|
| 底层 | 红黑树 | 分段连续数组 | 双向链表 |
| 随机访问 | O(log n)按key | O(1)按索引 | O(n) |
| 头尾操作 | 不适用 | O(1) | O(1) |
| 中间插入 | O(log n) | O(n) | O(1)已知位置 |
| 缓存友好性 | 差 | 中等 | 差 |
| 迭代器失效 | 仅被删除元素 | 插入删除可能大面积失效 | 仅被删除元素 |
| 扩容搬迁 | 无 | 无(新增chunk) | 无 |
map和unordered_map的区别和实现机制
核心区别
map底层红黑树,有序,O(log n)严格保证;unordered_map底层哈希表,无序,平均O(1)但最坏O(n)。
对比
map | unordered_map | |
|---|---|---|
| 底层结构 | 红黑树 | 哈希表(拉链法) |
| 元素顺序 | 按key有序 | 无序 |
| 操作复杂度 | O(log n)严格保证 | 平均O(1),最坏O(n) |
| 对key的要求 | operator<(严格弱序) | std::hash<Key> + operator== |
| 迭代器稳定性 | 插入删除不影响其他迭代器 | rehash时所有迭代器失效 |
| 范围查询 | 支持lower_bound/upper_bound | 不支持 |
| 内存局部性 | 差(节点分散) | 差(拉链法节点分散) |
unordered_map的哈希机制
- 插入/查找过程:
hash(key) % bucket_count→ 定位桶 → 遍历桶内链表用operator==比较; - 负载因子 =
size() / bucket_count(),超过max_load_factor()(默认1.0)时触发rehash; - rehash:分配更大的桶数组,所有元素重新哈希分配,O(n),所有迭代器失效;
- 可用
reserve(n)预分配桶来避免rehash; - libstdc++桶数量选素数(53, 97, 193…)使分布更均匀;
自定义key类型
// map:只需定义 operator< 或自定义比较器
std::map<MyType, int, MyCompare> m;
// unordered_map:需要哈希函数 + operator==
struct MyHash {
size_t operator()(const MyType& k) const {
// 组合多个字段的哈希值(借鉴boost::hash_combine)
size_t seed = std::hash<int>{}(k.x);
seed ^= std::hash<int>{}(k.y) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
return seed;
}
};
std::unordered_map<MyType, int, MyHash> m;
选用原则
- 需要有序遍历、范围查询、最坏情况保证 →
map; - 只需快速查找不关心顺序(最常见场景)→
unordered_map; - key类型难以定义好的哈希函数 →
map更方便可靠;
面试延伸
- 哈希冲突解决:标准库用拉链法;另一种是开放寻址法(线性探测等),缓存更友好但删除需墓碑标记。Google Abseil的
flat_hash_map用开放寻址+SIMD加速,性能优于标准库; - C++17
try_emplace:key不存在时才构造value,避免浪费构造开销; - C++23
flat_map:底层是排序的vector,二分查找O(log n),缓存友好性远优于红黑树,适合读多写少;
C++11新特性有哪些
类型推导
auto:编译器根据初始化表达式自动推导变量类型;decltype:获取表达式的类型而不实际计算,常用于模板编程中推导返回值类型;
右值引用与移动语义
- 右值引用(
T&&):绑定到临时对象,使得资源可以从即将销毁的对象中"窃取"而非拷贝; std::move:将左值转换为右值引用,触发移动构造/移动赋值;- 完美转发(
std::forward):在模板中保持参数的值类别(左值/右值)不变地传递;
智能指针
std::unique_ptr(独占所有权)、std::shared_ptr(共享所有权)、std::weak_ptr(弱引用),替代裸指针实现RAII;
Lambda表达式
- 语法:
[捕获列表](参数) -> 返回类型 { 函数体 }; - 捕获方式:
[=]值捕获、[&]引用捕获、[x, &y]混合捕获;
其他重要特性
nullptr:类型安全的空指针常量,替代C中的NULL(NULL本质是整数0,可能导致重载歧义);- 基于范围的for循环:
for (auto& x : container),简洁遍历容器; constexpr:编译期常量表达式,允许函数在编译期求值;override/final:override显式标注虚函数重写,final禁止进一步重写或继承;- 委托构造函数:构造函数在初始化列表中调用同类的另一个构造函数;
std::initializer_list:统一初始化语法,支持花括号初始化;- 变参模板(variadic templates):
template<typename... Args>,支持任意数量的模板参数; std::thread:标准库原生线程支持,配合mutex、condition_variable等同步原语;
移动语义有什么作用,原理是什么
解决什么问题
对于持有堆内存、文件句柄等资源的对象,传统的拷贝需要深拷贝所有资源,开销很大。但很多时候源对象是一个临时对象(马上要销毁),拷贝完就扔掉,这个深拷贝完全是浪费。移动语义允许直接"窃取"源对象的资源,避免无意义的深拷贝。
原理
通过移动构造函数和移动赋值运算符实现:
class String {
char* data;
size_t len;
public:
// 移动构造:窃取资源
String(String&& other) noexcept
: data(other.data), len(other.len) {
other.data = nullptr; // 源对象置为合法空状态
other.len = 0;
}
// 移动赋值
String& operator=(String&& other) noexcept {
if (this != &other) {
delete[] data; // 释放自己的旧资源
data = other.data; // 窃取源对象资源
len = other.len;
other.data = nullptr; // 源对象置空
other.len = 0;
}
return *this;
}
};
移动后源对象处于**合法但未指定(valid but unspecified)**的状态,通常意味着可以安全地析构或重新赋值,但不应再使用其内容。
何时触发移动
- 传入右值(临时对象、
std::move转换后的对象)时,重载决议选择移动构造/移动赋值; - 编译器在某些场景下自动使用移动:函数返回局部对象、
push_back临时对象等; std::move本身不移动任何东西,它只是将左值无条件转换为右值引用,让重载决议选择移动版本;
左值引用和右值引用的区别
左值与右值
- 左值(lvalue):有持久身份的表达式,可以取地址。如变量名、解引用、数组元素等;
- 右值(rvalue):临时的、即将销毁的表达式,不能取地址。如字面量
42、临时对象std::string("hello")、函数返回值等;
两种引用
左值引用 T& | 右值引用 T&& | |
|---|---|---|
| 绑定对象 | 左值 | 右值(临时对象、std::move的结果) |
| 主要用途 | 传递和修改已有对象,避免拷贝 | 实现移动语义,窃取临时资源 |
| 典型场景 | 函数参数void f(T& x) | 移动构造T(T&& other) |
特殊规则
const T&可以绑定右值:这就是C++98中void f(const string& s)能接受临时字符串的原因,但只能读不能移动;- 右值引用变量本身是左值:
T&& x = ...;之后,x本身有名字、有地址,是左值。要再次当右值传递需要std::move(x); - 万能引用(universal reference):
template<typename T> void f(T&& x)中的T&&不是右值引用,而是万能引用,通过引用折叠规则既能绑定左值也能绑定右值,配合std::forward<T>(x)实现完美转发;