如何使我的类免疫 C + + 中的“ auto value = copy of xy”地雷?

我有一个相当复杂的数学库,我的工作,我已经发现了一个讨厌的错误时,客户端代码使用自动。通过创建一个最小的再生实例来提出一个关于它的问题,我意识到我可以单独使用标准库再生一些类似的东西。看看这个简单的测试用例:

#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.
}

现场直播。(有趣的事实是: Clang 实际上优化了这个过程,写入“7”——3个真比特——以及一个对 _ _ asser_ fall 的调用。)

(是的,我知道 - but in this case it's handy to create a minimum reproducible example that's only a few lines long) Here's a 不使用 std: : Vector < bool > 的更长示例, and uses a custom container type, with assignment and copy/move deleted, and still shows the problem.

我知道底下发生了什么,有一个由操作符[]返回的代理类,用于实现 allTheData[1] = true和相关功能,客户端代码就像读取值一样,实际上是将代理存储在 booolValue 中,然后当客户端稍后修改它认为是 bool 的内容时,原始源数据就被修改了。“ auto”复制了代理。

程序员让代码做什么,代码就做什么,而不是程序员的意思。

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

代理的所有复制和移动构造函数以及赋值操作符都被声明为 private (也尝试了 = delete) ,但是在编译时没有捕捉到这个 bug。无论是否删除代理复制建构子,代理都会被复制。

我为这个 bug 找到的所有“修复”都集中在代码的客户端部分。比如: “不要使用 auto”、“强制转换为底层类型”、“通过 const ref 访问”等等。这些都是低于标准的修复程序,一旦发现了不良行为,您可以添加其中一个作为黑客修复程序,但潜在的问题仍然是抓住下一个不知情的用户。

我宁愿移除地雷也不愿意一直绕过它,并且竖起一个标志说“不要使用自动”,或者“一直使用常量”,只是标记雷区,它不会移除它。

我怎样才能使我的库对这个陷阱免疫? (不改变客户端代码!)

  • 第一个优先选择是代码作为书面工作-assert(allTheData[1] == false)通过
    • 当代理写到 auto 时,一种定义代理衰减类型的方法? 所以 decltype(boolValue)bool
    • 优先于复制的隐式转换运算符?
    • 有没有其他方法可以在不更改上面的代码片段的情况下通过这个测试?
  • 第二个优先选择是否有办法使向变量写代理成为编译错误?
    • 我将复制和移动构造函数声明为 delete,将移动和复制赋值操作符声明为 delete。
    • 是否有任何方法声明一个类不能成为左值?
  • 在提议的 c + + 未来标准中有什么可以解决这个问题的吗?

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

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

我在这里只使用向量,因为它是这个问题的一个非常简单的例子。这不是一个带有向量的 bug,而是一个带有代理类型模式的 bug,向量就是这个问题的一个例子。

奇怪的是,MSVC 的 Intellisense 引擎 有时候报告说,将一个“不移动、不复制”的代理类型复制为一个编译错误,但是 然后无论如何都能很好地编译它:

enter image description here
如果这个智能感知编译错误是一个真正的编译错误,那就太好了

8903 次浏览

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

有一个建议 P0672R0“自动”变量的隐式评价(从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;,然后使用可以专门化 std::decay

标准委员会会议的讨论强烈支持 使用宣言解决方案。然而,我无法找到更多关于这篇文章的更新,所以我个人认为在不久的将来,这篇文章或类似的东西不会在语言中实现。

因此,不幸的是,现在您唯一的解决方案是向用户介绍库使用的代理类。

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

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

我做了很多实验,但最终我找到了一种方法来缓解这个问题最常见的情况,这种方法可以收紧代理,因此你仍然可以复制代理,但是一旦你把它复制到一个堆栈变量,你就不能修改它,并且无意中破坏了源容器。

#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.
}

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

  • 使代理不可复制。

  • 仅此一点并不能阻止您将代理保存到 auto变量,因为 RVO 是强制的。为了抵消这种影响,应该通过引用返回代理。

  • 为了避免获得悬空引用,可以在默认函数参数中构造代理,该参数的生存期与正则参数相同,直到完整表达式结束为止。

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

  • 这可以防止与悬空代理引用进行任何交互,除非您将其 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的类。