每周技巧 #180:避免悬垂引用

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #180: Avoiding Dangling References

原文最初作为 TotW #180 发布于 2020 年 6 月 11 日。

作者:Titus Winters

更新于 2020 年 6 月 11 日。

快捷链接:abseil.io/tips/180

引言

与许多语言不同,C++ 缺少避免引用无效内存(也就是“悬垂引用”)所需的安全检查。你很容易解引用一个指向已被 delete 的对象的指针,或者沿着一个引用访问已经离开作用域的对象。即使是类类型也有这种风险。重要的是,我们正在围绕 viewspan 这些名称建立命名约定,用它们表示:“这是一个具有引用语义、可能悬垂的对象。”这些类型和所有具有引用语义的类型一样,永远不拥有它们所指向的底层数据。每当你看到这些类型的实例被存储起来时,都要格外留意。

悬垂,以及理解 C++

如果你从其他语言转向 C++,会遇到不少根本性的意外。C++ 类型系统比大多数语言复杂得多,需要有时相当微妙地理解引用、临时对象、浅层 const、指针、对象生命周期等。学习 C++ 时,一个最独特也最重要的问题是:拥有某个对象的指针或引用,并不意味着这个对象仍然存在。C++ 既没有垃圾回收,也不是引用计数语言,因此持有对象句柄不足以确保对象保持存活。

考虑:

1
2
3
4
5
6
int* int_handle;
{
  int foo = 42;
  int_handle = &foo;
}
std::cout << *int_handle << "\n";  // Boom

当我们用 operator* 解引用 int_handle 时,我们是在沿着一个指针访问生命周期已经结束的对象。这是 bug。形式上说,这是未定义行为,任何事情都可能发生。

令人不安的是,“任何事情都可能发生”的选项之一,就是“它做了你天真以为它会做的事”,也就是打印 42。*C++ 是一种不承诺诊断或响应你的 bug 的语言。*你的程序看起来能工作,并不意味着它是正确的。最多只能说明编译器碰巧选择了一个对你来说可用的结果。但不要误解:这和 int_handle 是空指针一样,仍然是 bug。

由此我们得到两个重点:

  • 与今天我们使用的大多数语言不同,程序能运行完或表现符合预期,与“它是正确的”之间只有很弱的相关性。其他语言会在编译期或运行期诊断我们的错误,而 C++ 选择把重点放在优化和效率上:花额外算力检查你有没有犯错,不是 C++ 的方式。在大多数语言中,“它能工作”是“它正确”的强证据。C++ 要求我们质疑这个证据。
  • 持有对象的句柄(指针或引用)并不能保证对象仍然存活且可访问。其他语言会通过运行时开销保持对象存活,或者静态限制你能写的代码。C++ 则专注于优化和效率。任何时候你使用句柄访问底层对象,都需要在脑中有一个证明:为什么你确信底层对象仍然活着。它可能已经离开作用域,也可能已经被显式 delete

非常重要的一点是,我们这里非正式讨论的“句柄”不仅适用于更显然的指针和引用,也适用于某些类类型的值。考虑迭代器:

1
2
3
4
5
6
std::vector<int>::iterator int_handle;
{
  std::vector<int> v = {42};
  int_handle = v.begin();
}
std::cout << *int_handle << "\n"; // Boom

这在本质上与前面的例子相同。在某些平台上,vector 迭代器实际上可能就是用指针实现的。即使这些迭代器是类类型,同样的语言规则也适用:解引用迭代器最终会在底层沿着指针或引用访问一个已经不在作用域内的对象(这里是 v[0])。

因为 C++ 没有定义代码使用无效指针、引用或迭代器时会发生什么,所以这样做的代码永远是不正确的(即使它看起来能工作)。这让 sanitizers 和调试迭代器等调试工具可以报告 bug,而没有误报。

可能悬垂的类类型

过去几年里,Abseil 和 C++ 标准库引入了更多具有类似“句柄”行为的类类型。最常见的是 string_view,它是某个连续字符缓冲区(通常是 string)的句柄。持有 string_view 和持有任何其他句柄类型完全一样:一般情况下并不保证底层数据仍然存活。程序员需要证明底层缓冲区比 string_view 活得更久。重要的是,string_view 提供的句柄不允许修改:string_view 不能用于修改底层数据。

另一个正变得常见的句柄设计是 span<T>,它表示任意类型 T 的连续缓冲区。如果 T 不是 const,那么 span 允许修改底层数据。如果 T 是 const,那么 span 不能修改它,就像 string_view 不能修改底层缓冲区一样。因此,span<const char> 类似于 string_view。虽然两种类型 API 不同,但对句柄或底层缓冲区的推理方式完全相同。

string_viewspan 作为函数参数时往往非常安全,因为它们可以抽象掉多种输入实参格式。由于存在悬垂引用的可能,只要这种设计的类型被存储起来,它们就会成为程序员错误的重要来源。任何句柄类型的每一次存储,都需要认真思考:为什么我们确信底层对象会在句柄的生命周期内保持有效?在容器中使用 string_viewspan 并不总是错误的,但这是一种微妙的优化,应该有清晰注释说明相关存储关系。把这些类型作为类的数据成员,很少是正确选择。

往后,C++ 程序员理解这些设计模式以及如何使用这些“引用参数类型”至关重要。为了帮助理解,类型设计者和库提供者倾向于为类型名称赋予如下含义:

  • view:不能用于修改底层数据的引用类型
  • span:可能用于修改底层数据的引用类型

既然这两个命名标志都暗示引用类型,那么存储任何库提供的、名为 “view” 或 “span” 的类型时,都需要像思考指针或引用生命周期一样思考:我怎么知道底层对象仍然存活?

注意事项和延伸阅读

流行的外部库 range_v3 和即将到来的 C++20 ranges 库对 “view” 有不同含义,虽然这些定义所描述的类型有重叠。在 ranges 中,“view” 意味着“可以 O(1) 拷贝的 range”。这包括 string_view。不过,这个定义并不排除修改底层数据。这个不匹配令人遗憾,C++ 标准委员会也基本认识到了这一点,但在提出担忧后,没人能就 “view” 的替代名称达成共识。

C++20 的 span 类型和 Abseil 的 Span 类型在可比较性和拷贝方面有略微不同的接口和语义。最显著的差异是 absl::Span::operator==,我们现在知道它很可能是一个设计错误。

关于现代引用参数类型背后的设计理论,见 Revisiting Regular Types

每周技巧 #177:可赋值性与数据成员类型

上一节

每周技巧 #181:访问 `StatusOr<T>` 的值

下一节