使用 C++20 三路比较进行更多无声行为改变

seh*_*ehe 43 c++ spaceship-operator stdtuple c++17 c++20

令我惊讶的是,我遇到了另一个障碍,例如C++20 行为用相等运算符破坏了现有代码?.

考虑一个简单的不区分大小写的键类型,用于例如std::setor std::map

// Represents case insensitive keys
struct CiKey : std::string {
    using std::string::string;
    using std::string::operator=;

    bool operator<(CiKey const& other) const {
        return boost::ilexicographical_compare(*this, other);
    }
};
Run Code Online (Sandbox Code Playgroud)

简单的测试:

using KeySet   = std::set<CiKey>;
using Mapping  = std::pair<CiKey, int>; // Same with std::tuple
using Mappings = std::set<Mapping>;

int main()
{
    KeySet keys { "one", "two", "ONE", "three" };
    Mappings mappings {
        { "one", 1 }, { "two", 2 }, { "ONE", 1 }, { "three", 3 }
    };

    assert(keys.size() == 3);
    assert(mappings.size() == 3);
}
Run Code Online (Sandbox Code Playgroud)

明显的解决方法

一个明显的解决方法是operator<=>在 C++20 模式下有条件地提供:编译资源管理器

#if defined(__cpp_lib_three_way_comparison)
    std::weak_ordering operator<=>(CiKey const& other) const {
        if (boost::ilexicographical_compare(*this, other)) {
            return std::weak_ordering::less;
        } else if (boost::ilexicographical_compare(other, *this)) {
            return std::weak_ordering::less;
        }
        return std::weak_ordering::equivalent;
    }
#endif
Run Code Online (Sandbox Code Playgroud)

令我惊讶的是,我遇到了另一种破坏性更改的情况——C++20 在没有诊断的情况下更改了代码的行为。

在我阅读std::tuple::operator<它时应该有效:

3-6)按字典序比较lhsrhsoperator<,即比较第一个元素,如果它们相等,则比较第二个元素,如果它们相等,则比较第三个元素,依此类推。对于非空元组,(3) 等价于

if (std::get<0>(lhs) < std::get<0>(rhs)) return true;
if (std::get<0>(rhs) < std::get<0>(lhs)) return false;
if (std::get<1>(lhs) < std::get<1>(rhs)) return true;
if (std::get<1>(rhs) < std::get<1>(lhs)) return false;
...
return std::get<N - 1>(lhs) < std::get<N - 1>(rhs);
Run Code Online (Sandbox Code Playgroud)

我知道从技术上讲,这些从 C++20 开始就不适用了,它被替换为:

通过合成三路比较(见下文)进行比较lhsrhs字典序的比较,即比较第一个元素,如果它们相等,则比较第二个元素,如果它们相等,则比较第三个元素,依此类推

和...一起

<、<=、>、>= 和 != 运算符分别由operator<=>和合成operator==(C++20 起)

事情是,

  • 我的类型没有定义operator<=>也没有operator==

  • 并且正如这个答案指出的那样提供operator<额外的东西会很好,并且应该在评估像a < b.

  1. C++20 中的行为改变是否正确/有意?
  2. 是否应该进行诊断?
  3. 我们可以使用其他工具来发现像这样的无声破损吗?感觉就像扫描整个代码库中的用户定义类型的使用tuple/pair不能很好地扩展。
  4. 除了tuple/之外还有其他类型pair可以表现出类似的变化吗?

Nic*_*las 46

基本问题来自这样一个事实,即您的类型是不连贯的,标准库直到 C++20 才调用它。也就是说,你的类型总是有点坏,但事情的定义足够狭隘,你可以摆脱它。

您的类型已损坏,因为它的比较运算符没有意义。它宣传它是完全可比的,并定义了所有可用的比较运算符。发生这种情况是因为您公开继承自std::string,因此您的类型通过隐式转换为基类来继承这些运算符。但是这一系列比较的行为是不正确的,因为您只用一个与其他比较不同的比较替换了其中一个。

而且由于行为不一致,一旦 C++ 真正关心你的一致性,可能会发生什么。

然而,更大的问题是与标准如何处理不一致operator<=>

C++ 语言旨在使用综合运算符之前优先使用显式定义的比较运算符。因此,如果您直接比较它们,继承自的类型std::string将使用您的类型operator<

然而,C++ 库有时会尝试变得聪明。

某些类型尝试转发给定类型提供的运算符,例如optional<T>. 它被设计为T在可比性方面表现相同,并且在这方面取得了成功。

但是,pairtuple尝试聪明一点。在 C++17 中,这些类型从未真正转发比较行为;相反,它综合了基于现有operator<operator==类型定义的比较行为。

因此,他们的 C++20 版本延续了综合比较的优良传统也就不足为奇了。当然,由于该语言参与了该游戏,C++20 版本决定最好遵循他们的规则。

除非......它不能跟着他们究竟。无法检测<比较是合成的还是用户提供的。所以没有办法在这些类型之一中实现语言行为。但是,您可以检测三向比较行为的存在。

所以他们做出一个假设:如果你的类型是三向可比的,那么你的类型依赖于合成运算符(如果不是,它使用旧方法的改进形式)。哪个是正确的假设;毕竟,既然<=>是新功能,旧类型不可能得到。

当然,除非旧类型继承自获得三向可比性的新类型。并且类型也无法检测到它;它要么是三向可比的,要么不是。

幸运的是,如果您的类型提供三路比较功能,那么合成的三路比较运算符pairtuple完全能够模仿 C++17 的行为。因此,您可以通过删除重载来显式地取消继承 C++20 中的三向比较运算符,从而恢复旧行为。operator<=>

或者,您可以使用私有继承并简单地公开using您想要的特定 API。

c++20 中的行为改变是否正确/故意?

这取决于你所说的“故意”是什么意思。

公开继承像这样的类型std::string在道德上总是有些可疑。与其说是因为切片/析构函数问题,不如说是因为它有点作弊。直接继承此类类型会导致 API 中发生意外且可能不适合您的类型的更改。

新版本的比较pair,并tuple正在做他们的工作,做他们是最好的,C ++可以允许。只是你的类型继承了它不想要的东西。如果您私下继承std::string并仅using公开了您想要的功能,那么您的类型可能会很好。

是否应该进行诊断?

这不能在某些编译器内在之外进行诊断。

我们可以使用其他工具来发现像这样的无声破损吗?

搜索您从标准库类型公开继承的情况。

  • @NicolBolas 是的 - 如果 `&lt;=&gt;` 可用,它会使用 `&lt;=&gt;`。无法真正区分您还以与“&lt;=&gt;”无关的方式单独提供了“&lt;”。如果一个类型提供了 `&lt;=&gt;`,那么您确实希望使用 `&lt;=&gt;` 来实现 `&lt;`(而不是两次 `&lt;`)。 (7认同)

seh*_*ehe 12

啊! @StoryTeller 用他们的评论指出了这一点

“我的类型没有定义 operator<=> 或 operator==” - 但std::string确实如此,由于 d[e]rived-to-base 转换,使它成为候选者。我相信所有支持比较的标准库类型都对其成员进行了大修。

事实上,一个更快的解决方法是:

#if defined(__cpp_lib_three_way_comparison)
    std::weak_ordering operator<=>(
        CiKey const&) const = delete;
#endif
Run Code Online (Sandbox Code Playgroud)

成功!编译器资源管理器

更好的想法

更好的解决方案,正如 StoryTeller 的第二条评论所暗示的那样:

我想非虚拟析构函数不再是避免从标准库容器继承的唯一令人信服的理由:/

将在这里避免继承:

// represents case insensiive keys
struct CiKey {
    std::string _value;

    bool operator<(CiKey const& other) const {
        return boost::ilexicographical_compare(_value, other._value);
    }
};
Run Code Online (Sandbox Code Playgroud)

当然,这需要对使用代码进行(一些)下游更改,但它在概念上更纯粹,并且可以防止将来出现这种类型的“标准蠕变”。

编译器资源管理器

#include <boost/algorithm/string.hpp>
#include <iostream>
#include <set>
#include <version>

// represents case insensiive keys
struct CiKey {
    std::string _value;

    bool operator<(CiKey const& other) const {
        return boost::ilexicographical_compare(_value, other._value);
    }
};

using KeySet   = std::set<CiKey>;
using Mapping  = std::tuple<CiKey, int>;
using Mappings = std::set<Mapping>;

int main()
{
    KeySet keys { { "one" }, { "two" }, { "ONE" }, { "three" } };
    Mappings mappings { { { "one" }, 1 }, { { "two" }, 2 }, { { "ONE" }, 1 },
        { { "three" }, 3 } };

    assert(keys.size() == 3);
    assert(mappings.size() == 3);
}
Run Code Online (Sandbox Code Playgroud)

剩余问题

我们如何诊断此类问题。它们非常微妙,以至于可以逃脱代码审查。有 2 个十年的标准 C++ 使这种情况更加恶化,其中它工作得非常好且可预测。

我想作为旁注,当与从标准库类型继承太多的用户定义类型一起使用时,我们可以预期任何“提升”运算符(考虑 std::variant/std::optional)都有类似的陷阱。

  • 当您只想存储数据时,从“std::string”继承是没有意义的,在这种情况下,简单地将其作为私有成员会更有意义。一些文献甚至声称永远不应该使用私有继承,而拥有私有成员是首选方式。举一个例子,Google C++ 风格指南指出[“如果你想进行私有继承,你应该将基类的实例作为成员包含在内。”](https://google.github.io/styleguide/cppguide .html#继承) (6认同)