C++20使用等号运算符是否会破坏现有代码的行为?

118

在调试这个问题时,我遇到了这个。

我将其简化到仅使用Boost Operators

  1. Compiler Explorer C++17 C++20

    #include <boost/operators.hpp>
    
    struct F : boost::totally_ordered1<F, boost::totally_ordered2<F, int>> {
        /*implicit*/ F(int t_) : t(t_) {}
        bool operator==(F const& o) const { return t == o.t; }
        bool operator< (F const& o) const { return t <  o.t; }
      private: int t;
    };
    
    int main() {
        #pragma GCC diagnostic ignored "-Wunused"
        F { 42 } == F{ 42 }; // OKAY
        42 == F{42};         // C++17 OK, C++20 infinite recursion
        F { 42 } == 42;      // C++17 OK, C++20 infinite recursion
    }
    

    This program compiles and runs fine with C++17 (ubsan/asan enabled) in both GCC and Clang.

  2. When you change the implicit constructor to explicit, the problematic lines obviously no longer compile on C++17

令人惊讶的是,两个版本都可以在C++20(v1v2)上编译,但它们会导致两行代码无限递归(在优化级别上崩溃或紧密循环),而这两行代码在C ++17上无法编译。

显然,通过升级到C++20引入这种潜在错误是令人担忧的。

问题:

  • 这是符合C++20的行为吗(我认为是)
  • 究竟是什么干扰了?我怀疑可能是由于C++20的新“太空船运算符”支持,但不理解它如何改变此代码的行为。

4
你们真是太快了。这个世界不配得到这样水平的支持。谢谢你们。 - sehe
1
我不认为这是一个重复的问题,但它肯定与此相关 https://dev59.com/IFIG5IYBdhLWcg3wkBsx - cigien
1
@cigien 感激不尽。那里的答案解释非常出色,有助于形成更完整的理解。 - sehe
1个回答

92

事实上,C++20不幸地使这段代码无限递归。

以下是一个简化的例子:

struct F {
    /*implicit*/ F(int t_) : t(t_) {}

    // member: #1
    bool operator==(F const& o) const { return t == o.t; }

    // non-member: #2
    friend bool operator==(const int& y, const F& x) { return x == y; }

private:
    int t;
};

让我们看一下42 == F{42}
在C++17中,我们只有一个候选者:非成员候选者(#2),所以我们选择它。它的主体是x == y,本身只有一个候选者:成员候选者(#1),它涉及将y隐式转换为F。然后该成员候选者比较两个整数成员,这完全没有问题。
在C++20中,初始表达式42 == F{42}现在有两个候选者:先前的非成员候选者(#2)和反转的成员候选者(#1 reversed)。仍然选择#2 - 我们恰好匹配两个参数,而不是调用转换,因此被选中。
现在,x == y又有两个候选者了:再次是成员候选者(#1),但也有了反转的非成员候选者(#2 reversed)。由于相同的原因,#2仍然是更好的匹配: 不需要转换。因此,我们改为评估y == x。无限递归。
非反转候选者优先于反转候选者,但仅作为打破平局的工具。更好的转换序列总是第一位。
好的,我们怎么修复它?最简单的方法是完全删除非成员候选者:
struct F {
    /*implicit*/ F(int t_) : t(t_) {}

    bool operator==(F const& o) const { return t == o.t; }

private:
    int t;
};

42 == F{42}这里被解释为F{42}.operator==(42),这样是可以正常工作的。

如果我们想要保留非成员函数的候选项,我们可以显式地添加它的反向候选项:

struct F {
    /*implicit*/ F(int t_) : t(t_) {}
    bool operator==(F const& o) const { return t == o.t; }
    bool operator==(int i) const { return t == i; }
    friend bool operator==(const int& y, const F& x) { return x == y; }

private:
    int t;
};

这使得42 == F{42}仍然选择非成员候选项,但现在中的x == y将更喜欢成员候选项,然后执行正常的相等性比较。
最后一个版本还可以删除非成员候选项。以下内容对于所有测试用例都可以在不使用递归的情况下工作(并且这是我今后在C++20中编写比较时的做法)。
struct F {
    /*implicit*/ F(int t_) : t(t_) {}
    bool operator==(F const& o) const { return t == o.t; }
    bool operator==(int i) const { return t == i; }

private:
    int t;
};

15
很好的说明。我在如何评价这种情况方面有些犹豫。作为一名专业人士,我很不满意看到C ++引入了兼容性陷阱。我很高兴看到这种情绪,但我担心似乎不会有太多实质性的改变。因此,库只是在运行时默默地崩溃 - 这样的事情可不好。我知道我编写的代码将遭受这些问题,而且我想象着如果当前的开发人员升级会发生什么事。他们可能会愤怒地诅咒我的代码 ¯_(ツ)_/¯ - sehe
7
我也对此非常不满,而且这是我的错。而这种情况是最糟糕的 - 保留C++17行为的唯一语言更改是不涉及该功能的任何部分(或者使“==”功能成为可选项,但没有人提出过可行的方法)。不仅它是我们无法真正修复的唯一问题,而且所有其他故障都会导致代码停止编译,而这个问题却在背后悄悄地继续编译。各种各样的糟糕情况。 - Barry
4
在这个例子中,你甚至可以重写F的函数体,使用auto operator<=>(F const&) const = default;一行代码就能为你提供所有比较操作。这对于C++20及以后版本是很好的,但对于过渡阶段来说显然不太好。 - Barry
1
我想我可以有一个静态检查,只需查找operator==实现,并在c++20模式下定期将它们有条件地替换为飞船... 我确实感觉未来会更好。然而,这是一个等待绊倒人们的地雷。 - sehe
11
我觉得编译器应该可以在编译时静态诊断这个问题(毕竟所有情况都发生在编译时)。例如,类似于“c++20兼容性警告:根据std模式将选择不同的重载函数,建议修复方法是...”。 - Dan M.
6
@Dan 我不是编译器专家,但如果你想尝试在clang中添加这样的警告,我相信很多人会非常感激。 - Barry

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接