ARTICLE DETAIL

资讯详情

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

C与C++核心区别详解:从语法到工程实践

C与C++核心区别详解:从语法到工程实践 1. 从一堆热搜词里看出来的真实需求先把话说在前头C 和 C 的区别这个话题网上的文章没有一万篇也有八千篇但绝大多数都在干一件事——列一张对比表然后告诉你C 是面向过程的C 是面向对象的完事。这种回答对考试有用对实际写代码几乎没用。我之所以想认真写这一篇是因为我注意到一个很有意思的现象搜索C 和 C 的区别的人和搜索vscode 配置 c/c 环境c 入门c 语言基础的人高度重合。这说明什么说明大部分问这个问题的人不是在做学术对比而是站在一个岔路口上——我到底该先学哪个我装了 VS Code 为什么跑不起来我写的.c文件和.cpp文件到底有什么不一样这些才是真问题。所以这篇不打算写成教科书式的语言规范对比而是从一个写过不少 C 和 C 代码的人的角度把这两个东西的关系、边界、以及实际开发中那些没人明说但你必须知道的差异讲清楚。不管你是刚接触编程的新手还是写了几年 C 想往 C 转的老手应该都能从里面找到对自己有用的部分。我会先讲清楚它们的历史渊源和本质关系再拆解语法和编程范式上的核心差异然后落到实际工程里——编译、链接、内存管理、调试这些真正会卡住人的地方最后给一条我认为比较靠谱的学习路径。中间会穿插一些我自己踩过的坑比如那个经典的extern C问题还有 VS Code 里.c和.cpp混编时的诡异报错。2. C 和 C 到底是什么关系不是父子是同源分家2.1 一段被简化了的历史很多人以为 C 是 C 的升级版这个说法对了一半但容易误导。准确的说法是C 最初确实是作为C with Classes带类的 C出现的由 Bjarne Stroustrup 在贝尔实验室开发目标是给 C 加上 Simula 那样的类机制同时保持 C 的效率。但发展到今天C 早就不是C 加个类那么简单了它有自己的标准库、模板元编程、异常处理、RAII 等等一整套体系。关键在于C 标准从来没有完全抛弃 C 的语法。你写的大部分合法 C 代码直接扔进 C 编译器里大概率也能编译通过注意是大概率后面会讲例外。这就是为什么很多人觉得学完 C 就等于学了半个 C。但反过来不成立。C 编译器不认识class、template、namespace、iostream这些东西。所以两者的关系更像是C 是 C 的一个真子集但这个子集在 C 里被一些新规则重新解释了。2.2 为什么这个区别在实际开发中很重要你可能会想知道这个有什么用用处大了。举个最常见的场景你在做一个嵌入式项目主控芯片的 SDK 是用 C 写的但你想用 C 写业务逻辑。这时候你就必须搞清楚哪些 C 代码能直接拿来用哪些需要包一层哪些会因为 C 更严格的类型检查而报错。再比如你维护一个十几年的老 C 项目想逐步引入 C 特性。如果你不知道两者的边界在哪很容易改着改着就编译不过了而且报错信息往往让人摸不着头脑。我见过太多人卡在为什么这段 C 代码在 C 里编译不过这种问题上。下面这张表先给你一个整体印象后面会逐条展开。维度CC编程范式面向过程多范式过程、面向对象、泛型、函数式类型检查相对宽松严格得多内存管理手动 malloc/free手动 new/delete RAII 智能指针标准库精简stdio、stdlib 等庞大STL、iostream、algorithm 等函数重载不支持支持命名空间无有 namespace编译产物函数名基本保持原样函数名会被 name mangling3. 语法层面的核心差异那些真正会让你编译报错的地方3.1 函数声明与定义void和空参数列表的坑这是新手最容易踩的一个坑没有之一。在 C 里void func()和void func(void)是两回事。前者表示参数个数不确定后者表示没有参数。而在 C 里void func()就明确表示没有参数。看这段代码// 在 C 里这是合法的但在 C 里会报错 void func() { // 函数体 } int main() { func(1, 2, 3); // C 编译器可能不报错C 编译器直接拒绝 return 0; }C 编译器对这种情况往往只是警告甚至不警告因为它认为func的参数列表未指定。但 C 编译器会直接报错因为它把func()理解成了无参函数。这个差异在维护老代码时特别容易出问题——一段在 C 下跑得好好的代码拿到 C 里就编译不过了。提示如果你在写 C 代码养成写void func(void)的习惯这样迁移到 C 时不会出问题。3.2 类型转换C 不让你随便转C 的类型转换非常宽松指针之间可以随便转void*可以隐式转成任何指针类型。C 把这些口子收紧了。// C 里完全合法 int *p malloc(sizeof(int) * 10); // malloc 返回 void*C 里隐式转换// C 里这样写会报错 int *p malloc(sizeof(int) * 10); // 错误不能把 void* 隐式转成 int* int *p (int*)malloc(sizeof(int) * 10); // 必须显式转换但更推荐用 new这个差异背后是有道理的C 希望类型系统能帮你挡住更多错误。void*隐式转换是很多 bug 的温床C 干脆不让你这么干。另外C 还引入了四种显式转换操作符static_cast、dynamic_cast、const_cast、reinterpret_cast。它们比 C 风格的强制转换更安全、更明确。比如static_castint(3.14)一看就知道是数值转换而(int)3.14你根本不知道作者想干什么。3.3 结构体从数据集合到完整的类在 C 里struct就是一堆数据的集合不能有函数不能有访问控制。在 C 里struct和class几乎一样唯一的区别是默认访问权限struct默认publicclass默认private。// C 的结构体 struct Point { int x; int y; }; void print_point(struct Point p) { // 函数必须写在外面 printf((%d, %d)\n, p.x, p.y); }// C 的结构体 struct Point { int x; int y; void print() { // 函数可以直接写在里面 std::cout ( x , y ) std::endl; } };这个差异看起来只是写法不同但它代表了两种完全不同的设计思路。C 的结构体是被动的数据和处理数据的函数是分离的C 的类/结构体是主动的数据和行为封装在一起。这就是面向对象和面向过程最直观的区别。3.4 字符串char*和std::string的世界C 里没有字符串类型只有字符数组和char*。你得自己管内存、自己算长度、自己处理越界。C 的std::string把这些都封装好了。// C 的字符串操作 char *str malloc(100); strcpy(str, hello); strcat(str, world); printf(%s, length: %zu\n, str, strlen(str)); free(str);// C 的字符串操作 std::string str hello; str world; std::cout str , length: str.length() std::endl; // 不需要手动释放我个人的经验是除非你在写对性能极度敏感的底层代码或者必须和 C 接口打交道否则在 C 里永远优先用std::string。手动管理char*带来的 bug 数量远超它省下来的那点性能。4. 编程范式的分水岭面向过程和面向对象到底差在哪4.1 用一个实际例子说明差异光说概念没意思我们用一个具体例子来看。假设要写一个简单的图形面积计算程序支持圆形和矩形。C 的写法通常是这样的typedef enum { CIRCLE, RECTANGLE } ShapeType; typedef struct { ShapeType type; union { struct { double radius; } circle; struct { double width, height; } rect; }; } Shape; double area(Shape *s) { switch (s-type) { case CIRCLE: return 3.14159 * s-circle.radius * s-circle.radius; case RECTANGLE: return s-rect.width * s-rect.height; } return 0; }C 的写法class Shape { public: virtual double area() const 0; virtual ~Shape() default; }; class Circle : public Shape { double radius; public: Circle(double r) : radius(r) {} double area() const override { return 3.14159 * radius * radius; } }; class Rectangle : public Shape { double width, height; public: Rectangle(double w, double h) : width(w), height(h) {} double area() const override { return width * height; } };两种写法都能跑但扩展性完全不同。如果现在要加一个三角形C 的写法需要改enum、改union、改area函数里的switch——三处修改而且很容易漏。C 的写法只需要新增一个类其他代码一行都不用动。这就是面向对象的核心价值对扩展开放对修改关闭。当然这不是说 C 的写法就不好在嵌入式等资源受限的场景里C 的写法反而更可控、更可预测。4.2 模板C 独有的泛型能力C 里要实现通用的代码基本只能靠void*加宏。C 的模板是编译期泛型类型安全且没有运行时开销。templatetypename T T max(T a, T b) { return a b ? a : b; } int main() { std::cout max(3, 5) std::endl; // int 版本 std::cout max(3.14, 2.71) std::endl; // double 版本 }C 要实现类似功能要么写多个函数要么用宏宏没有类型检查调试时展开后一团糟要么用void*丢失类型信息。模板是 C 里我最喜欢的特性之一STL 整个就是建立在模板之上的。4.3 异常处理错误处理的两种哲学C 的错误处理基本靠返回值。函数返回-1或者NULL表示出错调用者必须检查。问题是很多人不检查。FILE *f fopen(test.txt, r); if (f NULL) { // 必须处理但很多人忘了 }C 引入了异常机制try { throw std::runtime_error(something went wrong); } catch (const std::exception e) { std::cerr e.what() std::endl; }异常的好处是错误不会被忽略——如果你不 catch程序会直接终止至少你知道出问题了。坏处是异常的控制流不直观而且对性能有影响虽然现代编译器优化后影响很小。我的建议是在 C 里构造函数失败用异常其他情况看项目规范。很多游戏引擎和嵌入式项目会禁用异常这时候就退回 C 风格的错误码。5. 编译、链接与工程实践那些文档里不会写的坑5.1 Name Mangling为什么 C 和 C 不能直接互相调用这是实际工程中最常见的问题。C 编译器会对函数名进行名称修饰name mangling把参数类型信息编码进符号名里以支持函数重载。而 C 编译器不会。比如void foo(int)这个函数C 编译后的符号名可能就是foo而 C 编译后可能是_Z3fooi。这就导致C 代码调用 C 函数或者 C 调用 C 函数时链接器找不到符号。解决办法是用extern C// C 头文件供 C 代码调用 #ifdef __cplusplus extern C { #endif void foo(int x); void bar(void); #ifdef __cplusplus } #endif这段#ifdef __cplusplus的写法是标准做法几乎所有跨语言的头文件都这么写。它的意思是如果是 C 编译器就用extern C包起来告诉编译器这些函数按 C 的规则来别做 name mangling如果是 C 编译器__cplusplus没定义直接跳过。注意extern C只影响链接不影响语法。被extern C包起来的函数仍然是 C 函数只是符号名按 C 的规则生成。5.2 VS Code 里.c和.cpp混编的配置现在很多人用 VS Code 写 C/C这里有个坑VS Code 本身不是编译器它只是个编辑器。真正干活的是你配置的编译器gcc/g 或 clang/clang。当你打开一个.c文件时VS Code 的 C/C 扩展会用 C 的规则去解析打开.cpp文件时用 C 规则。如果你在同一个项目里混用c_cpp_properties.json里的配置就很重要{ configurations: [ { name: Win32, includePath: [${workspaceFolder}/**], defines: [_DEBUG, UNICODE], compilerPath: C:/mingw64/bin/g.exe, cStandard: c11, cppStandard: c17, intelliSenseMode: windows-gcc-x64 } ], version: 4 }关键点compilerPath指向g.exe而不是gcc.exe这样 C 标准库的头文件才能被正确找到。cStandard和cppStandard分别指定 C 和 C 的标准版本。如果你发现写 C 代码时没有代码提示八成是compilerPath配错了或者intelliSenseMode和你的实际编译器不匹配。5.3 编译命令的差异用命令行编译时C 和 C 用的编译器不同# 编译 C gcc -o program program.c -stdc11 # 编译 C g -o program program.cpp -stdc17 # 混合编译C 文件用 gccC 文件用 g gcc -c c_part.c -o c_part.o g -c cpp_part.cpp -o cpp_part.o g -o program c_part.o cpp_part.o最后链接的时候一定要用g因为 C 的标准库需要链接进去。如果你用gcc去链接 C 目标文件会报一堆undefined reference错误——这是新手非常容易踩的坑。6. 内存管理从 malloc/free 到智能指针的演进6.1 C 的手动管理及其代价C 里所有动态内存都得手动申请、手动释放int *arr malloc(sizeof(int) * 100); if (arr NULL) { // 处理分配失败 } // 使用 arr free(arr); arr NULL; // 防止悬空指针这套机制的问题在于忘记 free 就内存泄漏free 两次就崩溃free 后继续用就是未定义行为。大型 C 项目里内存问题是最难排查的 bug 类型之一。6.2 C 的 RAII 和智能指针C 的核心思想之一是 RAIIResource Acquisition Is Initialization——资源的生命周期绑定到对象的生命周期上。对象构造时获取资源析构时自动释放。{ std::vectorint arr(100); // 自动分配 // 使用 arr } // 离开作用域自动释放不需要手动 free对于需要手动管理的情况C11 引入了智能指针#include memory // unique_ptr独占所有权 std::unique_ptrint[] arr(new int[100]); // 不需要 delete离开作用域自动释放 // shared_ptr共享所有权引用计数 std::shared_ptrMyClass obj std::make_sharedMyClass();我个人的经验现代 C 代码里裸的new和delete应该几乎绝迹。如果你在写 C 还在手动new/delete大概率是没用好标准库。std::vector、std::string、std::unique_ptr这些工具能覆盖 95% 以上的场景。6.3 一个真实的踩坑案例我之前维护过一个项目C 和 C 混编。C 部分用malloc分配内存C 部分用delete释放——结果就是堆损坏程序随机崩溃而且崩溃点离真正的问题点很远排查了整整两天。根本原因malloc和new用的是不同的内存分配器malloc出来的内存必须用free释放new出来的必须用delete。混用就是未定义行为。提示在 C/C 混编项目里一定要明确内存的归属——谁分配的谁释放并且分配和释放必须用同一套 API。7. 该学哪个、怎么学一条务实的路径7.1 先回答该学哪个这个问题没有标准答案取决于你的目标想搞嵌入式、操作系统、驱动开发先学 C而且要学扎实。这些领域 C 是绝对主力C 用得相对少。想做游戏开发、桌面应用、高性能服务直接学 C。C 的语法在学 C 的过程中自然会掌握。想打算法竞赛C 是主流因为 STL 太方便了。但竞赛用的 C 其实是C with STL面向对象那套基本用不上。纯粹想理解计算机底层学 C。C 更接近硬件能让你理解指针、内存布局这些根本性的东西。我个人的建议是如果你时间有限直接学 C但在学 C 的过程中认真理解 C 的那部分指针、内存、数组。因为 C 的很多坑恰恰来自它的 C 遗产不理解 C 就理解不了 C 为什么这么设计。7.2 学习路径上的几个关键节点第一阶段把基础语法过一遍。变量、循环、函数、数组、指针这些 C 和 C 是共通的。指针一定要理解透这是后面所有内容的基础。第二阶段理解内存模型。栈、堆、静态区分别放什么指针和引用的区别值传递和引用传递的区别。这部分不过关后面写什么都是空中楼阁。第三阶段C 特有的东西。类、继承、多态、模板、STL。这时候你会发现之前学的 C 知识在这里被重新组织了。第四阶段工程实践。编译链接过程、调试技巧、内存检测工具Valgrind、AddressSanitizer、性能分析。这些是学校不教但工作天天用的。7.3 几个我踩过的学习坑第一个坑过早纠结学 C 还是学 C。我见过太多人在这个问题上纠结几个月结果两个都没学。其实前两周的内容两者几乎一样先动起来比什么都强。第二个坑只看不写。编程是手艺活看十遍不如写一遍。特别是指针和内存这块不亲手写几个会崩溃的程序你永远理解不了。第三个坑忽视调试能力。很多人学编程只学怎么写对不学写错了怎么找。实际上调试能力才是区分新手和老手的关键。学会看编译错误、学会用调试器、学会用printf大法这些比多学几个语法特性重要得多。第四个坑在环境配置上卡太久。VS Code 配置 C/C 环境确实有点麻烦但不要在这上面耗太多时间。如果配了两小时还搞不定直接装 Visual StudioWindows或者用在线编译器先把语法学了环境的事后面再说。8. 一些容易被忽略但很关键的细节8.1const的差异C 和 C 里const的含义有微妙差别。在 C 里const变量默认是外部链接的在 C 里const变量默认是内部链接的。这意味着在 C 里const int x 10;写在头文件里被多个源文件包含时不会造成重复定义而在 C 里会。这个差异在多文件项目里很容易出问题。C 里如果你想让const变量有外部链接需要显式加extern。8.2 布尔类型C99 之前C 没有布尔类型大家用int代替0 表示假非 0 表示真。C99 引入了_Bool和stdbool.h里的bool。C 从一开始就有bool类型是内置的。这个差异导致一些老 C 代码迁移到 C 时bool相关的逻辑可能需要调整。8.3 默认参数C 支持函数默认参数C 不支持void func(int a, int b 10); // C 合法C 不合法这个特性用起来方便但要注意默认参数是在编译期决定的如果你通过基类指针调用虚函数默认参数用的是基类的版本而不是派生类的。这是个很隐蔽的坑。8.4 结构体初始化C 和 C 的结构体初始化语法也有差异。C 里常用指定初始化器struct Point p { .x 1, .y 2 }; // C99C20 之前不支持这种写法C20 才引入了指定初始化器但限制比 C 多必须按声明顺序。这些细节看起来琐碎但在实际项目里往往就是这些小差异导致编译失败或者行为异常。我的建议是写 C 就用 C 的规范写 C 就用 C 的规范不要试图写两边都能编译的代码。那种代码通常两边都不地道维护起来更痛苦。9. 我个人的一些体会写了这么多年 C 和 C我最大的感受是这两个语言的关系不是新旧或者高低的关系而是不同工具适合不同场景的关系。C 像一把手术刀简单、直接、可控。你清楚地知道每一行代码在干什么每一块内存在哪里。代价是什么都得自己来容易出错。C 像一把瑞士军刀功能多、表达力强、抽象层次高。你可以用很少的代码表达很复杂的逻辑。代价是复杂度高学习曲线陡容易写出看起来很优雅但性能很差的代码。我见过用 C 写出极其优雅的系统代码的人也见过用 C 写出比 C 还难维护的代码的人。语言只是工具关键还是看用工具的人。如果你正在纠结学哪个我的建议是先花两周把 C 的基础语法和指针搞明白然后直接进入 C。在学 C 的过程中你会不断回头理解 C 的那些概念这种螺旋上升的学习方式比线性地先学完 C 再学 C效率高得多。最后分享一个我常用的判断标准当你写代码时如果脑子里想的是数据怎么流动、内存怎么布局那你是在用 C 的思维如果脑子里想的是对象之间怎么交互、接口怎么设计那你是在用 C 的思维。两种思维都有用关键是知道什么时候该用哪种。
返回列表