本周小贴士#227:小心空容器和无符号算术

作为#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()产生一个无符号类型。
  • 偏好可以尽可能在局部验证正确性的代码。
  • 尽量使代码直接对应于底层意图。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值