先说结论:在同等编译优化水平下,C++的运行速度并不比C慢;很多场景下,C++甚至更快。这个结论可能和很多人脑子里的刻板印象完全相反,但如果你愿意把“语言特性”和“实际生成的机器码”分开看,就容易理解了。
我见过不少从C转C++或者刚接触C++的开发者,都会有类似的疑虑:“C++那么多特性,类、虚函数、模板、异常,一个个听上去都是运行时开销,怎么可能不慢?”但实测做完之后,往往发现差距不在语言本身,而在写代码的人怎么用。这篇文章就把“C++相比C的运行速度”这个问题彻底拆开讲一遍,包含原理、实测方法、常见误区,以及我在实际项目中总结出来的几条性能经验。
1. 先立靶子:为什么大家总觉得C++比C慢
1.1 刻板印象从哪来——语法层面的“重”
C++的语法在视觉上确实比C重。C语言里一个函数调用的样子大概就是foo(x, y),但C++里你可能看到obj.method(x, y)、template<typename T>、std::vector<int>这类写法。人在评估性能时天然会受“看得见的复杂度”影响——你看到的东西越多,越觉得它慢。
再加上早期的C++编译器并不成熟,确实存在过“类封装之后性能下降”的时代。那时候的C++编译器对对象模型的处理还很粗糙,构造函数、析构函数、拷贝操作,动不动就内联出大量额外指令,对比C的裸结构体加函数,差距确实存在。这个历史背景让“C++慢”的观念一直传到现在。
另一个因素是从其他语言迁移过来的视角问题。如果你刚从Python、Java这类带有大量动态分派和运行时托管语义的语言切到C++,会觉得C++“已经很底层了”;但如果是从C切过来,会觉得C++“包裹了一层东西”。底层开发者天然更信任裸指针、裸内存和直白的函数调用。
1.2 真正决定性能的不是语言,是生成的代码
语言本身没有速度,编译器生成的机器码才有速度。CPU执行的是二进制指令,不是C代码,也不是C++代码。所以讨论“C++相比C的运行速度”,本质上要讨论的是:同样的逻辑,用两种语言描述之后,编译器各自生成什么样的汇编。
C++相比C增加了大量“语法糖”和“编译期机制”,但关键点在于:除了少数必须付出运行时代价的特性之外,大多数C++特性在生成机器码时会被编译器剥掉。比如std::vector<int>在开启优化之后,底层操作和手写的int *arr+size+capacity结构体操作几乎没有区别。模板类本质上就是编译器在编译期生成的一块定制代码,和使用手写C代码在内存布局上没有差异。
所以我认为,在评估速度之前首先要建立正确的模型:C和C++是“同一层”的语言,都直接编译为本地代码,中间没有解释器、虚拟机或者运行时垃圾收集。它们之间的性能差异,不可能是数量级的差异,只可能是具体使用方式带来的微小差距。这里的“微小”在个别热路径上会被放大,但在整体系统层面通常可以忽略。
2. 零成本抽象到底是不是真的零成本
2.1 没代价的抽象:模板、常量表达式、引用
C++的设计哲学里有一条著名的“零成本抽象”(zero-overhead abstraction),意思是:你不用某个特性,就不用为它付费;你用了某个特性,它生成的代码不会比你手动写底层的版本更差。虽然这个口号在细节上不完全成立,但大部分核心特性确实做到了。
模板就是典型的“编译期替你写代码”机制。模板实例化发生在编译期,编译完成之后,模板类/函数已经被展开成针对你传入类型的具体代码。这相当于你手动为每种类型写了一份专用实现,但编译器替你自动完成。比如std::sort在实例化之后就是一个针对特定类型和特定比较逻辑的定制函数,和手写一个专门的排序函数没有性能差别。
引用则本质上就是指针的语法替身,在生成的代码里就是一个地址,和一个C指针的成本完全相同。常量表达式constexpr更极端,它直接在编译期把结果算好,运行时连计算都省了。这在C语言里你得靠宏或者外部脚本做到。
很多C用户担心C++的类和构造函数会带来额外开销,但如果你看开启-O2之后的汇编代码,一个简单的结构体加构造函数,和C语言里的结构体初始化,生成的东西一模一样——就是几条mov指令。你为抽象写的代码在优化之后被折叠掉了。
2.2 有代价的特性:虚函数、继承、异常
诚实地说,有一部分C++特性确实有运行时成本,且无法通过优化完全消除。最典型的就是虚函数。只要有虚函数,每个对象里就要存一个虚表指针,每次虚调用都需要“取指针 → 查虚表 → 间接跳转”三步。相比普通函数调用的直接跳转,这多了一次内存访问和一次间接跳转,在热循环里会明显影响指令流水线和分支预测。
但注意,虚函数的开销只在“运行时多态调用”的场景出现。如果你不需要多态,完全可以用final标记类、或者干脆用模板替代虚函数,编译器就能把调用去虚拟化(devirtualize),直接静态绑定,这时候成本又归零了。
异常处理也常被认为是C++慢的主要原因之一。在大多数主流编译器实现里,异常采用“零成本异常处理”(zero-cost EH)模型:正常执行路径上不插入任何检查代码,只在函数表里记录可能抛异常的位置和对应的处理代码。也就是说,只要你不抛出异常,正常运行路径上一分钱都不花;一旦真正抛异常,运行时才会去查表做栈展开,那是另一套相対较慢的路径,但它只在出错时触发。所以异常对“正常速度”几乎没有影响,影响的是“错误处理速度”和代码体积。
2.3 编译器优化如何改变“成本结构”
C++相对C的一个隐藏优势是,类型信息更丰富,编译器能做更激进的优化。C语言在很多场景下只能通过指针和基本类型传递数据,优化器对数据的流动方式约束较少,反而导致一些优化无法安全执行。C++的类型系统和访问控制,加上引用语义和const限定,给优化器提供了更多“可以假定不变”的条件。
举一个我实际遇到的例子:一段图像处理代码,用C写时,因为到处都是指针,编译器无法确定某个函数是否修改了外部变量,于是每次循环都会重新读内存;用C++写时,通过引用传递并且加上const修饰,编译器确定数据不会变,把关键计算全部放进寄存器。同样逻辑,C++版本在特定编译器版本上跑出了接近一排的速度差距。
虽然这不是必然结果,但说明了:C++在工程实践中,由于表达更精确,优化器更容易找到优化路径。前提是你别用一堆让优化器晕头转向的动态特性和复杂对象图。
3. 用基准测试和数据说话
3.1 测试的姿势不对,结论就是错的
讨论C++和C性能时,如果只拿“某个群友的某某程序”当证据,基本没有参考价值。因为性能比较里最容易被忽略的是编译选项和测试方法的差异。我见过很多人拿C程序开-O2,拿C++程序不开优化就加std::vector,然后得出“C++慢了三倍”的结论,这属于测试姿势错误,不能代表语言本身。
正确的对比应该满足这么几个条件:
- 同一套算法逻辑:两边不能一边用高度优化的手写循环,一边用朴素STL容器。
- 相同的编译选项:至少都用
-O2,或者都用-O3,才公平。 - 相同的输入规模和运行环境:在同一个机器上跑多次取中位数。
- 排除启动开销:把核心计算循环跑足够多次,不要测进程启动、IO这些东西。
我在自己的Linux环境上大量做过这类对比。常见的结果是:两者最终性能差异在 ±5% 以内,在噪声范围内;个别C++场景因为底层容器的缓存友好性和编译期内联比C手写版本快10%~20%;反过来,也有C++版本因为用了虚函数或者频繁shared_ptr分配而比C慢20%,但那是特性误用,不是语言本身的锅。
3.2 典型对照组测试与结论
我列几个具有代表性的对照场景,供参考:
| 测试场景 | C实现思路 | C++实现思路 | 实测结论 |
|---|---|---|---|
| 数组累加/循环计算 | 裸指针 + for | std::vector+ 范围for | 性能几乎一致,开启优化后汇编接近 |
| 排序大量整数 | qsort+ 函数指针 | std::sort+ lambda | C++通常领先20%~30%,因为函数指针比较无法内联,而lambda可以直接内联 |
| 动态字符串拼接 | 手动realloc+memcpy | std::string++=/append | C++略快,因为std::string会按容量增长策略减少realloc次数 |
| 多态图形计算 | 结构体 + 类型字段 + if | 虚函数 + 继承 | 开启优化后可持平;保守场景虚函数稍慢3%~8% |
| 频繁小对象创建 | malloc/free | shared_ptr | C++明显慢,但这是因为shared_ptr有引用计数原子操作,正确做法是换unique_ptr或对象池 |
其中排序场景最值得展开说说。C的qsort接收函数指针,每次比较都要通过函数指针间接跳转,编译器无法把比较逻辑内联进排序内部;C++ 的std::sort接收的是模板参数,比较函数(尤其是lambda)在编译期就内联进排序函数,每趟比较都是一条简单的指令,没有跳转、没有间接寻址。这一点在几百万个整数时差距非常明显。这个例子充分说明:C++在“表达比较逻辑”这件事上,可以让编译器生成更高效的代码。
3.3 记住这张对照表
我把结论浓缩成一张对照表,方便你在日常讨论或者技术评审时直接引用:
| 维度 | C | C++ | 性能影响 |
|---|---|---|---|
| 函数调用 | 直接调用/函数指针 | 直接调用/inline/lambda | C++模板+lambda常优于C函数指针 |
| 容器 | 手写数组+结构体 | STL容器 | 正确使用时接近,C++少写bug且不慢 |
| 多态 | 手动switch/if | 虚函数 | 虚函数略有开销,但可优化掉 |
| 内存分配 | malloc/free | new/delete | 基本一致,异常分配复杂场景C++有额外信息 |
| 字符串处理 | 手动管理缓冲区 | std::string | 大多数场景C++更安全且不慢 |
| 错误处理 | errno/返回码 | 异常 | 正常路径性能不受影响 |
这张表的结论是:语言能力更丰富的C++,在“正确使用前置条件”下,性能和C处于同一档位;真正拉开差距的是用法,而不是语言。
4. 工具链优化与C++特定性能技巧
4.1 编译器选项与LTO其实是第一级优化
很多人对比性能只关心语言写法,忘了编译器才是“性能放大器”。同样的代码,-O0和-O3的差距可以到几倍甚至几十倍,比C和C++之间的差距大太多了。所以第一步先把编译选项拉满。
具体到C++,有几个非常关键的工具链选项值得提:
-O3:启用更多循环优化和向量化,适合计算密集场景。-march=native:让编译器针对当前CPU的指令集生成代码,自动启用AVX2、NEON这些向量指令。-flto:链接时优化,允许跨编译单元做内联和常量传播。这个对C++特别有帮助,因为C++大量依赖模板和inline,跨文件优化能大幅减少冗余代码。-fno-exceptions和-fno-rtti:如果你确定用不到异常和运行时类型信息,关掉它们可以减少代码体积,配合跳转表优化有时能带来微小的速度提升。但这属于“不用则免费”的收尾优化,不要依赖它获得巨大收益。
在我实际项目里,C++代码开-O3 -march=native -flto之后,和同样算法逻辑的C版本基本没有差距,甚至在向量化和循环展开上,C++反而更容易命中优化器喜欢的模式。
4.2 六条C++高性能编码经验
抛开语言之争,用C++写出能和C代码一样快甚至更快的代码,还是有一些具体技巧的。我把这些年沉淀下来的经验整理成六条:
第一,尽量使用“值语义 + 移动语义”,而不是“指针 + 手动拷贝”。现代C++的移动构造在函数返回、容器扩容时可以做到零复制,把数据从一个内存块“搬”到另一个内存块只需要改几个指针。相比之下,C风格的显式拷贝函数,一旦拷贝逻辑写得粗心,很容易多复制一两轮缓冲区。
第二,优先用模板而非虚函数。如果多态的类层级是固定的,编译期就知道实现,那么模板是你的最佳选择。模板没有虚表指针,没有间接跳转,编译器天然知道调用的具体函数,能做深度内联。
第三,注意容器选型。std::vector是缓存友好的,多数场景碾压std::list和std::map。C语言里手写链表和红黑树很容易在缓存命中率上输给std::vector线性扫描。C的代码如果只是“用结构体指针串起来”,不一定就高效。
第四,减少不必要的内存分配。C++写起来太方便了,容易不知不觉在循环里创建临时std::string或std::vector,每一次都可能触发堆分配。解决方法是复用缓冲区,或者使用reserve预分配容量。这一点在C里大家天生会注意,但C++的语法糖容易让人大意。
第五,善用编译期计算。constexpr、模板元编程、if constexpr,这些能力让C++可以在编译期完成大量决策。运行时少算,速度自然快。C语言在这一点上通常只能依赖宏,宏又容易破坏调试体验和类型安全。
第六,热路径上避免shared_ptr。这不是说智能指针不好,而是引用计数有原子变量的读取和递增/递减操作,在单线程热循环里完全是多余的。单线程用unique_ptr或裸指针;真正需要共享所有权时,再考虑shared_ptr,而且尽量用make_shared减少二次分配。
4.3 从C到C++的性能“搬运清单”
如果你手上有一段C代码,想用C++重写但不希望变慢,可以参考我整理的“搬运清单”:
- 结构体定义:保留,
struct本身C++兼容,不过可以添加构造函数和成员函数。 - 内存分配:
malloc/free换成new/delete,如果对象生命周期简单,直接换成栈对象或者std::vector。 - 数组操作:C风格裸数组换成
std::array(定长)或std::vector(变长),在开启优化后性能一致,但更安全。 - 函数指针:换成模板或者lambda,编译器能内联。
- 显式类型分支:如果逻辑固定,用模板或重载替换;如果必须运行时分派,优先考虑
std::variant替代裸switch。 - 字符串缓冲区:
char buf[1024]换成std::string,注意提前reserve。 - 错误路径:把
if (ret != 0)改成异常抛出,正常路径少一条分支判断。
做完这七步之后,用相同编译选项跑一遍基准,绝大多数项目不仅不会变慢,还会因为容器管理更精细、比较逻辑内联等机制变得更快。当然,前提是别中途引入不必要的虚函数和复杂对象图。
5. 常见误区、反例与排查实录
5.1 “C++慢”的经典误判
性能讨论里最容易翻车的是把“误用”当成“慢”。我举几个最常见的误判:
第一个是把std::string的所有操作都当成慢。实际上,std::string的短字符串优化(SSO)机制使得很短的字符串根本不做堆分配,直接存在对象内部。而C语言里大多数char*操作其实都在处理动态内存,反而容易触发堆分配。误判往往来自“看到封装就觉得慢”的心理预设。
第二个是认为“面向对象=慢”。其实类的成员函数和C结构体的外部函数编译后没有区别,数据成员就是结构体字段。只要不做虚函数,类的调用开销和C完全相同。
第三个是“模板会让代码膨胀,所以慢”。模板多实例化确实会增加代码体积,但现代CPU的指令缓存足够大,代码体积增大通常不会带来明显的性能损失。而且内联带来的好处往往远远大于指令缓存压力。我在测试中发现,模板版的排序、搜索代码在大多数场景都比C的函数指针版本快。
第四个是“C++的异常太慢,所以必须禁用”。前面说过,零成本异常处理在正常路径上不插入检查代码,真正产生成本的是抛异常的过程。如果你的程序正常路径上状态稳定,几乎没有异常路径被触发,那么禁用异常并不会带来明显收益。反而为了禁用异常改写出标志位判断,可能增加额外分支。
5.2 排查性能问题的方法论
当你真的遇到一段C++代码明显比预期慢时,不要急着“换回C”,而是按以下步骤排查:
第一步,先用perf或者系统性能分析工具看热点在哪里。通常你会发现慢的不是某个语言特性,而是某个具体函数、某个内存分配点或者某条连锁缓存缺失。
第二步,检查是不是频繁堆分配。常见线索是malloc和free的调用次数异常高。用-pg或者性能计数器统计一下分配次数,如果一次循环里分配了成百上千次,那就是分配模式的问题,不是语言的问题。
第三步,检查是不是虚函数导致分支预测失败。如果热点是一个间接调用,看看它能不能改成直接调用,或者通过模板消除多态。
第四步,检查缓存友好性。遍历方式是否跳跃?数据结构是否过于分散?std::vector的连续内存比std::list的散列节点更利于缓存,这一条在大型数据上尤其明显。
第五步,检查编译选项。会不会某个文件忘了开-O2?或者某个接口被编译器拒绝内联?C++可以通过__attribute__((always_inline))强制内联,但一般不用这么激进。
我在多次调优中反复验证的一条经验是:90%以上的“C++变慢”问题,最后定位出来都是内存分配次数太高或者虚函数误用,而这些用C重写也避免不了——因为C手写同样的对象生命周期管理只会更容易出错,不会更高效。
5.3 快速自查清单
最后给你一份可以直接贴在工位上的自查清单,写代码的时候对照一下:
- [ ] 所有热路径上的对象是否都尽量放在栈上或使用
std::vector? - [ ] 循环内是否避免了不必要的堆分配和临时对象?
- [ ] 多态调用是否真的需要虚函数,能否用模板或
std::variant替代? - [ ] 深层拷贝是否可以用移动语义或者引用/指针替代?
- [ ] 已开启
-O3和-flto? - [ ] 是否按输入规模提前
reserve了容器容量? - [ ] 大数据量的遍历是否是连续内存,而不是散列节点?
- [ ] 原子操作和锁的使用是否只出现在真正需要并发同步的地方?
如果这些问题都过了,你的C++代码性能基本不会低于同逻辑的C代码。剩下的差距,多半是优化水平问题,不是语言问题。
回到最初的问题,“C++相比C的运行速度”谁快谁慢——我在实际项目中反复验证的结论是:同一水平的代码,在相同编译选项下,C++基本可以做到和C持平,甚至在算法、容器、模板的合理组合下更容易榨出性能。这也和C++标准库花了大量精力做性能优化有关。C是一种简洁强大的语言,但把“简洁”等同于“快”,往往会错过C++在编译期帮你消除运行时开销的潜力。
我个人现在写性能敏感代码时,反而倾向于用C++,不是因为它语法花哨,而是因为它能让“意图”更明确:容器明确、生命周期明确、多态明确,编译器也因此更容易理解你的代码、生成更优的指令。最后再分享一个小技巧:做性能对比时,把自己的C和C++代码都丢到godbolt上看一下汇编,很多时候你亲眼看到两份汇编长得几乎一样,心里就不会再被“语言慢”这种说法牵着走了。