【问题标题】:Why does modifying a field that is referenced by another variable lead to unexpected behavior?为什么修改另一个变量引用的字段会导致意外行为?
【发布时间】:2020-11-01 08:20:13
【问题描述】:

我写的这段代码乍一看很简单。它修改引用变量所引用的变量,然后返回引用的值。重现奇怪行为的简化版本如下所示:

#include <iostream>
using std::cout;

struct A {
    int a;
    int& b;

    A(int x) : a(x), b(a) {}
    A(const A& other) : a(other.a), b(a) {}
    A() : a(0), b(a) {}
};

int foo(A a) {
    a.a *= a.b;
    return a.b;
}


int main() {
    A a(3);

    cout << foo(a) << '\n';
    return 0;
}

但是,当它在启用优化 (g++ 7.5) 的情况下编译时,它产生的输出与未优化的代码不同(即 9 没有优化 - 符合预期,3 启用了优化)。

我知道volatile 关键字,它可以防止编译器在存在某些副作用(例如异步执行和特定于硬件的东西)的情况下进行重新排序和其他优化,并且在这种情况下也有帮助。

但是,我不明白为什么我需要在这种特殊情况下将引用 b 声明为 volatile?这段代码的错误来源在哪里?

【问题讨论】:

  • volatile很少的解决方案。通常这是一个错误。
  • @AlanBirtles 帖子中的代码重现了所描述的问题,并且已经很短了。但是,我添加了缺失的包含以使其可编译,并在帖子中增加了对预期输出的强调。
  • 编译器看到的是a.b被读取了两次。启用优化后(-O1 就足够了)此读取被优化为单次读取,因此在返回语句中不会选择修改后的值。我尝试了各种-fno-strict-aliasing-fstrict-aliasing 标志(以及相关的警告),但没有发现任何导致编译器重新读取a.b 的内容。 (gcc 10.1,x64,通过编译器资源管理器)
  • 这种行为看起来像教科书的未定义行为。然而,我越看它,就越觉得它是一个 gcc 错误。每个对象都被初始化。因为没有悬空的参考。如果我们使所有内容都正确,则 constexpr clang 会给出错误“对 'a' 的子对象的引用不是常量表达式”。然而 gcc 给出了一个错误的错误信息:“'A(3)' is not a constant expression because it refer to an incompletely initialized variable”这可能只是一个措辞不当的错误文本,或者可能是 gcc 认为以某种方式子对象在这里未初始化。
  • 这样的案例,对极其简单的代码的优化被搞砸了,总是让我想知道有多少关键代码被错误编译并且没有人注意到

标签: c++ gcc g++ compiler-optimization volatile


【解决方案1】:

我找不到关于标准的 UB 来源。在我看来,这就像优化器的一个错误,它不会注意到 a.ba.a 都引用同一个对象:

  • 首先,foo() 在副本上工作。我把foo()改成引用传递,一致得到了预期的结果。我怀疑引用的初始化存在问题。但是提供的复制构造函数可以正确处理a.b

  • 然后我怀疑某些 UB 与同一表达式中不确定顺序操作的副作用有关。但是对*=的lhs的副作用是在rhs之后排序的,所以这里也没有UB。

  • *= 语句之后添加一些日志记录使其意外地按预期工作。这看起来很奇怪:看起来像不遵守严格别名约束时遇到的常见问题,即当编译器没有意识到指向的对象被修改并优化代码时,就好像值没有改变一样。在这种情况下,额外的代码会导致重新加载正确的值并找到不同的结果并不罕见。

  • 但是这里没有别名问题,因为原始成员和对它的引用都基于相同的类型。

当你消除了不可能的事情后,剩下的无论多么不可能,都必须是事实。
- 阿瑟·柯南·道尔爵士

在消除了 OP 代码中的 bug 和 UB 之后,唯一剩下的可能性就是优化器中的 bug。优化器似乎没有注意到 a.a 和 a.b 是同一个对象,并且它只是重用了 a.b 的最新已知值,该值已经在寄存器中。

【讨论】:

  • @KorelK 您的回答引用了一个编译器错误,但它也引发了一些关于编译器如何操作(以及“合理代码”与标准的作用)以及代码的预期结果的误解应该是,这最终会分散读者的注意力并误导他们。例如,它调用了内存更新和寄存器相互“竞争”的概念,这很可能被误解和/或误解。编译器错误很可能是基于优化期间做出的无效假设的更简单的别名/重用错误。
  • @KorelK 我认为推理不同:我继续根据 C++ 语言规范消除可能的 UB 案例,并得出一个错误的结论,我可以链接到这样的案例行为是正常的,但不适用。在您的回答中,您开始查看生成的代码,假设这是一个错误,而不验证是否有任何 UB 或实现相关的规则可以解释/允许此代码生成。
  • @KorelK 我不会低估你的实验结果,但我不同意你从中得出的结论。对sleep 的函数调用导致错误消失的原因有很多,其中许多更可信的是编译器以某种方式发出了导致存储到寄存器和mmemory 之间的竞争的程序集。此外,开发人员是否“通常”不会更新变量并返回对它的一些引用是无关紧要的。如果 C++ 规范允许,那么编译器必须支持它才能符合标准。
  • @KorelK 以我自己的经验,我必须说,我总是对优化器在分析中的准确程度以及在优化条件不能完全保证时的谨慎程度感到惊讶。我从未见过任何根据语言规范不允许的“权衡”:没有编译器供应商敢冒这种风险,因为 C++ 用于生命关键的医疗设备、核电站、电信网络、国防系统,甚至空间 X。
  • @KorelK 我不知道您所说的“简化假设”是什么意思,但优化可以做出的唯一简化假设是“保守”假设,即可以在少于允许的情况下应用优化.您删除的答案与编程语言定义和实现的基本原则不一致。也不清楚。
猜你喜欢
  • 1970-01-01
  • 2022-11-25
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-06-19
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多