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

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

本文档涵盖C++面试中最常见的基础知识、面向对象、STL、内存管理及现代C++特性,适合面试前系统复习。


题目1: 简述C++中newmalloc的区别

解答:

特性 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: 指针和引用的区别是什么?什么情况下该用引用?

解答:

  1. 本质差异:

    • 指针:独立的对象,存储内存地址,可以为空,可被重新赋值指向其他对象
    • 引用:对象的别名,必须初始化且绑定后不可更改,不存在空引用
  2. 语法层面:

    • 指针需要解引用(*ptr),引用直接访问
    • sizeof(指针)返回地址大小,sizeof(引用)返回原对象大小
    • 指针支持指针运算,引用不支持
  3. 使用场景:

    • 引用优先:函数传参(避免拷贝)、操作符重载(如operator[])、范围for循环
    • 指针必须:可能为空的情况、需要动态内存管理、多态(基类指针指向派生类对象)、数据结构(链表、树节点)

口头解答:

“指针和引用最大的区别可以总结为三点:引用不能为空、不能改绑、且不是独立对象

指针是一个变量,存着地址,你可以让它指向别的地方,也可以设成nullptr;但引用一旦初始化绑定了一个对象,就永远是那个对象的别名,不存在’空引用’这回事。

从使用场景看,现在的C++编程有个原则叫**’优先使用引用’**。比如函数传参,如果只是读取或者修改对象而不需要管理生命周期,传引用比传指针更安全,不用担心空指针解引用。但是,如果你需要表达’这个对象可能存在也可能不存在’,或者需要做动态内存管理,比如链表节点的next指针,那就必须用指针了。另外,多态的时候基类指针指向派生类对象,这也是指针更灵活的地方。”


题目3: const关键字在C++中有哪些用法?请区分常量指针和指针常量

解答:

const的五种常见用法:

  1. 常量变量const int a = 10; 不可修改
  2. 常量指针(Pointer to const):const int* p;int const* p;
    • 指向的内容不可变,但指针本身可变
  3. 指针常量(Const pointer):int* const p = &a;
    • 指针本身不可变(不能指向别处),但指向的内容可变
  4. 常量引用const int& ref = a; 常用于函数参数避免拷贝且承诺不修改
  5. 常量成员函数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++中的三种主要用法及作用

解答:

  1. 静态局部变量(函数内部):

    • 生命周期延长至程序结束,只在第一次进入函数时初始化,后续调用保持值
    • 存储在数据段(.data或.bss),而非栈上
  2. 静态全局变量/函数(文件作用域):

    • 限定链接性为内部链接(internal linkage),仅在当前翻译单元(.cpp文件)可见
    • 避免命名冲突,替代C语言中的匿名命名空间(但现代C++更推荐匿名命名空间)
  3. 类静态成员

    • 静态成员变量:属于类而非对象,所有对象共享一份,必须在类外定义(除非inline static C++17)
    • 静态成员函数:无this指针,只能访问静态成员,可直接通过类名调用

口头解答:

static在不同地方用效果完全不一样,我分三个层面来说:

首先,函数内部的static局部变量,这是用来实现单例或者计数器的。它和普通局部变量的区别是,它不会在函数结束时销毁,下次调用还能保持上次的值,而且只初始化一次。注意这是线程安全的(C++11以后),所以经常用来写线程安全的单例模式。

其次,文件作用域的static,就是加在全局变量或者函数前面,意思是’这个符号只在当前文件可见’,别的cpp文件即使extern也找不到它。这是为了防命名冲突。不过现代C++更推荐用匿名命名空间来做这个事。

最后,类里面的static,这是最重要的。静态成员变量是类的所有对象共享的,比如你想统计创建了多少个对象,就可以放个static计数器。它必须在类外定义,除非C++17用inline static静态成员函数没有this指针,不能访问非静态成员,经常用来当工厂方法或者工具函数。”


题目5: 虚函数实现多态的原理是什么?虚函数表(vtable)是如何工作的?

解答:

核心机制:

  1. 虚函数表(vtable): 编译器为每个含有虚函数的类生成一张静态函数指针表,按声明顺序存储虚函数地址
  2. 虚表指针(vptr): 每个对象实例在构造函数中隐含添加一个指针(通常为对象首地址),指向所属类的vtable
  3. 动态绑定: 通过基类指针/引用调用虚函数时,运行时通过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):

  1. 类显式声明了析构函数(通常意味着释放了资源)
  2. 类显式声明了拷贝构造函数或拷贝赋值运算符
  3. 类包含原始指针或文件句柄等需要所有权管理的资源

现代建议: 优先使用智能指针(unique_ptrshared_ptr)或容器(vectorstring),它们自动处理深拷贝,避免手动管理。

口头解答:

“浅拷贝就是简单的值复制,比如一个类里有指针,浅拷贝会把指针地址直接复制过去,这样两个对象就指向同一块内存了,析构的时候会重复释放,导致崩溃。深拷贝则是重新申请一块内存,把原指针指向的内容也复制过来,两个对象完全独立。

什么时候必须自己写拷贝构造函数? 当你类里有原始指针,或者管理着文件、网络连接这些资源的时候。这时候默认的浅拷贝肯定不行,你得自己写深拷贝逻辑,申请新内存复制内容。

不过现代C++有个更好的实践叫Rule of Zero:尽量不要在类里直接管理原始指针,用std::string代替char*,用vector代替动态数组,用智能指针代替裸指针。这些标准库类都做好了深拷贝,你就不用自己写拷贝构造了,编译器生成的默认版本就能工作,既安全又省事。”


题目8: C++11引入的智能指针有哪些?各自的使用场景和注意事项是什么?

解答:

三种智能指针(定义在<memory>):

  1. std::unique_ptr(独占所有权):

    • 语义:唯一拥有所指对象,不可复制,只可移动(move)
    • 用途:工厂函数返回动态对象、管理独占资源(文件、锁)
    • 开销:零开销抽象(与裸指针大小相同,无额外开销)
  2. std::shared_ptr(共享所有权):

    • 语义:引用计数,多个shared_ptr可指向同一对象,最后一个销毁时释放
    • 控制块:存储引用计数和自定义删除器,分配在堆上
    • 线程安全:引用计数原子操作线程安全,但对象本身非线程安全
    • 风险:循环引用导致内存泄漏(需配合weak_ptr)
  3. 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_ptrshared_ptr

自定义RAII类模板:

1
2
3
4
5
6
7
8
9
template<typename Resource, typename Deleter>
class RaiiHolder {
Resource res;
Deleter del;
public:
RaiiHolder(Resource r, Deleter d) : res(r), del(d) {}
~RaiiHolder() { if(res) del(res); }
// 禁止拷贝,允许移动...
};

口头解答:

“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)随机访问。

扩容机制:

  1. push_back/emplace_back发现finish == end_of_storage时触发
  2. 申请新内存(通常为1.5倍或2倍当前容量,GCC是2倍,MSVC是1.5倍)
  3. 移动或拷贝元素到新内存(C++11起优先使用移动语义)
  4. 释放旧内存
  5. 更新三个指针

复杂度: 均摊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::mapstd::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::vectorstd::map的迭代器失效?

解答:

std::vector迭代器失效场景:

  1. 扩容操作push_backemplace_backinsert导致容量变化时,所有迭代器、指针、引用失效
  2. 缩小容量shrink_to_fit(可能重新分配)
  3. reserve:若新容量大于当前,失效
  4. swap:迭代器仍指向原内存(已不属于容器)
  5. 安全操作pop_backclearerase(仅被删除元素及之后的迭代器失效,之前有效)

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&&):绑定到右值,允许”窃取”资源而非拷贝

移动语义机制:

  1. 移动构造函数Class(Class&& other) noexcept,转移资源所有权(如指针直接赋值后设原指针为nullptr)
  2. 移动赋值运算符Class& operator=(Class&& other) noexcept
  3. 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:

  1. static_cast(静态转换):

    • 用于相关类型的转换(如intdoublevoid*→具体类型*,基类指针↔派生类指针(向上安全,向下无检查))
    • 编译期检查,无运行时开销
    • 不能用于无关类型(如int*double*
  2. dynamic_cast(动态转换):

    • 仅用于多态类型(有虚函数的类层次)
    • 运行时类型检查(RTTI),安全的向下转型(基类→派生类)
    • 指针转换失败返回nullptr,引用转换失败抛出std::bad_cast
    • 有运行时开销(虚表查询)
  3. const_cast(常量转换):

    • 唯一用途:添加或移除const/volatile限定
    • 用于调用旧版API(需非常量参数但保证不修改)或去除const调用非const成员函数
    • 风险:通过转换后的指针修改原本const对象会导致未定义行为(UB)
  4. 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
2
3
4
5
  A
/ \
B C
\ /
D

类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_guardstd::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还加了barrierlatch这些更高级的同步工具。总的来说,优先用atomic,其次用lock_guard,需要灵活控制或者配合条件变量时用unique_lock。”


题目19: sizeofstrlen的区别?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: 构造和析构函数的调用顺序?涉及继承和成员对象时呢?

解答:

构造顺序(先根后叶,先成员后自己):

  1. 基类构造:按继承列表声明顺序(非初始化列表书写顺序),从最远基类开始
  2. 成员构造:按类中声明顺序(非初始化列表顺序)
  3. 构造函数体:执行 {} 内代码

析构顺序(与构造严格相反):

  1. 析构函数体执行
  2. 成员析构:按声明逆序
  3. 基类析构:按继承逆序

多继承: 基类按声明顺序依次构造,析构相反。若存在虚继承,虚基类最先构造,最后析构(由最终派生类直接构造)。

虚析构: 确保通过基类指针delete派生类对象时,析构顺序仍正确(先派生后基类)。

构造/析构中的虚函数: 构造和析构期间,对象的动态类型被认为是当前正在构造/析构的类层次,虚函数调用不会下降到派生类(派生类部分尚未构造或已销毁)。

口头解答:

“构造和析构的顺序是严格对称的,记住先构造的后析构就行。

具体规则是:构造时,先基类,后成员,最后自己。如果有多层继承,从爷爷到爸爸再到儿子;如果有多个基类,按继承时写的顺序来,与初始化列表里的顺序无关。成员变量也是按在类里声明的顺序构造,不是按初始化列表里的顺序。这点特别容易错,比如初始化列表写a(b), b(1),如果声明顺序是先b后a,那实际先构造b再构造a,代码逻辑就可能出问题。

虚基类特殊:虚继承的基类(比如菱形继承里的最顶层)会由最终的派生类直接构造,而且是最先构造、最后析构的。

析构就是完全倒过来:先执行自己的析构函数体,然后按声明逆序析构成员,最后逆序析构基类。

重要陷阱:在构造函数和析构函数里调用虚函数,不会调到派生类的版本。因为构造基类时,派生类那部分还没初始化,如果调到派生类虚函数,访问派生类成员就会崩溃。所以构造析构期间,虚函数就是当前类自己的版本,不会往下派发的。”