yuqi-zheng

编译器优化:循环写法并不重要


C++ 中一个反复被争论的话题是:范围 for、STL 算法这类更高级的循环写法,相较于手写的索引循环或裸指针遍历,是否隐藏了额外的性能开销?在任何开启了优化的现代编译器上,答案是否定的。编译器会把所有这些写法归约为相同的机器码。

本文是 Matt Godbolt 的《Advent of Compiler Optimisations 2025》系列中的一篇,它同时讲述了 Compiler Explorer 诞生的故事。

Compiler Explorer 的起源

2011 年,Matt Godbolt 和同事们正在讨论是否要在整个代码库中采用 C++11 的范围 for 循环。他们的担忧是性能:范围 for 是基于迭代器遍历的语法糖,而在 Java 这样的语言里迭代器是有开销的。C++ 里会不会也是这样?

Godbolt 没有去猜,而是写了一个小脚本:编译一段 C++、实时展示生成的汇编。这个脚本演化成了 Compiler Explorer —— 如今已是全世界最广泛使用的编译器探究工具之一。而当初催生它的那个性能问题,答案其实很明确。

求和 vector 的四种写法

考虑对 std::vector<int> 求和的四种惯用写法:

索引 for 循环

int sum = 0;
for (size_t i = 0; i < vec.size(); ++i)
    sum += vec[i];

这是最传统的形式。编译器必须计算 vec.size()(涉及一次指针相减和一次右移)、维护一个索引变量、每次迭代都按索引寻址 vector 数据。生成的代码是正确且快速的,但它保留了索引变量,比必需的工作略多。

指针遍历

int sum = 0;
for (const int* p = vec.data(); p != vec.data() + vec.size(); ++p)
    sum += *p;

这里程序员使用裸指针。编译器能看出起止指针可以只计算一次、直接使用,循环体中不再做 size 计算。内循环就是一次加载、一次加法、一次指针自增、一次比较。

范围 for 循环

int sum = 0;
for (int x : vec)
    sum += x;

尽管是最高层的写法,它产生的汇编与指针遍历完全相同。编译器先把范围 for 展开为基于迭代器的循环,然后把这些迭代器优化为裸指针,最终得到完全一样的指令序列。

std::accumulate

return std::accumulate(vec.begin(), vec.end(), 0);

STL 算法形式产生的汇编也一样。函数模板被内联,迭代器算术被化简为指针算术,结果是同样紧凑的内循环。

汇编告诉我们什么

循环写法生成最优代码可读性安全性
索引 for否(保留了索引变量)存在越界风险
指针遍历风险高
范围 for安全
std::accumulate安全

唯一稍差的形式是索引循环。编译器没有消除索引变量,留下了一点点额外的簿记工作。其他所有形式都被”规范化” —— 化简为同一种内部表示 —— 并生成最优的指针式迭代代码。

这件事为什么重要

实际含义很简单:写最能表达你意图的循环形式就好。范围 for 和 STL 算法不是在性能上让步。它们是更清晰的选择,而且产生的代码至少一样好(有时更好)。

裸指针循环并不比范围 for 更快。它们更难读、更容易写错(越界、边界计算错误),对优化器也没有任何好处。抽象的成本是零。

这就是 C++ 中”零成本抽象”的含义:语言提供了更高层的构造 —— 迭代器、范围 for、算法模板 —— 它们最终编译出与底层替代方案完全相同的机器码。你不会在运行时为抽象付费。

更普遍的启示

当你不确定某个 C++ 惯用写法是否带来性能成本时,不要凭感觉猜。把有代表性的代码片段粘到 Compiler Explorer 里看输出。答案通常是:现代编译器比我们对”开销”的直觉要聪明得多。

在本文这个具体问题上,答案十多年来都没变过:写让代码最清晰、最易维护的循环风格。剩下的交给编译器。