本周小贴士#180:避免悬空引用

作为TotW#180最初发表于2020年4月6日

由Titus Winters创作

介绍

与许多编程语言不同,C++缺乏必要的安全检查,以避免引用无效内存(即“悬空引用”)。您可以轻松地对已删除的对象指针进行解引用,或者跟随一个超出作用域的对象引用。即使是类类型也存在这种风险。重要的是,我们正在建立命名约定,围绕名称“view”和“span”表示“这是具有引用语义并可能悬空的对象”。像所有具有引用语义的类型一样,这些类型从不拥有它们指向的基础数据。每当您看到存储这些类型之一的实例时,请注意。

悬空引用和理解C++

如果您从其他编程语言转入C++,则会遇到一些基本的惊喜。C++的类型系统比大多数语言更加复杂,需要对引用、临时变量、浅层const、指针、对象生命周期等有时微妙的理解。在学习C++时最重要的问题之一是认识到,拥有指向对象的指针或引用并不意味着对象仍然存在。C++不具备垃圾回收或引用计数功能,因此持有对象的句柄并不能保证对象仍然存在。
考虑:

int* int_handle;
{
  int foo = 42;
  int_handle = &foo;
}
std::cout << *int_handle << "\n";  // Boom

当我们使用operator*对int_handle解引用时,我们正在遵循指向生命周期已结束的对象的指针。这是一个错误。在形式上,这是未定义的行为,任何事情都可能发生。

令人不安的是,“任何事情都可能发生”中的一个选项是“这样做就像你天真地想象的那样”——打印42。C++是一种不保证诊断或响应您的错误的语言。您的程序似乎可以工作,并不意味着它是正确的。最多只能说明编译器恰好选择了一个适合您的结果。但不要误解:这并不比int_handle是指向null的指针更不稳定。

从中我们可以得出两个重要的观点:

  • 与今天我们使用的大多数语言不同,“程序运行到完成或表现正常”与“这是正确的”只有微弱的相关性。其他语言会在编译时或运行时诊断我们的错误,C++选择聚焦于优化和效率:花费额外的计算能力来检查您是否犯了错误,这并非C++的方式。在大多数语言中,“它工作了”更好地证明了“它是正确的”。C++要求我们质疑这种证据。

  • 持有句柄(指针或引用)不保证访问对象是存在且有效的。其他语言在运行时有开销来保持对象的存活状态,或静态约束您可以编写的代码。C++聚焦于优化和效率。每次使用句柄访问底层对象时,您需要一种心理证明来理解为什么您确信底层对象仍然存在。它可能已经超出了作用域,也可能已经被显式删除。

重要的是要理解我们非正式的“句柄”讨论适用于某些类类型的值以及更明显的指针和引用。考虑迭代器:

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

这与之前的示例在道德上是相同的。在某些平台上,向量迭代器实际上可能被实现为指针。即使这些迭代器是类类型,相同的语言规则也适用:解引用迭代器将(在底层)最终遵循指向不再在作用域内的对象(在本例中为v [0])的指针或引用。

因为C++没有定义代码使用无效指针、引用或迭代器时会发生什么,因此这样做的代码总是不正确的(即使它看起来可以工作)。这允许调试工具(如sanitizer和调试迭代器)报告没有误报的错误。

可能会悬挂的类类型

在过去的几年中,Abseil和C++标准库一直在引入具有类似“句柄”行为的其他类类型。其中最常见的是string_view,它是一些连续字符缓冲区(通常是字符串)的句柄。持有string_view与持有任何其他句柄类型完全相同:没有通用保证底层数据存在。程序员需要证明底层缓冲区的生存期超过了string_view。重要的是,string_view提供的句柄不允许变异:string_view不能用于修改底层数据。

另一种常见的句柄设计是span,它是任何类型T的连续缓冲区。如果T是非const的,则span允许修改底层数据。如果T是const,则span无法修改它,就像string_view无法修改底层缓冲区一样。因此,span类似于string_view。尽管这两种类型具有不同的API,但关于句柄或底层缓冲区的推理方式完全相同。

string_view和span倾向于非常安全地用作函数参数,抽象出各种输入参数格式。由于可能存在悬挂引用的可能性,因此任何时候存储此设计类型的类型都成为程序员错误的重要来源。每个句柄类型的存储都需要进行关键思考,以了解为什么我们确定底层对象在句柄的生命周期内保持有效。在容器中使用string_view或span并不总是错误的,但这是一个微妙的优化,需要清晰的注释来描述相关的存储。在类的数据成员中使用这些类型很少是正确的选择。

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

  • view - 不能用于变异底层数据的引用类型
  • span - 可能用于变异底层数据的引用类型

由于这两个命名指标都暗示了引用类型,所以库提供的名为“view”或“span”的类型的任何存储都需要伴随着您在思考指针或引用的生命周期时使用的同样逻辑:我如何知道底层对象仍然存活?

注意事项和进一步阅读

流行的外部range_v3库和即将到来的C++20范围库对“view”有不同的含义,尽管这些定义所描述的类型有重叠之处。在范围中,“view”表示“可以在O(1)内复制的范围”。这包括string_view。但是,这个定义并不排除对底层数据的变异。这个不匹配是不幸的,并且被C++标准委员会广泛认可,但是在提出关注之后,没有人能够就“view”达成任何替代方案的共识。

C++20的span类型和Abseil的Span类型在比较和复制方面具有略微不同的接口和语义。最显著的区别在于absl::Span::operator==,我们现在知道这可能是一个设计错误。

有关现代引用参数类型底层设计理论的更多信息,请参见“Revisiting Regular Types”。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值