首页 / 资讯中心 / 文章详情

C++面试题汇总:从对象模型到并发,原理型答题思路

C++面试题汇总:从对象模型到并发,原理型答题思路 ★ FEATURED ARTICLE
C面试题汇总这几个字听起来像是一份可以考前突击背完的清单。但我见过太多备考者把精力花在“记住答案”而不是“重建原理”上最后挂在一些非常简单的问题上。某次模拟面试候选人流畅地背出移动语义和完美转发我问了一句“如果编译器已经做了返回值优化这段代码里还会调用移动构造吗”他当场卡住。从面试官角度说C面试题从来不是题库而是一种探测仪器。他们想看你对语言的理解到底止步于语法还是能深入对象模型和工程决策。同样一道题在不同深度的人嘴里说出来是两个味道。这篇文章按“底层逻辑、对象模型、拷贝控制、容器、并发、手撕代码”这条线把高频C面试题重新过一遍并附上我自己的答题思路。1. 先搞清楚面试官考C的底层逻辑再谈刷题1.1 为什么死记硬背的《C面试题汇总》最容易翻车C这类面试题有个特点它不怕你背因为它很少单点提问。比如“vector扩容时迭代器为什么会失效”你答“因为重新分配了内存地址变了”表面是对的但面试官马上会接一句“迭代器是对象不是裸指针吧它内部可以重新判断一下啊为什么还是要失效”。如果你没有从迭代器的设计语义和容器底层内存模型去理解这一问就把你的真实水平暴露了。另一个反直觉的点是C面试题越基础越危险。很多刷过大量题的人能谈复杂模板元编程却说不清“空类为什么大小是1”。面试官在这种题上踩两下基本就能判断你是理解型选手还是记忆型选手。面试官想听的从来不是那个结论本身而是你推导结论时暴露出来的思考路径。死记硬背还会带来一个隐蔽问题容易答得太“顺”。当你背的答案和面试官心中的正确答案一字不差时资深面试官不会感动反而会开始找边界情况来验证。他们会问“在什么条件下这个结论不成立”“如果换一个容器还成立吗”。这种连环追问恰恰是背题党的死穴。1.2 三个考查层级语法、对象模型、工程决策面试官提任何C问题底层其实都在做三层扫描。考查层级典型问题考察意图语法层const引用、函数重载、模板语言细节是否熟练有没有实际写过期对象模型层虚函数表、构造析构顺序、内存布局是否理解编译器如何落地这些语法工程决策层容器选型、智能指针设计、并发模型是否有在真实项目里做取舍的经验语法层的题相当于体检表上的基本信息答对不加分答错很危险。对象模型层的题是C面试的骨干虚表、内存对齐、构造析构顺序基本是必问区域。工程决策层才是真正的分水岭比如“为什么这里选map而不选unordered_map”“shared_ptr的引用计数线程安全吗代价是什么”没有实际工程经验的人很难编出让人信服的答案。准备的时候我建议按“先理解为什么再记忆是什么”的顺序。每个高频题都问自己一遍如果条件变化结论还成立吗比如“析构函数可以是虚的那构造函数可以吗”——这个问题就比单纯背“基类析构要加virtual”高一个维度。2. 对象模型与内存布局最容易暴露八股功底的地基题2.1 内存对齐与空类大小从两道小题看基本功先看一道非常经典的题struct A { char c; int x; };sizeof(A)是多少答案是8不是5。因为int需要4字节对齐char占1字节后要补3个填充字节。很多人能答出“对齐”但被追问“为什么要对齐”就含糊了。内存对齐的根因是硬件访问效率。CPU读取内存不是一字节一字节读的而是按总线宽度一次读多个字节。如果int跨了多个读取单元CPU需要两次甚至三次访问才能拼出一个整数某些平台上甚至会直接出异常。更细一层对齐还影响缓存利用率对齐的变量更不容易横跨cache line。这个知识点后面还有升级问法你能让这个结构体变小吗struct A { char c; double d; int x; }; struct B { double d; int x; char c; };同样是三个成员A很可能是24字节B可能只有16字节。原因是double对齐要求8A把1字节的char放在最前面后面为了对齐double白白补了7字节。把大对齐成员往前放小成员往后放就能省下填充空间。这个经验在写协议结构体、跨端序列化时非常实用。再说“空类大小为什么是1”。因为C里不同对象必须有不同的地址如果空类大小是0创建多个对象时地址就无法区分。所以标准规定最小编址单位为1字节。如果空类里有虚函数大小又会变成机器指针大小因为对象里要存一个隐藏的虚表指针vptr。这里不要只说结论要把“空类不空”的思路讲出来成员函数不占对象空间虚函数意味着对象要携带运行期多态信息所以对象变大。2.2 虚函数表的内在结构多态不再是黑魔法虚函数表这题面试官想听的不是“虚函数表存放虚函数地址”这种一句话答案而是想确认你知道它长什么样、放在哪、怎么被调用。在典型实现里有虚函数的类对象头部会插入一个vptr不同编译器实现位置有差异指向该类对应的虚函数表。虚表本质上是一个函数指针数组存放虚函数地址。除此之外虚表区域还可能包含type_info信息用于支持dynamic_cast和typeid如果是虚继承还会包含虚基类偏移等信息。调用一个虚函数时编译器生成的代码大致的路径是取出对象的vptr根据虚函数的槽位索引拿到函数指针然后间接调用。这就是动态绑定的本质——调用目标不是在编译期决定的而是在运行期根据对象的实际类型从对应的虚表里取出来。理解这个机制后再看“虚函数为什么比普通函数慢”。多了一次指针解引用和间接跳转会影响CPU分支预测热点路径上有缓存缺失风险。所以现代C代码里如果不是真正面向对象的多态场景很多人会倾向模板或std::variant来替代虚函数。虚表相关的连带考点还有“构造函数能不能是虚的”“析构函数为什么需要虚的”“a type有虚函数时对象内存怎么排布”。一个能讲清楚vptr初始化顺序的人基本不会被这类问题问倒。2.3 构造函数和析构函数里的虚函数调用这是个经典陷阱题“在构造函数里调用虚函数会发生动态绑定吗”答案是不会。直接原因和vptr的初始化时机有关。构造过程中从基类到派生类依次执行基类构造函数执行时对象的vptr还指向基类的虚表而不是最终派生类的虚表。要等派生类构造函数开始执行前vptr才被更新为派生类的虚表。所以在基类构造函数里调用虚函数实际执行的是基类版本。析构函数也是类似。析构顺序是先派生类后基类当进入基类析构时vptr已经被切回基类的虚表了此时再调虚函数同样只会调用基类版本。如果基类析构里调用了纯虚函数甚至会触发未定义行为。这个知识点的工程意义是不要在构造函数里做“通知其他模块”这类可能被派生类重写的事情。比如构造函数里启动一个线程线程回调里调用了某个虚函数很可能调到的不是预期版本。正确的做法是把这类初始化逻辑外提由使用者显式调用一个init方法。3. 构造析构、拷贝控制与RAII一道基础题引出十连问3.1 三/五法则什么时候必须自己写拷贝控制三法则说的是如果一个类需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个通常这三个都得自定义。C11之后移动语义加入进一步扩展成五法则析构、拷贝构造、拷贝赋值、移动构造、移动赋值。为什么这么强调因为默认的拷贝是浅拷贝。看这个例子class Buffer { char* data_; size_t size_; public: ~Buffer() { delete[] data_; } };如果Buffer被正常拷贝两个对象会指向同一块data_析构时同一块内存会被释放两次这是经典的double free问题。不自己写拷贝控制就是埋炸弹。面试答题建议分三步先定义什么是浅拷贝再说默认行为会产生什么后果最后给出正确做法——要么实现深拷贝要么用 delete禁止拷贝。C11之前禁止拷贝的写法是把拷贝构造和拷贝赋值放到private里且不实现现在直接 delete更干净意图也更明确。移动语义加入后五法则还有一个容易忽略的规则如果你声明了析构函数、拷贝构造或拷贝赋值编译器通常不会隐式生成移动构造和移动赋值。这意味着你觉得自己的类支持移动其实实际拷贝了性能完全没达到预期。这是面试里比“什么是移动语义”更高级的追问方向。3.2 基类析构函数为什么必须加virtual场景很简单class Base { public: ~Base(); }; class Derived : public Base { ... }; Base* p new Derived(); delete p;如果Base的析构函数不是虚函数delete p时编译器按照静态类型Base*调用析构Derived的析构函数根本不会执行。派生类里的资源清理、内部对象析构全部被跳过结果就是资源泄漏甚至是非预期的未定义行为。很多人在面试时解释“因为要保证派生类被正确析构”这没错但要补充底层原因虚析构让析构调用走动态绑定实际执行哪个析构函数由对象的真实类型决定。这和多态调用虚函数是同一个机制。还有一个反问题也常被问“那所有类的析构函数都写成虚的可以吗”不建议。加虚函数意味着对象内部要插入vptr对象体积变大虚表产生析构调用也多了间接跳转成本。如果一个类根本不准备被多态使用析构函数保持非虚反而更高效。判断标准不是“有没有子类”而是“会不会通过基类指针删除派生类对象”。3.3 shared_ptr循环引用与weak_ptr的破局思路循环引用是智能指针面试必问。典型场景是父节点持有子节点的shared_ptr子节点又持有父节点的shared_ptr两个引用计数互相牵制永远降不到0内存无法释放。解决办法是把其中一方的引用改为weak_ptr。比如子节点要访问父节点时用weak_ptr保存父节点使用时调用lock()临时提升为shared_ptr。这样父节点持有子节点的强引用子节点持有父节点的弱引用引用计数可以被正确清零。解开循环后对象图可以正常析构。这里有一个容易被追问的点lock()是原子的吗是。weak_ptr可以安全地lock()成shared_ptr这是标准库对控制块并发访问的保证。但要注意weak_ptr本身不知道该对象的其他shared_ptr是否还存活所以使用前一定要检查提升结果是否为空。工程上的建议是在设计对象图时想清楚所有权方向。通常“父持有子”是强引用“子回看父”用弱引用或裸指针。如果把所有权关系画成循环几乎都是在设计阶段就埋下了隐患。3.4 移动语义为什么值得单独准备移动语义是C11之后面试频率上升最快的知识点。首先要澄清一个常见误区std::move并不移动任何东西它只是一个类型转换把左值强制转换成右值引用真正发生移动是在移动构造函数或移动赋值运算符的实现里。对于一个管理动态资源的类移动构造的典型实现是把源对象的指针接管过来再把源对象置为空。比如Buffer(Buffer other) noexcept : data_(other.data_), size_(other.size_) { other.data_ nullptr; other.size_ 0; }为什么要用noexcept因为如果移动构造函数可能抛异常标准容器比如vector在扩容时会倾向于使用拷贝而不是移动以保证异常安全。一个明明可以高效移动的类因为漏写noexcept而退化到拷贝路径这类问题在性能敏感项目中很常见。移动语义和返回值优化RVO的关系也很爱考。函数返回局部对象时编译器可能完全省略拷贝和移动直接在目标位置构造。如果没有省略在C11之后会优先尝试移动。所以回答“移动一定比拷贝快”这个说法时要严谨当编译器连移动都省略时代价是0当移动被调用时通常比深拷贝快如果类没有移动构造就退化成拷贝。4. 容器与迭代器每次扩容都有人挂在这里4.1 vector扩容的心路历程size、capacity与迭代器失效vector底层是一块连续内存。size()是当前实际元素个数capacity()是已经分配能容纳的元素个数。插入新元素时如果size capacity就必须扩容。扩容大致分三步申请一块更大的连续内存把旧元素拷贝或移动到新内存释放旧内存。之所以要全量搬迁是因为连续内存结构无法做到原地扩展——后面的地址可能已经被其他数据占用。扩容系数通常是2或1.5换来均摊后的O(1)插入开销。这也是面试里“vector插入为什么均摊O(1)”的答案虽然单次扩容是O(n)但扩容次数以几何级数递减总代价平均下来是常数级别。迭代器失效的本质是迭代器内部通常封装了一个指向容器元素的指针。扩容后旧内存被释放但迭代器仍然傻傻指向那块已经不归你管的内存再访问就是未定义行为。这里要答出“不是迭代器这个对象本身失效而是迭代器指向的底层地址已经无意义了”。写代码时最常见的坑是缓存迭代器或指针比如auto* ptr vec[0]后继续push_back。一旦扩容ptr就悬空了。所以遍历中需要插入元素时优先考虑用下标配合reserve预先留足空间或者改用其他容器。4.2 迭代器失效的三种场景和一张自查表面试官最爱考的是“哪些操作会让迭代器失效”这里给一个我自己整理的超简自查表。容器插入操作的影响删除操作的影响vector扩容使所有迭代器失效不扩容时插入点之后的迭代器失效删除点之后的迭代器失效deque首尾插入不影响已有迭代器但可能使引用失效中间插入使迭代器失效删除首尾只使被删迭代器失效删除中间使所有迭代器失效list插入不影响任何迭代器只使指向被删元素的迭代器失效map / set插入不影响任何迭代器只使指向被删元素的迭代器失效unordered_maprehash可能使所有迭代器失效只使指向被删元素的迭代器失效这个表不用背理解每类容器的底层结构就推出来了。连续内存容器vector、deque的迭代器失效和内存搬迁高度相关节点式容器list、map、set每个元素都有独立节点插入释放不会影响其他节点的地址所以只影响被删除的那个迭代器。unordered_map的坑在于rehash时所有bucket都可能重建标准允许其迭代器失效这和“节点式容器插入不失效”的印象完全不同。所以写哈希容器的时候不要长时间持有它的迭代器不放。4.3 map和unordered_map一边讲平衡树一边讲哈希这组对比考的是数据结构功底和工程判断的混合题。map基于红黑树unordered_map基于哈希表。两者都能做键值存储但行为差异很大。红黑树是自平衡二叉搜索树保证操作复杂度为O(logN)天然保持键的有序性。所以需要按序遍历、范围查询、找前驱后继时map几乎是唯一选择。红黑树的代价是每个节点要额外存颜色、父子指针内存开销比较大。哈希表平均复杂度O(1)不关心顺序但它依赖一个良好的哈希函数。冲突过多时最坏可能退化到O(n)。负载因子超过阈值会触发rehashrehash期间有全量重建成本。自定义类型要作为unordered_map的key时需要提供哈希函数和相等判断这也是一个常见考点两个不同的key可能哈希到同一个bucket所以必须用operator二次确认。工程选型的简单经验数据量大、只做单点查询、不要求顺序优先unordered_map数据规模小、顺序无关紧要时map未必更慢但代码更稳需要有序输出范围时直接选map。面试回答时别只说“unordered_map更快”要补一句“平均和单次最优、最坏的区别还有哈希和比较的开销”这才显得你有真实场景判断力。5. 现代C并发和智能指针这是面试分水岭5.1 智能指针三兄弟的权责边界unique_ptr独占所有权禁止拷贝允许移动。对应那些“只有一个owner”的场景比如工厂函数返回一个对象调用方完全接管。编译器层面它不会产生额外的控制块性能最接近裸指针。shared_ptr共享所有权内部维护一个引用计数。每多一个shared_ptr指向同一对象计数加1最后一个析构时计数归零销毁对象并释放控制块。注意引用计数是原子的所以多个线程同时拷贝、销毁同一个shared_ptr不会导致计数错乱。但这句话经常被误解成“shared_ptr管理的对象是线程安全的”——不是。引用计数只保护它自己被指向对象的数据并发读写该锁还是要锁。weak_ptr不参与引用计数它只是“观察者”。典型用处就是上面说的破循环引用以及缓存场景想要拿对象时先lock()对象还活着就能拿到一个临时shared_ptr对象没了就返回空。面试加分点是清楚shared_ptr由两个东西组成对象指针和控制块。对象负责真正的数据控制块保存引用计数、弱计数、删除器等信息。用make_shared可以让这两个内存在同一次分配中获得减少一次动态分配shared_ptrT(new T)则是两次分配还要多一次new T和new control_block的异常安全间隙。5.2 多线程面试死锁、虚假唤醒、内存序多线程方向的题C面试主要集中在三块锁的使用、条件变量、内存序。std::lock_guard和std::unique_lock的区别是高频基础题。lock_guard构造时锁定析构时解锁简单轻量不能中途解锁。unique_lock更灵活允许延迟加锁、手动解锁、移动适合需要配合条件变量或更复杂锁生命周期的场景。条件变量有一个经典问题“为什么wait要用while而不是if”。原因是存在虚假唤醒spurious wakeup。即使没人调用notifyCPU也可能因某些平台原因让进程醒来。就算不存在平台级虚假唤醒notify_one唤醒了线程A线程A还没开始执行时其他线程可能抢到锁把条件又改回去了。所以从wait返回后必须重新检查条件是否成立直到条件为真才继续向下走。标准写法就是while (!ready) { cv.wait(lock); }死锁也是必考。四个必要条件互斥、持有并等待、不可剥夺、循环等待。对应解法通常是打破循环等待最简单的一条实践是“所有线程按同一顺序加锁”。还有std::scoped_lockC17里可以一次性锁多个互斥量内部按优化顺序避免死锁推荐在需要同时持有多把锁时直接用它。内存序这块不要求你把六种memory_order全部背下来。面试官更想确认你知道默认是seq_cst知道acquire/release用于配对同步知道relaxed只保证原子性不保证顺序。能用自己的话讲清楚“锁是同步上层语义原子变量同步底层数据流”就足够有区分度了。5.3 make_shared与enable_shared_from_this的工程意义如果你已经在项目里用shared_ptr面试官很容易开始连环追问。第一个问题是“你用什么创建shared_ptr”如果回shared_ptrT(new T)大概率会跟“为什么推荐make_shared”这个问题。make_shared有两个核心优势。第一一次内存分配同时安置对象和控制块减少动态分配次数第二异常安全。如果先new T再把裸指针传给shared_ptr构造函数这中间如果抛异常比如控制块分配失败裸指针就泄漏了。make_shared把两件事放进一个函数调用里异常安全自然成立。enable_shared_from_this和应用场景是另一个高频追问“回调函数里需要把一个指向自己的shared_ptr传给异步任务怎么写”如果直接shared_ptrT(this)外部又已经有了一个shared_ptr管理这个对象两套控制块会导致同一个对象被析构两次必挂。正确写法是让类继承enable_shared_from_thisT然后调用shared_from_this()获取与现有控制块共享的所有权。前提是这个对象确实已经被某个外部shared_ptr管理了否则会抛出bad_weak_ptr。答题时能把这个链条讲顺先有enable_shared_from_this再引出“为什么裸指针构造多个shared_ptr危险”你就把智能指针几个重要的边界条件一并覆盖了。6. 手撕代码环节从“能写出来”到“写出面试官想要的”6.1 准备手撕代码前先想清楚三件事很多C面试的人败在“算法会做但代码不像实际工程写的”。手撕代码不是只看结果对不对还看你怎么和面试官沟通、怎么处理边界、怎么在纸上写出容易维护的代码。第一件事是确认题目。不要拿过题就写。问清楚输入范围、是否有序、是否允许额外空间、是否需要处理重复元素。这些信息会直接影响解法选择。比如“两个数字之和”这个题如果数组未排序哈希法是标准解如果排序好了双指针更省空间。题面没说清楚就动手经常白写。第二件事是边界的优先级。空输入、单元素输入、溢出、重复元素、无效数据这些是C手撕题最容易挂的地方。边界处理能直接拉开差距因为很多人“主逻辑对了”但边界写崩了。第三件事是复杂度表达。先给一个显然正确的解法哪怕复杂度高一点再提优化方案比一上来就闷头写最优解更稳。最优解写错一步整题崩掉先用低复杂度版本建立信心再优化反而容易拿分。6.2 三个高频手撕题及现场讲解思路第一题反转单链表。ListNode* reverseList(ListNode* head) { ListNode* prev nullptr; ListNode* cur head; while (cur) { ListNode* next cur-next; cur-next prev; prev cur; cur next; } return prev; }这道题的讲解重点是循环不变量每次进入循环cur指向当前待处理节点prev指向已反转部分的头next暂存原后继。迭代法的空间复杂度是O(1)递归法空间复杂度是O(n)面试时最好主动指出这个差别。第二题两数之和。std::vectorint twoSum(std::vectorint nums, int target) { std::unordered_mapint, int seen; for (int i 0; i nums.size(); i) { auto it seen.find(target - nums[i]); if (it ! seen.end()) { return {it-second, i}; } seen.emplace(nums[i], i); } return {}; }需要说明为什么是“先查再插入”如果先把所有元素插入哈希表再二次遍历查目标遇到重复元素时会无法区分两个位置是否来自同一下标。先查当前元素再插入自己天然避免“用自己匹配自己”。第三题线程安全单例。class Singleton { public: static Singleton instance() { static Singleton inst; return inst; } private: Singleton() default; Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; };这个解法是现代C标准答案。编译器和标准库保证函数内静态局部变量的初始化是线程安全的C11起不再需要手动加锁。讲解答题时要提一句我们不提前担心单例析构顺序的问题因为语言规范保证了带静态存储期对象的析构整体顺序符合构造的逆序实际项目中极少需要手写双检锁。6.3 代码风格与边界条件的隐形加分点同样是解出一道题不同人写出来的代码观感天差地别。C面试手撕题更看重工程感。我每次复盘时会特别检查几个点函数参数是不是全都用了const引用能用emplace而不是push_back构造临时对象判断容器是否为空用的是empty()而不是size() 0。还有一个很容易暴露“没写过项目”的细节频繁使用裸指针和手动new/delete。手撕链表题当然没办法完全避免指针但能用nullptr就不用NULL能局部封装就局部封装不要在代码里出现“试图手动释放属于容器的内存”这种行为。边界测试的习惯也很有用。写完代码不要急着说“好了”可以主动说“我先跑几个边界空链表、单节点、重复元素、负数”。这句话在面试官眼里是“这个人是真干活的状态”比干巴巴写对主逻辑更拉好感。我自己的体会是手撕代码时把“交流”当重要一环解题过程是沟通题意的过程不是闷头表演。最后再说一点准备心法。C面试题汇总这类资料真正价值不是让你背答案而是帮你建立“知识点之间的连线”。我准备面试时会把每个题写成一张小卡片正面是问题背面写三到五个相关的追问方向睡前随机翻一张自己说给自己听。坚持两三周再面对面试官那些跳来跳去的问题你会明显感觉到乱飞的追问都能落到你熟悉的那张网上。
阅读完成 · 觉得有帮助?
咨询建站