【问题标题】:Should the assignment operator observe the assigned object's rvalueness?赋值运算符是否应该观察赋值对象的右值性?
【发布时间】:2014-01-02 15:46:00
【问题描述】:

对于类类型,可以分配给内置类型实际上不允许的临时对象。此外,默认生成的赋值运算符甚至会产生一个左值:

int() = int();    // illegal: "expression is not assignable"

struct B {};
B& b = B() = B(); // compiles OK: yields an lvalue! ... but is wrong! (see below)

对于最后一条语句,赋值运算符的结果实际上用于初始化一个非const 引用,该引用将在语句之后立即变得陈旧:引用不直接绑定到临时对象(它不能因为临时对象只能绑定到 const 或右值引用),但只能绑定到生命周期未延长的赋值结果。

另一个问题是,从赋值运算符返回的左值看起来好像不能移动,尽管它实际上是指一个临时值。如果有任何东西正在使用分配的结果来获取值,它将被复制而不是移动,尽管移动它是完全可行的。此时值得注意的是,问题是根据赋值运算符来描述的,因为该运算符通常可用于值类型并返回左值引用。任何返回对象引用的函数都存在同样的问题,即*this

一个潜在的解决方法是重载赋值运算符(或其他返回对象引用的函数)以考虑对象的类型,例如:

class G {
public:
    // other members
    G& operator=(G) &  { /*...*/ return *this; }
    G  operator=(G) && { /*...*/ return std::move(*this); }
};

C++11 提供了像上面那样重载赋值运算符的可能性,可以防止上面提到的微妙对象失效,同时允许将赋值结果移动到临时对象。这两个运算符的实现可能是相同的。尽管实现可能相当简单(本质上只是两个对象的swap()),但这仍然意味着需要额外的工作来提出问题:

返回对象引用的函数(例如赋值运算符)是否应该观察被赋值对象的右值性?

另一种选择(Simple 在评论中提到)是不要重载赋值运算符,而是用 & 明确限定它以将其使用限制为左值:

class GG {
public:
    // other members
    GG& operator=(GG) &  { /*...*/ return *this; }
};
GG g;
g = GG();    // OK
GG() = GG(); // ERROR

【问题讨论】:

  • 我同意& ref-qualifier 但我不喜欢&& 过载。稍微相关的是,我的一个见解是,带有 ref 限定符的成员函数与非成员函数具有相同的“语义”,因为 foo(T())T().foo() 都被禁止,而没有第一个不允许,但允许第二个。
  • @Dietmar:如果正确答案是“是”,那么委员会可能会进行一场尴尬的对话,他们决定是否修复编译器生成的复制/移动分配?所以这个问题可能有两个层面:(1)运营商是否应该在理想世界中这样做,以及(2)是否应该改变标准运营商来做到这一点。
  • @Simple: 是的,我同意解决该问题的另一种选择不是重载赋值运算符(或类似函数),而是明确限定它仅采用左值(我的意思是在问题但忘记了)。
  • (从已删除的评论中重新发布)我认为问题不仅限于分配。 任何返回左值引用的成员函数都可能导致悬空引用,例如auto& x = std::vector<int>{1,2,3}.front();
  • 值得注意的是,您仍然可以将 = default 与 ref 限定符一起使用,例如 widget& operator=(widget const&) & = default

标签: c++ c++11 rvalue-reference


【解决方案1】:

恕我直言,Dietmar Kühl 的原始建议(为&&& ref-qualifiers 提供重载)优于Simple 的建议(仅为& 提供)。 最初的想法是:

class G {
public:
    // other members
    G& operator=(G) &  { /*...*/ return *this; }
    G  operator=(G) && { /*...*/ return std::move(*this); }
};

Simple 建议删除第二个重载。两种解决方案都使这条线无效

G& g = G() = G();

(根据需要)但如果删除了第二个重载,那么这些行也无法编译:

const G& g1 = G() = G();
G&& g2 = G() = G();

我看不出他们不应该这样做的理由(Yakkpost 中解释了没有终身问题)。

我只能看到Simple 的建议更可取的一种情况:当G 没有可访问的复制/移动构造函数时。由于大多数可访问复制/移动赋值运算符的类型也具有可访问的复制/移动构造函数,因此这种情况很少见。

两个重载都按值接受参数,如果G 具有可访问的复制/移动构造函数,则有充分的理由。现在假设G没有有一个。在这种情况下,操作员应采用const G& 的参数。

不幸的是,第二个重载(实际上是按值返回)不应返回对*this 的引用(任何类型),因为*this 绑定到的表达式是一个右值,因此很可能成为一个生命即将到期的临时工。 (回想一下,禁止这种情况发生是 OP 的动机之一。)

在这种情况下,您应该删除第二个重载(根据Simple 的建议),否则该类无法编译(除非第二个重载是一个从未实例化的模板)。或者,我们可以保留第二个重载并将其定义为deleted。 (但是,既然& 的重载就已经足够了,为什么还要麻烦呢?)

外围点。

对于&&operator = 的定义应该是什么? (我们再次假设G 有一个可访问的复制/移动构造函数。)

正如Dietmar Kühl 所指出的和Yakk 所探索的那样,两个重载的代码应该非常相似,在这种情况下,最好按照@ 的代码来实现&& 的代码987654348@。由于预计移动的性能不会比副本差(并且由于 RVO 在返回 *this 时不适用),我们应该返回 std::move(*this)。总之,一个可能的单行定义是:

G operator =(G o) && { return std::move(*this = std::move(o)); }

如果只有G 可以分配给另一个G 或者G 具有(非显式)转换构造函数,这就足够了。否则,您应该考虑给 G 一个(模板)转发复制/移动赋值运算符,采用通用引用:

template <typename T>
G operator =(T&& o) && { return std::move(*this = std::forward<T>(o)); }

虽然这不是很多样板代码,但如果我们必须为许多类这样做,这仍然是一个烦恼。为了减少样板代码的数量,我们可以定义一个宏:

#define ASSIGNMENT_FOR_RVALUE(type) \
    template <typename T> \
    type operator =(T&& b) && { return std::move(*this = std::forward<T>(b)); }

然后在G 的定义中添加ASSIGNMENT_FOR_RVALUE(G)

(请注意,相关类型仅作为返回类型出现。在C++14中,它可以由编译器自动推导,因此,可以替换最后两个代码中的Gtype sn-ps by auto。由此可见,宏可以变成类对象宏,而不是类函数宏。)

另一种减少样板代码量的方法是为&amp;&amp; 定义一个实现operator = 的CRTP 基类:

template <typename Derived>
struct assignment_for_rvalue {

    template <typename T>
    Derived operator =(T&& o) && {
        return std::move(static_cast<Derived&>(*this) = std::forward<T>(o));
    }

};

样板变成继承和使用声明如下:

class G : public assignment_for_rvalue<G> {
public:
    // other members, possibly including assignment operator overloads for `&`
    // but taking arguments of different types and/or value category.
    G& operator=(G) & { /*...*/ return *this; }

    using assignment_for_rvalue::operator =;
};

回想一下,对于某些类型,与使用 ASSIGNMENT_FOR_RVALUE 相反,从 assignment_for_rvalue 继承可能会对类布局产生一些不良后果。

【讨论】:

  • "但是如果删除了第二个重载,那么这些行也无法编译:[...] 我看不出他们不应该这样做的理由" OTOH,你想用G&amp;&amp; g2 = G() = G();等表达什么?我看不出这在哪里有用。默认情况下,我认为没有可行的operator= 用于右值是可以接受的;您仍然可以在需要的极少数情况下添加它。
  • @DyP 我在谈论安全的观点,但我完全同意G&amp;&amp; g2 = G() = G(); 很奇怪(而且可能没有这样一行的代码)。赋值运算符被用作示例(在 OP 之后),但我想知道另一个更接近您的示例(但不完全相同)auto&amp; x = std::vector&lt;int&gt;{1,2,3}.front();(实际上是auto&amp;&amp; x = ...)是否会出现在某处。 (我仍然怀疑,但谁知道呢?)没关系:我可能会花费太多精力来解决实际上不存在的问题。
【解决方案2】:

第一个问题是这在 C++03 中实际上是不行的:

B& b = B() = B();

因为b 在行结束后绑定到过期的临时文件。

使用它的唯一“安全”方式是在函数调用中:

void foo(B&);
foo( B()=B() );

或类似的东西,其中临时对象的 line-lifetime 足以满足我们绑定它的生命周期。

我们可以将可能效率低下的B()=B() 语法替换为:

template<typename T>
typename std::decay<T>::type& to_lvalue( T&& t ) { return t; }

现在通话看起来更清晰了:

foo( to_lvalue(B()) );

通过纯铸造来实现。生命周期仍然没有延长(我想不出一种方法来管理它),但我们不会构造对象然后毫无意义地将一个对象分配给另一个对象。

所以现在我们坐下来研究这两个选项:

G operator=(G o) && { return std::move(o); }
G&&  operator=(G o) && { *this = std::move(o); return std::move(*this); }
G  operator=(G o) && { *this = std::move(o); return std::move(*this); }

顺便说一句,它们是完整的实现,假设G&amp; operator=(G o)&amp; 存在并且编写正确。 (不需要的时候为什么要重复代码?)

第一个和第三个允许返回值的生命周期延长,第二个使用*this 的生命周期。第二个和第三个修改*this,而第一个没有。

我会声称第一个是正确的答案。因为*this绑定了一个右值,调用者已经声明它不会被重用,它的状态无所谓:改变它是没有意义的。

first 和third 的生命周期意味着任何使用它的人都可以延长返回值的生命周期,而不受*this 的生命周期的约束。

B operator=(B)&amp;&amp; 的唯一用途是它允许您相对统一地处理右值和左值代码。不利的一面是,它可以让您在结果可能令人惊讶的情况下相对统一地对待它。

std::forward<T>(t) = std::forward<U>(u);

T&amp;&amp; 是右值引用时,应该可能无法编译而不是做一些令人惊讶的事情,例如“不修改t”。当T&amp;&amp; 是右值引用时修改t 同样是错误的。

【讨论】:

  • 对于你的第一段,他使用 OK 我相信他的意思是它可以编译,而不是他认为这是好的代码。
  • @Simple 它和B&amp; b = *(B*)nullptr; 差不多。当然,它可以编译,但生成的变量不可用。
  • @Yakk:我的意思是它确实可以编译,但正如下面段落中所解释的,我描述它实际上并没有做正确的事情。该评论令人困惑,我会澄清它...
  • @Yakk:不过,您在 cmets 中的示例甚至比 Dietmar 的代码还要糟糕。取消引用空指针是未定义的行为,这意味着 B&amp; b = *(B*)nullptr; 具有 UB。允许引用悬空不是 UB,它只是 UB 访问它。
猜你喜欢
  • 2013-05-01
  • 2011-08-02
  • 2019-10-06
  • 2013-10-13
  • 2020-08-17
  • 1970-01-01
  • 2021-08-28
相关资源
最近更新 更多