C/C程序设计面试很多人在刷题阶段都会陷入一个怪圈数据结构与算法题刷得飞起结果一上考场被问“new和malloc有什么区别”“为什么析构函数要写成虚函数”“vector扩容会发生什么”就卡壳了。这不是个别现象今年我带团队做面试复盘时翻看候选人记录发现挂在C/C基础题上的人远比挂在算法题上的人多。所以我整理了这份C/C程序设计面试题目清单不是单纯列题而是把每一类题背后面试官真正想确认的能力点讲清楚。适合正在准备校招、社招或者想系统自查C功底的同学也适合需要出题的工程师做参考。这份整理按面试中出现的频率和淘汰率分成几个模块每个模块下面我会先讲这个模块为什么存在再给题目、给解析、给坑点。我不打算把网上海量的题目都抄一遍那样没有意义我只挑那些真正能区分候选人的题目以及我在实际面试中观察到的典型错误答题方式。1. C/C面试题的地图与权重先搞清该复习什么1.1 面试官真正想从题目里看到什么很多人以为C/C面试题考的是记忆力背一背“静态变量初始化几次”“虚函数表怎么存放”就能过关。但面试官出题的目的从来不是让你背八股而是通过几个递进式的问题快速判断你的思维深度。拿“new和malloc的区别”这道经典题举例。初级回答是new是运算符malloc是函数new会调用构造函数malloc不会。到这里只能算及格。进阶回答是new的底层其实也调用了malloc只不过在malloc返回的原始内存上执行了构造函数delete则是先调用析构函数再调用free。更高级的回答会提到new[]和delete[]的匹配问题、new的nothrow版本、placement new、以及异常安全。同一个题目不同水平的回答直接把候选人分成三档。这就是面试题的意义所在——它是一把尺子。所以我的建议是复习时不要整段背诵答案而是把每个知识点当做一个可以向下追问的话题。你要知道它是什么还要知道它为什么存在、代价是什么、有哪些边界情况。1.2 不同年限候选人的考点权重差异我也见过不少候选人用同一套材料准备所有面试其实不同年限的考察权重差别很大。我根据实际面试复盘整理了一张大致的权重分布供你对照定位考点模块应届生/实习生1-3年经验3年以上/高级岗指针、内存、引用35%25%15%面向对象机制20%25%20%关键字细节static/const/volatile等15%15%10%STL容器与迭代器15%20%15%现代C智能指针/移动语义/lambda10%10%25%源码与设计权衡、多线程/性能5%5%15%应届生阶段面试官默认你项目经验少重点看基本功扎不扎实所以指针、内存、面向对象占比极高。到了3年以上项目经验和架构能力成为主菜但基础题依然会作为开胃菜出现目的是快速排除“写了很多年代码但没深究过语言本质”的人。高级岗反而会更垂直比如某个团队用C做过高性能服务就会深挖内存池、无锁队列、std::async底层的线程调度这些比泛泛的基础题更考验真实积累。2. 指针、引用和内存每一轮面试都绕不开的底层盘问2.1 传递方式的语义差异值、引用、指针这一块最常出的一道题是“写一个交换两个数的函数分别用指针和引用实现说说区别”。很多候选人能写出来但说不清底层。先说值传递。函数形参是实参的拷贝函数内部修改不会影响实参。这个结论大家都懂但我要提醒一个容易忽略的细节当对象很大时值传递会触发拷贝构造代价高。C11之后有了移动语义传右值会走移动构造但这并不能成为无脑传值的理由。引用传递本质上是指针传递的语法糖。底层都是传地址区别在于引用在语义上更安全——引用必须初始化不能为空也不能重新绑定到其他对象。我在面试中喜欢追加一个问题“引用占不占内存”标准答案是引用本身不一定有独立存储编译器通常直接用被引用对象的地址替换但这不是绝对的在某些上下文中引用会占用栈空间。要是候选人能答到这一层说明他真的看过汇编或者读过相关讨论。指针传递比引用更灵活但也更危险。用指针可以表达“可能为空”的语义比如函数参数可以传nullptr表示可选参数可以实现指针的重新指向比如链表节点的插入删除。面试官问你“为什么C有了引用还要保留指针”答案就在这里引用不能为空、不能重置无法表达空语义也无法满足某些底层操作的需求。2.2 常量化修饰const到底修饰谁这道题几乎是C面试必问而且错误率极高。来看这几行声明const char *p; // p指向的内容不可变 char *const p; // p本身的指向不可变 const char *const p; // 内容和指向都不可变我面试时让人解释这三者的区别很多人张口就错。有个简单可靠的判断方法从右往左读const修饰离它最近的类型。const char *pconst紧挨着char所以指向的char是常量不能通过p修改内容。char *const pconst紧挨着p本身所以p是常量指针不能改指别的地址。这个读法能应对绝大多数变体。但光会读还不够深一层的考点是“const修饰成员函数”。常成员函数内部不能修改成员变量不能调用非常成员函数这个规则背后是const限定了this指针的类型——常成员函数里的this是const T*而非常成员函数是T*。如果有人追问“那么mutable是用来干什么的”这就是加分项mutable成员变量在常成员函数里可以被修改典型应用是缓存、统计计数、以及锁的互斥量。2.3 动态内存管理malloc/free与new/delete的边界这道题作为内存模块的压轴题展开聊可以聊很久。我一般会出这样的组合题new和malloc都能分配内存它们的区别是什么如果构造过程中抛出异常内存会自动释放吗delete和delete[]用反了会发生什么先给核心答案。malloc只分配指定字节数的原始内存返回void*不执行任何构造逻辑new会在分配成功之后在原始内存上调用构造函数。对应的free只释放内存不调用析构函数delete在释放前先调用析构函数再释放内存。从实现角度说new底层通常复用malloc编译器在new里插入构造代码并在构造抛出异常时调用free回滚内存分配避免泄漏。delete与delete[]必须严格匹配这一点非常反直觉。很多人以为delete[]只是比delete多做几次析构调用所以用delete误释放数组时“运气好”没崩溃就以为没事。实际上对于需要调用析构函数的自定义类型编译器在new[]时会在内存头位置写入元素个数delete[]依赖这个头信息来确定要析构几次。你用delete去释放new[]出来的数组它会把这个头部当成普通数据解析错位轻则部分析构未调用重则直接崩溃。对于内置类型如int数组析构是平凡的所以常常“没出事”这给很多人造成了错误的安全感。内存泄漏的排查也是面试高频延伸。候选人如果能提到valgrind、AddressSanitizer、以及通过RAII把裸指针换成智能指针来杜绝这类问题那就很加分。3. 面向对象三件套继承、封装与多态的代价和陷阱3.1 虚函数与虚表的工作原理“多态是怎么实现的”这个问题几乎每个面试官都会问。标准答案是含有虚函数的类会有一个虚函数表表中存放该类的虚函数地址每个对象内部保存一个虚表指针vptr指向所属类的虚表。当通过基类指针或引用调用虚函数时运行时根据vptr找到虚表再从表中取出函数地址。但回答到此还不够。加分项包括虚表是每个类共享一份还是每个对象一份答案是一个类只有一个虚表对象之间共享vptr才是在每个对象中各自存储的一份。虚函数调用相比普通成员函数调用多一次间接寻址这是性能代价。如果支持final或类未多态使用场景编译器可以进行去虚化优化直接把虚函数调用转换成普通函数调用但前提是编译器能静态确定实际类型。还有一个容易出现分歧的点虚函数能否是内联函数严格说虚函数可以声明inline但只有在编译器通过静态类型能确定实际对象类型时非虚调用或直接以对象方式调用才有可能内联通过基类指针进行的虚调用无法内联因为运行时才知道入口地址。3.2 构造函数和析构函数里调用虚函数会发生什么这道题是面向对象模块里淘汰率最高的一道。面试官只需要问“基类构造函数里调用一个虚函数会执行基类版本还是子类版本”就能筛掉一大批。答案是执行基类版本。原因在于构造函数的调用顺序子类构造时先构造基类部分此时子类的vptr还没有初始化对象还处于“基类构造阶段”动态类型被视作基类。析构函数则相反子类析构函数先执行然后才执行基类析构所以当基类析构函数内部调用虚函数时子类部分已经析构完毕此时同样只能调用到基类版本。更深一层的考点是“为什么C不允许在构造函数中通过dynamic_cast做向下转换时安全使用”。在基类构造阶段对象还没有成为真正的子类对象任何依赖动态类型的行为都不符合直觉。这也是为什么很多C编码规范会明确要求构造函数和析构函数中不要调用虚函数。3.3 析构函数为什么要写成虚函数“delete指向派生类对象的基类指针时如果基类析构函数不是virtual会发生什么”答案是只调用基类析构函数派生类的析构函数不会被执行派生类持有资源就会泄漏。原理仍然在虚函数表上。虚析构函数会让析构函数成为虚函数通过基类指针delete时按照vptr找到的是派生类析构函数的入口从而按顺序执行派生类析构、然后自动执行基类析构。非虚析构则静态绑定到基类版本派生类析构被跳过。但我也要说明一个边界并不是所有类都适合加虚析构函数。一个类如果设计上根本不作为基类使用或者基类没有虚函数、不需要多态释放加上virtual会额外引入虚表指针增加对象体积。比较常用的做法是让多态基类的析构函数为纯虚析构函数并且必须给出函数体否则派生类析构时无法链接。这算是一个比较细节的知识点但高级岗位面试时偶尔会出现。4. static、const、volatile让人原形毕露的三组关键字4.1 static的五个使用场景static大概是C/C里含义最丰富的关键字了。面试官喜欢让候选人一口气说出static的用途能说全的人非常少。局部静态变量存储在静态存储区生命周期延长到程序结束初始化只进行一次。C11之后局部静态变量的初始化是线程安全的编译器会生成必要的锁或安全机制这一点很多人不知道。全局静态函数/变量限制在该编译单元内可见具有内部链接属性避免多文件编译时符号冲突。类静态成员变量所有对象共享一份必须在类外单独定义分配存储。在C17里可以用inline static简化。类静态成员函数不依赖具体对象没有this指针不能访问非静态成员。静态局部类或匿名命名空间相关话题也常和static关联但核心就是上面四类。常见陷阱静态局部变量的初始化时机是首次执行到定义处而不是程序启动时如果构造函数抛出异常下次执行到此处会重新尝试初始化。这类细节在并发场景的题目里会衍生出“局部静态单例是否线程安全”的问题这也是现代C面试的热门点。4.2 const在C和C里的执行差异const在C语言里更多是“编程约定”编译器默认接受const变量被外部修改的场景。但在C里const对象有更严格的语义不能通过该对象调用非常成员函数、不能修改成员。C语言里const int a 10; int *p (int*)a; *p 20;在运行时可以修改成功未定义行为C中这种强转修改通常仍是未定义行为但更关键的是编译器强烈倾向于把常量折叠比如const int a 10;出现在编译单元里后续使用a的地方很大可能被直接替换为10。所以我认为正确理解const的方式是const是编译期契约不是运行时保护它防止程序员从语法层面改写常量但无法对抗内存篡改。面试延伸题常问“const_cast是用来做什么的”答案就是去掉const属性但修改原本就定义为const的对象仍是未定义行为const_cast只适合修改“指向非const对象但以const形式保存”的场景。4.3 volatile的语义边界volatile是一个基本被C面试官深挖的关键字。它的核心语义是告诉编译器该变量的值可能被本线程之外的因素改变不要让编译器把它缓存到寄存器或做冗余消除。经典的错误示范是嵌入式或驱动开发里的状态寄存器以及多线程共享标志位。但我要提醒一个非常常见的误解volatile不能替代原子操作。A线程写volatile标志、B线程读在多核机器上代码顺序上看似正确的同步实际上可能因为CPU缓存、指令重排而导致B看不到A的修改。C11之后的正确做法是使用std::atomic。只有当你访问的是内存映射I/O寄存器这种场景时volatile才是恰当的选择。面试官如果继续问“volatile和const能一起修饰一个变量吗”答案是能两者语义不冲突问“volatile能修饰指针吗”答案也是能还有volatile int*与int* volatile的区别思路和const指针完全一致。5. 容器选型与迭代器失效STL题目里淘汰率最高的两个点5.1 vector的扩容机制与reserve的必要性vector是面试中出现频率最高的容器。第一问通常是“vector底层是怎么扩容的”。标准答案是vector内部维护一段连续内存容量不足时分配一块更大的内存把旧元素拷贝或移动过去释放旧内存。增长因子各家标准库实现不同常见的倍增因子是2倍或1.5倍。第二问是“为什么通常选择倍增而不是线性增长”。我给一个简单算账假设每次容量增加固定数量总拷贝次数会随着插入次数呈平方级增长倍增策略下每个元素平均被拷贝次数约等于常数整体均摊复杂度为O(1)。1.5倍和2倍的选择区别在于2倍增长更耗内存1.5倍在内存碎片、缓存利用和空间浪费之间更平衡。不同STL实现的策略差异曾被大量讨论但面试层面能答出均摊成本和空间权衡就合格了。第三问是“resize和reserve的区别”。reserve只扩大容量不改变size不构造元素resize会改变size多出来的元素会默认构造或指定值构造。最容易翻车的是reserve之后通过下标访问新容量内的元素这是越界访问vector不会因此自动扩大size。5.2 迭代器失效场景梳理这道题直接考候选人对容器底层理解的深度。我面试时喜欢把容器分类列出失效场景容器操作迭代器失效情况vector插入/删除导致内存重新分配全部迭代器失效vector删除中段元素被删位置之后的迭代器失效deque在两端插入/删除不影响已有元素迭代器deque在中部插入/删除可能全部失效依赖实现list插入/删除仅被删除元素的迭代器失效map/set插入/删除仅被删除元素的迭代器失效unordered_maprehash所有迭代器失效为什么map和list的迭代器不容易失效因为它们是节点式存储删除某节点只影响该节点的内存其他节点地址不变。vector失效是因为连续内存的搬移。理解了这个底层差异哪怕题目换个包装也能推理。经典考察代码是std::vectorint v {1,2,3,4,5}; for (auto it v.begin(); it ! v.end(); it) { if (*it % 2 0) v.erase(it); // 这里迭代器已失效 }正确的erase用法是接收erase的返回值让迭代器回到被删除元素的下一个位置。问“为什么一定要接收返回值”答案在于vector的erase将后续元素前移传入的迭代器所指内容已经被覆盖继续使用它就是未定义行为。5.3 map与unordered_map的差异化选择这道题适合以对比形式出题一个存储几百万个键值对、需要按键排序遍历的场景和一个只要求快速查询、不要求顺序的场景分别用哪个容器map底层是红黑树有序查找插入删除都是O(log n)unordered_map底层是哈希表查找平均O(1)最坏O(n)但内存开销更大且遍历时没有确定性顺序。如果面试官追问“哈希冲突怎么解决”需要提到开链法链地址法、二次探测、以及C标准中unordered_map实现通常采用开链法。实际项目中还有一种容易忽略的情况键是自定义类型时map需要提供operatorunordered_map需要提供hash函数和等价比较。如果自定义类型没有天然顺序而哈希容易构造unordered_map更合适反过来如果键天然有序且需要围绕顺序做范围查询map更合适。6. 现代C加入考点后的常客智能指针、移动语义与lambda6.1 三种智能指针的适用场景与循环引用现在面试C智能指针几乎成了必问项。基础问题是unique_ptr、shared_ptr、weak_ptr的区别。答案很简单unique_ptr独占所有权不可拷贝只能移动shared_ptr通过引用计数共享所有权weak_ptr不增加引用计数用于观察和打断循环引用。深一层的问题通常围绕shared_ptr的实现代价。shared_ptr的控制块里存储引用计数、弱引用计数和删除器。每次拷贝要原子递增引用计数所以拷贝shared_ptr比拷贝裸指针贵得多。很多人写代码时为了“方便”到处拷贝shared_ptr其实性能并不理想。循环引用是必考的坑。经典场景是两个对象互相持有shared_ptrstruct Node { std::shared_ptrNode next; std::shared_ptrNode prev; };如果两个节点互相引用引用计数永远到不了0内存泄漏。解决方式是把其中一个方向改成weak_ptr。weak_ptr调用lock()可以安全地获得一个临时的shared_ptr如果对象已释放则返回nullptr。还有一个延伸问题shared_ptr是否线程安全。标准说法是控制块的引用计数操作是线程安全的但指向的对象不会被自动保护。多个线程并发修改shared_ptr本身需要外部同步对同一个shared_ptr的拷贝和析构存在数据竞争。6.2 std::move与std::forward的区别这道题抽象程度高很多候选人答不清楚。我给出一个便于理解的说法std::move是无条件把左值强制转换成右值引用表示“我允许你从这里搬走资源”std::forward是在函数模板转发参数时根据实参类型决定转成左值引用还是右值引用保持值类别的信息不丢失。举个例子templatetypename T void wrapper(T arg) { func(std::forwardT(arg)); }如果外部传入左值T推导为Tforward会把arg转发为左值如果外部传入右值T推导为Tforward会把arg转发为右值。如果不做转发而直接传参原来传右值时在wrapper内部就会变成具名的左值移动语义就断了。移动后的对象处于“有效但未指定状态”通常需要能安全析构和赋值。这是标准库一致性的要求写自定义类时移动构造应该把源对象的资源指针置空避免双释放。6.3 lambda捕获的常见陷阱lambda是高频考点但很多候选人只停留在“能用”层面。按值捕获用[]这个知识点还有一个深坑按值捕获的变量在lambda创建时就已经拷贝而不是在调用时读取最新值。我在面试中经常出一道题定义lambda后修改外部变量lambda内部打印的是修改前还是修改后的值答案是修改前的值因为捕获已经完成。另一个坑是按引用捕获时lambda生命周期超过局部变量。比如把lambda返回给上层作用域或者存到容器里异步执行局部变量已经销毁调用时形成悬空引用。现代C实践中建议默认使用显式捕获列表避免无脑[]尤其是lambda会逃逸函数作用域时。还有为移动语义设计的广义捕获比如[p std::move(ptr)]这种写法允许把只能移动的资源捕获进lambda。能主动写出这种捕获方式的候选人通常C功底不弱。7. 现场撕题三类高频手写题的答题思路复盘7.1 手写String类考察拷贝控制与异常安全这是最经典的现场手写题之一考点覆盖构造函数、拷贝构造、赋值运算符重载、析构、移动语义和异常安全。一个能过关的版本大概长这样class String { public: String() : data_(new char[1]) { data_[0] \0; } String(const char* s) : data_(new char[strlen(s) 1]) { strcpy(data_, s); } String(const String other) : data_(new char[other.size() 1]) { strcpy(data_, other.data_); } String(String other) noexcept : data_(other.data_) { other.data_ nullptr; } String operator(String rhs) { swap(rhs); return *this; } ~String() { delete[] data_; } void swap(String other) noexcept { std::swap(data_, other.data_); } private: char* data_; };其中赋值运算符采用“拷贝并交换”是面试加分项。它先以传值方式接收参数如果实参是左值会走拷贝构造如果是右值会走移动构造然后通过交换把旧资源带进临时对象中销毁天然具备强异常安全。面试中如果候选人能主动提到“new[]必须配delete[]”、“拷贝构造要保证深拷贝”、“移动构造需要把源对象置空”基本可以判定功底扎实。7.2 线程安全的单例实现现场要求写单例时面试官真正的考察点是是否知道局部静态变量的初始化是线程安全的以及是否需要关心释放顺序。最简单的现代写法是class Singleton { public: static Singleton instance() { static Singleton inst; return inst; } private: Singleton() default; Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; };C11规定局部静态变量的初始化是线程安全的编译器会生成保护逻辑。这道题如果候选人坚持手写double-checked locking那就要追问内存屏障问题——在C11之前的实现很容易写错现代代码可以直接使用局部静态或std::once_flag。我还见过候选人被问“单例的析构顺序和依赖关系”。如果两个单例互相依赖程序退出时析构顺序不确定容易崩。解决方式是使用函数内局部静态并保持依赖单向避免交叉释放。7.3 排序和二分查找这类手写题要注意的细节排序和二分查找是手写题的基本盘但错误率意外地高。二分查找最常见的错误在于死循环和边界溢出。比较稳妥的写法int binarySearch(const std::vectorint a, int target) { int l 0, r static_castint(a.size()) - 1; while (l r) { int mid l (r - l) / 2; if (a[mid] target) return mid; else if (a[mid] target) l mid 1; else r mid - 1; } return -1; }mid l (r - l) / 2的写法能避免(l r) / 2溢出这是面试官希望看到的细节。如果是查找左边界或右边界就要注意边界条件返回的到底是第一个不小于target的位置还是最后一个等于target的位置都要在写之前和面试官确认清楚。手写快排时除了排序逻辑我更看重候选人会不会处理重复元素、是否原地排序、递归深度在极端情况下是否会爆栈。如果候选人能说一嘴“工程上建议使用std::sort因为它在快排基础上针对小片段切换插入排序和堆排序”那绝对是加分项。8. 细节题冷知识sizeof、宏与内联、位域这类送命题8.1 sizeof与strlen、数组名与指针细节题藏在最容易被忽略的地方。sizeof是运算符编译期求值计算类型或对象占用的字节数strlen是函数运行时扫描字符串直到遇到\0。对字符数组调用sizeof得到整个数组大小而对函数参数中的“数组”调用sizeof只能得到指针大小因为数组参数会退化为指针。明确说出“数组作为函数参数会发生退化”的候选人说明他理解函数调用机制。另一个容易混淆的题目是“sizeof空类为什么是1”。标准规定C中任何对象都必须有唯一地址所以空类实例的大小至少为1字节。如果类里只含一个char和一个int因为对齐大小不是145而通常是8。对齐规则是面试官喜欢追问的延伸可以回答成员起始地址是自身对齐数的整数倍结构体总大小需要对齐到最大成员对齐数。8.2 宏与内联函数的取舍与陷阱宏在C/C面试中依然会出现但考察重点已经从“会不会用”变成“为什么少用”。比如#define SQUARE(x) ((x) * (x))如果不加外层括号SQUARE(12)会展开成12*12结果完全不对。加了括号能解决大部分运算优先级问题但还有重复求值的坑比如SQUARE(x)会展开成(x)*(x)行为未定义。这是宏的经典缺陷。内联函数是C给出的改进方案既能像宏一样减少函数调用开销又在类型检查和求值次数上保持普通函数的语义。内联只是向编译器发出的建议编译器可以选择忽略。更深的知识点是内联函数与链接的关系内联函数定义通常写在头文件里每个编译单元各有一份定义但链接时会做合并处理。8.3 位域与内存对齐的计算位域在驱动开发、网络协议解析中使用频繁面试时也常被拿来考察内存布局理解。比如struct Flags { unsigned int a : 3; unsigned int b : 5; unsigned int c : 2; };a占3位b占5位c占2位合计10位。实际存储时位域成员的类型和其他普通成员一起参与对齐同一个字节内能否连续排布取决于编译器C标准只规定相邻位域可以打包存储但没有规定具体布局所以不同平台下位域的内存布局不保证一致。这一点对需要做协议解析的候选人是必须知道的前提。如果面试官问“位域和掩码操作相比有什么好处”答案是可读性更好代价是代码生成可能更慢、可移植性受限。最后说一点我自己的备考复盘习惯。我每次面完候选人都会把答案里那些“能引出一串追问”的知识点记下来然后按“是什么—为什么—代价是什么—边界在哪”四个维度重新过一遍C知识点。这个过程比单纯刷几百道题更有效。如果你也在准备面试我建议把这份题目清单当成进度表每道题先自己说一遍答案再对照解折补充遗漏然后尝试给自己出一道变体题。C/C的面试题万变不离其宗底层的原理清楚了换什么包装都不慌。