每周技巧 #224:避免使用 `vector.at()`
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #224: Avoid vector.at()。
原文最初作为 TotW #224 发布于 2023 年 8 月 24 日。
更新于 2024 年 1 月 24 日。
快捷链接:abseil.io/tips/224
在 google3 中没有 vector<T>::at() 的好用法,在其他 C++ 环境中好用法也相当少。相同推理也适用于 protobuf 中 RepeatedPtrField 等其他随机访问序列上的 at(),以及 optional<T>、absl::StatusOr<T> 等包装类型上的 value()。
at() 做什么?
at(size_type pos) 的规范如下:
返回指定位置
pos处元素的引用,并进行边界检查。如果pos不在容器范围内,则抛出std::out_of_range类型的异常。
这意味着我们可以把这个方法的契约看作两种不同行为:
- 检查
pos >= size()是否成立,如果成立则抛出std::out_of_range异常。 - 否则,返回索引
pos处的元素。
注意:规范没有直接讨论代码传入负索引的情况,但这种情况下也会抛出 std::out_of_range,因为 size_type 是无符号整数类型,调用 at(-5) 会让 pos 得到一个非常大的正值。
什么时候会用 at()?
既然 at() 的契约依赖边界检查逻辑,我们可以把情况分成两类:要么我们由构造可知索引有效,要么不知道。
如果我们已经知道序列足够大且查找会成功,那么额外的边界检查就是开销。例如,大多数 vector 访问都发生在从 0 到 size() 的循环中,我们已经知道操作会成功。因此,在已经知道边界检查会成功的情况下,我们很可能想要更常见的 operator[]()。
|
|
变为:
|
|
如果我们不知道序列是否足够大,抛异常是正确处理方式吗?通常不是。在 google3 构建中,抛异常会让程序以凌乱方式终止。许多(也许大多数)读者不一定会看出 at() 这样名字无害的方法有终止进程的风险。
|
|
可能更适合写成:
|
|
或者如果中止程序更合适:
|
|
所以至少在 google3 上下文中,at() 的使用都不是真的有用;对任何具体用例,都有更推荐的替代方案。
UB 呢?
遗憾的是,现实很少像“我们知道或不知道”这么干净:我们会犯错,代码也会随时间变化,让原先正确的假设失效。鉴于人类会出错,我们可以想象 at() 的一种用例。具体来说,如果我们完全一致地使用 at() 而不是 operator[],也许能确保即使程序以凌乱方式崩溃(不好),也不会触发未定义行为(UB)(更糟)。
虽然我们相信“避免 UB”是非常正当的目标,但仍然不认可专门使用 at(),原因正是前面讨论过的、它与异常纠缠在一起的语义。理想的未来方案是默认加固的 operator[],并在可证明安全时由编译器优化移除边界检查。at() 方法是这个方案的糟糕近似。
相反,我们鼓励用户坚持使用 operator[],并通过其他方式减少暴露于 UB 的机会,包括:
- 如果你的项目负担得起,建议在其他可用库中也在生产环境启用边界检查。
- 如果使用 ASAN 运行代码,那么越界访问元素时也会得到诊断。
事实上,你的项目很可能已经依赖其中一些保护了!
map 呢?
在 技巧 #202 中,我们讨论了在 map 和 set 等关联容器上使用 at()。总体来说,上面的错误处理逻辑仍适用:缺失键很可能应该通过记录日志或返回错误来处理,而不是以凌乱方式让进程崩溃。
不过,对于这些容器,“边界检查”的开销逻辑不同。在 std::vector 的例子中,执行边界检查的计算成本与实际工作(返回指定引用)的成本相近。对于关联容器,“边界检查”等价于执行必要的查找,无论这是树遍历、哈希还是其他方式。
顺着这个推理,如果我们已经知道键存在(不会抛异常),但无法保留迭代器或引用,因此必须再次查找,那么可以使用 at()。这种情况很少见:避免冗余 map 查找的方法见技巧 #132。
最终,关联容器中使用 at() 有一点小空间。这些情况比 vector 有更多细微差别。
启用异常的 C++ 呢?
在启用异常的环境中,对 at() 的看法可能会更有分歧。大体上,显式边界检查仍然可能比依赖异常有更好性能(也更不容易搞错)。可以为防御式减少 UB 提出论点,但相当清楚的是,惯用法是(并且会继续是)operator[](),而不是 at()。
理想情况下,代码应该尽量少假设它会在哪种环境中工作。基于会用哪些工具链编译代码来推理代码,通常很脆弱。对于使用 at()(或其他基于异常的 API)的代码来说,要正确就必须在两种不同构建模式下都正确:终止整个进程必须是可接受的,且更高层代码捕获异常并继续执行也必须是可接受的,因此库代码必须保持所有不变量。实践中,这意味着代码必须是异常安全的,且任何越界使用 at() 导致进程终止也必须是可接受的。
关于在启用异常环境中使用 at(),我们能给出的最好建议也许是:它用隐藏且常常不必要的错误处理,换取潜在 UB 的减少。这并不总是一个清晰取舍,但它仍然不像是常常值得付出成本的选择。
结束语
索引容器时,请留意我们处于哪种情况:索引是“由构造保证正确”,还是代码需要检测并处理无效索引?在这两种情况下,我们都能做得比使用基于异常的 std::vector<T>::at() API 更好。
类似思路也适用于其他基于异常的 API,例如 std::optional<T>::value() 和 absl::StatusOr<T>::value()(见技巧 #181)。对于非并发 C++ 代码中的错误处理,优先“先看清楚再行动”;检查一切正常之后,再避免使用那些包含自身检查的 API。