这份练卷是我在带团队和做技术面试的过程中,一点点攒出来的。起因很简单:我发现在招C/C++工程师时,很多候选人简历写得无可挑剔,但一聊到具体实现、环境搭建、编译链接过程,或者扔给他一段带内存问题的代码时,就开始含糊其辞。这不能全怪候选人,市面上针对C/C++的系统性练习资料确实太少了,大多数都是零散的八股文,背完就忘。
所以我就想,与其抱怨,不如自己动手整理一套覆盖"从环境到工程"全链路的综合练习卷。这套卷子不是让你背答案的,而是让你真刀真枪跑一遍、踩一遍坑、再回头看理论,这样留下来的东西才是自己的。无论你是准备校招的应届生、想转C++的后端/客户端开发,还是已经工作几年想系统查漏补缺的在职工程师,这套练习卷都值得你花一到两周时间认真过一遍。
1. 练习卷设计思路:这套题到底在考什么
先说一下我设计这套练习卷的底层逻辑。市面上的C/C++面试题集大多只覆盖语法和算法,但我多年的面试经验告诉我,真正能区分"会用C++"和"理解C++"的,往往是那些看似基础、实则牵扯到编译原理、操作系统、内存布局的问题。所以这套卷子被我刻意分成了五个维度:环境与工具链、语言核心、算法与数据结构、工程实践、性能优化。
1.1 从热搜词看行业需求:为什么这些内容会集中出现
我整理这套题时参考了大量近期开发者关注的热搜词,很有意思。搜索热度最高的几个方向,一个是"vscode配置c/c++环境"和"mingw-w64环境变量",这说明大量新手卡在了第一步——连编译环境都搭不起来;另一个是GESP竞赛相关的题目,比如物流网络、环线,这说明算法能力依然是衡量工程师水平的重要标尺;还有"c/c++ opc da"和"音视频c/c++开发教材"这类工业级方向,说明C/C++在工业自动化、音视频底层这些领域依然是不可替代的存在。
这几个热词恰好对应了工程师成长路径上的三个典型阶段:入门阶段解决环境问题,进阶阶段死磕语言和算法,高级阶段面对真实工程场景。因此我的练习卷也按这个逻辑编排,每一部分都是为下一部分做铺垫的。
1.2 五维考察模型:环境、语言、算法、工程、性能
这五个维度不是拍脑袋定的,而是我观察了团队里成长最快的几个工程师,发现他们无一例外在这五个方面都有扎实的基本功。环境维度考察的是你有没有独立搭建开发环境、解决编译问题的能力;语言维度考察的是你对C/C++语法、内存模型、编译链接机制的理解深度;算法维度考察的是你面对复杂逻辑时能否快速抽象出模型并用代码实现;工程维度考察的是你写的代码能不能在生产环境里稳定运行、能不能被别人维护;性能维度考察的是你在资源受限场景下能否写出高效的代码。
如果你能把这份练习卷完整做下来,并且真的理解了每个题背后的原理,那么你的水平已经超过了相当一部分实际工作了两三年的C/C++工程师。
1.3 练习方式建议:每天一个模块,周末复盘
我个人的建议是,不要想着一口气做完,而是每天安排一个模块,边做边记录错题和疑问。第一天专门搭环境、配置VS Code;第二天到第四天集中刷语言核心题和算法题;第五天到第六天做工程实践的题目,写一个完整的小项目;最后留一天做性能优化和总结复盘。这样一周下来,整个知识框架会比较立体。
2. 环境搭建篇:五分钟搭好MinGW-w64 + VS Code开发环境
我看过太多人卡在环境搭建这一步,尤其是刚接触C/C++的同学。很多人第一次装编译器,上来就选Visual Studio,结果十几个GB的安装包下载了半天,装完发现根本不知道从哪开始写代码。还有人在Linux下用gcc用得挺顺手,一换到Windows就懵了。所以我在这套练习卷里专门安排了一个任务:在Windows上手动配置一套完整的C/C++开发环境,不允许用一切图形化的一键安装包。
2.1 为什么选MinGW-w64而不是MSVC或Clang
Windows下的C/C++编译器主要就是三家的:微软的MSVC(Visual Studio自带)、MinGW-w64(GCC的Windows移植版)、Clang(LLVM前端)。很多新手会问,既然Visual Studio那么强大,为什么还要折腾MinGW-w64?我的回答是:练习卷的目的不是让你熟练掌握某一个IDE,而是让你理解编译链接的本质。
MSVC的编译命令被Visual Studio的图形界面包了一层,你很难直观看到预处理、编译、汇编、链接这四个步骤的中间产物;而MinGW-w64配合命令行或VS Code的tasks.json,你每一步做了什么编译器都看得清清楚楚。另外,GCC对C++新特性的支持比较激进,对C++11/14/17/20的覆盖相当完整,这对于练习新语法也很友好。Clang也不错,它的报错信息比GCC更友好,但在Windows上配起来不如MinGW-w64省心,所以我建议初学者先用MinGW-w64,后面有兴趣再折腾Clang。
2.2 完整安装步骤与版本选择
如果你用的是64位Windows系统,记住要下载x86_64架构、posix线程模型、seh异常的版本。这几个参数对新手来说确实很迷惑,我简单解释一下:架构选x86_64是因为现在是64位系统的主流;线程模型选posix是因为它支持std::thread,而win32线程模型不支持,你后面学C++多线程时会痛不欲生;异常模型选seh是因为它是64位下的推荐方案,比sjlj性能更好而且不会污染代码体积。
注意:安装MinGW-w64时,解压路径要记住,不要有中文和空格。我见过无数人把MinGW解压到"Program Files"目录,结果环境变量配好后依然提示找不到gcc。原因就是路径里的空格让命令行解析出了偏差。建议直接解压到D:\mingw64这样的纯英文路径。
说白了,MinGW-w64是免安装的绿色版,解压即用。但问题是怎么让系统认识它。右键"此电脑"→"属性"→"高级系统设置"→"环境变量",在"系统变量"里找到Path,把你的mingw64\bin目录追加进去,然后新建一个CMD窗口,输入gcc --version。看到版本信息就说明装好了。这里有个新手特别容易踩的坑:配置完环境变量后,你之前打开的那个终端窗口是读不到新配置的,必须重新开一个。
2.3 VS Code详细配置:tasks.json、launch.json、c_cpp_properties.json
编译器装好了,接下来就是编辑器。VS Code本身只是一个编辑器,需要通过插件来获得语法提示、调试、编译的能力。先装四个插件:C/C++(微软官方那个,名字就叫"C/C++")、C/C++ Extension Pack、Code Runner(可选)、Chinese Language Pack(如果你习惯中文界面)。装完插件后,你第一次打开一个.cpp文件时,VS Code会提示你选择一个编译器,这时候选择gcc.exe对应的路径即可。
然后是最关键的三件套配置。在项目根目录下建一个.vscode文件夹,里面放三个JSON文件。第一个是c_cpp_properties.json,它负责告诉IntelliSense你用的是什么编译器、什么标准、头文件在哪里。我这里给出一个可以直接用的配置,编译器路径换成你自己的实际路径,C标准设置为c17,C++标准设置为c++17,这个标准覆盖了目前绝大多数场景。
{ "configurations": [ { "name": "MinGW", "includePath": [ "${workspaceFolder}/**", "D:/mingw64/lib/gcc/x86_64-w64-mingw32/12.2.0/include/c++", "D:/mingw64/x86_64-w64-mingw32/include" ], "defines": [ "_DEBUG", "UNICODE", "_UNICODE" ], "compilerPath": "D:/mingw64/bin/gcc.exe", "cStandard": "c17", "cppStandard": "c++17", "intelliSenseMode": "windows-gcc-x64" } ], "version": 4 }第二个是tasks.json,它负责定义编译任务。你要让VS Code知道,按Ctrl+Shift+B时执行什么命令。我的习惯是直接调用g++编译当前文件并生成一个带-g调试信息的可执行文件。这里需要注意的是,args数组里的每一项都是一个参数,要用双引号包裹。${file}表示当前打开的文件名,${fileDirname}\${fileBasenameNoExtension}.exe表示将可执行文件输出到当前文件所在的目录,并以原文件名命名。
{ "version": "2.0.0", "tasks": [ { "type": "cppbuild", "label": "C/C++: g++.exe 生成活动文件", "command": "D:/mingw64/bin/g++.exe", "args": [ "-fdiagnostics-color=always", "-g", "${file}", "-o", "${fileDirname}\\${fileBasenameNoExtension}.exe" ], "options": { "cwd": "D:/mingw64/bin" }, "problemMatcher": [ "$gcc" ], "group": { "kind": "build", "isDefault": true }, "detail": "调试器生成的任务。" } ] }第三个是launch.json,它负责定义调试配置。没有这个文件,你按F5时会弹出一个选择调试器的界面,小白到这里就又懵了。所以我直接把配置写出来,你复制到你的launch.json里即可。miDebuggerPath要指向gdb.exe的实际路径,program要指向你编译生成的exe文件,preLaunchTask对应tasks.json里的label字段,这样按F5时会先自动编译再启动调试。
{ "version": "0.2.0", "configurations": [ { "name": "C/C++: g++.exe 生成和调试活动文件", "type": "cppdbg", "request": "launch", "program": "${fileDirname}\\${fileBasenameNoExtension}.exe", "args": [], "stopAtEntry": false, "cwd": "${fileDirname}", "environment": [], "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "D:/mingw64/bin/gdb.exe", "setupCommands": [ { "description": "为 gdb 启用整齐打印", "text": "-enable-pretty-printing", "ignoreFailures": true } ], "preLaunchTask": "C/C++: g++.exe 生成活动文件" } ] }2.4 环境变量配置与命令行编译验证
配置完VS Code,我强烈建议你再手动走一遍命令行编译的流程,因为这是理解编译原理的最佳途径。打开一个CMD或PowerShell窗口,cd到你的代码目录,然后手动输入g++ main.cpp -o main.exe -g,再输入.\main.exe。这一步能跑通,说明你的环境变量、编译器、链接器全部正常。然后你还可以进阶尝试一下预处理、编译、汇编、链接的四步分解:g++ -E main.cpp -o main.i查看预处理后的代码,g++ -S main.i -o main.s查看汇编代码,g++ -c main.s -o main.o生成目标文件,最后g++ main.o -o main.exe完成链接。
我为什么强调命令行?因为一旦你理解了这四个步骤,后面遇到很多编译错误时,你就能判断出错误发生在哪个阶段。找不到头文件是在预处理阶段,语法错误是在编译阶段,符号未定义是在链接阶段。定位到阶段后,排错效率会翻倍。
2.5 常见环境错误排查:找不到gcc、路径有空格、中文乱码
搭环境最常见的三个问题,我一次性说清楚。第一个,提示"gcc不是内部或外部命令":检查环境变量是否正确配置,配置完是否重新打开了终端,路径里是否有中文或空格。第二个,编译成功后运行时中文乱码:这是因为GCC在Windows下默认输出UTF-8编码,而CMD控制台默认使用GBK编码,解决办法是在代码文件开头加一句system("chcp 65001");或者在VS Code的设置里把terminal.integrated.profiles.windows的默认编码改成UTF-8。第三个,VS Code提示"c and c++ compiler paths differ. c compiler may not work":这是因为c_cpp_properties.json里compilerPath指向了gcc.exe,而IntelliSense需要的是g++.exe,或者你的tasks.json里调用的是gcc编译C++文件。C++代码请统一使用g++,C代码用gcc,不要混用。
3. 语言核心篇:指针、内存、编译链接四个必考点
环境搞定了,接下来是真正的硬仗。这一部分我针对C/C++里最容易被问倒、也最影响实际开发水平的四个知识点出了题:指针、内存管理、编译链接、C++特有的资源管理。很多工作三五年的工程师,写业务代码没问题,但一遇到深拷贝、浅拷贝、悬空指针、内存泄漏就头大,根源就在于这四个知识点没有吃透。
3.1 指针和const的排列组合:你能分清几种含义
先说个最简单的题目:const int *p、int * const p、const int * const p,这三者有什么区别?别笑,我真的在面试高级岗位时问过这道题,能完全答对的人不超过一半。const int *p是指向const int的指针,指针本身可以改,但指向的值不能通过它修改;int * const p是const指针,本身不能改,指向一个可变的int;const int * const p两者都不可改。延伸到引用就是const int &r,还有C++11新增的右值引用int &&rr。
为什么要考这个?因为在实际开发中,const修饰符是接口设计的一部分,它告诉调用者这个参数是只读的还是可改的,编译器也会帮你检查。一个规范的函数签名void processData(const std::vector & data)比void processData(std::vector & data)的信息量大得多。我个人的习惯是:函数参数能加const就加const,能用引用就不用指针,除非参数可能为空才用指针。这个习惯能让你的代码自带文档属性,别人一看签名就知道该怎么传参。
然后是指针和数组的关系。int arr[5]里arr到底是什么?类型层面的回答是int[5],而数组名在表达式里会退化成指向首元素的指针int*。但要注意两个例外:sizeof(arr)拿到的是整个数组的大小20字节,&arr拿到的是指向整个数组的指针int(*)[5]。这两个例外正是面试官最喜欢的坑。我在练习卷里专门出了一道题:给定int arr[5],分别输出sizeof(arr)、sizeof(&arr)、sizeof(*arr)的结果,很多人会答错。实际上,sizeof(arr)是20(5个int),sizeof(&arr)在64位系统上是8(一个指针的大小),sizeof(*arr)是4(一个int)。
3.2 内存布局与结构体对齐:一行#pragma pack引发的血案
C/C++工程师必须对内存布局有直觉,否则你写出的结构体在网络传输、二进制文件读写、嵌入式寄存器操作等场景下都会出大问题。练习卷里我要求手工计算一个结构体的大小:
struct Example { char a; int b; char c; double d; };不查资料,直接说出sizeof(struct Example)是多少?很多人会脱口而出是14(1+4+1+8),但正确答案是24。原因是结构体成员对齐规则:每个成员的偏移量必须是其自身大小的整数倍,结构体总大小必须是最大成员大小的整数倍。a占1字节,b要从偏移4开始(因为int是4字节对齐),所以a后面空了3个字节;c占1字节,d要从偏移16开始(因为double是8字节对齐);最终结构体大小必须是8的倍数,所以是24。
这个知识在实际工作中有多重要?想象一下你要把一个结构体直接写入文件或通过网络发送给对端,如果两端机器的对齐方式不同,读出来的数据全是乱的。解决办法是使用#pragma pack(1)或者用静态断言static_assert来保证结构体大小为预期值。我强烈建议在涉及二进制协议的结构体定义后面,加一行static_assert(sizeof(MyStruct) == 预期值, "size mismatch");,这样编译器能在编译期帮你发现对齐问题,比运行时排查快得多。
3.3 C++的RAII与智能指针:不要手动new/delete
如果让我选C++最重要的一个理念,我选RAII。资源获取即初始化,简单来说就是用一个类去包装资源的生命周期,构造函数里获取资源,析构函数里释放资源,这样无论函数正常返回还是抛出异常,资源都能被自动释放。C++11之后,我们用unique_ptr、shared_ptr、weak_ptr来管理堆内存,基本告别了手动new/delete。
练习卷里我出了一道"找内存泄漏"的题,原题大概是这样的:
void processData(const std::vector<int>& data) { int* tmp = new int[data.size()]; for (size_t i = 0; i < data.size(); ++i) { tmp[i] = data[i] * 2; } if (data.size() > 10) { return; } for (size_t i = 0; i < data.size(); ++i) { std::cout << tmp[i] << std::endl; } delete[] tmp; }看似最后有delete[],但实际上当data.size() > 10时,函数提前return了,delete[]这句永远不会执行,内存泄漏。正确的写法是用std::vector tmp(data.size())或者std::unique_ptr<int[]> tmp(new int[data.size()])。这个例子生动展示了RAII的价值:你用智能指针或STL容器,析构时会自动释放,根本不存在"忘记在某个分支释放"的问题。
3.4 编译链接:声明与定义、头文件保护、静态库动态库
最后一个语言核心点,其实已经是编译原理的范畴了。C/C++程序的编译单元是一个.cpp文件,它和它include进来的所有头文件组成一个翻译单元。编译器把每个翻译单元编译成.o目标文件,链接器再把所有.o和库文件链接成可执行文件。这个过程中有一个最常见的错误:undefined reference to "xxx"。原因基本上就是函数只有声明没有定义、调用了静态库里的函数但没链接对应库、链接顺序不对。
链接顺序是新手特别容易忽略的坑。在g++命令中,依赖库要放在源文件后面,比如g++ main.cpp -lssl -lcrypto,如果写成g++ -lssl -lcrypto main.cpp就会链接失败。这是GNU链接器的工作方式决定的,它从左到右扫描目标文件,记录未解决的符号,然后在右边的库中寻找。还有头文件保护不能省,虽然现在有#pragma once这个非标准但主流编译器都支持的写法,但我建议你也要知道#ifndef/define/endif的经典写法,因为某些老旧的编译器不支持#pragma once。
4. 算法题解篇:从物流网络和环线看透图论题的套路
看到热搜词里出现GESP的题目,我挺欣慰的,说明现在的算法训练越来越系统化了。我在练习卷里放了两道与图论相关的题目,一道是对应"物流网络"这类模型的最小生成树问题,一道是对应"环线"的拓扑排序/判环问题。算法能力不是靠背题,而是靠建立思维模型。
4.1 物流网络题解:最小生成树的并查集实现
这类题目的核心模型是:有N个物流节点,M条可建设的线路,每条线路有建设成本,要求让所有节点连通且总成本最小。这就是经典的最小生成树问题。解法有两个:Prim算法和Kruskal算法。我推荐优先掌握Kruskal算法,因为它的实现简单且直观,而且配合并查集后时间复杂度是O(ElogE),完全够用。
Kruskal的核心思路是先把所有边按权值从小到大排序,然后依次检查每条边,看它的两个端点是否已经连通。如果不连通,就选择这条边,并把两个端点合并到一个集合里。这个"检查是否连通"和"合并集合"的操作,就是并查集的拿手好戏。我在练习卷里特意要求:不允许使用现成的库,必须手写并查集。很多同学用简单的数组模拟,search时一路找到根节点,merge时把一棵树挂到另一棵树上,这个朴素实现会有O(N)的查询复杂度。优化方案是路径压缩和按秩合并,前者在find时把沿途节点全部指向根,后者把深度小的树挂到深度大的树上,这样均摊下来接近O(1)。
class UnionFind { private: std::vector<int> parent; std::vector<int> rank; public: UnionFind(int n) : parent(n), rank(n, 0) { for (int i = 0; i < n; ++i) parent[i] = i; } int find(int x) { if (parent[x] != x) { parent[x] = find(parent[x]); // 路径压缩 } return parent[x]; } bool merge(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) return false; // 已经连通 if (rank[rootX] < rank[rootY]) { parent[rootX] = rootY; } else if (rank[rootX] > rank[rootY]) { parent[rootY] = rootX; } else { parent[rootY] = rootX; rank[rootX]++; } return true; } };这里的rank就是树的深度上界,按秩合并的好处是避免树退化成链表。路径压缩和按秩合并经常被合称为"并查集的两个优化",两者的组合让单次操作的时间复杂度摊还到反阿克曼函数的量级,这在实践中几乎可以视为常数。所以你在写任何需要判断连通性、合并集合的题目时,并查集应该是第一选择。
4.2 环线题解:拓扑排序与DFS判环的思路
第二道题目的模型是:在一个单向交通网络里,判断是否存在环,如果存在环就输出环上的节点,否则输出一种合法的行驶顺序。两种主流解法,一种是拓扑排序,一种是DFS染色法。拓扑排序的思路是维护一个入度数组,每次从图中取出入度为0的节点(即没有前驱的节点),把它加入结果序列,然后删除它以及它发出的所有边,更新相关节点的入度。重复这个过程,如果最终能处理完所有节点,说明图中无环;如果还有节点剩余且都入度不为0,说明图中有环。
拓扑排序的优点是把"是否存在环"和"输出一个拓扑序列"两个问题一并解决了。DFS染色法的思路则是用三种状态标记节点:0表示未访问,1表示正在访问中,2表示访问完毕。在DFS遍历时,如果发现一个指向"正在访问中"节点的边,说明你找到了一个环。这个方法的优点是可以直接输出环上的节点,只需要在回溯时记录路径。
std::vector<std::vector<int>> graph; std::vector<int> state; // 0未访问 1访问中 2完成 std::vector<int> path; bool hasCycle = false; void dfs(int u) { state[u] = 1; path.push_back(u); for (int v : graph[u]) { if (state[v] == 1) { hasCycle = true; return; } else if (state[v] == 0) { dfs(v); if (hasCycle) return; } } state[u] = 2; path.pop_back(); }为什么我在练习卷里同时给这两道题?因为物流网络考察的是"如何建立最小代价的连通",环线考察的是"如何识别依赖关系中的矛盾"。前者在现实中的对应是数据中心的网络规划、城市管网设计、电路布线;后者在现实中的对应是包管理器依赖解析、编译任务调度、死锁检测。C/C++工程师写底层系统时,天天面对这类问题,不建立好模型根本无法下手。
4.3 算法练习建议:从暴力到最优的三步走
我见过很多同学拿着算法题直接想最优解,想了半天想不出来就放弃了。我的建议是,练习时一定要走"暴力→优化→最优"三步。第一步,先用最朴素的方式把题目做出来,哪怕时间复杂度是O(N^2)或O(N!),至少保证正确性;第二步,分析暴力解法的瓶颈在哪里,是重复计算还是无用枚举,尝试用空间换时间、排序、双指针等手段优化;第三步,再考虑有没有更优的算法模型。这一步一步走下来,你对算法模型的理解会比直接背模板深刻得多。
5. 工程实践篇:OPC DA接口封装与内存管理实战
C/C++工程师和底层工业设备打交道是家常便饭,热搜词里的"c/c++ opc da getitemid函数"就是工业自动化领域非常典型的场景。OPC DA是工业通信里常用的数据访问标准,早期基于COM组件来实现,而COM接口最让人头疼的就是它返回的字符串、数组、接口指针都需要手动管理生命周期。这一部分练习卷,我围绕OPC DA的接口封装设计了一套题目,帮你把纯语言知识转化为工程能力。
5.1 接口文档阅读:getitemid和dwaccessrights的含义
很多新手拿到一份陌生的接口文档时,不知道从哪里读起。我教大家一个方法:先看接口的返回值和错误码,再看参数的类型和方向,最后看是否有需要手动释放的资源。以OPC DA的getitemid函数为例,它的作用是根据Item的路径或名称获取一个唯一标识符,之后所有对这个Item的读写操作都通过这个ID来完成。
HRESULT GetItemID( LPCTSTR szItemID, DWORD dwAccessRights, OPCITEMDEF* pItemDef );dwaccessrights这个参数尤其关键,它用位掩码的形式表示访问权限,常见的值是OPC_READABLE(可读)和OPC_WRITEABLE(可写),两者按位或组合。在向服务器添加Item时,你要先通过查询接口获取该Item支持的访问权限,再根据你的业务需求设置对应的权限位。如果请求的权限与Item实际支持的权限不匹配,服务器会返回一个错误。这个处理逻辑在工程中非常常见,你需要知道怎么判断、怎么容错。我见过的很多工业软件崩溃,就是因为在添加Item时没有检查dwaccessrights的返回值,然后对不支持写入的Item执行了写操作。
5.2 COM组件生命周期管理:Release与智能指针组合拳
在Windows下的COM编程里,最经典的一句口头禅是"AddRef和Release必须成对出现"。这句话和C++里的new/delete成对出现是一样的道理。具体到OPC DA,当你调用CoCreateInstance获得一个IOPCServer接口指针后,用完后必须调用pServer->Release()。如果你在多个函数之间传递这个指针,每次都要addRef。稍不留神,引用计数就乱了,不是泄漏就是悬空。
在练习卷的工程实践环节,我要求你用C++的RAII机制封装一个COM指针的智能指针。这里我不能直接使用ATL的CComPtr,因为那就失去练习意义了。核心思路是:构造函数里接收一个原始COM指针并AddRef,拷贝构造和赋值操作符里对新的指针AddRef、对旧的指针Release,析构函数里Release。这样一个封装类就可以像普通的栈对象一样使用,不用担心忘记释放。
template <typename T> class ComPtr { private: T* ptr_; public: ComPtr(T* ptr = nullptr) : ptr_(ptr) { if (ptr_) ptr_->AddRef(); } ComPtr(const ComPtr& other) : ptr_(other.ptr_) { if (ptr_) ptr_->AddRef(); } ComPtr& operator=(const ComPtr& other) { if (this != &other) { if (ptr_) ptr_->Release(); ptr_ = other.ptr_; if (ptr_) ptr_->AddRef(); } return *this; } ~ComPtr() { if (ptr_) ptr_->Release(); } T* operator->() const { return ptr_; } T** operator&() { return &ptr_; } };写到这里我要强调一个细节:为什么拷贝构造和赋值操作符都要AddRef?因为C++的拷贝意味着你要拥有同一个对象的多一个"引用",既然你要长期持有它,就必须通知COM增加引用计数。这就是RAII思想在COM领域的延伸。
5.3 日志系统的设计:线程安全与性能取舍
工程实践还有一个基本功,就是日志系统。很多人觉得日志系统不就是printf加个时间戳吗?真到了生产环境完全不是这样。你需要考虑多线程同时写日志时的线程安全,需要考虑频繁I/O带来的性能损耗,需要考虑日志文件按大小或日期自动切分。
练习卷里我要求你设计一个最小但完整的日志系统,要求:支持INFO/WARN/ERROR三个级别,支持多线程安全写入,支持按文件大小滚动。最基础的做法是用一个全局互斥锁保护写入操作,每次写日志时加锁。更进一步,可以采用双缓冲机制——一个后台缓冲区用于I/O,一个前端缓冲区收集日志,满了就交换。这其实就是生产者消费者的经典场景。关于日志级别,我强烈建议发布版本保留WARN和ERROR,INFO级日志可以通过宏开关在编译期裁剪掉,因为生产环境下高频INFO日志对性能的拖累是肉眼可见的。
5.4 代码审查自查表:每个C/C++工程师都应掌握的十项检查
面试官最爱问的一个问题是"你平时怎么保证你的代码质量"。我的回答是:写代码时就在心里做代码审查。我总结了一个十项自查表,练习卷里要求每位学习者对照自己的代码逐项检查:
- 所有指针使用前是否为NULL或空检查
- 所有动态分配的内存/资源是否都有对应的释放路径(包括异常路径)
- 拷贝构造、赋值操作符、析构函数是否成对出现(三/五法则)
- 函数参数是否能用const引用代替值传递或指针
- 循环里是否有不必要的重复计算(比如strlen在循环条件里调用)
- 头文件是否包含了不必要的内容导致编译变慢
- 是否使用了未定义行为的代码(如有符号整数溢出、悬空引用)
- try-catch块是否捕捉了过于宽泛的异常类型
- 是否有隐式类型转换可能带来精度损失
- 代码注释是否解释了业务意图而不是复述语法
这十项检查不用做到完美,但每次提交代码前过一遍,可以帮你避免绝大多数低级问题。
6. 进阶方向篇:音视频开发为什么执着于C/C++
热搜词里还有一个方向,就是"音视频c/c++开发教材"。很多同学问过我,音视频开发技术栈那么多,为什么底层核心还是C/C++的天下?这个问题问得非常好,它涉及到C/C++这个语言在现代技术生态中的定位问题。
6.1 为什么音视频领域非C/C++不可
音视频领域的核心任务是编解码、采集渲染、传输封装,这些任务普遍具有三个特点:性能要求极高、需要操作底层硬件、实时性要求严格。以视频编解码为例,H.264或H.265编码一帧1080p的图像,要在几十毫秒内完成数亿次运算,这种量级下Java/Python这种带垃圾回收的语言根本扛不住,GC的停顿足以让你掉帧。另一个关键点是音视频库的生态,FFmpeg、x264、x265、WebRTC这些核心库全部是用C或C++写的,你不用C/C++就无法直接调用这些底层优化过的能力。
6.2 性能优化三板斧:缓存命中率、SIMD、内存池
如果你决定往音视频方向深入,那最好尽早掌握性能优化的三板斧。第一,缓存命中率。程序员写的代码对CPU缓存是否友好,性能差距可能达十倍。比如遍历一个二维数组,应该按行遍历而不是按列遍历,因为数组在内存中是按行存储的,按行遍历时每一行都能命中CPU缓存。第二,SIMD指令。现代CPU都支持单指令多数据流,你可以在一条指令里同时处理4个或8个float。GCC和Clang的自动向量化有时候不太聪明,手动使用Intrinsic函数是音视频工程师的家常便饭。第三,内存池。频繁地new/delete会触发系统调用和堆管理器的锁竞争,在性能敏感场景下,更好的做法是预先分配一大块内存,自己管理空闲块。
6.3 练手项目建议:从零写一个音视频播放器
光看书和看视频是学不会音视频开发的,必须上手写项目。我推荐从最简单的播放器开始:不用FFmpeg,直接用Windows的Media Foundation或Linux的V4L2读视频帧,用SDL2渲染窗口,用OpenAL播放音频,先把一个MP4文件解码并同步播放出来。这个过程你会经历文件解封装、视频解码、音频解码、音视频同步四大难题,每一个都是独立的知识体系。在你完成之后,再回头看FFmpeg的源码或API,就能理解它每一层抽象到底解决了什么问题。
7. 排查速查表:VS Code环境与编译链接常见坑
最后一部分,我整理了一份问题速查表,包含了我在带新人和自己开发过程中实际踩过的坑。这些都不是什么高深的问题,但每一个都能让人卡上半天。我做成表格,方便你遇到问题时直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| gcc --version提示"不是内部或外部命令" | 环境变量未配置或终端未重启 | 检查Path是否有mingw64/bin,重开终端 |
| 编译时报"undefined reference to `WinMain@16'" | 文件没有main函数或入口点设置错误 | 在.cpp里写一个标准int main() |
| 编译成功后运行时控制台中文乱码 | 源码UTF-8与终端GBK编码冲突 | 终端执行chcp 65001切换UTF-8 |
| VS Code提示compiler paths differ | 编译器路径指向gcc但编译C++ | 统一配置为g++.exe |
| 链接报"undefined reference to printf" | 静态库顺序问题或库未链接 | 把库放在源文件后面链接 |
| 运行时弹出"0xc0000135: DLL not found" | 缺少运行时DLL或PATH未包含DLL目录 | 将MinGW的bin目录加入PATH |
| 调试时无法命中断点 | 编译时没有加-g参数 | 在tasks.json中添加"-g" |
| 每次按F5重新编译很慢 | 全量编译导致 | 后续可引入CMake增量构建 |
| #include 报No such file | includePath未配置 | 在c_cpp_properties.json中加编译器自带头文件目录 |
| 结构体字节数比预期大 | 编译器默认对齐 | 使用#pragma pack(push,1)或static_assert校验 |
这张表只是抛砖引玉,我建议你每踩一个新坑,就把它记录到你自己的速查表里。工程师的成长曲线,本质上就是踩坑和填坑的曲线。
再说一个调试技巧。当程序运行崩溃时,不要急着看代码,先看崩溃的堆栈和异常信息。在VS Code里按F5启动调试,程序崩溃时会停在出错那一行,左侧调用堆栈会显示完整的函数调用链。大多数内存错误,比如访问空指针、数组越界、对象释放后再使用,都能通过堆栈快速定位到出错函数。如果堆栈被破坏得没法看,可以用AddressSanitizer重新编译:g++ -fsanitize=address -g main.cpp -o main.exe,它能告诉你精确的内存错误类型、发生位置、分配/释放位置,简直是排查内存问题的神器。
我个人在实际操作中的体会是,C/C++学习没有捷径,但绝对有方法。环境搭建、语言基础、算法模型、工程实践、性能优化这五个维度的练习,是一个完整的闭环。每一个维度都和其他维度相互支撑,跳过一个环节,后面的路都会走得很别扭。如果你能认真把这套练习卷过一遍,再带着问题去写两三个真实项目,我相信你对C/C++的理解会上一个台阶。最后再分享一个小技巧:练习过程中一定养成写笔记的习惯,不用写得多工整,把踩过的坑和当时的思考记录下来就行,三个月后再回头看,你会发现自己已经走了很远。