C++20三路比较带来更多的静默行为变化

47

让我感到惊讶的是,我遇到了另一个问题,类似于 C++20行为破坏现有代码等于运算符?

考虑一个简单的不区分大小写的键类型,可用于例如std::setstd::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);
    }
};

简单测试:

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);
}
  • 使用C++17版本,两个断言均通过 (编译器浏览器)。

  • 切换到C++20版本后,第二个断言失败了 (编译器浏览器)

    output.s: ./example.cpp:28: int main(): Assertion `mappings.size() == 3' failed.


显而易见的解决方法

一个显而易见的解决方法是在C++20模式下有条件地提供 operator<=>: 编译器浏览器

#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

问题

我惊讶地发现另一个破坏性更改的案例 - 即C++20更改代码行为而没有诊断。

根据我的阅读std::tuple::operator< ,它应该可以工作:

3-6)按字典顺序比较lhsrhs,即比较第一个元素,如果它们相等,则比较第二个元素,如果那些相等,则比较第三个元素,以此类推。 对于非空元组,(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);

我知道从C++20开始这些技术上不再适用,取而代之的是:

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

与此同时

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

问题在于,

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

  • 正如这篇答案所指出的,提供额外的operator<也没问题,并且当评估简单表达式如a < b时应该使用。

  1. C++20中的行为变化是否正确和有意义?
  2. 是否应该有诊断?
  3. 我们能使用其他工具发现类似的静默变化吗?感觉在代码库中扫描使用自定义类型的tuple/pair不可行。
  4. 是否有其他类型,除了tuple/pair,可能表现出类似的变化?

8
我的类型没有定义运算符<=>或运算符==,但是std::string定义了这些运算符,因此由于从派生到基类的转换,它成为了一个候选。我相信所有支持比较的标准库类型都已经进行了成员重构。 - StoryTeller - Unslander Monica
8
我猜非虚析构函数不再是避免从标准库容器继承的唯一强有力的理由 :/ - StoryTeller - Unslander Monica
1
@StoryTeller-UnslanderMonica:“从未如此。” https://quuxplusone.github.io/blog/2018/12/11/dont-inherit-from-std-types/ - Quuxplusone
1
@Quuxplusone 很好的写作。由于CTAD的缘故,也可以说是相当新的特性(以及对该死的initializer_list / {}初始化难题的切线),但前提确实没有多大变化。您无法摆脱与继承的紧密耦合,这意味着放弃任何未来的保证,因为标准确实会发生变化。 - sehe
@Quuxplusone,ci字符串是std中的痛点。它们最令人讨厌的特性之一是它们与常规字符串不共享相同的存储空间...我认为编写一个不区分大小写的映射表会更有效。首先,接受string_view作为find()参数的映射表将是一个不错的选择。 - Michaël Roy
显示剩余4条评论
3个回答

50
基本问题源于你的类型不一致,而标准库直到 C++20 才开始关注这个问题。也就是说,你的类型一直存在问题,但限定得够窄,所以你可以逃避这个问题。
你的类型存在问题,因为它的比较运算符毫无意义。它宣称自己是完全可比较的,定义了所有可用的比较运算符。这是因为你从 std::string 公开继承,所以你的类型通过隐式转换继承了这些运算符。但是这些比较的行为是不正确的,因为你只替换了其中一个,其余的都没有更改。
由于行为不一致,一旦 C++ 真正关注你的一致性,任何事情都有可能发生。
然而,更大的问题是标准在对 operator<=> 的处理不一致。
C++ 语言被设计成在使用合成运算符之前优先使用显式定义的比较运算符。因此,如果直接比较从 std::string 继承的你的类型,它将使用你的 operator<。
然而,C++ 标准库有时试图变得聪明一些。
某些类型尝试转发给定类型提供的运算符,例如 optional。它旨在在可比性方面与 T 表现相同,并且它成功地实现了这一点。
然而,pair 和 tuple 尝试变得更加聪明。在 C++17 中,这些类型从未实际转发比较行为;相反,它们基于类型上现有的 operator< 和 operator== 定义来合成比较行为。
因此,它们在 C++20 版本中继续了这种优秀传统的合成比较行为。当然,由于语言参与了这个游戏,C++20 版本决定最好遵循它们的规则。
除了...它无法完全遵循它们的规则。没有办法检测到 < 比较是合成的还是用户提供的。因此,在这些类型中无法实现语言行为。但是,你可以检测到三向比较的存在。
因此,它们做出了一个假设:如果你的类型是三向可比较的,则你的类型依赖于合成运算符(否则,它使用改进后的旧方法)。这是正确的假设;毕竟,既然 <=> 是一个新功能,旧类型就不可能获得该功能。
除非当然,旧类型继承自获得三向可比性的新类型。而类型无法检测到这一点;它要么是三向可比的,要么不是。
现在很幸运的是,pairtuple的合成三向比较运算符可以完美地模仿C++17的行为,如果您的类型没有提供三向比较功能。因此,您可以通过在C++20中显式禁用三向比较运算符(通过删除operator<=>重载)来恢复旧的行为。
另外,您可以使用私有继承并仅公开using您想要的特定API。

C++20中的行为变化是正确的/故意的吗?

这取决于您对“故意”的理解。从公共继承像std::string这样的类型一直都有一些道德上的争议。不是因为切片/析构器问题,而是因为它有点作弊。直接继承这些类型会让您面临您未预期且可能不适合您的类型的API更改。 pairtuple的新比较版本正在尽其所能地完成工作,这是C++所允许的最好的方式。只是您的类型继承了它不想要的东西。如果您私有继承自std::string并仅公开using所需的功能,则您的类型可能会很好。

是否应该进行诊断?

这不能在某些编译器内部诊断。

我们可以使用其他工具来发现这样的悄悄变化吗?

搜索公共继承自标准库类型的情况。

同意。我认为行为的改变是有意的,因为这实际上是“tuple::operator<=>”等操作符的记录更改。我不认为库变得更加严格了(因为“std::set”对于排序使用完全相同的假设),这也是为什么它仍然可以直接与“std::set<CiKey>”一起使用的原因。 - sehe
关于诊断,没有任何概念可以检查运算符的一致性这一事实只是故事的一部分。同样真实的是编译器可以发出警告:“此代码在c++20之前调用了CiKey::operator<,但现在调用了std::string::operator<=>。”嗯,我认为类似的警告已经添加到了c++20编译器中。 - sehe
1
这是真的,尽管@sehe即使实现了所有六个比较,仍然会遇到问题... 呜呼 - Barry
1
@Barry:你是说pair会优先调用operator<=>,即使有用户提供的运算符也会被调用吗? - Nicol Bolas
8
@NicolBolas 是的,如果 <=> 可用,它将使用 <=>。无法区分您还单独提供了 <,与 <=> 无关。如果类型提供了 <=>,则 真正 想要使用 <=> 来实现 <(而不是两次使用 <)。 - Barry
1
@NicolBolas 或许把“这是因为”放到回答的开头会让它更具有积极的语气。如原文所述,那些段落给人一种误导性的“敌对愤怒的发泄”的感觉,这可能会导致人们跳过阅读。 - kfsone

12

啊!@StoryTeller在他们的评论中说得非常好:

"我的类型没有定义operator<=>或operator==" - 但是std::string定义了,这使它成为由派生到基础转换的候选对象。我相信所有支持比较的标准库类型都经过了成员的彻底改造。

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

#if defined(__cpp_lib_three_way_comparison)
    std::weak_ordering operator<=>(
        CiKey const&) const = delete;
#endif

成功!Compiler Explorer

更好的思路

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

我猜非虚拟析构函数不再是避免继承标准库容器的唯一令人信服的原因了 :/

在这里避免继承:

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

    bool operator<(CiKey const& other) const {
        return boost::ilexicographical_compare(_value, other._value);
    }
};
当然,这需要对使用代码进行一些下游更改,但它在概念上更加纯粹,并且隔离了将来可能出现的“标准蔓延”的影响。 编译器资源管理器
#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);
}

剩余问题

我们如何诊断这些问题。它们非常微妙,会逃脱代码审查。由于有两个十年的标准C ++,在其中这种情况运行得非常良好且可预测,所以情况变得更加恶化。

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


6
如果你只需要数据存储,那么从std::string继承是没有意义的,这种情况下,将其作为私有成员会更加合理。甚至有些文献声称永远不应该使用私有继承,而将其作为私有成员是首选方式。例如,Google C++编程风格指南中提到:“如果想要进行私有继承,应该将基类的实例作为成员包含进来。” - Mysterious User
1
@神秘用户 一切明白。 在这种情况下,它并不涉及存储问题。它涉及继承整个接口和基本类型的可替换性。因此我提到“当然,这需要对使用代码进行下游更改,但在概念上更加纯粹”。我认为这里主要争论的是解耦。顺便提一下,这也是最初提出此问题的原因。 - sehe
1
实际上,你只需要使用适当的比较器模板参数来定义std::set,而不是使用自定义键类型吗?(我通常尽可能多地使用自定义类型,但在这个玩具示例中确实不需要。) - Konrad Rudolph
@KonradRudolph 我通常更喜欢这样做,因为我发现当 std::less<> 做预期的事情时(例如用 array + lower_bound/upper_bound 替换 set<>),它使得容器/算法更易于替换。实际上,我尝试通过专门化来解决这个问题,但是 std::tuple/std::pair 指定使用 operator< 而不是 std::less<> - sehe

3
这并不是关于std::string::operator=()的不同行为的回答,但我必须指出,创建不区分大小写的字符串应该通过自定义模板参数Traits来完成。
例如:
// definition of basic_string:
template<
    class CharT,
    class Traits = std::char_traits<CharT>,   // <- this is the customization point.
    class Allocator = std::allocator<CharT>
> class basic_string;

不区分大小写的字符串示例几乎直接来自cppreference (https://en.cppreference.com/w/cpp/string/char_traits)。我已经添加了针对不区分大小写字符串的using指令。

#include <cctype>
#include <cwctype>
#include <iostream>
#include <locale>
#include <string>
#include <version>

template <typename CharT> struct ci_traits : public std::char_traits<CharT>
{
    #ifdef __cpp_lib_constexpr_char_traits
    #define CICE constexpr
    #endif

private:
    using base = std::char_traits<CharT>;
    using int_type = typename base::int_type;

    static CICE CharT to_upper(CharT ch)
    {
        if constexpr (sizeof(CharT) == 1)
            return std::toupper(static_cast<unsigned char>(ch));
        else
            return std::toupper(CharT(ch & 0xFFFF), std::locale{});
    }

public:
    using base::to_int_type;
    using base::to_char_type;

    static CICE bool eq(CharT c1, CharT c2)
    {
        return to_upper(c1) == to_upper(c2);
    }
    static CICE bool lt(CharT c1, CharT c2)
    {
        return to_upper(c1) < to_upper(c2);
    }
    static CICE bool eq_int_type(const int_type& c1, const int_type& c2)
    {
        return to_upper(to_char_type(c1)) == to_upper(to_char_type(c2));
    }
    static CICE int compare(const CharT *s1, const CharT *s2, std::size_t n)
    {
        while (n-- != 0)
        {
            if (to_upper(*s1) < to_upper(*s2))
                return -1;
            if (to_upper(*s1) > to_upper(*s2))
                return 1;
            ++s1;
            ++s2;
        }
        return 0;
    }
    static CICE const CharT *find(const CharT *s, std::size_t n, CharT a)
    {
        auto const ua(to_upper(a));
        while (n-- != 0) {
            if (to_upper(*s) == ua)
                return s;
            s++;
        }
        return nullptr;
    }
    #undef CICE
};

using ci_string = std::basic_string<char, ci_traits<char>>;
using ci_wstring = std::basic_string<wchar_t, ci_traits<wchar_t>>;

// TODO consider constexpr support
template <typename CharT, typename Alloc>
inline std::basic_string<CharT, std::char_traits<CharT>, Alloc> string_cast(
    const std::basic_string<CharT, ci_traits<CharT>, Alloc> &src)
{
    return std::basic_string<CharT, std::char_traits<CharT>, Alloc>{
        src.begin(), src.end(), src.get_allocator()};
}

template <typename CharT, typename Alloc>
inline std::basic_string<CharT, ci_traits<CharT>, Alloc> ci_string_cast(
    const std::basic_string<CharT, std::char_traits<CharT>, Alloc> &src)
{
    return std::basic_string<CharT, ci_traits<CharT>>{src.begin(), src.end(),
                                                    src.get_allocator()};
}

int main(int argc, char**) {
    if (argc<=1)
    {
        std::cout << "char\n";
        ci_string hello = "hello";
        ci_string Hello = "Hello";

        // convert a ci_string to a std::string
        std::string x = string_cast(hello);

        // convert a std::string to a ci_string
        auto ci_hello = ci_string_cast(x);

        if (hello == Hello)
            std::cout << string_cast(hello) << " and " << string_cast(Hello)
                    << " are equal\n";

        if (hello == "HELLO")
            std::cout << string_cast(hello) << " and "
                    << "HELLO"
                    << " are equal\n";
    }
    else
    {
        std::cout << "wchar_t\n";
        ci_wstring hello = L"hello";
        ci_wstring Hello = L"Hello";

        // convert a ci_wstring to a std::wstring
        std::wstring x = string_cast(hello);

        // convert a std::wstring to a ci_wstring
        auto ci_hello = ci_string_cast(x);

        if (hello == Hello)
            std::wcout << string_cast(hello) << L" and " << string_cast(Hello) << L" are equal\n";

        if (hello == L"HELLO")
            std::wcout << string_cast(hello) << L" and " << L"HELLO" << L" are equal\n";
    }
}

您可以在此处尝试运行代码:https://godbolt.org/z/5ec5sz

1
嗯,这似乎离题太远了,我很想指出这不是正确的大小写转换。如果我们要“精确”,我会指出你应该使用适当的区域感知排序规则,并具有完全的UNICODE意识。这需要ICU或其他工具来实现 :) 此外,我注意到<string_view>被引入,但随后却缺乏使用它来创建“安全”接口的兴趣。现在我有再次进行代码审查的冲动。啊。 - sehe
只有在排序数据仅在一个语言环境中访问时,区域设置感知的排序才是合适的,这通常意味着它既不会被持久化也不会被传输。即使如此,在除了专门用于执行此类比较的库之外避免编写执行区域设置感知比较的代码,因为程序员无法了解程序可能使用的所有可能的语言环境,包括一些可能在编写代码时不存在的语言环境。 - supercat
我完全同意您使用string_view的观点... 我只花了足够的时间来提供一个had oc示例,在更大的注释中,除非有人想要完全回避operator=()的问题,否则这并不是一个解决方案。 - Michaël Roy
@MichaëlRoy 谢谢。不,我想要“回避”将现有代码库升级到C++20时可能出现的问题。因此,代码结构是确定的。并且可能是不同的(我经常遇到从boost::variant<...>派生的ADT)。我认为这些库将面临大量问题例如此问题 - sehe
无论如何,@supercat是正确的:这不适用于使用情况,这基本上是我的全部观点。而且,我还没有检查cppreference上的示例是否也有错误,但至少我会说,在调用std::toupper时屏蔽后进行转换存在错误:当CharT为有符号时,它将获得未定义的行为(例如,当ch == -128时)。此外,我认为分配器支持是标准课程。我不确定继承comparison_category是否仍然准确。实际上,我怀疑它。看到eq_int_type和类似的缺失以及缺乏constexpr也令人不安。 - sehe
我会相应地更新答案代码。_[我现在看到,掩码的错误在cppreference上并不存在。]_。并且完成,请参见godbolt - sehe

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