尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

搜狗C++笔试题深度解析:从内存多态到并发新特性

搜狗C++笔试题深度解析:从内存多态到并发新特性 每年到了校招季总有人问我搜狗2016年那套C工程师笔试题到底考了什么。这套题在圈子里流传得挺广很多人拿着它当练手材料也有人把它当成衡量自己C水平的标尺。坦白说这套题放在今天来看也不落伍它没有堆砌冷门语法而是把一个C工程师日常开发中最容易翻车、也最能体现功底的知识点挨个筛了一遍指针和内存、多态和虚函数、STL容器选型、手写算法、并发同步外加几道新标准特性的考察。如果你正准备C方向的技术面试或者想自查一下基础是否扎实这期内容值得仔细看完。1. 这份笔试题的整体风格不考偏题专考“基本功的坑”1.1 为什么搜狗这类公司偏爱用C考底层功底搜狗的核心业务是搜索、输入法、浏览器这些产品对性能的要求极高后端服务要处理海量请求客户端要保证流畅输入体验。在这种场景下C的优势在于可以直接管理内存、精准控制资源、做底层性能优化。笔试面试官想找的不是会背API的人而是真正理解内存布局、对象生命周期、并发模型的人。记得我当年做这套题时的直观感受大部分题看起来都“见过”比如指针和引用的区别、new和malloc的区别、构造函数里能不能调虚函数但每道题往下深挖一层就能筛掉一大批只是背了面经的候选人。面试官真正想看到的是你对“为什么”有清晰的解释而不是只会说结论。1.2 整张卷子的知识点分布与考察逻辑从流传出来的题目和面试者的复盘来看这套题大致覆盖五个方向C基础语法指针/引用、const、内存布局、类型转换面向对象机制虚函数、多态、构造函数与析构函数的调用顺序标准库与算法容器选型、迭代器失效、排序/查找类手写算法并发与多线程互斥锁、条件变量、原子操作、回调陷阱新标准特性C11引入的关键字、智能指针、移动语义我发现它有一个很聪明的出题策略把“背答案就能答对”的题和“理解了才能答对”的题混在一起。比如“指针和引用的区别”属于前者而“构造函数中调用虚函数会发生什么”属于后者。后者往往配一个小代码段让你判断输出结果不少人在这种题上栽跟头。2. 语言基础题指针、引用、const、内存全是基本功2.1 指针与引用一个关于“绑定”的经典送分题先看这道高频题#include iostream using namespace std; int main() { int a 10; int b 20; int* p a; int r a; p b; // 指针指向改变 r b; // 这里发生了什么 cout a b endl; cout *p r endl; return 0; }很多新人以为r b是让引用 r 改绑到 b这是最大的误解。引用在初始化时就绑定了 a之后所有的赋值操作都是给 a 赋值。所以输出结果是20 20和20 20吗不对仔细算r b等价于a ba 变成 20b 还是 20*p 现在是 b 的值 20r 是 a 的值 20。所以四行输出全是 20。面试官的考察点在这里指针是“保存地址的变量”可以重新赋值指向其他对象引用是“对象的别名”一旦绑定不能改绑。另一个常考区别是sizeofsizeof(p)在64位系统上是8字节sizeof(r)是sizeof(int)也就是4字节。还有一点引用必须初始化不能存在空引用指针可以为空但也正因如此使用指针前必须判空。2.2 const的“多重身份”变量、指针、成员函数const 是C笔试的常青树。最经典的一道题就是让你说出下面几个声明的区别const int* p1; // 指向常量的指针不能通过p1修改指向的值 int const* p2; // 与p1相同const修饰的是 *p int* const p3; // 指针常量不能修改p3指向的地址 const int* const p4; // 既不能改地址也不能改值记忆方法一直很好用从右往左读遇到*就拆开。const int* p中先读*p是const int说明*p不能被修改int* const p中先读p是const指针说明 p 本身不能被修改。还有一道常见的题目考察 const 成员函数class A { public: void f() const { /* 这里能修改成员变量吗 */ } };在 const 成员函数里this 的类型是const A*意味着不能修改普通成员变量。如果想在 const 函数里修改某个成员变量必须把该变量声明为mutable。面试时如果要答得更有深度可以补充一句const 成员函数的本质是给 this 指针加了底层 const编译器把它当作重载的一部分所以 const 版本和非 const 版本可以同时存在。2.3 new/malloc与内存管理堆和栈的爱恨情仇搜狗这套题里有一道老生常谈new 和 malloc 的区别。标准答案是new/delete 是操作符malloc/free 是函数new 会调用构造函数malloc 只分配原始内存new 返回类型安全的指针malloc 返回 void* 需要强转new 失败抛出 bad_allocmalloc 失败返回 NULLnew 可以重载malloc 不行但光背这些不够。面试官会接着问一个更实际的问题“如果你用 malloc 分配了一块内存然后在这块内存上构造对象会有什么问题”这就是 placement new 的考点。正确的做法是void* mem malloc(sizeof(MyClass)); MyClass* obj new (mem) MyClass(); // 使用完之后 obj-~MyClass(); free(mem);这个考点出现在笔试题里通常不是为了让你写这段代码而是考察你是否理解“内存分配”和“对象构造”是两件事。理解了这一点你对内存泄漏、对象生命周期、RAII 都会有更深的认识。内存泄漏的隐蔽场景往往是异常路径比如函数中途抛出异常后续的 delete 没执行处理方式是使用智能指针或 RAII 封装。3. 面向对象与多态虚函数表里的门道3.1 虚函数到底是怎么实现多态的C笔试题里虚函数相关的题目几乎是必考的。常见的一道概念题是有虚函数的类实例化后的对象大小是多少class Base { public: virtual void func() {} private: int x; };在64位系统上答案是16字节4字节的 int 加上4字节对齐填充再加上一个8字节的虚函数表指针。如果去掉 virtual大小是4字节。从这里可以看出只要类里有虚函数编译器就会悄悄给对象插入一个 vptr虚函数表指针指向该类的虚函数表 vtable。虚函数表是类级别的每个类一张表表里存放的是函数指针。对象通过 vptr 找到自己的虚函数表再通过偏移量调用对应的函数。继承时派生类会重写虚函数表中对应槽位指向自己的函数实现从而实现在运行时根据实际类型调用不同的函数。有个容易答错的问题虚函数表是编译期生成的还是运行期生成的答案是编译期生成。vptr 在构造对象时被初始化但表的生成完全发生在编译期。概念上运行期只是通过 vptr 找到表并调用函数。3.2 构造函数、析构函数里的虚函数陷阱这套题里出现过一道非常经典的输出题考的是构造函数中调用虚函数class Base { public: Base() { show(); } virtual void show() { cout Base; } }; class Derived : public Base { public: Derived() { show(); } virtual void show() { cout Derived; } }; int main() { Derived d; return 0; }输出结果是BaseDerived不是很多人以为的DerivedDerived。原因在于当执行 Base 的构造函数时对象的动态类型还是 Basevptr 指向 Base 的虚函数表只有进入 Derived 构造函数体时vptr 才被更新为 Derived 的虚函数表。所以构造函数里调用虚函数不会发生多态这是C的一个设计原则在构造和析构期间对象的类型就是当前正在构造/析构的类。同样的道理也适用于析构函数。所以如果一个类的析构函数不是 virtual通过基类指针 delete 派生类对象时行为是未定义的可能只调用基类析构函数导致派生类资源泄漏。这就是为什么“带虚函数的类应该把析构函数写成虚的”。3.3 高频设计模式题从单例到观察者搜狗笔试题里还有一类题不算纯语言题但非常考察工程能力就是设计模式。最常见的是让手写一个单例模式。经典写法class Singleton { public: static Singleton getInstance() { static Singleton instance; return instance; } private: Singleton() {} Singleton(const Singleton) delete; Singleton operator(const Singleton) delete; };这个写法的好处是C11 标准保证了局部静态变量初始化的线程安全性。如果你在回答时能主动提到在 C11 之前这种写法存在线程安全问题需要加锁C11 之后编译器会生成保护代码保证只初始化一次这题基本就拿下了。观察者模式、工厂模式在笔试题里更多以扩展题形式出现比如“写出一个简单工厂模式”或“观察者模式中如何避免悬挂指针”。后者是C特有的坑观察者生命周期比主题对象短主题通知观察者时可能访问到已销毁的对象解决方案是使用弱引用或从观察者注册表中安全移除。4. STL与算法容器选型与手写算法4.1 容器对比vector、list、map、unordered_map怎么选搜狗的笔试里有一道简答题请说明 vector 和 list 的区别以及 map 和 unordered_map 的适用场景。这种题只要逻辑清晰很容易拿分关键是答出底层原理。vector 底层是连续内存随机访问 O(1)但在中间插入删除需要搬移元素O(n)list 底层是双向链表任意位置插入删除 O(1)但随机访问 O(n)每个节点有额外的指针开销。迭代器方面vector 的插入可能导致迭代器失效因为可能触发扩容重新分配内存list 的插入不影响其他迭代器但删除当前节点后当前迭代器失效。map 基于红黑树内部有序查找 O(log n)unordered_map 基于哈希表查找平均 O(1)但内存开销更大且不保证有序。面试官经常追问什么时候一定要用 map答案是当你需要有序遍历键值对的时候或者需要查找前驱后继时红黑树天然支持这些操作。如果只做单点查询且无顺序需求优先 unordered_map。4.2 手写排序算法快速排序、快速幂与相关基础笔试里手写排序算法也很常见快速排序出现的概率最高。标准的递归版快排void quickSort(vectorint arr, int left, int right) { if (left right) return; int i left, j right, pivot arr[left (right - left) / 2]; while (i j) { while (arr[i] pivot) i; while (arr[j] pivot) j--; if (i j) { swap(arr[i], arr[j]); i; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); }写快排时常犯的错误有三个递归终止条件写错导致栈溢出pivot 选择不当导致最坏 O(n^2)交换后的边界调整不对导致死循环。我在模拟面试时见过不少候选人背下了代码但一追问“为什么 ij 而不是用 ij”就卡壳了。原因是 ij 能保证区间划分正确避免左右区间交叉时遗漏元素。如果你备的是搜狗这套题还要注意快速幂。虽然不是排序但经常出现在算法小题里long long fastPow(long long base, long long exp, long long mod) { long long result 1; base % mod; while (exp 0) { if (exp 1) result result * base % mod; base base * base % mod; exp 1; } return result; }快速幂的原理是把指数拆成二进制利用乘法的结合律减少乘法次数。很多人在循环里忘记取模导致溢出这点要特别注意。冒泡、选择这类简单排序也偶尔出现主要是考察写代码的基本功和是否了解稳定性概念。冒泡排序是稳定排序选择排序是不稳定排序因为选择排序可能把相同元素的相对顺序打乱。4.3 字符串、数组和C11的初始化语法笔试题里还有一组基础题专门考字符串数组初始化和 C 风格字符串与 std::string 的转换。看到热搜词里也有“c字符串数组初始化”、“c字符串转数组”说明这确实是高频的失分点。一个典型的题目char str1[] hello; char str2[] {h, e, l, l, o}; cout sizeof(str1) sizeof(str2) endl;答案是 6 和 5。str1 是字符串字面量初始化末尾自动补一个 \0str2 是字符数组的显式初始化没有 \0。如果你用 strlen(str2) 去计算长度会读到栈上往后的任意字节直到遇到 0这就有未定义行为。正确做法是用 sizeof 或显式加上 \0。std::string 转字符数组的场景也常考。用str.c_str()拿到const char*注意返回的指针在 string 被修改或销毁后就失效了。要想安全拷贝用std::copy或memcpystd::string s hello; std::vectorchar buf(s.begin(), s.end()); buf.push_back(\0);C11 以后数组和容器的初始化也经常考比如int arr[] {1, 2, 3};、arrayint, 3 arr{1, 2, 3};、vectorint v{1, 2, 3};。注意vectorint v(10, 1);表示10个1而vectorint v{10, 1};表示一个initializer_list里面只有两个元素10和1。这种“括号匹配”细节就是面试官最爱挖的坑。5. 并发编程线程同步与ABA问题5.1 线程同步三板斧mutex、条件变量、atomic搜狗的服务端并发场景多笔试题中对并发的考察从来不含糊。一道典型题是说明互斥锁、条件变量、原子变量的使用场景和区别。互斥锁用于保护临界区确保同一时刻只有一个线程访问共享资源。条件变量用来解决“等待条件满足”的问题必须配合互斥锁使用。在 C 里标准写法是std::unique_lockstd::mutex lk(mtx); cv.wait(lk, []() { return ready; });注意这里必须用unique_lock而不是lock_guard因为 wait 内部需要暂时释放锁等其他线程 notify 后再重新获取锁unique_lock才支持这种操作。原子变量用于保护简单的共享变量比如计数器、标志位。它的底层利用硬件提供的原子指令性能远高于锁。但原子变量不能保护复杂的临界区如果多个操作之间需要保持一致性仍然需要锁或事务内存。这里特别要提醒volatile 不能用于线程同步。volatile 的作用是告诉编译器不要对这个变量的访问做优化每次都要从内存读取但它不保证操作的原子性也不能阻止CPU乱序。很多人把它和atomic搞混这是面试官非常喜欢的陷阱。我见过不止一个候选人在“为什么不能用volatile保证线程安全”这题上翻车。5.2 不说ABA问题的无锁编程都是耍流氓ABA问题几乎是无锁编程必考的知识点热搜词里也有“aba问题c”说明关注它的人非常多。我先说背景无锁数据结构通常用 CASCompare And Swap操作更新共享变量。CAS 的语义是只有当当前值等于预期值时才把值更新为新值否则失败。ABA问题的场景是这样的线程1 读取共享变量当前值为 A线程1 被挂起线程2 将值从 A 改成 B再从 B 改回 A线程1 恢复执行CAS 发现当前值仍然是 A于是成功更新为 C问题在于值虽然是 A但期间已经发生了两次修改。如果数据结构基于指针构建A 指向的节点可能已经被释放并重建CAS 成功更新后会导致悬空指针或数据错误。解决思路通常有两种一是使用带版本号的原子变量比如std::atomicstd::shared_ptrT或boost::atomic提供的 tagged 指针每次修改版本号加1CAS 时同时比较值和版本号二是避免立即释放内存采用延迟回收机制比如使用hazard pointer。笔试题里一般不会让你完整实现而是要求说清楚 ABA 问题的成因和解决办法。能画图说明、举出具体场景的候选人在面试官眼中是真正写过无锁代码的。我在实际项目中确实踩过一次 ABA 的坑用自以为聪明的无锁设计实现了有锁中简单的功能结果线上出现偶发数据错乱排查耗时很久。后来老老实实改用带版本号的 CAS问题消除。5.3 回调函数与线程安全看到热搜词里有“c回调函数例子”这也是搜狗这类笔试的常客。回调函数本质是把函数指针或可调用对象作为参数传递在另一个上下文里执行。C里有三种常见写法函数指针void (*callback)(int)std::functionstd::functionvoid(int) callback函数对象仿函数重载operator()使用回调时的一个重要考点是线程安全如果回调在另一个线程中被执行那么回调里访问的共享数据必须加锁否则会出现数据竞争。另一个考点是回调中调用虚函数是否会发生多态答案是会的因为调用回调本质上是普通函数调用对象是完整的与构造函数里的特殊阶段无关。还有一个隐藏很深的坑回调函数内部如果持有某个对象的原始指针而对象已经析构调用回调就会崩溃。安全的做法是改用std::weak_ptr或shared_ptr捕获在回调开始时先lock()判断对象是否存活。6. 新标准特性与综合实战6.1 C11/14/17 必考新特性速查这套题里有一道概念题constexpr 是哪个版本的C引入的答案是 C11。C11 是C历史上变化最大的一次标准更新笔试题的覆盖面非常大。常考的 C11 特性包括auto自动类型推导nullptr替代 NULL 的类型安全空指针右值引用与移动语义、std::movestd::unique_ptr、std::shared_ptr、std::weak_ptrlambda 表达式constexpr编译期求值static_assert编译期断言std::thread和原子库C14 放宽了constexpr函数内可以包含的语句限制允许在constexpr函数中使用局部变量、循环和分支。C17 主要引入了std::optional、std::variant、结构化绑定、if constexpr等。有一个常被追问的点constexpr和const的区别。const 表示“在程序运行期间不会修改”但它的值可能直到运行时才确定constexpr表示“可以编译期求值”它必须满足更严格的条件。如果面试官问“constexpr 一定比 const 高效吗”正确的回答是不一定constexpr的主要价值是让某些计算在编译期完成减少运行时开销但前提是编译器真的会在编译期求值否则它只是强制了可编译期求值的性质。另外关于 C 的运行库经常有人搞混。如果你在 Windows 上写 C会看到 Microsoft Visual C Redistributable 这个程序它提供运行时组件很多 C 编译出来的程序依赖它但这不是语言标准的一部分。这类问题偶尔出现在招聘答疑帖里不属于笔试题范畴就不展开了。6.2 综合实战题手写线程安全单例并分析如果把这套题的所有知识点串起来最好的载体就是“手写一个线程安全的单例并分析内存序”。这道题可以用来同时考察面向对象、并发和新标准。我第一次在面试官面前写单例写的是最经典的 DCLPDouble-Checked Locking Pattern版本class Singleton { public: static Singleton* getInstance() { if (instance nullptr) { std::lock_guardstd::mutex lock(mutex_); if (instance nullptr) { instance new Singleton(); } } return instance; } private: Singleton() {} static Singleton* instance; static std::mutex mutex_; };如果面试官不再追问这版本看着还行。但只要追问一句“这个版本在C11之前有什么问题”就知道考点在哪里了不加volatile时编译器可能对指令重排导致instance先被赋值而构造函数还没执行完另一个线程就拿到了一个半成品对象的指针。C11 之后用std::atomicSingleton*配合acquire/release内存序可以修正或者干脆用上面提到的static局部变量版本因为它的初始化已经由标准保证为线程安全。遇到这种追问时能主动说出“最推荐的是 Meyers SingletonC11 以后它简洁又线程安全”的候选人往往能赢得面试官的认可。这题背后考察的其实是你在真实工程里对线程并发、指令重排、对象生命周期的理解而不是会不会背代码。6.3 从笔试题到工程能力这些考点为什么重要整理完这套题你可能会觉得它有点“学院派”谁会真的在日常代码里手写快排谁会去考虑虚函数表的内存布局但换个角度看这些知识点恰好构成了一条完整的底层认知链。指针和引用让你理解变量和内存的关系虚函数表让你理解运行时多态的代价内存管理让你理解智能指针为什么存在并发同步让你理解为什么有锁和无锁需要权衡新标准特性让你知道如何写出更现代、更安全的代码。我自己的体会是笔试只是第一关真正的分水岭在面试追问里。能完整讲出“为什么”的候选人通常也在实际项目中踩过坑。比如你用过std::atomic就会更理解 ABA你被迭代器失效坑过就会更清楚 vector 和 list 的适用边界。所以这套题最好的打开方式不是背答案而是把每个考点当成一个引子顺着它去查资料、写例子、看源码。等你能不看资料把每个点都讲透再去面对笔试面试底气会完全不一样。最后分享一个我自己刷题时的习惯每道题做完强制自己写一段总结说明“这题在真实开发中对应什么场景”。比如const成员函数对应接口设计时的只读约束new/malloc 区别对应内存池或对象池的实现constexpr对应高性能场景下的编译期优化。带着这种视角刷题你不会觉得自己在看一堆零散语法而是在补一张完整的知识拼图。搜狗这套笔试题能流传这么多年恰恰说明它考察的正是C工程师长期受用的核心能力。
返回列表