函数无意中使参考参数无效-出了什么问题?


54

今天,我们发现了仅在某些平台上间歇性发生的讨厌的错误的原因。归结起来,我们的代码看起来像这样:

class Foo {
  map<string,string> m;

  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }

  void B() {
    while (!m.empty()) {
      auto toDelete = m.begin();
      A(toDelete->first);
    }
  }
}

在这种简化的情况下,问题似乎很明显:B将键的引用传递给A,这会在尝试打印之前删除地图条目。(在我们的示例中,它不是打印出来的,而是以更复杂的方式使用),这当然是未定义的行为,因为key在调用之后是一个悬空的引用erase

解决这个问题很简单-我们只是将参数类型从更改const string&string。问题是:首先如何避免这个错误?似乎两个函数都做对了:

  • A没有办法知道是key指它即将销毁的东西。
  • B可以在将副本传递给之前进行复制A,但是被调用者的工作不是决定按值还是按引用获取参数吗?

有没有我们不能遵循的规则?

Answers:


35

A没有办法知道是key指它即将销毁的东西。

确实如此,A但您知道以下几点:

  1. 其目的是破坏某物

  2. 它采用的参数要销毁的事物的类型完全相同

鉴于这些事实,这是可能A,如果它需要的参数作为指针/引用破坏其自身的参数。这不是C ++中唯一需要解决这些注意事项的地方。

这种情况类似于operator=赋值运算符的性质意味着您可能需要关注自我赋值。这是可能的,因为this参考参数的类型和参考参数的类型相同。

应当注意,这只是有问题的,因为A以后打算key在删除条目后使用该参数。如果没有,那就很好。当然,让一切正常运行变得很容易,然后有人可能会销毁它,然后更改A使用key

那将是发表评论的好地方。

有没有我们不能遵循的规则?

在C ++中,您不能假设如果盲目地遵循一组规则,那么代码将100%安全。我们不能为所有事情制定规则。

考虑上面的第二点。A可能采用了与键不同类型的某些参数,但是对象本身可能是映射中键的子对象。在C ++ 14中,find可以采用与键类型不同的类型,只要它们之间存在有效的比较即可。因此,如果这样做m.erase(m.find(key)),即使参数的类型不是键类型,也可以销毁该参数。

因此,“如果参数类型和键类型相同,则按值取值”之类的规则将无法为您节省。您不仅需要更多信息。

最终,您需要根据经验告知自己的特定用例并做出判断。


10
好吧,您可能有“永不共享可变状态”规则或双重“永不更改共享状态”规则,但是随后您将很难编写可识别的c ++
Caleth

7
@Caleth如果您想使用这些规则,C ++可能不是您想要的语言。
user253751 '16

3
@Caleth您是否在描述Rust?
马尔科姆

1
“我们不能为所有事情制定规则。” 我们可以。cstheory.stackexchange.com/q/4052
Ouroborus

23

我要说的是,您违反了一条相当简单的规则,该规则可以为您节省:单一责任原则。

现在,A传递了一个参数,该参数用于既从地图上删除项目,进行其他一些处理(如上图所示进行打印,显然是实际代码中的其他内容)。在我看来,将这些责任合并在一起似乎是问题的很多根源。

如果我们有一个函数,那只是删除从地图的价值,而另一个只是不从地图值的处理,我们不得不从每个更高级别代码中调用,所以我们最终会像这样的东西:

std::string &key = get_value_from_map();
destroy(key);
continue_to_use(key);

诚然,我使用的名称无疑比真实名称更容易引起问题,但是,如果这些名称确实有意义,那么几乎可以肯定的是,它们明确表明我们将在引用之后继续使用该引用。无效。上下文的简单更改使问题更加明显。


3
嗯,这是一个有效的观察,它仅适用于这种情况。有很多例子说明了尊重SRP的问题,但仍然存在该函数可能使其自身参数无效的问题。
Ben Voigt,2013年

5
@BenVoigt:只是使它的参数无效并不会引起问题。无效后继续使用该参数会导致问题。但最终是的,您是对的:虽然在这种情况下可以挽救他,但毫无疑问,这还不够。
杰里·科芬

3
在编写简化示例时,您必须省略一些细节,有时发现其中一个细节很重要。在我们的例子中,A实际上是key在两个不同的地图中查找的,如果找到,则删除条目并进行一些额外的清理。因此,尚不清楚我们是否A违反了SRP。我不知道我现在是否应该更新这个问题。
Nikolai

2
为了进一步说明@BenVoigt的观点:在Nicolai的示例中,m.erase(key)第一个责任cout << "Erased: " << key是第二个责任,因此此答案中显示的代码结构实际上与示例中的代码结构没有什么不同,在现实世界中,这个问题被忽略了。单一责任原则并不能确保甚至更不可能使单一行动的矛盾顺序在现实世界的代码中出现在附近。
sdenham

10

有没有我们不能遵循的规则?

是的,您未能记录该功能

如果没有对参数传递协定的描述(特别是与参数有效性有关的部分-是在函数调用的开始还是整个过程中),就无法确定错误是否在实现中(如果调用协定)是该参数在调用开始时是有效的,该函数必须在执行任何可能会使该参数无效的操作之前复制一个副本),或者在调用者中(如果调用合同规定该参数必须在整个调用中保持有效),则调用者不能将引用传递给要修改的集合中的数据)。

例如,C ++标准本身指定:

如果函数的参数具有无效值(例如,函数域外的值或对于其预期用途无效的指针),则该行为是不确定的。

但是它无法指定这仅适用于调用的瞬间,还是整个函数的执行。但是,很明显,在许多情况下,甚至只有后者是可能的-即当无法通过创建副本使参数保持有效时。

在现实世界中,有很多这种区别起作用。例如,将a附加std::vector<T>到自身


“它无法指定这仅适用于调用的瞬间,还是整个函数的执行。” 在实践中,一旦UB被调用,编译器几乎可以完成整个函数所需的任何操作。如果程序员没有抓住UB,这可能会导致一些非常奇怪的行为。

@snowman虽然很有趣,但是UB重新排序与我在此答案中讨论的内容完全无关,这是确保有效性的责任(因此UB永远不会发生)。
Ben Voigt

这正是我的观点:编写代码的人需要负责避免UB,以避免整个兔子洞充满问题。

@Snowman:没有“一个人”在项目中编写所有代码。这就是界面文档如此重要的原因之一。另一个问题是,定义良好的接口减少了一次需要推理的代码量-对于任何不重要的项目,有人不可能“负责”考虑每条语句的正确性。
Ben Voigt

我从没说过有人写所有代码。在某个时间点,程序员可能正在看一个函数或正在编写代码。我想说的是,无论谁在看代码,都必须小心,因为在实践中,一旦涉及到编译器,UB就会具有感染力,并且会从一行代码传播到更广泛的范围。这可以追溯到您违反功能约定的观点:我同意您的观点,但指出它可能会成为一个更大的问题。

2

有没有我们不能遵循的规则?

是的,您无法正确测试。您并不孤单,而且您在正确的地方学习:)


C ++有很多不确定行为,不确定行为以微妙而烦人的方式表现出来。

您可能永远无法编写100%安全的C ++代码,但是您可以通过使用多种工具来降低在代码库中意外引入未定义行为的可能性。

  1. 编译器警告
  2. 静态分析(警告的扩展版本)
  3. 仪器测试二进制文件
  4. 硬化的生产二进制文件

对于您的情况,我怀疑(1)和(2)是否会有所帮助,尽管总的来说我还是建议使用它们。现在,让我们专注于其他两个。

gcc和Clang都带有一个-fsanitize标志,该标志可检测您编译的程序以检查各种问题。-fsanitize=undefined例如将赶上符号整数下溢/溢出,由过高数量等移...在你的具体情况,-fsanitize=address-fsanitize=memory会一直会挑上的问题......为您提供有一个测试调用该函数。为了完整起见,-fsanitize=thread如果您有多线程代码库,则值得使用。如果您无法实现二进制文件(例如,您有没有第三方库的第三方库),那么valgrind尽管它通常较慢,但您也可以使用它。

最近的编译器还具有丰富财富的可能性。与检测二进制文件的主要区别在于,强化检查的设计对性能的影响很小(<1%),使其通常适用于生产代码。最著名的是CFI检查(控制流完整性),其目的是阻止堆栈粉碎攻击和虚拟指针劫持,以及其他破坏控制流的方法。

(3)和(4)的要点都是将间歇性故障转换为特定故障:它们都遵循快速故障原则。这意味着:

  • 当您踩到地雷时,它总是会失败
  • 立即失败,将错误指向您,而不是随机破坏内存等。

将(3)与良好的测试覆盖率相结合应该可以在大多数问题投产之前就将它们捕获。在生产中使用(4)可能是令人讨厌的错误与漏洞利用之间的区别。


0

@note:本文只是在Ben Voigt的答案之上添加了更多的论点。

问题是:首先如何避免这个错误?似乎两个函数都做对了:

  • A无法知道该密钥是指将要销毁的东西。
  • B可以在将副本传递给A之前进行复制,但是被调用者的工作不是决定通过值还是通过引用获取参数吗?

这两个功能都做对了。

问题出在客户端代码内,该代码未考虑调用A的副作用

C ++没有直接的方式来指定语言的副作用。

这意味着您(和您的团队)应确保副作用(例如副作用)在代码中(作为文档)可见,并与代码一起维护(您可能应该考虑记录前提条件,后置条件和不变式)以及出于可见性原因)。

代码更改:

class Foo {
  map<string,string> m;

  /// \sideeffect invalidates iterators
  void A(const string& key) {
    m.erase(key);
    cout << "Erased: " << key; // oops
  }
  ...

从这一点开始,您在API之上有了一些东西,可以告诉您应该对其进行单元测试。它还告诉您如何使用(而不使用)API。


-4

我们如何首先避免这个错误?

避免错误的方法只有一种:停止编写代码。其他一切都以某种方式失败了。

但是,在各个级别上测试代码(单元测试,功能测试,集成测试,验收测试等)不仅可以提高代码质量,而且可以减少错误数量。


1
这是完全废话。有只有一种方式,以避免错误。完全避免错误的唯一方法是完全不使用代码,这是很简单的,但在最初编写代码和在测试时,可以大大减少错误的出现。每个人都知道测试阶段,但是在编写代码时遵循负责任的设计实践和习惯用法通常可以以最低的成本产生最大的影响。
科迪·格雷
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.