简介一位在 22 届秋招中斩获华为、荣耀、联发科、海康威视等 offer 的求职者将自己备考 C 岗位时的知识笔记沉淀为这份 PDF 资料适合即将参加校园招聘或社会招聘 C 开发岗的读者使用。内容围绕 C 基础、操作系统、计算机网络、数据库四大高频模块展开重点讲解 C 语言特点、C/C 区别、struct 与 class 的区别、头文件引用顺序、extern C、预编译到链接的完整流程、static 关键字、数组与指针差异等面试常考点并加入面向对象、智能指针、四种 cast 转换、模板与 STL 等机制的问答式总结。资源共 1 个 PDF 文件压缩包约 5.28MB便于在零散时间快速翻阅和考前集中记忆。目前已有 40068 人学习下载说明该资料在求职者中获得了较高认可对照这份清单复习可帮助自己快速定位薄弱环节、节省从零整理笔记的时间系统地完成 C 八股文的查漏补缺。1. C八股文复盘基础题的区分度藏在细节边界里「C八股文」这个说法在候选人圈子里流传很广背熟了不一定稳背不熟大概率凉。某开发者秋招时把这份总结反复过了几轮最后拿到几家头部公司的意向。我拆这份资源时最直观的感受是它不只是在陈述知识点而是在每个知识点上划出了「成立的条件」和「失效的边界」。面试官最爱问的基础题往往不是考你知不知道而是考你知不知道「在什么情况下会不一样」。这份资源的价值也正在这里它不是让你把答案背下来而是让你把每个知识点背后的机制、边界、坑位都理顺。适合正在准备校招、社招的C方向候选人也适合想快速检查自己基础是否扎实的在职工程师。2. C与C的分界线static、struct与编译四步法2.1 struct/class与C语言结构体的边界差异C和C之间的差异几乎每次面试都会遇到。「说说C语言和C的区别」这种题目看似送分实际能卡住不少人因为大多数人只背了「C是面向对象、C是面向过程」这一句再往下问细节就接不上了。真正的区分度在struct/class、编译流程、关键字机制这些具体点上。先看struct和class。C里struct默认访问权限是publicclass默认是private继承关系上struct默认公有继承class默认私有继承。还有一个容易被漏掉的区别class关键字可以用于定义模板参数和typename等价而struct不能。这意味着写模板时只能用class或typename开头写成struct T会直接编译报错。#include iostream struct BaseStruct { // struct 成员默认 public外部可以直接访问 int value; void show() const { std::cout value std::endl; } }; class BaseClass { // class 成员默认 private外部不可直接访问 int value; public: void setValue(int v) { value v; } int getValue() const { return value; } }; // 模板参数可以用 class 或 typename不能用 struct template class T class Wrapper { T data; public: void set(const T v) { data v; } }; int main() { BaseStruct bs; bs.value 42; // struct 默认 public直接可访问 bs.show(); BaseClass bc; // bc.value 1; // 错误class 默认 private bc.setValue(42); return 0; }这段代码演示的是struct和class最基础的访问差异。BaseStruct的value不需要额外写public就能被外部读写BaseClass的value被private保护只能通过公有接口setValue/getValue操作。最后一行Wrapper 展示class作为模板参数是合法的如果换成struct 会在编译期直接报语法错误。面试时如果能顺手把「struct不能定义模板参数」这个冷门差异带出来这题基本能拿满分。C语言的结构体和C结构体的差别更明显。C的结构体内不允许有函数存在访问权限只能是public不能继承使用时要带着struct关键字比如struct Node n才是完整的声明。C的结构体则补足了所有这些限制可以有成员函数、静态成员、虚函数允许public/protected/private三种访问控制可以继承声明对象时也可以省略struct关键字。很多从C转过来的开发者会好奇「结构体里能不能放函数」答案很清楚C里不能C里可以C的struct本质就是一个默认访问权限为public的类。2.2 static的五个应用场景与生命周期控制static关键字在C里一身多用是面试的高频考点。它一共有五个典型应用场景覆盖了从普通变量到类成员的方方面面。第一定义全局静态变量和局部静态变量。初始化的静态变量在数据段分配内存未初始化的静态变量在BSS段分配内存直到程序结束才释放。局部静态变量只初始化一次函数退出后值不丢失。第二定义静态函数静态函数只能在本源文件中使用。第三定义文件作用域的静态全局变量它拥有全局生命周期但只能在本文件内访问。第四定义类中的静态成员变量它属于类而不属于某个对象所有对象共享同一块存储空间。第五定义类中的静态成员函数它是类的一部分而不是对象的一部分没有this指针。#include iostream class Counter { public: Counter() { count_; } static int getCount() { return count_; } private: static int count_; // 静态成员变量声明 }; int Counter::count_ 0; // 必须在类外定义并初始化 int main() { Counter a; Counter b; Counter c; std::cout objects: Counter::getCount() std::endl; return 0; }这段代码展示静态成员变量的共享存储机制。三个Counter对象共用一个count_每构造一个对象自增一次最后输出objects: 3。静态成员变量必须在类外单独定义少了这行会报未定义引用错误。静态成员函数getCount没有this指针它的作用域是类级别的调用时不依赖任何对象实例。这也意味着它不能直接访问类中的非静态成员——因为没有对象地址可以用来定位成员。关于初始化时机有一个重要区分。C语言里全局和静态变量的初始化发生在任何代码执行之前属于编译期初始化C标准则规定全局或静态对象当且仅当对象首次用到时才进行构造。这个差异在面试里经常被单独拿出来问因为跨编译单元的静态对象初始化顺序在C里是不确定的实际工程中往往要靠局部静态变量加函数封装来规避。2.3 预编译、编译、汇编、链接四步全流程与头文件查找C程序从源码到可执行文件要经历四个阶段这也是面试必考的流程题。预编译阶段做四件事删除所有#define并展开宏处理#if、#ifdef等条件编译指令处理#include把被包含的文件插入到预编译指令的位置过滤所有注释并添加行号和文件名标识。编译阶段做词法分析、语法分析、语义分析、代码优化、目标代码生成最终产出汇编代码。汇编阶段把汇编代码转成机器可以执行的指令。链接阶段把多个目标文件合成一个可执行程序。链接阶段最关键的区别是静态链接和动态链接。静态链接在链接时就把函数代码复制进可执行文件之后删除静态库不影响程序运行Windows下静态库后缀是.libLinux下是.a。动态链接在链接阶段不拷入函数实现只记录重定位信息真正运行时才去加载动态库删掉动态库后程序直接无法启动Windows下是.dllLinux下是.so。对比项静态链接动态链接链接时机链接阶段拷入代码运行时再加载删除库后程序照常运行程序无法启动Windows后缀.lib.dllLinux后缀.a.so头文件查找路径也是常考细节。尖括号用于系统头文件查找路径是编译器设置的头文件路径再到系统变量。双引号用于自定义头文件查找路径先是当前头文件目录再到编译器设置路径再到系统变量。双引号比尖括号多查一层当前目录所以项目内自定义头文件要用双引号标准库头文件用尖括号。#ifdef __cplusplus extern C { #endif int c_function(int a); #ifdef __cplusplus } #endif这段代码解决的是C调用C库时的名称修饰问题。C支持函数重载编译器生成符号时会把参数类型加进函数名C语言不支持重载编译C代码时函数符号就是函数名本身。如果不用extern C包裹C编译器会按C规则修饰c_function这个名称链接时找不到对应的C库符号。extern C是在告诉编译器这块代码按C语言的规则编译符号名保持原始状态。提示extern C只影响链接符号名不影响函数内部的实现逻辑。不要以为加了它就能让C代码直接在C编译器里编译。3. 指针与内存管理数组边界、野指针与堆栈真相3.1 数组与指针的四个差异数组和指针的辨析是C基础题里的经典陷阱。「数组名就是指针」这句话误导了很多人它只在一个很窄的上下文中成立数组作为函数参数传递时退化为指向首元素的指针。其他场景下数组和指针差异非常明显。第一个差异是赋值。同类型的指针变量可以相互赋值数组不行只能一个一个元素的赋值或拷贝。第二个差异是存储方式。数组在内存中连续存放存储空间不是在静态区就是在栈上指针本身是一个变量存放的是其他变量在内存中的地址指针指向哪里由程序员决定。第三个差异是sizeof结果。数组用sizeof得到的是整个数组占用的字节数指针用sizeof得到的只是指针本身的字节数32位平台固定4字节64位平台固定8字节。第四个差异是数组名不能自增自减指针可以。#include iostream int main() { int arr[5] {1, 2, 3, 4, 5}; int* p arr; // 数组名退化为首元素地址 std::cout sizeof(arr) sizeof(arr) std::endl; // 20 std::cout sizeof(p) sizeof(p) std::endl; // 864位 p; // 合法指针可以移动 // arr; // 编译错误数组名不是左值不能自增 return 0; }这段代码把最核心的差异压缩在一起验证。arr的类型是int[5]sizeof给出整个数组的字节数20p的类型是int*sizeof只算指针大小8。p让指针移动到第二个元素的地址arr则是编译错误因为数组名表示的是一个固定地址不是可改写的变量。面试回答「数组和指针的区别」时把这四个点按赋值、存储、sizeof、自增的顺序说全基本不会被追问。3.2 函数指针与指针函数定义、写法与回调场景先分清楚名词。指针函数的本质是一个函数返回值是指针写法是int* fun(int x, int y)。函数指针的本质是一个指针指向一个函数写法是int (fun)(int x, int y)。区别就在括号的位置括号把fun括在一起声明的是指向函数的指针。#include iostream int add(int a, int b) { return a b; } // 参数里放函数指针构成回调 int apply(int (*callback)(int, int), int x, int y) { return callback(x, y); } // 指针函数返回 int*本质是函数 int* makeArray(int n) { return new int[n]; } int main() { // 函数指针 int (*fp)(int, int) add; std::cout fp(2, 3) fp(2, 3) std::endl; // 5 // 回调把函数指针作为参数传入 std::cout apply(add, 10, 20) apply(add, 10, 20) std::endl; // 指针函数调用后拿到指针 int* arr makeArray(5); arr[0] 1; delete[] arr; return 0; }代码中fp是一个函数指针变量初始化为add的地址调用fp(2, 3)等价于add(2, 3)。apply接收一个函数指针作为第一个参数在函数体内部回调它这是典型的回调结构。makeArray是指针函数返回堆上分配的内存指针用完必须delete[]。函数指针最常见的应用场景就是回调我们调用别人提供的API称为Call别人的库反过来调用我们传入的函数就叫Callback。3.3 野指针的成因与防止野指针就是指针指向的位置不可知、不正确、没有明确限制。最常见的产生原因是释放内存后没有及时把指针置空指针仍保留着原来内存的地址但内存可能已被系统回收或重新分配给其他数据这时候继续访问就会产生非法访问。资源里给出的防止手段非常实用我总结成四步并一直在用定义指针时初始化为NULL用malloc或new申请内存后立即判空释放内存后立刻把指针置NULL优先使用智能指针让释放时机自动化。前三条看着简单实际代码里很多人只做了第一条。#include iostream int main() { int* p new int(42); // 释放后置空避免野指针 delete p; p nullptr; // 二次释放保护 if (p ! nullptr) { delete p; p nullptr; } return 0; }这个例子演示的是标准的安全释放习惯。delete后立即置空后续哪怕代码里出现了一个if判断并尝试再次delete也会因为p已经是nullptr而跳过从根源上规避了同一块内存被释放两次的未定义行为。智能指针则更进一步它利用RAII把释放动作绑定在对象生命周期上对象析构时自动释放内存连手动置空都省了。实际排查野指针问题最有效的定位工具是AddressSanitizer它能直接报告use-after-free发生的具体代码行。3.4 程序section与堆栈分区数据和代码分别待在哪一个程序从低地址到高地址依次有代码段、数据段、BSS段运行时还会多出堆区和栈区。数据段存放已初始化的全局变量和静态变量。代码段存放执行代码是只读的代码段头部还包含少量只读常量。BSS段存放未初始化的或初始化为0的全局变量和静态变量程序执行之前BSS段自动清零。可执行程序创建后运行时新增堆区和栈区堆从低地址向高地址增长栈从高地址向低地址增长。段存放内容特点代码段执行代码、只读常量只读数据段已初始化的全局变量、静态变量可读写BSS段未初始化的或初始化为0的全局/静态变量执行前自动清零堆区new/malloc分配的内存程序员控制释放栈区局部变量、函数参数自动分配释放这里有个容易记错的知识点初始化为0的全局变量到底放BSS还是data答案是BSS。BSS段专门放未初始化的、以及显式初始化为0的全局变量和静态变量程序执行前由系统清零。只有初始化成非0值的变量才进入数据段。程序启动的过程其实是操作系统加载器在干活先创建进程并分配私有进程空间把可执行文件的数据段和代码段映射到虚拟内存然后读入导入符号表找出所有依赖的动态链接库接着对每个动态库做加载、匹配符号、递归处理依赖再初始化全局变量并调用全局对象的构造函数最后进入入口点函数。3.5 内存对齐规则与计算示例内存对齐是面试里计算题的高发区。对齐跟数据在内存中的位置有关如果一个变量的内存地址正好位于它长度的整数倍就叫自然对齐。比如4字节的int起始地址应该能被4整除。不对齐时CPU读取一个变量可能要多访问几次内存虽然结果一样但效率显著变差。资源总结了四条对齐规则实际操作时可以压缩成三条。第一条第一个数据成员放offset为0处后续成员存储起始位置必须是自身大小为成员的子成员大小的整数倍。第二条结构体作为成员时要从其内部最宽基本类型成员的整数倍地址开始存。第三条结构体总大小必须是内部最大基本类型成员的整数倍不足的补齐。#include cstdio struct S1 { char a; // 偏移 0 int b; // 对齐到 4偏移 4~7 short c; // 对齐到 2偏移 8~9 }; // 当前大小 10补齐到 4 的整数倍 12 struct S2 { char a; // 偏移 0 int b; // 对齐到 4偏移 4~7 double c; // 对齐到 8偏移 8~15 }; // 总大小 16最大对齐 8正好 struct S3 { char a; // 偏移 0 S2 s; // S2 内部最宽成员 double对齐到 8偏移 8~23 char b; // 偏移 24 }; // 当前大小 25补齐到 8 的整数倍 32 int main() { std::printf(sizeof(S1)%zu\n, sizeof(S1)); std::printf(sizeof(S2)%zu\n, sizeof(S2)); std::printf(sizeof(S3)%zu\n, sizeof(S3)); return 0; }S1的char占1字节、int占4字节、short占2字节int不能从偏移1开始要补齐到4的整数倍才从偏移4开始short从偏移8开始此时总大小10还要按最大对齐数4补齐到12。S2加入double后对齐数从4升到8整个大小16。S3嵌套S2时S2内部最宽基本类型是double所以S2成员要从偏移8开始最终S3补到32。实际工程里结构体频繁创建时对齐造成的空间膨胀直接影响内存占用和缓存命中率这也是面试官问这个问题的真实动机。4. 资源分配避坑排查new/malloc、const/define与传值细节4.1 new与malloc底层实现与安全边界new和malloc的区别是经典八股也是实际编码中最常踩坑的分界点。先说结论new是操作符malloc是函数new分配内存后调用构造函数delete释放前调用析构函数malloc只有内存分配没有构造析构malloc需要自己计算字节数并强转指针new不需要指定字节数也不需要强转new分配失败抛出bad_alloc异常malloc失败返回nullptrnew可以被重载malloc不行。malloc的底层实现按申请大小走两条路小于128K时调用brk()从堆区扩展连续内存大于128K时调用mmap()使用内存映射。malloc内部采用内存池的管理方式减少内存碎片先向系统申请大块内存作为堆区再把堆区分成多个内存块用隐式链表把空闲块串联起来。用户申请内存时从堆区找一块合适的空闲块。new的底层可以理解为四步流程创建新对象将构造函数作用域赋给新对象让this指向新对象执行构造函数中的代码返回新对象。#include cstdlib #include iostream struct Item { int id; explicit Item(int v) : id(v) {} }; int main() { // malloc只分配裸内存不构造对象 int* raw static_castint*(std::malloc(sizeof(int) * 8)); if (raw nullptr) { return 1; } raw[0] 10; std::free(raw); // 只释放内存 // new分配内存 调用构造函数 Item* item new Item(100); std::cout item-id std::endl; delete item; // new[] 必须配 delete[] Item* arr new Item[3] { Item(1), Item(2), Item(3) }; delete[] arr; return 0; }注意代码中的配对关系malloc配freenew配deletenew[]配delete[]。new[]会在堆上额外记录元素个数信息delete[]靠这个信息逐一调用析构函数如果误用delete会跳过析构导致对象内部资源泄漏。还有一点容易出错malloc出来的内存如果被static_cast强转成对象指针对象没有被构造访问成员就是未定义行为。这就是C里推荐new而不是malloc的根本原因——语言特性保证构造和析构成对出现。4.2 const与define阶段、类型与存储差异const和define都能定义常量但两者的机制完全不同。阶段上const在编译期生效define在预处理期生效。类型上const定义的常量带类型信息编译器会做类型检查define是纯文本替换没有类型概念。存储上C语言中const定义的常量存储在内存中需要额外空间define定义的宏常量在使用处直接被替换成操作数运行时不会专门去内存读取它。#include iostream #define PI 3.14159 int main() { const double pi 3.14159; double r 2.0; double s1 PI * r * r; // 预处理期直接替换成 3.14159 * r * r double s2 pi * r * r; // 编译期类型检查后取值 // const 修饰指针的四种写法 const int* a; // a指向的内容不可改(*a)不变 int const* b; // 与 const int* 等价 int* const c; // 指针本身不可改a不变 const int* const d; // 指针和内容都不可改 return 0; }const修饰指针的四种写法是必考题。快速记忆方法是看const离谁近const在左边修饰的是指向的内容const在右边修饰的是指针变量本身。const int* a和int const* a是同一个意思都是内容不可变指针可变int* const a是指针本身不可变内容可变const int* const a两头都锁死。把const和指针结合的这几套写法说清楚后面再遇到「const int *a和int *const a的区别」这种追问就完全没有压力。4.3 四种cast转换的适用范围C有四类显式类型转换各自有明确的使用边界。static_cast用于编译期已知的类型转换比如int转double、void*转具体指针它不做运行时检查合法性靠程序员保证。dynamic_cast用于多态类型的运行时转换依赖RTTI转换失败时指针返回nullptr、引用抛出bad_cast异常。const_cast用来去掉或添加const属性。reinterpret_cast用于指针和整数之间的底层强制转换可以把一个指针重新解释成完全不相关的类型最危险通常只在涉及硬件地址或协议解析时使用。cast典型场景风险static_cast基本类型转换、编译期确定的类型转换需要程序员保证类型合法dynamic_cast基类指针/引用转派生类指针/引用安全但运行时开销const_cast去掉const限定修改原本const对象是未定义行为reinterpret_cast指针与整数互转、底层二进制重解释平台相关谨慎使用dynamic_cast有两个使用前提类继承体系中必须有虚函数才能形成运行时多态转换的目标类型必须是多态类型。如果转换失败指针版本返回nullptr引用版本抛出bad_cast。实际工程里过多使用dynamic_cast通常意味着类的设计可能有更优解更推荐用虚函数接口来替代显式类型判断这也是面试官追问到这题的潜台词。4.4 传值、引用与深浅拷贝的隐藏问题C有三种传参方式值传递、引用传递、指针传递。值传递时形参是实参的副本函数内修改形参不影响实参传对象时还会拷贝整个对象并调用拷贝构造函数效率低。引用传递不做拷贝只是绑定对象函数内修改会影响实参。指针传递的效果与引用类似但指针可能为空安全性不如引用。对象作为参数时值传递会触发浅拷贝还是深拷贝取决于拷贝构造函数如何实现。浅拷贝的问题可以从一个经典场景说清楚类HasPtr里有一个string指针ps编译器默认生成的拷贝构造函数只复制指针本身导致两个对象指向同一块堆内存。函数结束时两个对象的析构函数对同一块内存分别delete一次产生未定义错误。深拷贝则会在拷贝前先开辟与源对象相同大小的空间再把内容逐字节复制过去两个对象持有独立内存不会出现重复释放。#include cstring #include iostream class StringBuffer { public: StringBuffer(const char* s) { len_ std::strlen(s); buf_ new char[len_ 1]; std::memcpy(buf_, s, len_ 1); } // 深拷贝构造 StringBuffer(const StringBuffer other) : len_(other.len_), buf_(new char[other.len_ 1]) { std::memcpy(buf_, other.buf_, len_ 1); std::cout copy constructor std::endl; } // 深拷贝赋值 StringBuffer operator(const StringBuffer other) { if (this other) return *this; // 自赋值检查 delete[] buf_; len_ other.len_; buf_ new char[len_ 1]; std::memcpy(buf_, other.buf_, len_ 1); return *this; } ~StringBuffer() { delete[] buf_; } private: char* buf_; size_t len_; }; int main() { StringBuffer a(hello); StringBuffer b a; // 调用深拷贝构造 return 0; }深拷贝三件套是拷贝构造、拷贝赋值、析构函数必须成组出现。注意operator里的执行顺序先做自赋值检查再释放旧内存再分配新内存否则自己给自己赋值时会把数据先delete掉然后从一块已释放的内存拷贝必翻车。这里是血泪经验拷贝赋值运算符里delete旧资源必须发生在new之前顺序不能反。如果一个类定义了析构函数且析构中释放了资源这个类几乎必然需要自定义的拷贝构造函数和拷贝赋值运算符。4.5 五条高频踩坑记录与排查方法踩坑记录1基类指针delete派生类对象时程序不崩但资源泄漏。现象用基类指针指向new出来的派生类对象delete该指针析构函数只执行了基类的部分派生类里的资源没有释放内存占用持续增长。原因基类析构函数没有加virtualdelete基类指针时不触发动态绑定派生类析构函数被跳过。解决把基类析构函数声明为virtual。判断标准是这个类是否会被继承且是否可能通过基类指针删除派生类对象。满足任意一条基类析构就该是虚函数。踩坑记录2程序运行一段时间后内存持续增长最终被系统杀掉。现象长时间运行的服务内存曲线只涨不降没有明显的大对象泄漏点。原因new/malloc和delete/free没有一一配对或者父类析构非虚导致子类构造函数中申请的资源没被释放。解决先定位高频分配内存的模块再逐一核对每处new是否都有匹配的delete。更彻底的做法是全面换成std::unique_ptr和std::shared_ptr让释放时机由对象生命周期决定减少手动管理。踩坑记录3delete后继续访问指针区域数据内容随机变化。现象指针在delete之后没有被置空代码后续某个分支仍在读取它读出来的值时对时错。原因指针变成野指针指向的内存可能已被系统回收也可能已被其他申请复用。解决每次delete后将指针置nullptr。这不是技术问题是习惯问题但坚持做能避免大量难排查的运行时崩溃。踩坑记录4sizeof结构体比按成员大小相加得出的结果大。现象结构体每个成员的类型和大小都清楚可sizeof结果比想象中多几个字节。原因编译器按自然边界对齐成员之间被填充了padding字节变量起始地址必须落在自身长度的整数倍上。解决按对齐规则计算实际大小。希望紧凑排列可以用#pragma pack(1)代价是CPU访问性能下降一般仅在网络协议解析或文件格式读写时才会使用。踩坑记录5申请内存后立即崩溃崩溃点在一个内层库函数里。现象new或malloc成功后调用某个库函数时崩溃错误信息指向一个无效的内存地址。原因数组下标越界多1或少1的边界操作破坏了相邻内存。或者malloc返回nullptr后没有判空就直接使用。解决规范边界检查用std::vector这类带边界信息的容器替代裸数组。malloc和new之后立即判空这是资源里反复强调的第一条安全准则。5. 面向对象三座山重载重写、构造析构与多态实现5.1 重载与重写实现机制与区别重载和重写是面向对象题目里最容易混的概念。重载是同一个作用域内函数名相同、参数列表不同的多个函数C通过命名倾轧技术在编译阶段把参数类型拼进函数名里形成不同的底层符号从而区分同名函数。重载不关心返回类型只比较参数的类型、个数、顺序。重写是派生类中重新定义基类的虚函数函数名、参数列表、返回值类型必须与基类一致只有函数体不同而且基类中被重写的函数必须有virtual修饰。重写是运行时动态绑定的基础含有虚函数的类会生成一张虚函数表对象中保存指向虚表的虚指针调用虚函数时通过虚表查找实际函数地址。多态性正是一个接口多种实现重载属于静态多态重写属于动态多态。#include iostream // 重载同一作用域同名不同参数 void print(int v) { std::cout int: v std::endl; } void print(double v) { std::cout double: v std::endl; } // 重写派生类重新实现基类虚函数 class Base { public: virtual void greet() { std::cout hello from base std::endl; } }; class Derived : public Base { public: void greet() override { // C11 建议显式写 override std::cout hello from derived std::endl; } }; int main() { print(10); // 第一个 print print(3.14); // 第二个 print Base* p new Derived(); p-greet(); // 输出 hello from derived delete p; return 0; }两个print函数在编译期就确定了调用目标属于静态多态。greet是虚函数p虽然是Base*类型实际指向Derived对象运行时通过虚表找到Derived的版本属于动态多态。override是C11关键字它不改变语义只做编译期校验如果基类没有匹配的虚函数编译器直接报错能提前拦截拼写和签名错误。5.2 构造函数家族与空类默认行为C的构造函数按用途可以分四类默认构造函数、初始化构造函数、拷贝构造函数、移动构造函数。默认构造函数不需要参数。初始化构造函数通过参数列表给成员赋值比如Student(int n, int a) : num(n), age(a)。拷贝构造函数用同类对象初始化新对象。移动构造函数用于把另一个对象的资源转移过来配合移动语义减少不必要的拷贝开销。有一个关键陷阱只要定义了有参构造函数编译器就不再提供默认构造函数。这导致很多新手在Student(int n, int a)之后尝试Student s;会直接编译错误。如果还需要无参构造必须显式写出来。class Empty {}; // 编译器自动生成 // Empty() {} // Empty(const Empty) {} // Empty operator(const Empty) {} // ~Empty() {}一个空类默认会生成无参构造、拷贝构造、拷贝赋值运算符、析构函数这四样。注意析构函数默认是非虚的所以空类可以被实例化但不能安全地作为多态基类。如果类里写了有参构造无参构造不再自动生成但拷贝构造和拷贝赋值仍会自动生成。实际编码中类里只要出现了指针成员且需要管理资源自动生成的那套浅拷贝往往是不够用的。5.3 初始化顺序与析构顺序从父类到成员的排列规则初始化顺序是必背规则但很多人凭直觉记反了。规则可以浓缩成一句父类构造函数优先成员类对象构造函数其次最后才是自身构造函数。具体拆分创建派生类对象时先调用基类构造函数如果类里有成员类对象成员类构造函数先于该类自身构造函数调用基类构造函数的调用顺序是类派生列表中的出现顺序而不是成员初始化表中的书写顺序成员类对象的构造顺序是代码中声明的顺序也不是初始化列表的顺序。#include iostream class Base { public: Base() { std::cout Base ; } ~Base() { std::cout ~Base ; } }; class Member { public: Member() { std::cout Member ; } ~Member() { std::cout ~Member ; } }; class Derived : public Base { public: Derived() { std::cout Derived ; } ~Derived() { std::cout ~Derived ; } private: Member m_; }; int main() { Derived d; std::cout std::endl; return 0; }输出顺序是Base Member Derived析构时严格反转Derived ~Member ~Base。这段代码值得多跑几遍把顺序记牢。初始化顺序里还有一个隐藏的坑基类构造函数执行期间派生类还没被构造此时如果基类构造函数调用了虚函数不会按多态绑定到派生类的实现而是直接执行基类版本。这是C里一个经典的行为陷阱面试被追到这一层时能答出来就是加分项。5.4 多态的动态绑定与虚析构多态分为静态多态和动态多态。静态多态由编译器在编译期间完成根据实参类型推断调用哪个函数比如重载和模板都是静态多态。动态多态在运行时完成绑定需要三个前提基类中必须有虚函数派生类中必须重写虚函数必须通过基类类型的指针或引用来调用虚函数。三者缺一个动态绑定都不成立。纯虚函数是虚函数后面加0包含纯虚函数的类称为抽象类不能实例化。派生类必须实现全部纯虚函数才能创建对象。虚析构的问题与多态紧密关联如果基类析构函数不是虚函数那么用基类指针delete派生类对象时就不会触发动态绑定派生类析构函数不被调用资源泄漏。因此设计为基类的类析构函数应当声明为virtual。这也是空类大小为什么是1字节、含虚函数类大小会额外增加一个虚表指针大小的原因。class Shape { public: virtual ~Shape() {} // 基类析构必须是虚函数 virtual double area() const 0; // 纯虚函数 }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.14159 * r_ * r_; } private: double r_; }; int main() { Shape* s new Circle(2.0); std::cout s-area() std::endl; delete s; // 正确调用 Circle 析构再调用 Shape 析构 return 0; }这段代码把抽象类和虚析构放进同一个完整例子。Shape包含纯虚函数area本身不能实例化Circle实现area后可以构造。delete s时先执行Circle析构再执行Shape析构两级析构链完整不会泄漏Circle内部的资源。如果去掉Shape析构前面的virtualdelete s就只调用Shape析构Circle析构被跳过。多态条件下的资源安全本质上就靠virtual析构这一道关卡。6. 从八股到面试用追问树检验掌握程度八股背得熟不等于面试答得好面试官从你的答案中挑一个点继续往下追问答不出来前面再流畅也打折。我自己常用的方法是把每个知识点扩展成一棵追问树每个主答案至少准备三个方向原理是什么边界在哪参数或场景变了会怎样。举个例子。当面试官问「new和malloc的区别」主干回答是new是操作符会构造、malloc是函数不构造。往深一层追问malloc底层多大用brk、多大用mmap小于128K走brk大于128K走mmap。再往下追问为什么要设这个阈值因为小块内存用堆扩展成本更低大块内存用mmap可以做到独立映射、释放时直接还回系统。这一串追问都能答上八股的层次感就出来了。再比如被问到「static成员函数为什么没有this指针」答完「因为它属于类不属于对象」之后主动补一句「所以它不能直接访问非静态成员但可以通过传入的对象参数或者静态成员来间接操作」这就把边界划清楚了。我建议按资源里的题目顺序做一次自测每个问题先口头回答然后写一个最小可运行代码去验证。答错或卡壳的题目单独标记后续隔三天再答一遍。我当时连续背了好几天重载和重写的区别结果被追问「虚表指针放在对象头部还是尾部」时当场卡住因为从来没动手验证过对象的实际内存布局。从那以后我养成了一个习惯所有八股题里涉及内存布局和调用顺序的都强制自己写一段代码并编译运行用输出结果说话。验证过一次的知识面试时再被追问就不容易慌。希望帮到你。本文还有配套的精品资源点击获取