CVTE — C++面经
1、4 种智能指针有哪些分类?分别有什么作用?
可以看我之前专门总结的:智能指针全解析
std::unique_ptr<T>
- 语义:独占所有权(single ownership)。不可拷贝,可移动。
- 用途:表达唯一资源所有权、用于 RAII、替代裸指针用于对象生命周期管理。轻量、无引用计数开销。
- 示例:
auto p = std::make_unique<Foo>(args);
std::unique_ptr<Foo> q = std::move(p); // 转移所有权std::shared_ptr<T>
- 语义:共享所有权(引用计数 control block)。最后一个
shared_ptr销毁时释放资源。 - 用途:多个持有者场景(观察者、缓存、任务系统),需要注意循环引用(用
weak_ptr打破)。 - 示例:
auto sp = std::make_shared<Foo>(args);
auto sp2 = sp; // 引用计数 +1std::weak_ptr<T>
- 语义:观察者指针,不增加引用计数,不拥有资源。通过
lock()获取shared_ptr(如果资源还活着)。 - 用途:打破
shared_ptr循环引用、缓存中的弱引用、临时访问。 - 示例:
std::weak_ptr<Foo> wp = sp;
if (auto s = wp.lock()) { /* safe to use s */ }std::auto_ptr<T>(已弃用)
- 语义:C++98 的智能指针,拷贝是转移语义(有问题),已在 C++11 标准被废弃并由
unique_ptr取代。不要在新代码中使用。
补充
boost::intrusive_ptr:计数存在对象内部,适合某些嵌入式/低内存场景。- 设计原则:能用
unique_ptr就用unique_ptr,只有确实需要共享所有权时才用shared_ptr。
2、Lambda 的作用、C++11→C++20 演进、捕获类型、this 捕获风险
Lambda 的作用(为什么用)
- 本地函数对象:像内联的匿名函数,适合回调、算法短闭包、局部策略等。
- 捕获外部状态:相比普通函数,lambda 能捕获外部变量(值或引用),更方便编写闭包逻辑。
- 类型轻量:编译期生成闭包类型(函数对象),可内联优化,无堆分配(除非你显式
new)。
C++11 / C++14 / C++17 / C++20 的主要改进
- C++11:引入 lambda。支持捕获列表(
[=],[&],[a, &b])、mutable、显式返回类型-> R、能捕获this(捕获this是捕获指针)。(参考:cppreference 总览)(CPP参考) - C++14:两个重要增强:
- 泛型 lambda:参数可以用
auto,使 lambda 成为模板。 - 初始化捕获:例如
[x = std::move(vec)],可以在捕获时做 move 或计算表达式,从而实现“move capture”。(这点便于把不可拷贝资源移入闭包中)。
- 泛型 lambda:参数可以用
- C++17:若干细节改进(比如允许用
[=, *this]捕获对象副本 —— 把*this的值拷贝到闭包里),并增强了constexpr支持(能把一些 lambda 标记为constexpr,在编译期求值更友好)。(可把*this按值捕获以避免仅捕指针) - C++20:模板参数列表的 lambda(即显式模板参数列表
[]<typename T>(T x){...},增强泛型 lambda 的可读性和能力),并对捕获/默认构造/拷贝行为等做了若干修正以提升一致性。
总结:从 C++11 的基础闭包到 C++14 的泛型/初始化捕获,再到 C++20 的模板化语法,lambda 越来越灵活且可以安全地移动/持有资源。
捕获的类型(几种常见形式)
- 捕值(by value):
[x]或[=]把外部值拷贝到闭包成员中。拷贝语义由被捕变量类型决定(若是可移动可用move初始化)。 - 捕引用(by reference):
[&x]或[&]捕外部变量的引用(闭包保存引用)。风险是闭包比被捕对象存活时间长会悬垂引用。 - 捕
this(捕指针):[this]捕this指针(注意:保存的是指针,不是完整对象)。C++17 可写为[*this]把对象拷贝(按值)到闭包中。 - 初始化捕获(C++14):
[m = std::move(x)]。可用于将独占资源移动进闭包。(流利的C++)
捕获 this 的风险
- 捕获
this(或[=]默认捕值含this)只是把this指针保存进闭包。若闭包在对象销毁后调用,会出现悬挂指针(UB)。 - 安全做法:
- 如果闭包执行期可能超出对象生存期,不要捕
this;改捕weak_ptr(对象由shared_ptr管理)或用[*this](C++17,把对象拷贝到闭包中)或使用enable_shared_from_this+shared_ptr(见下一题)。 - 使用
init-capture以std::move把资源搬入闭包,从而避免引用到外部临时/局部变量。
- 如果闭包执行期可能超出对象生存期,不要捕
3、enable_shared_from_this() 与 shared_from_this():作用、原理、使用场景
问题出发点
当一个对象需要在其成员函数内部生成一个指向自身的 shared_ptr(比如把 this 交给异步任务,使其被延长生存期),如果你直接 std::shared_ptr<T>(this),会产生两个独立的 control block(双重管理),最终会发生双重 delete。为避免这个问题,用 enable_shared_from_this。
enable_shared_from_this<T> 做了什么(原理)
enable_shared_from_this在对象中保存一个弱引用weak_ptr<T>(隐含成员),当对象第一次由shared_ptr<T>管理时(例如通过std::make_shared<T>),shared_ptr的实现会把自己的 control block 的弱引用指向这个对象填入enable_shared_from_this的内部弱指针(实现细节由库实现)。- 之后在对象内部可以调用
shared_from_this(),该方法会返回一个指向相同 control block 的shared_ptr(引用计数正确增长),不会产生新的 control block。
使用示例
struct My : std::enable_shared_from_this<My> {
std::shared_ptr<My> getPtr() {
return shared_from_this(); // safe: returns shared_ptr that shares ownership
}
};
int main() {
auto p = std::make_shared<My>();
auto p2 = p->getPtr(); // p and p2 share same control block
}注意事项 / 陷阱
- 必须把对象最初用
shared_ptr管理(例如make_shared或shared_ptr<T>(new T))。若对象直接在栈上创建或用unique_ptr管理再调用shared_from_this()会出错(通常抛std::bad_weak_ptr或 UB)。 - 如果想在构造函数里获取
shared_ptr(把shared_from_this()放在构造里),通常也会失败,因为 control block 还没建立:不要在构造过程中调用shared_from_this(),除非你知道 object 已被 shared_ptr 管理(工厂函数模式可解决)。 - 场景:回调注册、异步操作发出
shared_ptr给队列/线程池以延长对象生命周期、缓存需要弱指针检查时配合weak_ptr。
4、常用的 STL 容器(概览)
已全面总结的STL文章:STL全解析
- 序列容器(sequence)
std::vector<T>:动态数组,随机访问 O(1)。首选容器(缓存友好)。std::deque<T>:双端队列,支持两端高效插入。std::list<T>:双向链表,节点稳定地址、插入/删除 O(1),但不缓存友好,迭代成本高,近年来用得少。
- 关联容器(有序)
std::map<Key,T>/std::set<Key>:基于自平衡树(通常红黑树),有序、支持 range query、O(log n)。适用于需要有序遍历或前驱/后继操作。
- 无序关联容器(哈希)
std::unordered_map/std::unordered_set:哈希表,平均 O(1) 查找/插入/删除,适合快速精确匹配。注意 rehash/迭代器失效语义。
- 容器适配器
std::stack,std::queue,std::priority_queue:在已有容器上封装后端行为。
- 字符串
std::string:按需管理字符数组(像vector<char>),注意短字符串优化和可变/不可变语义(C++17 起std::string的小调整)。
- 其他
std::array<T, N>:固定长度数组,栈分配,轻量。std::span<T>(C++20):视图,不拥有元素,适合函数参数传递。
5、vector 的扩容机制是怎么样的?移动还是复制?深拷贝还是浅拷贝?
扩容策略(总体)
std::vector用连续内存。当size() == capacity()时,再插入会触发 重新分配(reallocate):分配更大的内存块,把现有元素搬到新内存,然后释放旧内存。- 增长因子是实现定义的,但主流实现(libstdc++ / MSVC)通常采用几何增长(常见是 约为倍增),以保证
push_back的摊销复杂度为常数(amortized O(1))。你不能依赖精确倍数,应该通过reserve()预留以避免扩容。(CPP参考)
移动还是复制?(何时用 move,何时用 copy)
- 在 C++11 之后,当
vector重新分配时,库会尝试 移动构造已有元素到新内存以减少开销,但 前提是移动构造不会抛异常(或满足某些条件)。为了保证强异常安全,标准库在搬迁时通常使用std::move_if_noexcept的策略:- 如果
T的移动构造是noexcept(或T不可拷贝),则使用移动构造。 - 否则可能使用拷贝构造以维持异常安全(对某些实现还有细节,但总体思想是“只在安全的情况下使用移动”)。(CPP参考)
- 如果
拷贝是深拷贝还是浅拷贝?
- 这取决于类型
T的拷贝/移动构造函数:对内置类型或 POD(plain-old-data)是按位复制;对用户类型,copy/move 构造由类型实现决定(可能是深拷贝、引用计数的浅复制、或其他语义)。vector只会调用T的构造/移动/拷贝函数,那么语义由T本身定义。
工程建议
- 对能支持
noexcept移动的类型,标记移动构造为noexcept,以帮助容器在扩容时使用 move(提高性能)。 - 对元素生命周期敏感或拷贝代价大的场景,调用
reserve(n)预分配以避免多次 reallocation。
6、在循环里面用迭代器遍历 map 时,什么时候会导致迭代器失效?
std::map / std::set(基于树)
- 插入(insert):不会失效已有迭代器。
- 删除(erase):只失效被删除的那些迭代器,而其他迭代器保持有效。
- 因此在
for (auto it = m.begin(); it != m.end(); ++it)中删除当前元素时要小心:先记录下it_next = std::next(it),再m.erase(it), 然后it = it_next;或者使用it = m.erase(it)(从 C++11 起某些 overload 返回下一个 iterator)。查看标准实现细节便可确认erase重载返回值。(CPP参考)
std::unordered_map
- 插入:通常不会失效已有迭代器,但当触发 rehash(例如负载因子过高)时,会重新分配桶并使所有迭代器失效(因为底层数组变了)。
- erase:会失效被删除的迭代器,但其他迭代器通常保持有效(详见标准和实现)。若你在遍历过程中进行可能触发 rehash 的插入,不能假设迭代器有效。(CPP参考)
小结(实践准则)
- 如果在迭代过程中做
erase,使用安全模式:it = container.erase(it);(若该重载返回 iterator),或auto next = std::next(it); container.erase(it); it = next;。 - 避免在遍历时做会触发 rehash/resize 的插入;若必须,考虑先收集要插入/删除的 key,遍历结束后统一执行变更。
7、class 和 struct 有什么区别?
在 C++ 中 语法上的区别非常小:
- 默认访问权限:
struct:成员和基类默认 public。class:成员和基类默认 private。
- 其他:没有其它语义差别(都支持继承、虚函数、成员、模版等)。
- 习惯上:
struct常用于聚合类型 / POD / 数据包,class常用于封装良好、有行为(方法)的对象。
- 习惯上:
示例:
struct S { int x; }; // x 默认 public
class C { int x; }; // x 默认 private8、虚函数的实现机制?vtable 与 vptr 的内存布局在哪里存放?
基本实现思路(常见编译器实现)
- 虚函数表(vtable):对每个含虚函数的类,编译器生成一个静态的“虚表”——一个函数指针数组(指向该类或覆盖函数的实现)。这个表通常被放在只读数据段(程序静态区)。
- 虚表指针(vptr):每个对象通常含有一个指向其类 vtable 的隐藏指针(
vptr),常放在对象的内存开始处(编译器实现细节)。当创建对象时,构造函数会把vptr指向对应类的 vtable。 - 调用过程:通过基类指针调用虚函数时,编译器生成代码读取对象的
vptr,然后从 vtable 中取出对应 slot 的函数指针并跳转调用(动态绑定)。
多重继承 / 虚继承 情况
多重继承或虚继承时,编译器可能需要 多个 vptr 或在 vtable 中保存额外偏移信息来修正 this 指针。实现更复杂,但基本思想不变:动态查表并调用,对象内可能有多个指针或隐形偏移。
存放位置
- vtable:编译时生成,放在程序的静态/只读数据段(不在每个对象里重复存放),可被多个对象共享。
- vptr:每个对象的一块内存(对象头部或实现选择的位置),占用对象内存(通常 8 字节/指针大小)。
小结
虚函数的动态分派靠 vptr→vtable 的查表机制实现;这是运行时多态的常见实现方式。实现细节(位置、是否有多 vptr)取决于编译器与继承模型。
9、构造函数里可以使用 this 指针吗?
可以使用 this,但要小心。
主要注意两点:
- 对象尚未完全构造:在基类构造期间,虚函数的动态绑定仍然指向基类(派生类的虚表尚未安装);因此,在构造函数内部调用虚函数会调用当前类(正在构造的类)的版本,而不是派生类覆盖版本(这是语言定义,不是 BUG)。
- 成员的初始化顺序:使用
this访问成员前必须确保该成员已初始化(成员按声明顺序初始化)。在初始化列表里可以引用前面已初始化的成员,但在成员尚未初始化使用会产生未定义行为。
示例问题点:
struct Base {
Base() { foo(); } // 在 Base 构造阶段,虚调用会解析到 Base::foo
virtual void foo() { /* base */ }
};
struct Derived : Base {
Derived() : Base() {}
void foo() override { /* derived */ } // 不会被 Base() 内部的 foo() 调用
};工程建议:
不要在构造/析构过程中依赖虚函数多态行为。若需要构造期间调用可覆盖行为,采用工厂函数(对象构造后再调用 init 方法)或把可变部分外置。
10、进程和线程的区别?进程的通信方式有哪些?
进程(process) vs 线程(thread)
- 进程:拥有独立的地址空间(虚拟地址空间)、文件描述符表(可以继承)、资源表等。进程间隔离较好,崩溃影响小。
- 线程:属于同一进程、共享进程地址空间、文件描述符等资源;每线程有自己栈和寄存器上下文。线程切换成本比进程低(通常),通信更容易(共享内存),但需要同步以防竞态。
进程间通信(IPC)方式(Linux/Unix 常用)
- 匿名/命名管道(pipe / FIFO):半双工/全双工(pipe),适合父子或本地进程间通信。
- Unix Domain Socket(本地域套接字):比 TCP 快、能传递文件描述符。
- TCP/UDP Socket:进程可通过网络协议通信(本机也可通过 loopback)。
- 共享内存:最快的 IPC(零拷贝),但需要同步机制(信号量、futex 等)。
- 消息队列:内核维护的消息队列,进程发送接收消息。
- 信号:异步通知(但信息量小),适用于简单通知/中断。
- RPC / gRPC / DBus 等高级机制:用于分布式或复杂进程交互。
11、线程间的同步和互斥怎么解决?
基本原语(C++ 标准库)
std::mutex+std::lock_guard/std::unique_lock:互斥访问临界区。用 RAII 防止死锁。std::recursive_mutex:允许同一线程多次锁(谨慎使用)。std::shared_mutex(C++17):读写锁,shared_lock多读、独占写。std::condition_variable:线程间等待/通知(生产者-消费者)。std::atomic<T>:原子操作、无锁计数(对简单标量非常高效),支持fetch_add,compare_exchange_weak/strong等。
高级/系统级
- 信号量(POSIX sem_t / C++20
std::counting_semaphore)。 - futex(Linux 用户态快速等待机制,用于实现高性能锁)。
- lock-free 数据结构(基于 CAS / 原子操作),并发库(Folks:concurrent hash maps)。
实战建议
- 优先使用标准库(
std::mutex、std::atomic)和 RAII(std::lock_guard)。 - 对短临界区使用自旋锁或 CAS;对长时间阻塞使用阻塞式互斥。
- 尽量减少锁粒度,避免持有锁做 IO/阻塞操作。
- 对复杂并发使用现成成熟的数据结构或并发库,避免自己实现复杂无锁算法(容易出错:ABA 等问题)。
12、死锁(Deadlock):产生条件、预防与解决
四个必要条件(Coffman 条件)
- 互斥(Mutual Exclusion):资源一次只被一个线程占用。
- 持有并等待(Hold and Wait):线程持有资源并等待其它资源。
- 不可剥夺(No Preemption):资源不能被强行剥夺,必须由占有者释放。
- 循环等待(Circular Wait):存在一个资源等待的环。
预防 / 避免策略
- 资源有序分配:按固定顺序获取锁(全局顺序),破坏循环等待。
- 尝试获取(try_lock)+ 回溯:若无法获得,释放已持有锁并重试。
- 避免持锁做阻塞操作:减少持锁时间。
- 使用锁层次与超时:例如
std::timed_mutex,超时后回退。 - 死锁检测:收集锁等待图,后台检测环路并采取回滚策略(复杂,通常用于数据库等系统)。
- 使用事务/乐观并发:通过版本、回滚避免锁的使用。
例子(两把锁导致死锁)
std::mutex m1, m2;
void t1() {
std::lock_guard<std::mutex> l1(m1);
std::this_thread::sleep_for(...);
std::lock_guard<std::mutex> l2(m2); // 可能等待
}
void t2() {
std::lock_guard<std::mutex> l2(m2);
std::this_thread::sleep_for(...);
std::lock_guard<std::mutex> l1(m1); // 可能等待 -> deadlock
}解决:始终以相同顺序获取锁,或使用 std::scoped_lock(m1, m2)(C++17,避免死锁)。
13、new 和 malloc 的区别
| 特性 | new / delete | malloc / free |
|---|---|---|
| 调用构造/析构 | new 会调用构造函数,delete 调用析构函数 | 不调用构造或析构(裸内存) |
| 返回类型 | 类型化指针(T*) | 返回 void*,需强转 |
| 失败行为 | 抛 std::bad_alloc(可用 nothrow 版本返回 nullptr) | 返回 NULL |
| 重载 | operator new/delete 可被重载 | 通常不重载(实现层) |
| 对象数组 | new[]/delete[] 具有元素析构语义 | malloc 分配数组,需手工构造/析构 |
placement new:new (buf) T(args...) 在已有内存位置构造对象,须显式调用析构 obj->~T()。
14、只有 2GB 物理内存,new 一个 4GB 对象,能实现吗?为什么?
区别:虚拟内存 vs 物理内存
在现代操作系统(有虚拟内存)下,分配(allocation)和实际物理页面分配(物理内存或交换)是不同的事:
new(或malloc)通常向操作系统请求虚拟地址空间(通过mmap/brk),操作系统可以采用 overcommit(允许分配超出物理内存的虚拟内存),即直到程序真正访问这些页面时(触发缺页),系统才为页面提供物理内存或 swap。- 因此 仅凭物理内存小于请求大小并不一定会导致申请失败;是否成功依赖于 OS 的 overcommit 策略(Linux
/proc/sys/vm/overcommit_memory)、可用 swap、以及当前内存压力。
结论:
- 如果系统允许 overcommit,
new可能返回一个指针(当你实际写入这些页面时,内核再分配物理页或用 swap);在内存不足或 overcommit 被禁用且无法分配对应物理页时,分配会失败(抛std::bad_alloc)或在触发 OOM 时被内核终结。 - 实际可用性取决于操作系统策略与运行时内存压力,所以不能保证在只有 2GB 物理内存的机器上安全
new出 4GB 并能成功使用。
15、传输层有哪些协议?
常见传输层协议(OSI 模型中的 Transport Layer):
- TCP:面向连接、可靠、字节流、顺序传输、拥塞控制与重传机制。
- UDP:无连接、不可靠、无顺序保证、低开销,适合实时音视频/简单协议/自实现可靠性。
- SCTP:面向消息、多流、部分可靠选项、四次握手、支持多宿主(multi-homing)。
- DCCP:提供拥塞控制的无连接数据报服务。
- QUIC:虽然底层走 UDP,但提供了类似传输层的特性(多路复用、可靠性、TLS 1.3 集成、连接迁移)。在很多文献里 QUIC 被视为“下一代传输协议”。
16、在 Linux 下,client/server 建立连接通信需要调用哪些函数?
以经典 TCP socket 编程为例(IPv4 简化流程):
Server(监听端)
socket() -> bind() -> listen() -> accept() -> recv()/send() -> close()示例步骤:
int s = socket(AF_INET, SOCK_STREAM, 0);setsockopt(s, SOL_SOCKET, SO_REUSEADDR, ...)(可选)bind(s, ...):绑定地址/端口listen(s, backlog):进入监听int c = accept(s, &peer_addr, &peer_len):接受连接(阻塞或非阻塞)- 使用
recv/send或read/write与客户端通信 close(c)然后最终close(s)
Client(主动发起端)
socket() -> connect() -> send()/recv() -> close()示例步骤:
int s = socket(AF_INET, SOCK_STREAM, 0);connect(s, server_addr, addrlen):发起连接(三次握手)send/recv或read/writeclose(s)
DNS / 地址解析:现代代码通常使用 getaddrinfo() 进行地址解析以支持 IPv4/IPv6。 非阻塞/异步 I/O:select/epoll/poll 或异步框架(libuv、asio)用于高并发。
17、编码题:两个线程交替打印字符串(线程 A 打印 "1234",线程 B 打印 "abcd"),要求输出 "1a2b3c4d"
给一个简单且稳健的 C++11+ 实现,使用 std::mutex + std::condition_variable 做线程间同步(按字符逐个交替打印):
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
int main() {
const std::string nums = "1234";
const std::string chars = "abcd";
std::mutex m;
std::condition_variable cv;
bool turn_num = true; // true -> number's turn; false -> char's turn
size_t i = 0, j = 0;
std::string result;
result.reserve(nums.size() + chars.size());
auto tnum = std::thread([&](){
while (i < nums.size()) {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return turn_num; }); // wait until number's turn
result.push_back(nums[i++]);
turn_num = false;
lk.unlock();
cv.notify_one();
}
});
auto tchar = std::thread([&](){
while (j < chars.size()) {
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return !turn_num; }); // wait until char's turn
result.push_back(chars[j++]);
turn_num = true;
lk.unlock();
cv.notify_one();
}
});
tnum.join();
tchar.join();
std::cout << result << '\n'; // 输出: 1a2b3c4d
return 0;
}解释:
- 用
turn_num标记当前轮到哪一线程打印。每次打印完该线程修改turn_num并notify_one()唤醒对方。 - 该代码假设两个字符串长度相同(若长度不同,需在循环中做边界处理与最后唤醒保证)。
- 这是最直接、可读的方式;也可用
semaphore、atomic+ busy-wait(不推荐)或更高级的协程/管道方式。
