我第一次在代码评审里看到那种 for 循环。
长得像一段咒语。
你看得懂。
但你不想再看第二遍。
std::vector<int> v{1, 2, 3};
for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
std::cout << *it << "\n";
}
这段当然没错。
而且非常“C++”。
问题在于。
它太像仪式了。
你真正想表达的只有一句。
“把容器里的每个元素都看一眼。”
可你得先写三段类型。
再写一次 begin。
再写一次 end。
最后还要小心别把 ++it 写成 it++,然后在热点路径里被人盯着性能。
那几年我们遍历代码。
经常像在写流水线。
每个人都能写。
每个人都能写错。
尤其是写错 it != end() 这种。
不是错在逻辑。
错在手滑。
更尴尬的是。
这种错误还挺“有道理”。
编译器不一定帮你抓。
这玩意是怎么来的
C++ 不缺“遍历”。
它缺的是一个能在现场写出来的“for each”。
别的语言早就有了。
Java 有增强 for。
Python 甚至把遍历当成日常语言习惯。
C++ 当然也有人想要。
但 C++ 的习惯是。
你要的语法糖。
得先过两关。
第一关。
不能牺牲性能模型。
第二关。
不能让类型系统更乱。
所以在 C++11 之前。
大家就先靠库和宏顶着。
比如 Boost 时代很流行的 BOOST_FOREACH。
好用。
也很“那个年代”。
你要在工程里解释一个宏如何展开。
解释到最后。
总感觉自己在给未来的维护者留一份谜题。
后来委员会做了一件朴素的事。
既然 STL 的世界观已经是“range = begin + end”。
那就把这套世界观。
做成语法。
于是 C++11 有了范围 for。
写起来就一眼能懂。
std::vector<int> v{1, 2, 3};
for (int x : v) {
std::cout << x << "\n";
}
你终于可以像说话一样写代码。
“for each x in v”。
它到底在帮你干什么
老程序员对“语法糖”有点戒心。
不是不喜欢。
是怕它背后藏了成本。
范围 for 的好处是。
它不神秘。
你把它当成编译器替你展开的一段模板化写法就行。
下面这段不是标准原文。
但意思差不多。
for (auto&& __range = v;
auto __it = std::begin(__range), __end = std::end(__range);
__it != __end;
++__it) {
int x = *__it;
std::cout << x << "\n";
}
关键点在这里。
范围表达式先被保存起来了。
所以你写 for (auto x : make_vec())。
那个临时对象不会立刻死。
它会活到循环结束。
这也是为什么。
它能既“顺手”。
又不至于变成悬空引用制造机。
当然。
你如果在循环体里把迭代器保存出去。
那还是老规矩。
对象活多久,迭代器就活多久。
别把锅甩给语法。
第一坑:你以为拿的是元素,其实拿的是拷贝
很多人第一次写范围 for。
都会写成这样。
std::vector<std::string> names{"alice", "bob"};
for (auto name : names) {
name += "!";
}
运行没报错。
结果也没变化。
然后你会怀疑人生。
其实原因很简单。
auto name 是按值。
你每次拿到的是一份拷贝。
你改的是拷贝。
容器里的原件不动如山。
这就引出范围 for 的第一条工程经验。
你遍历什么。
要先想清楚。
你要的是“读”。
还是“改”。
只读而且元素可能很大。
通常写成这样更稳。
std::vector<std::string> names{"alice", "bob"};
for (const auto& name : names) {
std::cout << name << "\n";
}
你得到的是引用。
不拷贝。
也不允许误改。
你读代码的人。
一眼就知道你想干嘛。
如果你确实要修改元素。
就把引用写出来。
std::vector<std::string> names{"alice", "bob"};
for (auto& name : names) {
name += "!";
}
这时候改的就是容器里的元素本尊。
老代码里经常有那种“遍历又改又读”的函数。
你用 auto&。
基本能让意图清爽很多。
第二坑:遍历 map 的时候,别把 pair 复制到手软
std::map 和 std::unordered_map 里。
元素类型是 std::pair<const Key, T>。
写范围 for 很自然。
std::unordered_map<std::string, int> cnt{{"a", 1}, {"b", 2}};
for (auto& kv : cnt) {
kv.second += 1;
}
这里的 auto& kv 很关键。
你如果写 auto kv。
每次都会拷贝一个 pair。
小数据你感觉不到。
大对象你就会在性能分析里看到它冲你挥手。
更重要的是。
pair 的 first 是 const。
你本来也改不了 key。
所以用引用遍历。
几乎永远更接近你想要的语义。
它不只遍历容器
范围 for 的设计其实挺“标准库主义”。
它不关心你是不是 vector。
它只关心你是不是一个 range。
也就是。
能不能找到 begin。
能不能找到 end。
数组也可以。
int a[] = {1, 2, 3};
for (int x : a) {
std::cout << x << "\n";
}
你自己写的类型也可以。
只要你把边界给出来。
#include <vector>
struct IntRange {
std::vector<int> data;
auto begin() { return data.begin(); }
auto end() { return data.end(); }
auto begin() const { return data.begin(); }
auto end() const { return data.end(); }
};
int main() {
IntRange r{{1, 2, 3}};
for (const auto& x : r) {
(void)x;
}
}
这里我故意写了 const 版本。
不是为了啰嗦。
是为了让你的类型更像标准容器。
你想让别人放心遍历它。
就别让 const 对象突然遍历不了。
这是一种“接口礼貌”。
一个老工程场景:边遍历边清理
范围 for 不是万能。
它让你更容易写出“看着很对”的代码。
也让你更容易写出“看着很对但其实不对”的代码。
比如你想边遍历边删除。
std::vector<int> v{1, 2, 3, 4};
for (auto x : v) {
if (x % 2 == 0) {
// v.erase(...); // 这里你会卡住
}
}
你会发现。
你根本没有迭代器可用。
这不是语法限制。
是设计在提醒你。
“删除元素”这件事。
本来就不适合用最朴素的遍历表达。
在 C++11 里。
更常见的做法是用算法表达。
先 remove_if。
再 erase。
代码更像一句话。
而且不容易写出迭代器失效的事故。
这也是范围 for 的一个隐藏价值。
它把“只是看看”的遍历写得更简单。
同时也在暗示你。
复杂操作别硬塞进遍历语法里。
小结
范围 for 循环看起来只是省了几行字。
但真正省下来的。
是注意力。
你不用每天在 begin/end 上做体操。
你也不用在评审里盯着 ++it 和 it++。
你把精力留给真正重要的东西。
语义。
性能。
生命周期。
写 C++ 久了你会发现。
很多坑不是“你不会写”。
而是“你太忙”。
范围 for 干的事很朴素。
它替你把那点忙。
从手指上拿走。