C++高频面试题集锦(20题)

C++高频面试题集锦(20题)
NyxXC++高频面试题集锦(20题)
本文档涵盖C++面试中最常见的基础知识、面向对象、STL、内存管理及现代C++特性,适合面试前系统复习。
题目1: 简述C++中new和malloc的区别
解答:
| 特性 | new/delete |
malloc/free |
|---|---|---|
| 本质 | C++运算符(operator) | C标准库函数 |
| 类型安全 | 自动计算类型大小,返回正确类型指针 | 返回void*,需手动转换 |
| 构造/析构 | 调用构造函数和析构函数 | 仅分配/释放内存,不调用 |
| 失败处理 | 默认抛出std::bad_alloc异常 |
返回NULL |
| 内存对齐 | 支持对齐的new(C++17) |
C11起支持aligned_alloc |
| 重载 | 可重载(全局或类内) | 不可重载 |
关键区别: new是类型感知且会触发构造函数的,而malloc只是原始内存分配。
口头解答:
“这两个的主要区别在于,new是C++的运算符,而malloc是C的库函数。最核心的一点是类型安全和构造行为——new会自动计算你需要多少内存,返回对应类型的指针,而且它会调用对象的构造函数;malloc只是分配指定字节数的原始内存,返回void*,需要你自己强制类型转换,也不会调用构造。
另外,失败时的行为也不同:new默认会抛出bad_alloc异常,而malloc返回空指针。还有,在C++里我们一般提倡用new/delete,因为它更符合RAII思想,可以和智能指针配合。当然,实际工程中如果追求极致性能或者做底层内存池,可能会用malloc,但上层还是建议封装成类来管理生命周期。”
题目2: 指针和引用的区别是什么?什么情况下该用引用?
解答:
本质差异:
- 指针:独立的对象,存储内存地址,可以为空,可被重新赋值指向其他对象
- 引用:对象的别名,必须初始化且绑定后不可更改,不存在空引用
语法层面:
- 指针需要解引用(
*ptr),引用直接访问 sizeof(指针)返回地址大小,sizeof(引用)返回原对象大小- 指针支持指针运算,引用不支持
- 指针需要解引用(
使用场景:
- 引用优先:函数传参(避免拷贝)、操作符重载(如
operator[])、范围for循环 - 指针必须:可能为空的情况、需要动态内存管理、多态(基类指针指向派生类对象)、数据结构(链表、树节点)
- 引用优先:函数传参(避免拷贝)、操作符重载(如
口头解答:
“指针和引用最大的区别可以总结为三点:引用不能为空、不能改绑、且不是独立对象。
指针是一个变量,存着地址,你可以让它指向别的地方,也可以设成nullptr;但引用一旦初始化绑定了一个对象,就永远是那个对象的别名,不存在’空引用’这回事。
从使用场景看,现在的C++编程有个原则叫**’优先使用引用’**。比如函数传参,如果只是读取或者修改对象而不需要管理生命周期,传引用比传指针更安全,不用担心空指针解引用。但是,如果你需要表达’这个对象可能存在也可能不存在’,或者需要做动态内存管理,比如链表节点的next指针,那就必须用指针了。另外,多态的时候基类指针指向派生类对象,这也是指针更灵活的地方。”
题目3: const关键字在C++中有哪些用法?请区分常量指针和指针常量
解答:
const的五种常见用法:
- 常量变量:
const int a = 10;不可修改 - 常量指针(Pointer to const):
const int* p;或int const* p;- 指向的内容不可变,但指针本身可变
- 指针常量(Const pointer):
int* const p = &a;- 指针本身不可变(不能指向别处),但指向的内容可变
- 常量引用:
const int& ref = a;常用于函数参数避免拷贝且承诺不修改 - 常量成员函数:
int func() const;承诺不修改类成员(mutable成员除外)
记忆口诀: const在*左边修饰内容,在右边修饰指针。
口头解答:
“const在C++里主要是用来承诺’只读’,增强代码安全性。我重点说一下面试常考的常量指针和指针常量怎么区分。
你可以看const相对于*的位置:如果const在*左边,比如const int* p,这叫指向常量的指针,意思是p不能改(内容不能动),但p本身可以指向别的地址;如果const在*右边,比如int* const p,这叫*常量指针,指针本身是常量,只能一直指向这个地址,但地址里的内容可以改。
实际编程中,常量引用用得特别多,比如函数参数void func(const string& s),这样既避免了拷贝开销,又向调用方保证我不会乱改你的字符串。还有const成员函数,在类里如果方法不会修改成员变量,就一定要声明为const,这样const对象才能调用它。”
题目4: static关键字在C++中的三种主要用法及作用
解答:
静态局部变量(函数内部):
- 生命周期延长至程序结束,只在第一次进入函数时初始化,后续调用保持值
- 存储在数据段(.data或.bss),而非栈上
静态全局变量/函数(文件作用域):
- 限定链接性为内部链接(internal linkage),仅在当前翻译单元(.cpp文件)可见
- 避免命名冲突,替代C语言中的匿名命名空间(但现代C++更推荐匿名命名空间)
类静态成员:
- 静态成员变量:属于类而非对象,所有对象共享一份,必须在类外定义(除非
inline staticC++17) - 静态成员函数:无
this指针,只能访问静态成员,可直接通过类名调用
- 静态成员变量:属于类而非对象,所有对象共享一份,必须在类外定义(除非
口头解答:
“static在不同地方用效果完全不一样,我分三个层面来说:
首先,函数内部的static局部变量,这是用来实现单例或者计数器的。它和普通局部变量的区别是,它不会在函数结束时销毁,下次调用还能保持上次的值,而且只初始化一次。注意这是线程安全的(C++11以后),所以经常用来写线程安全的单例模式。
其次,文件作用域的static,就是加在全局变量或者函数前面,意思是’这个符号只在当前文件可见’,别的cpp文件即使extern也找不到它。这是为了防命名冲突。不过现代C++更推荐用匿名命名空间来做这个事。
最后,类里面的static,这是最重要的。静态成员变量是类的所有对象共享的,比如你想统计创建了多少个对象,就可以放个static计数器。它必须在类外定义,除非C++17用inline static。静态成员函数没有this指针,不能访问非静态成员,经常用来当工厂方法或者工具函数。”
题目5: 虚函数实现多态的原理是什么?虚函数表(vtable)是如何工作的?
解答:
核心机制:
- 虚函数表(vtable): 编译器为每个含有虚函数的类生成一张静态函数指针表,按声明顺序存储虚函数地址
- 虚表指针(vptr): 每个对象实例在构造函数中隐含添加一个指针(通常为对象首地址),指向所属类的vtable
- 动态绑定: 通过基类指针/引用调用虚函数时,运行时通过vptr找到vtable,再索引到实际函数地址
关键细节:
- 单继承:派生类vtable先复制基类表,覆盖重写项,追加新虚函数
- 多继承:对象包含多个vptr,分别指向不同基类的vtable
- 构造/析构时vptr会动态切换,确保调用正确的函数版本
- 虚析构函数通过vtable定位派生类析构函数,避免内存泄漏
开销: 每个对象多一个指针(通常8字节),间接寻址的性能损失(现代CPU缓存友好)。
口头解答:
“C++的多态主要靠虚函数表这个机制实现。编译器会给每个有虚函数的类建一张表,叫vtable,里面存着这个类所有虚函数的地址。然后每个对象在内存里会多一个隐藏的成员叫vptr,指向这张表。
当你通过基类指针调用虚函数时,比如Base* p = new Derived(); p->func();,程序不会直接调用Base::func,而是去查p指向的对象里的vptr,找到vtable,再看func在这个表里的第几个位置,最终调用到Derived::func。这就是运行时绑定。
有几个细节值得注意:第一,构造函数里调用虚函数不会有多态性,因为这时候vptr还没初始化完,正在从基类往派生类切换;第二,这就是为什么析构函数要设成虚函数——如果基类指针指向派生类对象,delete的时候需要通过vptr找到派生类的析构函数,否则派生类部分析构不了,会内存泄漏;第三,这个机制是有开销的,每个对象多一个指针大小,还有间接寻址的cache miss可能,所以高频调用的热点函数不适合设为虚函数。”
题目6: 为什么基类的析构函数通常要声明为虚函数?如果不这样做会有什么风险?
解答:
原因:
当通过基类指针删除派生类对象时(Base* ptr = new Derived(); delete ptr;),若基类析构非虚,则只调用基类析构函数,派生类析构被跳过,导致派生类部分资源泄漏。
具体风险:
- 派生类成员(特别是托管资源的智能指针、文件句柄等)未释放
- 若派生类在堆上申请了内存,直接泄漏
例外情况:
若类不被继承(final)或明确作为值语义使用(如标准库容器),非虚析构更轻量(无虚表开销)。可用= default显式定义虚析构。
C++ Core Guidelines建议: 任何有虚函数的类都应有虚析构函数,或受保护的非虚析构(防止多态删除)。
口头解答:
“这是为了防止内存泄漏和资源未释放。场景是这样的:你有个基类指针指向派生类对象,比如Animal* p = new Dog();,然后你delete p。如果Animal的析构函数不是虚函数,编译器就会直接调用Animal的析构,Dog的析构被完全跳过了。那Dog里如果有string、vector或者文件句柄这些成员,就全泄漏了。
所以规则很简单:如果类里有虚函数,析构函数就一定要是虚的。反过来,如果这个类明确不会被继承,比如用了final关键字,或者就是个简单的值类型,那非虚析构反而更好,因为可以省一个vptr的空间。
另外补充一点,有时候我们会把基类析构设为protected且非虚,这是为了防止用户通过基类指针delete对象,强制要求派生类自己管理生命周期,这也是一种设计模式。”
题目7: 什么是深拷贝(Deep Copy)和浅拷贝(Shallow Copy)?什么情况下必须自定义拷贝构造函数?
解答:
概念对比:
- 浅拷贝:逐字节复制,指针成员直接复制地址值,导致多个对象指向同一堆内存
- 深拷贝:为指针成员重新分配内存,复制指向的内容,对象间完全独立
触发场景:
- 默认拷贝构造函数执行浅拷贝
- 当类包含原始指针管理资源(
char*、int*等)时,必须自定义深拷贝
必须自定义拷贝构造的情况(Rule of Three/Five):
- 类显式声明了析构函数(通常意味着释放了资源)
- 类显式声明了拷贝构造函数或拷贝赋值运算符
- 类包含原始指针或文件句柄等需要所有权管理的资源
现代建议: 优先使用智能指针(unique_ptr、shared_ptr)或容器(vector、string),它们自动处理深拷贝,避免手动管理。
口头解答:
“浅拷贝就是简单的值复制,比如一个类里有指针,浅拷贝会把指针地址直接复制过去,这样两个对象就指向同一块内存了,析构的时候会重复释放,导致崩溃。深拷贝则是重新申请一块内存,把原指针指向的内容也复制过来,两个对象完全独立。
什么时候必须自己写拷贝构造函数? 当你类里有原始指针,或者管理着文件、网络连接这些资源的时候。这时候默认的浅拷贝肯定不行,你得自己写深拷贝逻辑,申请新内存复制内容。
不过现代C++有个更好的实践叫Rule of Zero:尽量不要在类里直接管理原始指针,用std::string代替char*,用vector代替动态数组,用智能指针代替裸指针。这些标准库类都做好了深拷贝,你就不用自己写拷贝构造了,编译器生成的默认版本就能工作,既安全又省事。”
题目8: C++11引入的智能指针有哪些?各自的使用场景和注意事项是什么?
解答:
三种智能指针(定义在<memory>):
std::unique_ptr(独占所有权):- 语义:唯一拥有所指对象,不可复制,只可移动(move)
- 用途:工厂函数返回动态对象、管理独占资源(文件、锁)
- 开销:零开销抽象(与裸指针大小相同,无额外开销)
std::shared_ptr(共享所有权):- 语义:引用计数,多个shared_ptr可指向同一对象,最后一个销毁时释放
- 控制块:存储引用计数和自定义删除器,分配在堆上
- 线程安全:引用计数原子操作线程安全,但对象本身非线程安全
- 风险:循环引用导致内存泄漏(需配合weak_ptr)
std::weak_ptr(弱引用):- 语义:不增加引用计数,观察shared_ptr指向的对象,可能已失效
- 用途:打破循环引用、缓存实现、观察对象生命周期
- 使用前必须调用
lock()提升为shared_ptr并检查有效性
最佳实践: 默认用unique_ptr,需要共享所有权时用shared_ptr,避免裸指针。
口头解答:
“C++11引入了三个智能指针,分别对应不同的所有权模型。
首先是unique_ptr,表示独占所有权。它不能被拷贝,只能被移动,比如工厂函数返回一个对象就用它。它的好处是零开销,和裸指针一样快,但自动释放内存,出了作用域就析构。
然后是shared_ptr,共享所有权。它内部有个引用计数,几个人shared_ptr指向同一个对象,计数就几,最后一个死掉的时候才真正释放。要注意它有两个开销:一是引用计数的原子操作有轻微性能损耗,二是它容易搞出循环引用——比如A持有shared_ptr,B又持有shared_ptr,那这两个对象永远释放不了。这时候就需要第三个weak_ptr。
weak_ptr是弱引用,它看着shared_ptr管理的对象,但不增加引用计数。它可能指向一个已经销毁的对象,所以用之前必须调用lock()尝试提升成shared_ptr,成功才能用。最常见的场景就是解决循环引用,比如树结构里父节点用weak_ptr指向子节点,或者缓存系统里用weak_ptr观察对象是否还活着。
总结下来就是:能用unique_ptr就别用shared_ptr,必须共享就用shared_ptr,有循环引用风险的地方用weak_ptr打断。”
题目9: 解释RAII(Resource Acquisition Is Initialization)机制,并举例说明
解答:
核心思想: 将资源的生命周期绑定到对象的生命周期,利用构造函数获取资源,析构函数释放资源,确保异常安全(即使抛出异常,栈展开也会调用析构函数)。
关键优势:
- 异常安全:相比
try-finally或手动释放,RAII保证即使发生异常,资源也会被释放 - 可组合性:资源管理对象可作为其他类的成员,自动处理依赖关系
- 代码简洁:无需显式释放,避免忘记释放导致的泄漏
标准库实例:
std::lock_guard<std::mutex>:构造时加锁,析构时解锁std::ifstream/std::ofstream:构造时打开文件,析构时关闭std::vector:构造时申请内存,析构时释放,自动调用元素析构- 智能指针:
unique_ptr、shared_ptr
自定义RAII类模板:
1 | template<typename Resource, typename Deleter> |
口头解答:
“RAII是C++资源管理的核心哲学,全称是’资源获取即初始化‘。简单说就是把资源包装成一个类,在构造函数里获取资源,在析构函数里释放资源。这样只要对象活着,资源就可用;对象一销毁,资源立刻释放,哪怕中间抛了异常,栈展开的时候也会调析构。
举个例子,比如多线程里的锁,手动lock和unlock很容易忘或者异常时没unlock,造成死锁。用std::lock_guard,你构造时传入mutex,它自动lock,出了作用域guard对象销毁,自动unlock,绝对安全。
文件操作也一样,ifstream打开文件,不用显式close,对象销毁时自动关。还有智能指针,都是RAII的体现。
如果你要自己实现,比如管理一个数据库连接,就在类构造函数里connect,析构函数里disconnect。注意要处理好拷贝语义——通常资源管理类是不可拷贝的(删拷贝构造),但可移动(C++11的移动语义),这样既能保证唯一所有权,又能高效地转移资源。”
题目10: std::vector的底层实现是什么?扩容机制是怎样的?如何优化频繁扩容的性能?
解答:
底层结构:
三个指针(或指针+size+capacity):
start:指向数组起始finish:指向最后一个元素的下一个位置end_of_storage:指向分配内存的末尾
元素在连续内存(堆上)存储,支持O(1)随机访问。
扩容机制:
- 当
push_back/emplace_back发现finish == end_of_storage时触发 - 申请新内存(通常为1.5倍或2倍当前容量,GCC是2倍,MSVC是1.5倍)
- 移动或拷贝元素到新内存(C++11起优先使用移动语义)
- 释放旧内存
- 更新三个指针
复杂度: 均摊O(1),但单次扩容O(n)。
优化策略:
- 预分配:已知大致数量时用
reserve(n),避免多次扩容 - 初始化时指定大小:
vector<T> v(n);直接分配n个空间 - shrink_to_fit():C++11起可释放多余容量(请求向操作系统归还内存,非强制)
- 使用
emplace_back:避免临时对象构造和拷贝
迭代器失效: 扩容后所有迭代器、指针、引用均失效。
口头解答:
“vector底层就是一个动态数组,用三个指针管理:指向开头、指向末尾元素后一个位置、指向分配内存的边界。数据存在堆上,保证连续内存,所以支持O(1)的随机访问。
扩容机制是重点:当空间满了再push_back,它会重新申请一块更大的内存,通常是原来的1.5倍或2倍,然后把老数据move过去,释放旧内存。GCC是2倍,VS是1.5倍。为什么是1.5-2倍?因为均摊下来时间复杂度是O(1),如果用固定增量就是O(n)了。
怎么优化? 如果你知道大概要存多少元素,一定要先用reserve预分配。比如你要读100万个数,先v.reserve(1000000),这样一次分配到位,避免中间多次拷贝构造。另外,C++11以后用emplace_back代替push_back,可以直接在vector内存里构造对象,省去一次拷贝或移动。
特别注意:扩容会让所有迭代器失效。如果你拿了begin()迭代器在做循环,中间一旦发生扩容,这个迭代器就悬空了,继续用会崩溃。”
题目11: std::map与std::unordered_map的区别?各自适用场景是什么?
解答:
| 特性 | std::map |
std::unordered_map |
|---|---|---|
| 底层结构 | 红黑树(平衡BST) | 哈希表(桶+链表/红黑树) |
| 元素顺序 | 按键升序排列 | 无序 |
| 查找复杂度 | O(log n) | 平均O(1),最坏O(n)(哈希冲突严重) |
| 插入复杂度 | O(log n) | 平均O(1),最坏O(n) |
| 内存占用 | 较少(仅节点指针开销) | 较多(哈希表预分配桶数组) |
| 迭代器稳定性 | 插入删除不影响其他迭代器 | 重哈希时所有迭代器失效 |
| 比较要求 | 需定义operator<或比较器 |
需定义hash函数和operator== |
适用场景:
map:需要有序遍历、范围查询(lower_bound)、对最坏情况性能敏感(如实时系统)unordered_map:纯查找场景、键类型可哈希、不要求顺序、数据量大时平均性能更优
哈希冲突处理: C++11起使用桶+链表/树(冲突严重时链表转红黑树,阈值通常为8)。
口头解答:
“这两个都是关联容器,但实现完全不同。map底层是红黑树,一种平衡二叉搜索树,元素按键自动排序,查找插入都是O(log n);unordered_map是哈希表,平均O(1)查找,但元素是无序的。
选哪个看场景:如果你需要按顺序遍历,或者做范围查询(比如找所有key在10到20之间的),必须用map。如果只是查值、插值,不在乎顺序,且数据量大,unordered_map更快。
但要注意几点:第一,内存,哈希表要预分配桶数组,通常比树结构费内存;第二,最坏情况,如果哈希函数设计不好或者有人恶意构造冲突数据,unordered_map会退化成链表,变成O(n);第三,自定义类型做key,map只需要重载<,unordered_map需要特化hash函数和==。
还有迭代器稳定性:map插入删除不影响别的迭代器,但unordered_map如果触发重哈希(rehash),所有迭代器都会失效,这点在并发场景特别要注意。”
题目12: 哪些操作会导致std::vector和std::map的迭代器失效?
解答:
std::vector迭代器失效场景:
- 扩容操作:
push_back、emplace_back、insert导致容量变化时,所有迭代器、指针、引用失效 - 缩小容量:
shrink_to_fit(可能重新分配) - reserve:若新容量大于当前,失效
- swap:迭代器仍指向原内存(已不属于容器)
- 安全操作:
pop_back、clear、erase(仅被删除元素及之后的迭代器失效,之前有效)
std::map迭代器失效场景:
- 插入:不会导致任何迭代器失效(树节点分配独立)
- 删除:仅被删除节点的迭代器失效,其他完全保持有效
- 关键特性:除被删元素外,迭代器始终指向原节点,即使树结构旋转调整
std::unordered_map特殊:
- 插入:可能导致重哈希,所有迭代器失效
- 删除:仅当前迭代器失效
最佳实践: 修改容器时,先取得下一个有效迭代器,如it = vec.erase(it)(C++11起erase返回下一个迭代器)。
口头解答:
“这是面试常考的坑点,不同容器规则完全不一样。
对于vector,记住一条:只要发生重新分配内存(扩容),所有迭代器、指针、引用统统失效。所以push_back、emplace_back、insert(如果导致扩容)都会让之前的begin()、end()变成野指针。但是erase和pop_back只会影响被删位置之后的迭代器,前面的还是有效的。
map就不一样了,它是树结构,插入只是新增节点,不会导致已有迭代器失效。删除也只会让你删的那个节点的迭代器失效,其他的迭代器完全不受影响,可以继续用。这是map的一个重要稳定性。
unordered_map比较特殊,插入时如果负载因子太高触发rehash,整个桶数组要重建,这时所有迭代器都会失效,和vector类似。
所以编程时的安全做法是:如果你要在循环里修改容器,比如删除符合条件的元素,对于vector要写it = vec.erase(it),因为erase会返回下一个有效迭代器;对于map可以直接m.erase(it++),先++保存下一个再删当前。千万别在循环里直接++it然后erase,那样会崩溃。”
题目13: 解释C++11的移动语义(Move Semantics)和右值引用(Rvalue Reference)
解答:
核心概念:
- 左值(Lvalue):可寻址,有持久身份(变量、返回左值引用的函数)
- 右值(Rvalue):临时对象,即将销毁(字面量、临时返回值、std::move的结果)
- 右值引用(T&&):绑定到右值,允许”窃取”资源而非拷贝
移动语义机制:
- 移动构造函数:
Class(Class&& other) noexcept,转移资源所有权(如指针直接赋值后设原指针为nullptr) - 移动赋值运算符:
Class& operator=(Class&& other) noexcept - std::move:将左值强制转为右值引用,启用移动而非拷贝(实际仍是左值,只是类型转换)
关键约束:
- 移动操作应标记
noexcept,否则STL容器在某些场景(如vector扩容)会优先选择拷贝而非移动(强异常安全保证) - 移动后源对象应处于有效但未定义状态(Valid but unspecified state),通常置空
完美转发(Perfect Forwarding):
结合万能引用(T&& + 类型推导)和std::forward,保持参数的值类别(左/右值性)转发给下层函数。
口头解答:
“移动语义是C++11解决大对象拷贝开销的关键特性。核心思想是:与其深拷贝临时对象,不如直接把它的资源’偷’过来。
具体实现靠右值引用(T&&)。右值就是那些临时的、马上要销毁的值,比如函数返回的临时对象。以前我们只能拷贝它们,现在可以定义移动构造函数,参数是Class&&,里面直接把原对象的指针搬过来,把原对象的指针设为空。这样只交换了几个指针,效率极高。
std::move的作用是把一个左值强制当成右值来处理,让它能走移动构造。注意move本身不移动任何东西,只是类型转换,真正的移动发生在构造函数里。
还有完美转发,用模板和std::forward保持参数的左右值属性完美传递给下一层函数,这在写泛型代码或者工厂函数时特别有用。
重要提示:移动构造函数最好标记noexcept(不抛异常)。因为vector扩容时,如果移动操作可能抛异常,为了异常安全,它宁愿用拷贝不用移动。另外,移动后的对象虽然有效,但值不确定了,别再用它,除非重新赋值。”
题目14: Lambda表达式的捕获列表有哪些方式?mutable关键字的作用是什么?
解答:
捕获方式([]内):
| 语法 | 含义 |
|---|---|
[] |
不捕获任何外部变量 |
[=] |
值捕获所有使用到的外部变量(拷贝) |
[&] |
引用捕获所有使用到的外部变量 |
[=, &var] |
默认值捕获,但var引用捕获 |
[&, var] |
默认引用捕获,但var值捕获 |
[this] |
捕获当前对象指针(类成员函数内) |
[*this] |
C++17起,值捕获整个对象(拷贝) |
[x, y] |
显式值捕获x和y |
生命周期注意:
- 值捕获:拷贝发生在lambda定义时,而非调用时
- 引用捕获:需确保lambda生命周期内原变量有效,常用于函数返回lambda时的悬垂引用风险
mutable关键字:
- 默认lambda的
operator()是const成员函数,无法修改值捕获的变量(即使拷贝) mutable允许修改值捕获的变量(修改的是拷贝,不影响外部原变量)- 不影响引用捕获(引用捕获的变量始终可修改原值)
泛型Lambda(C++14): 参数可用auto,实质是模板函数对象。
口头解答:
“Lambda的捕获方括号里有几种写法:空就是不捕获;=是值捕获所有用到的变量,相当于拷贝一份进来;&是引用捕获,相当于存了引用。也可以混合,比如[=, &x]表示其他的值捕获,但x引用捕获;或者显式[a, b]只捕获a和b。
注意生命周期:值捕获是在lambda定义的时候拷贝,不是调用的时候。引用捕获要千万小心,如果你在一个函数里返回一个lambda,而这个lambda引用捕获了局部变量,那外部调用时局部变量早没了,这就是悬垂引用,会崩溃。
mutable关键字干什么用呢?默认情况下lambda是const的,值捕获进来的变量不能修改。加mutable就可以修改这些拷贝进来的值,但记住改的是拷贝,外面的原变量不变。如果是引用捕获,外面本来就能改,不需要mutable。
C++17还有个[*this],值捕获整个对象,防止多线程里this指针失效的问题。还有C++14的泛型lambda,参数写auto,类似于模板,用起来很灵活。”
题目15: C++的四种类型转换(cast)分别是什么?使用场景有何不同?
解答:
C风格强制转换(type)的问题: 过于暴力,可在任意类型间转换,无编译期检查,难以定位。
C++四种cast:
static_cast(静态转换):- 用于相关类型的转换(如
int↔double,void*→具体类型*,基类指针↔派生类指针(向上安全,向下无检查)) - 编译期检查,无运行时开销
- 不能用于无关类型(如
int*→double*)
- 用于相关类型的转换(如
dynamic_cast(动态转换):- 仅用于多态类型(有虚函数的类层次)
- 运行时类型检查(RTTI),安全的向下转型(基类→派生类)
- 指针转换失败返回
nullptr,引用转换失败抛出std::bad_cast - 有运行时开销(虚表查询)
const_cast(常量转换):- 唯一用途:添加或移除
const/volatile限定 - 用于调用旧版API(需非常量参数但保证不修改)或去除const调用非const成员函数
- 风险:通过转换后的指针修改原本const对象会导致未定义行为(UB)
- 唯一用途:添加或移除
reinterpret_cast(重解释转换):- 最危险,进行底层的位模式重解释
- 用于不相关类型的指针转换(如
int*→char*)、指针与整数的转换 - 高度依赖实现,不可移植,通常用于底层编程(硬件寄存器访问)
口头解答:
“C++四种cast比C的(int*)p安全多了,各司其职。
static_cast是最常用的,用于编译器认为合理的类型转换,比如整数转浮点,或者基类指针转成派生类指针(向上转型)。但要注意,它不做运行时检查,向下转的时候如果对象实际不是那个派生类,结果未定义。
dynamic_cast是专门给多态类用的,会查虚表做运行时类型检查。基类指针转派生类指针,如果转错了返回nullptr,安全但有性能开销。引用转错了会抛异常。
const_cast专门用来加或者去掉const。比如你有个const对象,但必须要传给一个旧API(它参数不是const但保证不修改),就用const_cast去掉const。但绝对不要用它去修改原本就是const的变量,那是未定义行为,可能直接崩溃。
reinterpret_cast是最底层的,简单粗暴重新解释二进制位。比如你把int*转成char*来看内存布局,或者指针和整数互转。这种转换不可移植,不同平台结果可能不同,只在不得已的底层代码里用。
总结:能不用cast就不用,必须用的时候选最严格的那个,static_cast能满足就别用reinterpret_cast。”
题目16: 什么是菱形继承(Diamond Inheritance)?虚继承(Virtual Inheritance)如何解决二义性问题?
解答:
问题描述:
1 | A |
类D同时继承B和C,B和C都继承A,导致D中有两份A的成员,访问时产生二义性(D::a不明确)。
虚继承解决方案:
- 语法:
class B : virtual public A {}; - 机制:B和C共享一份A的实例(虚基类),由最终的派生类D负责构造A
- 内存布局:对象增加虚基类指针(vbptr),指向共享的虚基类实例
代价:
- 额外指针开销(vbptr)
- 访问虚基类成员需间接寻址,性能略降
- 构造函数复杂:虚基类由最派生类直接初始化,忽略中间类的初始化列表
替代方案: 优先使用组合(Composition)而非多重继承,或用接口类(纯虚类)避免数据成员重复。
口头解答:
“菱形继承是C++多重继承的经典问题。比如A是基类,B和C都继承A,然后D又同时继承B和C。这时候D里其实有两份A的成员,一份从B来,一份从C来。如果你写D d; d.x = 10;(假设x是A的成员),编译器会报错说二义性,不知道你要改B里的A还是C里的A。
虚继承就是解决这个的。在B和C继承A的时候加上virtual关键字,这样B和C就不会各存一份A了,而是共享同一份,放在对象布局的最后,由最终的D来负责构造A。
但这有代价:第一,对象里要多存一个vbptr(虚基类指针)来指向共享的A,内存多了;第二,访问A的成员要间接寻址,慢一点;第三,构造函数的规则变了——D的构造函数必须直接初始化A,B和C构造函数里对A的初始化会被忽略。
实际工程中,多重继承能避免就避免。如果只是为了多态,可以用纯虚基类(接口),里面没有数据成员,就不会有二义性问题;或者干脆用组合代替继承。”
题目17: 内联函数(inline)与宏定义(#define)的区别?现代C++中inline的实际作用是什么?
解答:
对比维度:
| 特性 | 内联函数 | 宏定义 |
|---|---|---|
| 处理阶段 | 编译器 | 预处理器(文本替换) |
| 类型检查 | 有完整的类型检查和转换 | 无,纯文本替换,易出错 |
| 调试 | 支持调试(可打断点) | 无法调试 |
| 作用域 | 遵循命名空间、类作用域 | 全局文本替换,可能污染 |
| 副作用 | 参数只求值一次 | 参数可能多次求值(如MAX(a++, b++)) |
| 递归 | 可递归(编译器酌情内联) | 不可递归 |
现代C++中inline的演进:
- C++98/03:建议编译器内联展开,避免函数调用开销(寄存器保存/恢复)
- C++17以后:
inline的主要作用是允许函数在多个翻译单元定义(ODR例外),避免链接重复定义错误。是否内联展开由编译器优化器决定(基于代码大小、调用频率等),与inline关键字无关
类内定义: 类体内定义的函数自动隐式inline。
强制内联: __attribute__((always_inline))(GCC/Clang)或__forceinline(MSVC)。
口头解答:
“内联函数和宏最大的区别在处理时机和类型安全。宏是预处理器干的纯文本替换,没有类型检查,写错了编译器报的错误根本看不懂;而且宏的参数可能多次求值,比如写个#define MAX(a,b) ((a)>(b)?(a):(b)),调用MAX(x++, y++)会让x或y加两次,出大bug。内联函数是真正的函数,有类型检查,参数只求值一次,能调试,还能访问类私有成员。
但现代C++里,inline关键字的作用已经变了。以前我们写inline是为了让编译器把函数代码直接展开到调用处,省去函数调用开销。但现在编译器优化非常智能,它自己会决定是否内联,跟写不写inline没关系了。
现在inline真正的意义是解决重复定义问题。正常情况下函数不能在多个cpp文件里定义,但inline函数可以,链接器会合并它们。所以在头文件里定义函数,必须加inline,否则多个cpp包含会链接错误。类里直接写的函数默认就是inline的。
如果真想强制内联,GCC用__attribute__((always_inline)),MSVC用__forceinline,但一般不建议,相信编译器的优化更明智。”
题目18: C++11提供了哪些线程同步机制?std::lock_guard与std::unique_lock的区别?
解答:
线程支持库(<thread>、<mutex>、<condition_variable>):
互斥锁(Mutex):
std::mutex:基础互斥,不可递归std::recursive_mutex:允许同一线程多次加锁std::timed_mutex:支持超时尝试加锁(try_lock_for/until)std::shared_mutex(C++14):读写锁,支持共享读/独占写
锁管理(RAII包装):
std::lock_guard:最简单的RAII锁,构造时加锁,析构时解锁,不可拷贝不可移动,不支持中途解锁std::unique_lock:更灵活的锁包装,支持:- 延迟加锁(
defer_lock) - 超时加锁(
try_lock_for) - 随时
unlock()和重新lock() - 与
condition_variable配合使用(必须) - 可移动(不可拷贝)
- 延迟加锁(
条件变量(std::condition_variable):
- 配合
unique_lock实现线程等待/通知机制,避免忙等待
原子操作(<atomic>):
std::atomic<T>:无锁线程安全操作,适用于简单数据类型(int、指针等),性能优于mutex
同步工具:
std::future/std::promise:异步结果传输std::async:启动异步任务std::barrier(C++20)、std::latch(C++20):线程同步点
口头解答:
“C++11标准库终于提供了跨平台的线程支持。同步机制主要有几类:
首先是互斥锁,std::mutex最常用,也有recursive_mutex(允许同一线程重复加锁,防止死锁)和shared_mutex(读写锁,读共享写独占)。
但不要直接用裸mutex,要用RAII包装。std::lock_guard是最轻量的,构造时lock,析构时unlock,出了作用域自动释放,防死锁。但它很死板,构造时必须拿到锁,且不能中途解锁。
std::unique_lock更灵活,它可以延迟加锁(先构造不锁,后面再lock),支持超时try_lock,还能随时unlock。最重要的是,条件变量condition_variable必须用unique_lock,因为它在等待时要自动解锁和重新加锁。
还有原子操作std::atomic,对于简单的计数器、标志位,用atomic比mutex快得多,因为它底层是CPU的原子指令,不需要操作系统介入。
C++20还加了barrier和latch这些更高级的同步工具。总的来说,优先用atomic,其次用lock_guard,需要灵活控制或者配合条件变量时用unique_lock。”
题目19: sizeof和strlen的区别?sizeof在数组和指针上的行为差异?
解答:
核心区别:
| 特性 | sizeof |
strlen |
|---|---|---|
| 本质 | 运算符(compile-time,C++11起极少数情况runtime) | 库函数(<cstring>,runtime) |
| 计算对象 | 类型或对象占用的总字节数(包括padding) | C风格字符串的有效长度(不含’\0’) |
| 数组 | 返回整个数组大小(元素个数×单个大小) | 遇到’\0’停止,可能与数组大小不同 |
| 指针 | 返回指针本身大小(32位4字节,64位8字节) | 从指针地址开始遍历找’\0’ |
数组退化为指针:
- 数组名作为函数参数传递时退化为指针,
sizeof返回指针大小而非数组大小 - 在定义数组的作用域内,
sizeof(array)/sizeof(array[0])可得元素个数
结构体大小(内存对齐):sizeof结果可能大于成员大小之和,因编译器插入padding以满足对齐要求(如4字节或8字节对齐)。
C++11 sizeof...: 用于可变参数模板,计算模板参数包中参数个数。
口头解答:
“sizeof是运算符,strlen是函数,这是最本质的区别。sizeof在编译期就确定了,计算的是类型或变量占用的内存总字节数;strlen是运行时去遍历字符数组,数到’\0’为止的长度,不算结尾符。
对于数组要特别注意:如果你在定义数组的代码块里,sizeof(数组名)得到的是整个数组的字节数,除以单个元素大小就能得到长度,这很常见。但如果数组作为函数参数传进去了,比如void func(char arr[]),这时候arr实际上已经退化成指针了,sizeof(arr)返回的是指针大小(64位系统就是8),而不是数组长度,这是个经典陷阱。
结构体用sizeof也要注意内存对齐。编译器会在成员之间插入padding,让成员按自然边界对齐,所以sizeof结果可能比你想的大。比如struct里有char和int,通常char后面会补3个字节,让int对齐到4字节边界。
C++11还扩展了sizeof的用法,sizeof...可以算模板参数包里有几个参数,写泛型代码时很有用。”
题目20: 构造和析构函数的调用顺序?涉及继承和成员对象时呢?
解答:
构造顺序(先根后叶,先成员后自己):
- 基类构造:按继承列表声明顺序(非初始化列表书写顺序),从最远基类开始
- 成员构造:按类中声明顺序(非初始化列表顺序)
- 构造函数体:执行
{}内代码
析构顺序(与构造严格相反):
- 析构函数体执行
- 成员析构:按声明逆序
- 基类析构:按继承逆序
多继承: 基类按声明顺序依次构造,析构相反。若存在虚继承,虚基类最先构造,最后析构(由最终派生类直接构造)。
虚析构: 确保通过基类指针delete派生类对象时,析构顺序仍正确(先派生后基类)。
构造/析构中的虚函数: 构造和析构期间,对象的动态类型被认为是当前正在构造/析构的类层次,虚函数调用不会下降到派生类(派生类部分尚未构造或已销毁)。
口头解答:
“构造和析构的顺序是严格对称的,记住先构造的后析构就行。
具体规则是:构造时,先基类,后成员,最后自己。如果有多层继承,从爷爷到爸爸再到儿子;如果有多个基类,按继承时写的顺序来,与初始化列表里的顺序无关。成员变量也是按在类里声明的顺序构造,不是按初始化列表里的顺序。这点特别容易错,比如初始化列表写a(b), b(1),如果声明顺序是先b后a,那实际先构造b再构造a,代码逻辑就可能出问题。
虚基类特殊:虚继承的基类(比如菱形继承里的最顶层)会由最终的派生类直接构造,而且是最先构造、最后析构的。
析构就是完全倒过来:先执行自己的析构函数体,然后按声明逆序析构成员,最后逆序析构基类。
重要陷阱:在构造函数和析构函数里调用虚函数,不会调到派生类的版本。因为构造基类时,派生类那部分还没初始化,如果调到派生类虚函数,访问派生类成员就会崩溃。所以构造析构期间,虚函数就是当前类自己的版本,不会往下派发的。”







