如何让我的班级免受 C++ 中“自动值 = 代理副本”地雷的影响? 我怎样才能让我的图书馆不受这个陷阱的影响? 无需更改客户端代码!通过在代理类的操作符末尾添加“&&”来减少损害=

问题描述

我正在开发一个相当复杂的数学库,当客户端代码使用 auto 时,我发现了一个令人讨厌的错误。在创建一个最小的复制案例来提出一个问题的过程中,我意识到我可以单独使用标准库来复制类似的东西。查看这个简单的测试用例:

#include <vector>
#include <assert.h>

int main()
{
    std::vector<bool> allTheData = {true,false,true};

    auto boolValue = allTheData[1]; // This should be false - we just declared it.
    assert(boolValue == false);
    boolValue = !boolValue;
    assert(boolValue == true);

    assert(allTheData[1] == false); // Huh? But we never changed the source data! Only our local copy.
}

Live on Godbolt。 (有趣的事实:Clang 实际上将其优化为写入“7” - 3 个真实位 - 并调用 __assert_fail。)

是的,我知道 std::vector 很烂 - 但在这种情况下,创建一个只有几行长的最小可重现示例很方便)这是一个 longer example that doesn't use std::vector<bool>,并使用自定义容器类型,删除了分配和复制/移动,仍然显示问题。

我了解幕后发生的事情,operator[] 返回了一个代理类,用于实现 allTheData[1] = true 和相关功能,编写的客户端代码好像它正在读取值实际上是在存储代理在 boolValue 中,然后当客户端稍后修改它认为是 bool 的内容时,会改为修改原始源数据。 TLDR:“自动”复制了代理。

代码做了程序员告诉它要做的事情,而不是程序员的意思。

如果程序员想要 boolValue 的更改来更新源数据,他们会做 auto& boolValue = ...,它与返回 operator[]T& 实现一起工作,但不是那些需要伪造的自定义代理类似引用的行为。

代理的所有复制和移动构造函数以及两个赋值运算符都被声明为私有(也尝试过 = delete),但是在编译时没有发现这个错误。无论是否删除复制构造函数,都会复制代理。

我为这个错误找到的所有“修复”都集中在代码的客户端部分。它们是:“不要使用自动”、“转换为底层类型”、“通过常量引用访问”等。这些都是不合标准的修复,一旦发现不良行为,您可以添加其中之一作为黑客修复,但潜在的问题仍然是抓住下一个毫无戒心的用户

我宁愿移除地雷,也不愿一直绕过它,并张贴“不要使用自动”或“始终使用常量”的标志,只是标记雷区,并不会移除它。

>

我怎样才能让我的图书馆不受这个陷阱的影响? (无需更改客户端代码!)

  • 首选是代码按书面方式工作 - assert(allTheData[1] == false) 通过
    • 一种在写入 auto 时定义代理衰减类型的方法?。那么 decltype(boolValue)bool 吗?
    • 优先于复制的隐式转换运算符?
    • 有没有其他方法可以在不更改上面的代码片段的情况下完成此操作?
  • 第二个偏好有没有办法使向变量写入代理会导致编译错误
    • 我将复制和移动构造函数声明为删除,并将移动和复制赋值运算符声明为删除。仍然编译。
    • 无论如何要声明一个类不能成为左值?
  • 拟议的 C++ 未来标准中是否有任何内容可以解决这个问题?

还有一个问题是代码如下:

std::vector<bool> ReadFlags();
... later ...
auto databaseIsLockedFlag = ReadFlags()[FLAG_DB_LOCKED];
if (databaseIsLockedFlag) <-- Crash here. Proxy has outlived temporary vector.

在这里只使用矢量,因为它是一个非常简单的问题示例。这不是vector的错误,这是代理类型模式的错误,其中vector是一个例子来说明问题。

奇怪的是,MSVC 的 Intellisense 引擎有时将复制 no-move-no-copy 代理类型报告为编译错误,但无论如何编译它

enter image description here


如果这个智能感知编译错误一个真正的编译错误,那就太好了。叹息

解决方法

通过在代理类的操作符末尾添加“&&”来减少损害=

(和运算符 +=、-= 等)

我进行了大量试验,但我最终找到了一种方法来缓解最常见的问题,这会收紧它,因此您仍然可以复制代理,但是一旦将其复制到堆栈变量中,您就可以不要修改它并无意中破坏了源容器。

#include <cstdio>
#include <utility>

auto someComplexMethod()
{
  struct s
  {
    void operator=(int A)&& {std::printf("Setting A to %i",A);}
  };
  return s();
}

int main()
{
  someComplexMethod() = 4; // Compiles. Yay

  auto b = someComplexMethod(); 
  // Unfortunately that still compiles,and it's still taking a 
  // copy of the proxy,but no damage is done yet.

  b = 5; 
  // That doesn't compile. Error given is: 
  //   No overload for '='  note: candidate function not viable: 
  //   expects an rvalue for object argument

  std::move(b) = 6; 
  // That compiles,but is basically casting around the 
  // protections,aka shooting yourself in the foot.
}
,

是的,这确实是个问题。 Afaik 除了在调用站点更改不理想的代码之外,当前的 C++(在撰写本文时为 C++20)中没有解决方案。

提案 P0672R0 Implicit Evaluation of “auto” Variables(来自 2017 年)试图解决这个确切的问题。它像您的案例一样在数学库中用作示例代理类,并像您一样给出带有 std::vector<bool> 的示例。它提供了更多来自这种模式的问题的例子。

本文为此提出了 3 种解决方案,均以语言实现:

  • 运算符符号:

    class product_expr
    {
        matrix operator auto() { ... }
    };
    
  • 使用声明:

    class product_expr
    {
        using auto = matrix;
    };
    
  • 衰变的专业化:

    使auto x= expr定义为typename std::decay<decltype(expr)>::type x=expr;然后使用used can特化std::decay

标准委员会会议上的讨论强烈支持使用声明解决方案。但是,我无法在这篇论文中找到更多更新,因此我个人认为在不久的将来不会在该语言中实现这篇论文或类似的东西。

因此,不幸的是,目前您唯一的解决方案是让您的用户了解您的库使用的代理类。

,

我有一个模糊的想法,不确定它的实用性。它不会覆盖 auto 推导出的内容(这似乎不可能),而只会导致将代理复制到格式错误的变量中。

  • 使代理不可复制。

  • 由于强制 RVO,仅凭这一点并不能阻止您将代理保存到 auto 变量。为了抵消它,您可以通过引用返回代理。

  • 为避免获得悬空引用,您在默认函数参数中构造代理,该参数与常规参数具有相同的生命周期 - 直到完整表达式结束。

  • 用户仍然可以将引用保存到代理。为了使这种误用更难,您返回一个右值引用,并 && 限定所有成员函数。

  • 这会阻止与悬空代理引用的任何交互,除非您 std::move 它。这应该足够模糊以阻止您的用户,但如果不是,您将不得不依赖一些消毒剂或在代理的析构函数中设置一个(易失性?)标志,并在每次访问它时检查它(这是 UB ,但应该足够可靠)。

示例:

namespace impl
{
    class Proxy
    {
        Proxy() {}
      public:
        static Proxy construct() {return {};}
        Proxy(const Proxy &) = delete;
        Proxy &operator=(const Proxy &) = delete;
        int *ptr = nullptr;
        int operator=(int value) && {return *ptr = value;}
    };
}

impl::Proxy &&make_proxy(int &target,impl::Proxy &&proxy = impl::Proxy::construct())
{
    proxy.ptr = &target;
    return std::move(proxy);
}

那么:

int x = 0;
make_proxy(x) = 1; // Works.
auto a = make_proxy(x); // error: call to deleted constructor of 'impl::Proxy'
auto &b = make_proxy(x); // error: non-const lvalue reference to type 'impl::Proxy' cannot bind to a temporary of type 'impl::Proxy'
const auto &c = make_proxy(x); // Compiles,is a dangling reference. BUT!
c = 2; // error: no viable overloaded '='
auto &&d = make_proxy(x); // Compiles,is a dangling reference.
d = 3; // error: no viable overloaded '='
std::move(d) = 2; // Compiles,causes UB. Needs a runtime check.

使用重载运算符更难实现这一点,它(() 除外)不能有默认参数。但仍然可行:

namespace impl
{
    struct Index
    {
        int value = 0;
        Proxy &&proxy;
        Index(int value,Proxy &&proxy = Proxy::construct()) : value(value),proxy(std::move(proxy)) {}
        Index(const Index &) = delete;
        Index &operator=(const Index &) = delete;
    };
}

struct B
{
    int x = 0;
    impl::Proxy &&operator[](impl::Index index)
    {
        index.proxy.ptr = &x;
        return std::move(index.proxy);
    }
};

唯一的缺点是,由于任何参数最多允许一个用户定义的隐式转换,因此此 operator[] 仅适用于 int 参数,而不适用于具有 operator int 的类.