1. 拿到这份C++笔试题之前,先搞清楚岗位到底要什么
最近有位准备秋招的朋友把这份《金山办公2020校招C++开发工程师笔试题(一)》翻出来做了一遍,做完之后跑来跟我讨论,说“题目看着都不难,但好几个空差点写错”。我让他把题目和答案一起发我看了看,又陪他逐题过了两遍。我俩一致的感受是:这份卷子出得很有代表性,它不像某些互联网大厂那样堆砌偏题怪题,而是踏踏实实地考察一个C++开发工程师在办公软件领域干活时真正用得上的基本功。
金山办公这家公司大家应该不陌生,核心产品是WPS Office那一整套办公套件,覆盖文字处理、表格计算、演示文稿、PDF阅读与转换这些日常办公场景。这些产品有一个共同的底层特征:全都依赖C++重资产客户端技术栈,需要处理超大文档的解析、渲染、格式转换,需要跟操作系统底层交互,需要在老旧的Windows运行库兼容环境里保证稳定性。这种业务形态决定了它对C++工程师的要求,不是“刷题刷得溜”,而是“底层原理吃得透、STL用得稳、内存意识极强”。你去看这份笔试题,它问的虚函数、智能指针、STL容器、内存对齐,表面上全是教科书知识,实际上每一道题都能在办公软件的真实开发场景里找到对应的坑。
所以我建议准备类似笔试的同学,别抱着“背概念”的心态去刷这份卷子。更好的姿势是:把每道题当成一个入口,往里挖到源码级、内存级的原理,同时想一想“如果我在开发一个上千万字的文档编辑器,这道题对应的知识点会在哪里引爆”。这篇文章就按这个思路,把这份试卷的核心考点拆开讲透,每个考点附带真题风格、答案思路、追问方向和真实开发中的对应场景。
先交代一下这份卷子的整体画像。它分为客观题和编程题两大块,客观题里又包含单选题、多选题、填空题和判断题。从题目分布看,C++核心语法占比最高,其次是STL和数据机构,再往后是操作系统、网络和数据库相关内容。这不是一份“算法竞赛型”的卷子,而是一份“工程素养型”的卷子。它默认你已经具备基本的C++编码能力,考的是你能不能写出健壮、高效、不出内存事故的代码——这正是办公软件客户端开发最看重的能力。
2. 虚函数与多态:这份卷子最常翻牌的基础考点
2.1 一道必考题:基类析构函数为什么要声明为virtual
这份试卷的填空和选择题里,多态相关的题目出现了不止一次,其中最有代表性的就是“基类的析构函数为什么不建议声明为非虚函数,如果声明了会怎样”。很多刚复习完语法的人能背出答案:如果不把基类析构函数声明为virtual,当通过基类指针delete一个派生类对象时,只会调用基类的析构函数,派生类的析构函数不会被调用,导致派生类中动态分配的资源无法释放,出现内存泄漏。但笔试通常不会只满足于这个结论,它往往会在多选题里再追问一句:面对这种情况,有哪些安全隐患。
举个例子。你写了一个文档解析器的抽象基类:
class Parser { public: Parser() {} ~Parser() { releaseResource(); } // 假如这里有资源释放逻辑 virtual bool parse(const std::string& file) = 0; };然后派生出PDFParser和DocxParser。正常的写法理应是:
class Parser { public: virtual ~Parser() {} };一旦漏掉virtual,你通过Parser* p = new PDFParser()再delete p的时候,析构函数调用链就会断在基类那一层。PDFParser内部可能持有打开的文件句柄、解码器缓冲、临时目录路径,这些资源全部得不到释放。短期看只是内存泄漏,长期跑一个服务型进程,比如批量文档转换后台,一天下来内存蹭蹭涨,最后直接OOM崩溃。
这类问题的完整理解要从析构顺序切入。C++对象的析构顺序和构造顺序严格相反:先构造的先析构。当一个派生类对象被完整析构时,会先执行派生类析构函数的函数体,再逐层向上调用基类析构函数。问题是,编译器在执行delete表达式的时候,是根据静态类型来决定调用哪个析构函数的。如果静态类型是Base,且Base的析构函数不是virtual,编译器就直接调用Base::~Base(),根本不会进入派生类的析构函数。析构顺序链条在第一步就断了,派生类资源自然没机会清理。
反过来也是一类经典追问:构造和析构函数里能不能调用虚函数?正确答案是:尽量不要,因为构造函数和析构函数期间虚函数不会呈现多态行为。对象在构造过程中,是先构造基类部分再构造派生类部分,当基类构造函数执行时,对象还只是“半个派生类对象”,此时调用虚函数,解析到的是基类版本而非派生类版本。析构也是一样,析构到基类层次时,派生类部分已经先被销毁了,虚函数只能调到基类实现。笔试填空题如果问到“构造函数中调用虚函数会发生什么”,答案就是“静态绑定,调用当前正在构造的类对应的版本”。
2.2 虚函数表的底层机制:办公软件动态扩展模块的基石
笔试选择里还有一类常见问法,问的是虚函数的实现机制:一个包含虚函数的类,其对象内存布局中会多出一个指针,这个指针指向虚函数表(vtable),表中每一项是一个虚函数的函数指针。每个类维护一张自己的虚函数表,派生类如果覆盖了某个虚函数,表中对应位置的指针就会被替换为派生类版本的地址。
这里有一个容易被忽略的细节:虚函数表是类级别的,不是对象级别的。同一个类的所有对象共享同一张虚函数表,每个对象里只存了一个指向这张表的指针,这就是为什么虚函数会带来一点点额外的内存开销。如果笔试考“虚函数导致对象变大的原因”,回答应该是:对象中增加了一个vptr指针,通常占8字节(64位平台),而不是“复制了整个虚函数表”。
办公软件里为什么离不开多态?一个很典型的场景就是文档格式支持。WPS这类办公套件要支持doc、docx、dot、rtf、html、txt等多种文本格式的导入导出,每种格式的解析规则和序列化规则差异极大。如果不用虚函数,解析器主流程里就得写一个巨大的switch-case,每支持一种新格式就要改主流程代码,侵入性极强,极易改崩老功能。用抽象基类定义parse和serialize接口,每种格式提供一个子类实现,主流程只依赖抽象基类,新增格式不需要改动已有代码,这就是开闭原则在真实产品里的落地。笔试不会直接问开闭原则,但它考的多态,本质上就是在确认你有没有建立这种设计意识。
还有一个高频变形题:“一个类有多个虚函数,它的虚函数表有多大?”答案不是“有几个虚函数就有几项”,而是至少包含所有虚函数对应的函数指针项,另外析构函数如果也是虚的,也会占一项。派生类中新增的虚函数会追加在表的末尾。至于“纯虚函数能不能实例化”“抽象类能不能作为参数类型”,这类小知识点虽然简单,但容易在判断题里设坑,复习时过一遍即可。
3. 智能指针与RAII:笔试从用法一直追到源码实现
3.1 unique_ptr、shared_ptr和weak_ptr的区别
办公软件研发岗的笔试卷,智能指针几乎是必考模块。这份卷子也不例外,而且它的考查方式很典型:先让你填unique_ptr、shared_ptr、weak_ptr三者各自适合什么场景,再让你判断某段使用shared_ptr的代码是否存在循环引用问题。
基础答案大家都会:unique_ptr独占所有权,不允许拷贝,只能移动,适合“一个对象只有一个所有者”的场景;shared_ptr共享所有权,通过引用计数管理生命周期,最后一个持有者销毁时释放对象;weak_ptr不增加引用计数,用于打破循环引用或者安全地观察某个shared_ptr管理的对象是否存在。
笔试答题只写到这个层面是不够的,因为这类题往往还有二问:“shared_ptr的引用计数存哪儿?为什么weak_ptr能判断对象是否存活?”这里面的关键机制是控制块。shared_ptr在堆上分配一个单独的控制块,里面保存两个计数:一个是use_count,也就是强引用计数;另一个是weak_count,也就是弱引用计数。真正持有对象的管理者是shared_ptr自身,weak_ptr只是持有一个指向控制块的指针。当一个shared_ptr被创建,use_count变成1;拷贝一次加1;销毁一个减1。use_count归零时,对象被销毁,但控制块不一定会被立刻释放,它要等weak_count也归零才释放。这就是weak_ptr能安全判断对象是否存活的原因:它检查的是控制块里的use_count,对象虽然销毁了,但控制块里的信息还在。
还有一道特别经典的选择题:“std::make_shared和直接new传给shared_ptr构造有什么区别?”答案是:make_shared会一次性分配一块内存,同时容纳对象和控制块,只做一次内存分配;而new加shared_ptr构造会先分配对象内存,再在构造shared_ptr时分配控制块内存,一共两次分配。笔试考这个点的目的,是看你有没有真正读过智能指针的源代码实现,而不是只会用。从实际性能角度讲,make_shared减少一次堆分配,对高频创建销毁对象的场景优化明显;但代价是控制块和对象在同一块内存里,只要weak_ptr还存活,对象所占的内存即使已经析构也不能归还给系统,可能造成内存碎片和驻留问题。如果笔试的多选题里有“make_shared的潜在缺点”这个选项,指的就是这个。
3.2 循环引用:这个坑WPS这类大型客户端必须躲开
循环引用是shared_ptr使用中最经典的陷阱。笔试往往会给出类似这样的代码片段,让你判断会不会内存泄漏:
class Parent { public: std::shared_ptr<Child> child; }; class Child { public: std::shared_ptr<Parent> parent; }; std::shared_ptr<Parent> p = std::make_shared<Parent>(); std::shared_ptr<Child> c = std::make_shared<Child>(); p->child = c; c->parent = p;不用细想,这两个对象谁都不会被销毁。因为Parent的use_count被child里的parent引用加到了2,Child的use_count被执行栈上的c加到了2。函数结束,栈上的p和c销毁,各自use_count从2减到1,但彼此内部仍然互持,引用计数始终到不了0,内存泄漏就在这个看似正常的代码里发生了。
办公软件里循环引用一点都不罕见。文档对象和其内部的样式对象可能互指,界面控件和文档对象可能互指,事件监听器和业务对象之间更容易形成环。笔试正确答案是把其中一个方向的引用换成weak_ptr,比如Child的parent改成std::weak_ptr ,这样Child观察Parent时不会增加引用计数,环就被打破了。
我建议复习的时候自己动手把shared_ptr的控制块结构画一遍,把use_count和weak_count的生命周期理清楚。笔试问“weak_ptr能访问对象成员吗”,答案是不能,它要调用lock()提升为shared_ptr才能访问;问“lock()失败返回什么”,返回一个空的shared_ptr,而不会像裸指针那样悬空。这类细节不需要死记硬背,理解了控制块机制,现场推导也来得及。
还有一类关于智能指针的高频判断题:智能指针能不能用于C风格数组?unique_ptr可以,因为unique_ptr<T[]>提供了专门针对数组的特化版本,支持operator[];但shared_ptr对数组的支持就差很多,早期标准里shared_ptr<T[]>是不完整的,直接使用会有问题。如果笔试题给的是一个C风格数组指针赋值给shared_ptr的代码,多半要在正确性上打个问号。办公软件里大量操作字节流缓冲,这种细节在实际编码里真的会碰到。
4. STL容器三连问:扩容、迭代器失效与底层选择逻辑
4.1 vector扩容为什么不是简简单单“翻倍”
STL相关的题目占了这份笔试不小的比重,其中vector是绝对主角。最经典的题目形式是:给定一个vector,连续push_back若干次,问过程中内存重新分配了几次;另一个变形是问“vector的扩容因子通常是多少,为什么”。如果你只背过“VS下是1.5倍、GCC下是2倍”,那说明还没完全吃透。
vector扩容的基本逻辑是:当size()等于capacity()时,分配一块更大的内存,把旧元素拷贝或移动到新内存,释放旧内存。扩容因子过大会浪费空间,过小会导致频繁扩容,每次扩容都是O(n)的元素搬运。2倍因子在均摊分析下,每次push_back的复杂度是O(1)均摊,这是数学上可证明的:因为每次扩容搬运的元素数量总和是O(n),平摊到n次插入里就是O(1)。1.5倍的好处是内存复用率更高,更适合对内存碎片敏感的平台;而2倍的几何级数增长在所有情况下都能保证严格均摊O(1)。笔试如果深入问“为什么1.5倍也能保证均摊复杂度”,你得能说出一个关键点:只要扩容因子大于1,元素搬运的总代价就是O(n)级别,均摊下来都是常数复杂度。
关于“移动构造会不会参与vector扩容”,这里有个C++11之后的重要变化:当元素类型提供了noexcept的移动构造函数时,vector扩容会用移动构造搬元素;如果没有noexcept的移动构造,而是可能抛出异常的拷贝构造,vector为保证异常安全会退回拷贝构造。这个知识点笔试直接考的概率不高,但面试追问经常出现,因为实际编码中它会影响vector的性能和异常安全性。
办公场景里最直接相关的例子是 Document 数据结构。一份大型文档可能有几十万个段落对象,如果用vector存储段落指针,频繁插入删除导致扩容时,几万次对象搬运的成本会直接卡顿用户界面。所以大型编辑器的核心数据结构很少直接用裸vector,而是用vector<pair<char*, size_t>>这类轻量描述结构,或者干脆用块状链表/rope数据结构。笔试题考vector扩容,本质上是考察你有没有这种“容量和性能”的敏感度。
4.2 迭代器失效:看起来能跑,跑了就崩
迭代器失效是这份笔试中区分度非常高的一类考点,选择题出得也很刁钻。比如:vector执行erase操作后,哪些迭代器会失效?map执行erase后会失效吗?list呢?
vector的情况最严格:erase一个元素后,包括被删除位置在内、一直到end()的所有迭代器全部失效,因为元素需要前移填补空洞,地址变化了;insert导致扩容时,所有迭代器全部失效;insert不扩容但插在非尾部位置时,插入位置及其后的迭代器失效。list的情况最宽松:除了指向被删除元素的迭代器,其他迭代器都不失效,因为链表节点在内存中不连续,erase一个节点不影响其他节点的地址。map和set基于红黑树,erase一个节点时其他节点的内存地址不变,因此也只有被擦除节点的迭代器失效。
笔试最常见的坑是循环中边遍历边erase。很多人写这样的代码:
for (auto it = v.begin(); it != v.end(); ++it) { if (*it % 2 == 0) { v.erase(it); } }在vector中erase之后,it已经失效,再对it执行++操作是未定义行为。正确的写法是:
for (auto it = v.begin(); it != v.end();) { if (*it % 2 == 0) { it = v.erase(it); } else { ++it; } }因为erase返回的是指向下一个有效元素的迭代器。作std::map和std::list里,传统写法用erase(it++)通常也能工作,但在vector里绝不能这么干。办公软件的文档大纲编辑、表格行筛选等场景,本质上都是“遍历容器+条件删除”的反复操作,笔试考这点非常有针对性。
4.3 map与unordered_map:办公软件里的千万级别查询怎么选
笔试还有一道题问map和unordered_map的底层数据结构和时间复杂度。标准答案是:map是红黑树,有序,查找/插入/删除均为O(log n);unordered_map是哈希表,无序,平均O(1),最坏O(n)。接下来通常会追问:什么时候用unordered_map更好,什么时候必须用map。
办公软件里有一个非常典型的二分点:缓存某个文档ID对应的缓存对象指针,访问频率极高,且对顺序没有要求,用unordered_map更合适,因为哈希查找的常数时间远快于红黑树的log n;如果需要按照键的顺序遍历文档目录、生成有序目录树,或者执行范围查询FindNext等操作,map更合适。
还有一个笔试容易踩的坑:unordered_map的自定义键类型必须提供哈希函数和相等比较函数,而map只需要提供小于运算即可。有的选择题会给一段代码,用自定义结构体作为map键时重载了operator<,编译没问题;换到unordered_map后忘了写std::hash特化,直接编译失败。这道题考的就是你对“有序容器靠小于号,无序容器靠哈希”这个底层区别的理解,而不是背模板代码。
5. 内存布局与字节对齐:大型文档处理稳不稳,全看这些细节
5.1 字节对齐的规则与计算
这份笔试题中涉及内存对齐的填空题,是很多人的失分点。题目的典型形式是:给定一个结构体,问sizeof()的值是多少。比如:
struct Test { char a; // 1字节 double b; // 8字节 int c; // 4字节 };如果你直接把1+8+4算成13,那就掉坑里了。在64位平台上,double的对齐要求是8字节,所以结构体首地址对齐到8,char填充7个字节后放double,再放入int,整体占用1+7+8+4=20字节,再对齐到8的倍数,最终sizeof得到24字节。如果把成员顺序换成double b; int c; char a;,占用的空间会变成8+4+1+3=16字节,比原来的排列节省了8字节。这就是为什么资深C++开发者对成员声明顺序有强迫症:同一组字段,调整顺序就能省内存,在大规模对象数组中节省的非常可观。
笔试经常给的另一个变体是包含数组或指针的结构体的对齐计算。对齐规则的本质是编译器确保每个成员的起始地址都能被其对齐系数整除。对齐系数一般等于成员类型大小,但有平台差异。比如在32位平台double的可能对齐到4字节,64位Windows和Linux下通常是8字节。如果记不准确,最好的办法是平时在编译器里用offsetof宏打印每个成员的偏移量验证一下,笔试前心里就有底。
办公软件中字节对齐的重要性,体现在自定义文件格式的内存映射解析上。读写二进制文档时,如果直接把文件缓冲区强转为结构体指针,而结构体没有按文件格式的对齐要求声明,可能在某些平台上直接触发总线错误或解析出错误字段。WPS这类软件长期在x86、ARM架构上跨平台运行,内存对齐是必须考虑的基础问题。
5.2 C++对象的内存布局:从vptr到成员变量的排布
与内存对齐相关,笔试还喜欢考C++对象的内存布局。例如:一个包含虚函数的类对象,其内存布局从低地址到高地址依次是vptr、按声明顺序排列的非静态成员变量;static成员变量不属于对象实例,存储在静态存储区,布局中不占空间。
有一个选择题很有迷惑性:基类和派生类的成员变量在对象中的排布顺序。答案是基类子对象在前,派生类新增成员在后;多个基类按继承声明顺序排列。如果基类有虚函数,每个基类对应的vptr排在相应基类子对象的开头。多重继承下派生类可能有多个vptr,分别对应不同基类的虚函数表。这类题目在纸上画一遍对象布局图就能拿分,不需要死记硬背。
理解了对象布局之后,有一类追问必考:C风格的强制类型转换,把一个Base*转成另一个不相干类型的指针,会发生什么。答案是未定义行为,可能得到错误的数据、错误的vptr,甚至崩溃。办公软件中做插件系统或者脚本绑定的时候,如果到处用C风格强转,迟早会出现“数据错位但程序不立即崩溃、隔很久才炸”的疑难bug。C++提供的static_cast、dynamic_cast、const_cast、reinterpret_cast各有明确的语义和限制,笔试选择题里经常考它们之间的区别,核心考点是:dynamic_cast用于多态类型的向下转换,失败时返回nullptr(指针)或抛出bad_cast异常(引用);static_cast不做运行时检查,转换失败是未定义行为。
6. 从笔试题到面试追问:把一个考点答成一棵树
很多同学做笔试题目,对了答案就翻篇,这是最可惜的。这份试卷的题目覆盖面广,但每一道题背后都能生发出三到五层追问,面试官问到的概率极高。我陪朋友复盘时发现,这份笔试真正难的不是第一层答案,而是题目里留的那些“钩子”——也就是你写完答案之后,面试官紧接着问的那个“为什么”。
以虚函数那道题为例。第一层:基类析构函数为什么要声明为虚函数?答出资源泄漏即可。第二层:构造和析构函数中调用虚函数会怎样?这考的是对象生命周期中的动态类型变化。第三层:虚函数表的指针存在对象的哪个位置?多个继承体系下对象里会有几个vptr?这考的是内存布局。第四层:虚函数能不能是static的?构造能不能是virtual的?这考的是概念边界。每一层往下挖一点,你从“会背答案”变成“真的理解”的时间,也就是把一道题扩成十个知识点的过程,这恰恰是准备校招C++笔试最有效的方法。
这种“追问题”的复习方法,比刷题数量重要得多。我自己带过几个准备校招的同学,一个典型的对比是:A同学刷了三百道题,每道题对完答案就过,笔试过了但面试时被问深一层就卡壳;B同学只系统整理了五十个核心知识点,但每个知识点都向下挖了三层,笔试和面试反而都更稳。笔试是筛选“下限”的,面试才是决定“上限”的,而那些看似简单的知识点,正是面试官用来区分你是“背了答案”还是“吃透了原理”的最好工具。
7. 备考C++校招笔试的节奏安排与资料取舍
复习安排上,我根据自己的经验和朋友实践,推荐一个四周计划,适合目标明确奔着C++开发岗位、每天能投入四小时左右的同学。
第一周主攻C++语言核心:虚函数、多态、继承、拷贝控制、运算符重载、const语义、左值右值与移动语义、智能指针。这一周的目标是“闭上眼睛能画出类对象的内存布局”。第二周主攻STL:vector、list、map、unordered_map、set的底层结构和迭代器失效规则,以及算法库里的排序、查找、二分边界问题。每天写两段涉及容器操作的小代码,验证各种操作后的迭代器状态。第三周主攻操作系统和网络的基础题:进程线程区别、上下文切换开销、互斥锁与条件变量、select/poll/epoll区别、TCP三次握手与四次挥手、TCP和UDP的应用场景。不要贪深,把底层原理吃透。第四周集中做试卷和复盘,把错题按知识点归类,每道错题写五十字的知识点笔记,考前一小时只看笔记。
资料方面,我不建议囤一大堆书。一本经典的C++教材,加上cppreference网站查漏补缺,再配上两三套历年真题就足够。闲鱼里有不少二手讲义,但内容质量参差不齐,反而容易带偏复习方向。真正高价值的复习材料,是你在实际编码过程中积累的“报错笔记”和“踩坑记录”,比如:某次因为忘记把析构函数声明为virtual,导致一个后台转换服务内存持续上涨;某次在循环里erase vector迭代器,程序在Debug模式直接断言失败。这些真实案例在笔试时你会“条件反射”地想到,比任何面试资料都管用。
做历年真题的时候,我还有一个室验过的技巧:给自己限时。单选题五分钟做完,大题二十分钟。模拟真实考场的高压状态,训练自己在时间压力下稳定输出。很多同学平时做题正确率很高,一到考场因为前面选择题纠结太久,后面的大题时间不够,反而写不完。建议是先做有把握的题,标记纠结的题,最后再返回去填。这个答题顺序对笔试得分的影响,有时比你多复习几百道题还大。
8. 我在实际复习这份试卷过程中印象最深的三件事
第一件事,是shared_ptr循环引用的那道题。我第一次做的时候,觉得代码写得没什么问题,两个对象互指而已,结果一算引用计数,发现两个对象都不可能被释放,后背一阵发凉。后来我在一个文档缓存模块里真的遇到过类似的bug:两个缓存对象互相引用,程序跑了一段时间内存越涨越离谱,最后通过排查引用计数才发现是循环引用。从那以后,我写shared_ptr的代码之前都会先画一遍引用关系图,一旦发现成环的可能,马上换成weak_ptr。笔试考这个点,真的不是脱离实际的八股。
第二件事,是vector扩容那道题。笔试要求计算出重复插入过程中的拷贝次数,我一开始只背了“2倍扩容”这个结论,但真正做题时才发现要结合一定数量的插入次数和初始容量一个个推演。推了一两遍之后,对扩容机制的理解从“背答案”变成了“能自己推导”。之后我在优化一个高频日志收集模块时,故意提前reserve了足够的容量,硬是把每秒上万次日志追加的延迟降了下来。知识和实际场景一旦打通,记忆就会非常牢固。
第三件事,是内存对齐那道填空题。我最早学的时候就觉得这是编译器的“内部事务”,和我写业务代码没什么关系。直到有一次我在读一个自定义二进制文档格式时,发现同样的代码在不同架构平台上解析结果不一样,追了一天最后定位到结构体填充字节不一致。从那天起我再也不小看sizeof和offsetof这种基础细节。笔试把它拿出来考,背后其实是在筛选那些“真的写过系统级代码”的人,而不仅仅是“把语法书翻了三遍”的人。
这份《金山办公2020校招C++开发工程师笔试题(一)》并不难,但它很有性格,它不像算法题那样烧脑,也不像框架题那样追新,它考的就是你作为一个C++底层开发者的“内功”。如果你正准备校招,我真心建议从这份试卷入手,把里面每一个考点当成入口,往下挖三到五层,你就知道办公软件这个领域到底需要什么样的C++工程师了。