作为#227最初发表于2023年11月16日
作者:James Dennett
基于索引的循环(仍有其用途)
现代C++代码现在使用基于索引的for循环的频率比以前低了,因为现在有了基于范围的for循环,但有时我们在迭代时仍需要使用索引。并行遍历多个容器就是这样一个情况;另一个情况是当我们想要从单个容器中处理多个相邻元素时。在这个技巧中,我们将看一个针对第二种情况的陷阱。
看似合理的代码
让我们从一些可能是正确的代码开始:
for (int64_t i = 0; i < v.size() - 1; ++i) {
ProcessPair(v[i], v[i+1]);
}
这段代码明智地在调用ProcessPair()之前检查了有效索引,那为什么我们说它只是“可能”正确呢?一个仔细的单元测试(或几乎任何模糊测试)将覆盖v为空的情况。如果我们的for循环周围的代码确保在那种情况下永远不会达到for循环,那么一切都好。但如果我们在v为空的情况下执行我们的循环,C++会给我们带来麻烦。
无符号类型到不可挽救
回想一下,我们的风格指南警告不要在C++中使用无符号类型。风格指南还说:
由于历史意外,C++标准也使用无符号整数来表示容器的大小 - 许多标准机构的成员认为这是一个错误,但在这一点上实际上是不可能修正的。
仔细观察,我们可以看到我们的示例正中风格指南讨论的问题的下怀。在检查我们是否有有效的v[i]和v[i+1]元素时,鉴于两个元素都需要有效,我们似乎在检查i是否小于v.size() - 1是正确的。然而,对于一个空容器,v.size()是零(到目前为止,还好!),但因为v.size()的类型是无符号的,当我们从零中减去一时,我们得到的不是值-1,而是给定类型的最大值。然后,检查i是否小于v.size() - 1的结果对于任何小的i值都为真,因此代码将使用v的越界索引 - 导致未定义行为。
我们应该如何修复?
有趣的是,如果我们让代码更直接地表达其意图,我们的问题就会消失。
我们所说的“更直接地表达其意图”是什么意思?这里的意图是什么?这里循环条件的目的是确保用于索引v的索引i和i + 1是有效的。
鉴于C++中的索引是基于零的,我们通过检查i < v.size()来测试容器中一个(非负)索引i的有效性。检查两个索引的有效性将是多余的(尽管如果我们愿意可以这样做):如果i + 1是有效的,那么我们知道i是有效的(因为这里i从不为负)。"i + 1是有效的"直接翻译成C++是i + 1 < v.size()。我们原始的代码i < v.size() - 1作为关于索引有效性的声明没有这样直接的翻译。
重写的代码i + 1 < v.size()看起来几乎与i < v.size() - 1相同,但关键的不同在于我们从不减去,所以我们避免了环绕到一个巨大正值的危险。我们是否因为计算i + 1而冒着溢出的风险?只有当i已经是其类型(int64t)的最大值时 - 所以我们是安全的。这种差异有时通过注意到int64_t的常见、有用的值距离溢出还有很长的距离,而对于像uint64_t这样的无符号类型,非常常见的值0是该类型的最小值,所以更容易无意中环绕。
修正后的代码,无需模糊测试
通过这一个小改动,我们现在健壮的代码看起来像这样:
for (int64_t i = 0; i + 1 < v.size(); ++i) {
ProcessPair(v[i], v[i+1]);
}
对v的索引显然是安全的,无需进一步查看以知道v是否可能为空。
现在我们可以对修正后的代码进行模糊测试,感受到我们的for循环是无bug且易于审查的温暖和舒适。
注意:这只是编写此循环的一种(健壮的)方式;还有许多其他方式。
总结
虽然我们的修复并没有改变太多源代码的字节,但它涉及到许多想法:
- 正如风格指南所说,在C++中对无符号类型进行算术运算时要小心。
- 记住,容器.size()产生一个无符号类型。
- 偏好可以尽可能在局部验证正确性的代码。
- 尽量使代码直接对应于底层意图。