拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C/C++校招面试八股复盘:从sizeof、对齐到虚函数与内存管理

C/C++校招面试八股复盘:从sizeof、对齐到虚函数与内存管理

秋招那年我面到第七家的时候,遇到一位特别爱问"最基础"问题的面试官。前面聊项目聊得挺顺,他突然来一句:"struct 里放一个 char、一个 int、再放一个 char,sizeof 是多少?"我脱口而出 6,然后看着他在简历上画了个圈。那天晚上回去把对齐规则重新推了一遍才反应过来,这道题问的根本不是加法,而是你到底有没有在内存这个层面上真正想过代码长什么样。

这篇东西是我自己复盘校招笔试和面试时整理的一份 C/C++ 基础题清单。它不追求覆盖所有偏门考点,只把那些"每年都会出现、而且面试官特别爱顺着往下追问"的点拉出来讲透。适合正在准备秋招春招的在校生,也适合工作一两年想回头把底层补一补的人。下面每一块我都会先说题目长什么样,再说答案是什么,最后讲清楚为什么答案是这样——因为真正拉开差距的从来不是结论,而是你解释结论的方式。

1. 校招八股的真相:基础题决定你会不会被"一票否决"

1.1 笔试的三个分数段与失分分布

C/C++ 岗的笔试通常两到两个半小时,前一半是选择题和填空,后一半是三到四道编程题。很多人把精力全砸在编程题上,觉得选择题靠直觉就能过,结果往往是被选择题卡在门外。

我统计过自己和身边同学的十几场笔试,大致分成这么几个档位:

分数段典型表现主要卡在哪
60 分以下编程题只做出半道选择题大面积错,指针和 sizeof 基本靠蒙
60 到 80 分编程题过一到两道选择题错 5 到 8 个,集中在对象模型和关键字
80 分以上编程题全过或差一点选择题错 1 到 2 个,扣在偏门语法或位运算

看这张表能发现一个规律:编程题决定你的上限,选择题决定你的下限。而选择题里失分最狠的,几乎永远是那几个看上去最没技术含量的东西——数组名、对齐、虚函数表位置。这些题不考你会不会写业务逻辑,只考你有没有真正理解编译器在你背后做了什么。

注意:笔试选择题往往不倒扣分,遇到完全没思路的先用排除法,别空着。但如果你对某一个考点有清晰记忆,也别因为"感觉太简单了肯定有坑"而改答案,改错的概率远大于改对。

1.2 为什么面试官偏爱从"最基础"的地方开始追问

站在面试官的角度想一件事就够了:项目是可以包装的。你在简历上写"主导了某模块性能优化",面试官没法当场验证你到底做了多少。但基础知识是包不了的,你懂就是懂,不懂就是不懂,而且成本极低——随便问一句就能判断出你的底子。

更关键的是,基础题有极强的"可追问性"。一个 sizeof 的问题能往外延伸出对齐规则、编译器默认对齐值、#pragma pack、位域、空结构体、带虚函数的类,一路问下去能连问十分钟。面试官真正想看的不是你会不会背答案,而是你能不能在被追问到第三层、第四层的时候依然保持逻辑清晰。

所以准备八股的正确姿势不是背结论,而是给每个结论都配一条"为什么"的链条。你背了"虚析构要加 virtual",面试官追问"为什么"你答不上来,那这道题等于没答;你能把基类指针 delete 派生类对象的过程讲清楚,哪怕结论记混了,面试官也会觉得你有救。

2. 指针、数组与 sizeof:笔试选择题的重灾区

2.1 数组名退化这件事,决定了你一半的选择题

数组名在绝大多数表达式里会"退化"成指向首元素的指针,但有三个场合例外:作为 sizeof 的操作数、作为取地址符 & 的操作数、以及用字符串字面量初始化字符数组。记住这个例外清单,一大半选择题就稳了。

char str[] = "hello"; char *p = str; sizeof(str); // 6,包含结尾的 '\0' sizeof(p); // 8,64 位平台上指针大小 strlen(str); // 5,不算 '\0' strlen(p); // 5 sizeof(*str); // 1,*str 是 char

最容易翻车的是把数组当函数参数传进去之后还去 sizeof:

void f(int a[10]) { sizeof(a); // 8,参数里 int a[10] 等价于 int *a } void g(int (&a)[10]) { sizeof(a); // 40,引用传数组,不会退化 }

int a[10]写在形参列表里,编译器直接把它当成int *a,那个 10 是纯装饰,不产生任何约束。这也是为什么很多老代码里会用引用传数组,就是为了保住长度信息。

2.2 指针运算与二级指针的典型陷阱

指针的加减单位是"元素个数",不是字节数,这一点在选择题里被反复利用:

int a[5] = {1, 2, 3, 4, 5}; int *p1 = &a[0]; int *p2 = &a[4]; p2 - p1; // 4,元素个数 (char *)p2 - (char *)p1; // 16,字节数 *(p1 + 3); // 4 p1[3]; // 4,等价写法 3[p1]; // 4,也能编译过,因为 a[b] 展开成 *(a + b)

最后那行3[p1]不是段子,是真实能编译的表达式,因为下标运算符本质就是加减再解引用,交换律成立。笔试里偶尔会拿它当送分题。

再就是指针数组和数组指针的区别,核心就一句:[]的优先级高于*。

int *arr[10]; // 指针数组:10 个 int* int (*arr)[10]; // 数组指针:一个指向 int[10] 的指针

判断方法是从变量名出发,先看它和谁结合。*arr[10]里 arr 先和[]结合,所以它是个数组,元素类型是int *;(*arr)[10]里 arr 先和*结合,所以它是指针,指向一个长度为 10 的 int 数组。

还有一个高频坑是字符串字面量的修改:

char *s = "hello"; s[0] = 'H'; // 未定义行为,字面量在只读段 char s2[] = "hello"; s2[0] = 'H'; // 合法,s2 是栈上的副本

2.3 结构体字节对齐:一道题区分"看过书"和"真写过"

对齐规则说到底只有两条,背下来就能手算:

第一,每个成员的偏移量必须是min(该成员自身大小, 编译器对齐参数)的整数倍,不够就在前面补空洞。第二,整个结构体的大小必须是"所有成员里最大的那个对齐值"的整数倍,不够就在尾部补齐。

拿开头那道题举例:

struct A { char a; // 偏移 0,占 1 字节 int b; // int 对齐 4,偏移补到 4,占 4 字节 char c; // 偏移 8,占 1 字节 }; // 总大小:9 向上取整到 4 的倍数 = 12

如果把顺序调一下,结果完全不同:

struct B { char a; // 偏移 0 char c; // 偏移 1 int b; // 对齐 4,偏移补到 4 }; // 总大小:8

同样的成员,只调换顺序就省下 4 个字节。这就是"成员按大小从大到小排列"这条优化建议的由来,不是玄学,是算得出来的。想看编译器实际排布,可以用 offsetof 宏验证:

#include <stddef.h> offsetof(struct A, b); // 4 offsetof(struct A, c); // 8

至于为什么要对齐,答案在硬件层面:CPU 读取内存是按字长成块读的,一个 4 字节的 int 如果跨在两个块之间,可能需要两次访存再拼接,某些架构下甚至直接抛异常。对齐是用一点空间浪费换访问效率。

当然也有需要关掉对齐的场景,比如网络协议包解析、文件格式读写,这时候用#pragma pack(1):

#pragma pack(1) struct C { char a; int b; char c; }; // sizeof(struct C) == 6

提示:#pragma pack是编译期指令,属于非标准扩展,跨平台代码里最好用静态断言把结构体大小钉死,避免换编译器之后悄悄变了布局却没人发现。

3. 内存分区与生命周期:被追问最多的底层模型

3.1 五段式内存布局和它的实际意义

进程的内存空间通常被划成这么几块,虽然叫法有差异,但本质一样:

区域存放内容生命周期典型特征
栈局部变量、函数参数、返回地址随作用域向下增长,默认 8MB 左右
堆new / malloc 出来的内存手动管理向上增长,易碎片
已初始化数据段有初值的全局/静态变量程序运行期进可执行文件
未初始化数据段(BSS)无初值的全局/静态变量程序运行期不占文件体积
常量区字符串字面量、const 常量程序运行期只读
代码段机器指令程序运行期只读

BSS 段单独存在的原因值得多想一层:如果一块内存全是 0,没必要在可执行文件里真的存一堆 0,只需要记下"有这么一段、大小多少",加载时统一清零就行。这是可执行文件体积优化的经典手段。

int g_init = 1; // 已初始化数据段 int g_uninit; // BSS static int s_val = 2; // 已初始化数据段 const char *s = "abc"; // 指针 s 在数据段,字面量 "abc" 在常量区 void f() { int local = 0; // 栈 int *heap = new int(1); // 堆 }

顺着这个模型能问出很多题:为什么局部数组不能返回?因为栈帧在函数返回时就被回收了。为什么递归太深会崩?因为栈空间有限,每层调用都要压栈。为什么常量区的东西不能改?因为它映射成了只读页。

3.2 new/delete 与 malloc/free 的本质差别

这道题几乎每场笔试都有,但很多人只能答出"一个会调构造一个不会"。完整的对比应该是这样:

对比项new / deletemalloc / free
本质运算符,可重载库函数
是否调构造/析构调用不调用
返回类型具体类型指针void*,需强转
大小计算编译器自动算手动传字节数
失败行为抛 bad_alloc返回 NULL
数组形式new[] / delete[]无

new 在底层其实是三步:先调operator new分配原始内存,再在这块内存上调用构造函数,最后返回指针。delete 反着来:先调析构,再调operator delete释放。

这解释了一个经典错误:

A *p = new A[3]; delete p; // 错误:只调用一次析构,且释放方式可能不匹配 delete[] p; // 正确

为什么delete p会出问题?因为编译器在new[]分配的内存前面往往记录了一个数组长度,delete[]会读取这个信息逐个调用析构。你用delete就跳过了这段逻辑,结果是部分对象没析构,内存释放的起点也可能错位。

还有一个能加分的点叫 placement new,它只负责在已分配的内存上构造对象,不分配:

char buf[sizeof(A)]; A *p = new (buf) A(); // 在 buf 上构造,不申请新内存 p->~A(); // 手动析构

3.3 野指针、悬垂指针、内存泄漏的三种典型写法

这三个概念经常被混着问,其实成因完全不同。

野指针是指针本身没初始化,指向一个随机地址:

int *p; // 野指针 *p = 1; // 未定义行为

悬垂指针是指针曾经有效,但指向的对象已经没了:

int *p = new int(1); delete p; *p = 2; // 悬垂,访问已释放内存

注意delete p之后 p 自己的值没变,它还是指向那块旧地址,所以工程里通常要求delete之后立刻置空。

内存泄漏最容易出现在异常路径或者提前 return 的分支上:

void f() { int *p = new int(1); if (some_condition) { return; // p 泄漏 } delete p; }

C++ 里对付这个的标准答案是 RAII,把资源绑定到对象的生命周期上:

void f() { auto p = std::make_unique<int>(1); if (some_condition) { return; // 出作用域自动释放 } }

提示:std::make_unique和std::make_shared不只是语法糖。前者能避免裸 new 的异常安全问题,后者能把控制块和对象内存合并分配,少一次内存申请。代码评审里看到裸 new,通常都会被要求改掉。

4. C++ 对象模型:虚函数不是"背概念"能糊弄过去的

4.1 vptr 与虚表:从 sizeof 题说起

只要一个类有虚函数,它的对象里就会多出一个隐藏的指针成员,指向该类的虚函数表,这个指针通常叫 vptr。64 位平台上它占 8 字节。

class Base { virtual void f(); int a; }; sizeof(Base); // 16 = vptr(8) + int(4) + 填充(4)

如果类没有虚函数,同样的成员只需 8 字节。这道题真正的考点是:你要知道虚函数是有存储成本的,不是零开销抽象。

再往下追问多继承:

class A { virtual void fa(); }; class B { virtual void fb(); }; class C : public A, public B { };

C 的对象里会有两个 vptr,分别对应 A 和 B 两个基类子对象。这就是为什么多继承下指针转换时可能会有偏移调整,也是为什么很多大厂代码规范里对多继承态度谨慎。

虚函数表本身在编译期就确定好了内容,通常放在只读数据段。对象里的 vptr 在构造函数执行期间被设置,这也是"构造函数里调虚函数不产生多态"的根本原因——那时候 vptr 还没指向派生类的表。

4.2 构造析构顺序与虚析构的必要性

顺序规则很干净:构造是基类、成员、自身;析构是自身、成员、基类。像剥洋葱,反着来。

虚析构的必要性用一个例子就能说明白:

class Base { public: ~Base() { } }; class Derived : public Base { int *data; public: Derived() { data = new int[100]; } ~Derived() { delete[] data; } }; Base *b = new Derived(); delete b; // 只调用了 Base::~Base

问题出在delete b这一步:编译器看 b 的静态类型是Base*,如果~Base不是虚函数,它就直接调Base::~Base,Derived::~Derived根本不会执行,data那块内存就漏了。给基类析构加上virtual之后,调用会被派发到实际类型的析构函数,派生类部分先清理,再回到基类。

由此还能牵出一条经验规则:只要一个类可能被当作多态基类使用,析构函数就必须是虚的。反过来,如果一个类不打算被继承,可以考虑加final,既明确意图又给编译器优化空间。

构造函数里调用虚函数是另一道常考题:

class Base { public: Base() { f(); } // 调用的是 Base::f,不是派生类版本 virtual void f() { } };

原因前面说过,基类构造阶段 vptr 还指向基类的虚表,多态派发无从谈起。析构阶段同理,派生类先析构完,vptr 回退到基类,此时在基类析构里调虚函数也只会命中基类版本。

4.3 拷贝控制:三法则、五法则和深浅拷贝

先看一个能直接写出 bug 的类:

class String { char *data; public: String(const char *s) { data = new char[strlen(s) + 1]; strcpy(data, s); } ~String() { delete[] data; } };

这个类没写拷贝构造,编译器会生成一个默认版本,做的是逐成员拷贝,也就是把data这个指针的值复制一份。结果是两个对象指向同一块内存:

String a("hi"); String b = a; // 浅拷贝,b.data 和 a.data 指向同一块

出作用域时两个析构函数都会delete[] data,同一块内存被释放两次,程序直接崩。这就是深拷贝要解决的问题——拷贝构造里重新分配一块内存,把内容复制过去:

String(const String &o) { data = new char[strlen(o.data) + 1]; strcpy(data, o.data); } String &operator=(const String &o) { if (this == &o) return *this; // 自赋值检查 delete[] data; // 先释放旧资源 data = new char[strlen(o.data) + 1]; strcpy(data, o.data); return *this; }

赋值运算符比拷贝构造多了两件事:自赋值检查、释放已有资源。自赋值不检查的话,a = a会先把 a 自己的内存删掉,再去读已经被删的内容,直接踩空。

C++11 之后这套规则扩展成了五法则,加上移动构造和移动赋值:

String(String &&o) noexcept : data(o.data) { o.data = nullptr; // 把源对象的指针摘走,避免它析构时释放 }

移动语义的核心是"窃取资源"而不是复制,代价从 O(n) 降到 O(1)。工程里更推荐的是 Rule of Zero:让资源由智能指针或标准容器管理,自己的类一个拷贝控制函数都不写,编译器生成的版本反而最不容易出错。

5. 关键字专题:const、static、volatile 的边界

5.1 const 的四种组合与"就近原则"

const 修饰指针的组合是笔试填空题的常客,用一句话就能记牢:const 修饰它左边的东西,左边没有就修饰右边。

写法读法指针本身可改指向内容可改
const int *p指向常量的指针可以不可以
int const *p同上可以不可以
int * const p常量指针不可以可以
const int * const p都不可改不可以不可以

注意前两种写法语义完全等价,只是把 const 放在了不同类型名的位置,这也是"就近原则"能统一解释它们的原因。

再看 const 成员函数:

class T { int a; public: int get() const { // this 的类型是 const T*,不能改成员 return a; } };

const 成员函数里的 this 是const T*,所以不能修改普通成员。如果确实需要在 const 函数里改某个成员,可以给成员加mutable,典型场景是缓存和互斥锁。

const 还能构成重载:

T &operator[](size_t i); const T &operator[](size_t i) const;

非 const 对象优先选非 const 版本,const 对象只能选 const 版本。这不是语法糖,而是让只读访问真正带上 const 语义,防止你把一个 const 容器的元素改掉。

5.2 static 的三层含义

static 在不同位置含义完全不同,这也是它容易混淆的原因。

修饰局部变量时,它改变的是生命周期,不影响作用域:

void f() { static int cnt = 0; // 只初始化一次,程序结束才销毁 ++cnt; }

每次调用 f,cnt 都保留上次的值。这条特性常被用来实现函数内单例或者统计调用次数。

修饰全局变量和函数时,它改变的是链接属性,从外部链接变成内部链接:

static int g_val = 1; // 只在当前编译单元可见 static void helper() { } // 同理

好处是避免命名冲突,坏处是每个编译单元各有一份,不适合放大对象。

修饰类成员时,它表示成员属于类而不是对象:

class Counter { static int total; public: static int get() { return total; } // 没有 this 指针 };

静态成员函数没有 this,所以不能访问非静态成员。C++17 之后可以用inline static int total = 0;在类内直接初始化,省掉类外定义的样板代码。

5.3 volatile 能做什么、不能做什么

volatile 的作用是告诉编译器:这个变量的值随时可能被外部改变,每次访问都要老老实实从内存读,不许缓存到寄存器里,也不许把相邻的读写重排掉。

它最典型的场景是内存映射的硬件寄存器,或者信号处理函数里用来打断主循环的标志位:

volatile int flag = 0; // 某个中断里改 flag void handler() { flag = 1; } // 主循环轮询 while (!flag) { }

如果 flag 不加 volatile,编译器看到循环里没人改它,很可能把读取优化成只读一次,循环就变成死循环。这是 volatile 真正能解决的问题。

但它经常被误解成"多线程同步工具",这是错的。volatile 不保证原子性,也不保证内存序。两个线程同时++一个 volatile 变量,依然可能丢更新;一个线程写标志、另一个线程读数据,也依然可能读到旧值。多线程场景该用std::atomic或者互斥锁加条件变量:

std::atomic<bool> flag{false}; // ... flag.store(true, std::memory_order_release);

volatile 和 const 可以同时使用,比如const volatile int *p,表示程序自己不写这块内存,但硬件会写,读的时候必须重新取。

6. 手撕代码环节:面试官其实在看这五件事

6.1 高频题型清单与权重

手撕代码的题目类型其实相当集中,把下面这些练透,覆盖八成以上的场次没问题:

题型出现频率常见变体
链表反转 / 合并极高区间反转、K 个一组
快速排序 / 归并排序高求第 K 大、链表排序
二分查找高找边界、旋转数组
字符串处理高手写 strcpy、字符串转整数
二叉树遍历中层序、按层打印、最近公共祖先
动态规划中背包、最长子序列
LRU 缓存中哈希加双向链表
多线程交替打印中条件变量、信号量

C/C++ 岗还有一个其他语言岗不怎么考的方向:手写标准库函数和智能指针。strcpy、memcpy、shared_ptr的引用计数部分、单例模式、生产者消费者模型,这些出现的概率比想象中高。

手写 strcpy 的标准答案长这样:

char *my_strcpy(char *dst, const char *src) { assert(dst != NULL && src != NULL); char *ret = dst; while ((*dst++ = *src++) != '\0') { // 空循环体 } return ret; }

面试官会重点看四点:返回值是不是char *好支持链式调用、有没有处理 NULL、const 有没有加在 src 上、循环写法够不够简洁。

顺便区分一下memcpy和memmove:前者要求源和目标内存不重叠,后者通过判断方向处理了重叠情况。答出这个区别通常能拿到额外印象分。

6.2 边界条件清单:写完先自测一遍

代码写完不等于结束,主动说一句"我先走几个边界用例"往往比多写十行更有用。下面这份清单可以在心里过一遍:

  • 输入指针是不是可能为 NULL
  • 容器长度为 0 或者 1 时结果对不对
  • 首元素、尾元素是否被漏处理
  • 有重复元素时逻辑是否还成立
  • 数值运算会不会溢出,参数有没有负数
  • 递归的终止条件是否覆盖所有分支
  • 循环变量的自增位置是否会造成死循环或越界

把这份清单练成肌肉记忆,比临场硬想靠谱得多。面试官看到你主动检查边界,对代码质量的信任度会直接上一个台阶。

6.3 从"能跑"到"能过审"的细节

面试和刷题最大的区别是有人看着你写,所以过程本身也是评分项。

先花一两分钟把思路讲清楚,等面试官点头再动手。很多人一上来就敲键盘,写到一半发现方向不对,返工的时间比先讲清楚多得多。

变量名要有意义,i、j用在循环里没问题,但把链表节点叫node1、node2就不太好了。写完之后主动报一下时间和空间复杂度,这是基本功,不用等别人问。

申请的内存要配对释放,new和delete、new[]和delete[]都不要错配。面试官看到裸 new 很可能追问"如果中间抛异常怎么办",这时候你能接上 RAII 或者unique_ptr,就把一个扣分点变成了加分点。

还有一点常被忽略:别用太冷门的库函数。你用了一个面试官一时没反应过来的接口,他不会觉得你知识面广,只会觉得这段代码他不放心。用最朴素、最通用的写法就对了。

7. 面试追问的应对:把八股讲成自己的理解

7.1 追问链路长什么样

面试官问基础题通常不是问一个孤立的知识点,而是带着一条预设的追问链路来的。你提前把链路想清楚,回答的时候就能掌握节奏。

拿虚函数举例,一条典型链路是这样的:虚函数怎么实现多态的,到你答 vptr 和虚表,接着问虚表放在哪,然后问为什么构造函数不能是虚函数,再问为什么基类析构要加 virtual,最后落到多继承下有几个 vptr、虚继承为什么引入 vbptr。整条链走下来是十分钟,考的是你有没有形成完整的知识网络。

再比如 sizeof,链路是:算一个结构体大小,到对齐规则,到为什么需要对齐,到怎么关掉对齐,到#pragma pack(1)的代价,最后可能落到缓存行和伪共享。你会发现每一个"为什么"背后都能再挖一层。

7.2 用"结论—原理—代价"三段式回答

被追问的时候最忌讳只给结论。我自己的习惯是按三段式组织语言:先给答案,再解释机制,最后补一句这个方案的代价或者适用边界。

比如问"为什么基类析构要虚",三段式可以这么答:结论上必须加,否则基类指针删派生类对象时派生类析构不会执行;原理上是因为析构函数的调用也走虚表派发,不加 virtual 就按静态类型调用;代价上加了虚函数之后类会有 vptr,对象变大,而且不再满足某些平凡类型的条件,不能被 memcpy 直接搬运。

这个结构的好处是它让面试官感觉到你不仅知道怎么做,还知道这么做要付出什么。工程能力很多时候就体现在后一半。

7.3 不会的时候怎么接

坦白说,面试里遇到完全不会的题是常态,关键看你怎么接。

最差的做法是沉默或者硬编。稍微好一点的做法是承认不会,然后补一句你知道的相邻知识,再给出你的推测思路。比如问你某个 C++20 概念的具体语义,你可以说"这个我没实际用过,但按我对标准演进的理解,它大概是为了解决某某问题,我猜它可能是通过某某机制实现的"。面试官通常不会因为这一题否定你,但如果你的推测思路清晰,反而会加印象分。

还有一个技巧是主动把问题往你熟悉的方向引导。面试官问一个偏门的编译器扩展,你可以说"这个我不熟,不过我记得它和内存对齐有关,对齐这块我比较熟,要不要我讲一下#pragma pack的坑"。把话题拉回你的主场,这是一个很实用的经验。

我在整个秋招里最大的体会是,八股本身并不难,难的是把它讲成"我记得"而不是"我背过"。背过的答案在第二个追问下就会露馅,而真理解的东西哪怕表达得生涩一点,面试官也听得出来。我自己的做法是每个知识点都逼自己用一句话说清"它解决什么问题、怎么解决的、代价是什么",这三句话凑不齐,就说明这块还没真正吃透。还有一个特别笨但特别有效的办法:把常见考点整理成问答对,每隔几天随机抽一条自问自答,答不顺的做标记,反复几轮下来基本就烂熟了。

返回列表