在C++中重载赋值运算符

19

据我所理解,当重载 operator= 时,返回值应该是一个非const引用。


A& A::operator=( const A& )
{
    // check for self-assignment, do assignment

    return *this;
}

为了允许在如下情况下调用非常量成员函数,它是非常量的:


( a = b ).f();

但是为什么它应该返回一个引用?如果返回值不声明为引用,比如按值返回,会在什么情况下出现问题?

假定复制构造函数已正确实现。


1
如果你希望人们将赋值操作看作语句而不是表达式,你可以返回 void。这将防止 (a=b)=ca=(b=c) 或任何其他可能显示值和引用之间差异的恶作剧发生。 - Steve Jessop
当我需要防止对象在从堆栈中弹出时自动销毁时,我发现将赋值运算符返回为void很有用。对于引用计数的对象,您不希望在不知道它们的情况下调用析构函数。 - cjcurrie
10个回答

20

不返回引用会浪费资源并导致奇怪的设计。为什么要为运算符的所有用户创建副本,即使几乎所有用户都会放弃该值?

a = b; // huh, why does this create an unnecessary copy?

另外,如果您的类使用内置赋值运算符时也不会进行类似的复制,这将令用户感到惊讶。

int &a = (some_int = 0); // works

是的,我知道这是浪费。我只是想知道是否有情况下,按值返回或引用返回会使赋值操作出错/产生不正确的值。 - jasonline
@Johannes:抱歉,我不明白你的最后一句话。能解释一下吗? - jasonline
@jasonline:obj1=obj2 返回一个临时值。当使用您的类的人尝试创建对(obj1=obj2)的引用时,将看到:1-如果它是非const引用,则不会编译,2-它将创建对临时对象(而不是obj1或obj2)的引用,这会使他们感到困惑,因为原始类型不是这样工作的(请参见litb的示例)。 - tiftik
@jasonline:是的。你尝试编译了吗? - tiftik
@jasonline,VC++使用了非标准扩展。将警告级别提高,你会看到它发出警告。在严格标准模式下,临时变量不会绑定到非const引用。 - Johannes Schaub - litb
显示剩余2条评论

16

在重载运算符时一个好的建议是“像基本类型一样进行”,而对于基本类型的赋值,其默认行为就是这样。

如果你觉得有必要,在其他表达式中禁用赋值可以选择不返回任何东西,但是返回副本根本没有意义:如果调用者想要拷贝,他们可以从引用中进行复制,如果他们不需要拷贝,那么生成一个不需要的临时变量是没有必要的。


4

因为f()可以修改a。(我们返回一个非const引用)

如果我们返回a的值(即复制品),f()将修改副本,而不是a


为什么不让它返回一个const引用呢?这样你就不会复制对象,也不能修改返回的对象。 - Yngve Hammersland

3

我不确定您希望多频繁地执行此操作,但是像这样的代码:(a=b)=c; 需要使用引用才能正常工作。

编辑:好吧,实际上还有更多内容。很多原因都半历史性质的。避免将值返回作为右值的原因不仅仅是为了避免将其复制到临时对象中。以 Andrew Koenig 最初发布的示例为基础进行(轻微)变化,考虑以下代码:

struct Foo { 
    Foo const &assign(Foo const &other) { 
        return (*this = other);
    }
};

现在假设你正在使用一个旧版本的C ++,其中赋值返回一个rvalue。在这种情况下,(* this = other); 将产生该临时变量。然后,您绑定到该临时变量的引用,销毁该临时变量,并最终返回对已销毁临时变量的悬挂引用。
自那之后实施的规则(扩展用于初始化引用的临时对象的生命周期)至少会减轻(并可能完全解决)此问题,但我怀疑任何人在这些规则编写后重新访问了这种特殊情况。这有点像一个丑陋的设备驱动程序,其中包括解决硬件不同版本和变体中数十个错误的修补程序-它可能可以重构和简化,但如果他们能帮助的话,最终没有人确定某些看似无害的更改会破坏当前工作的东西,最终没有人想要甚至看它。

是的,它确实需要。但你说得对,这样编码很奇怪。 - jasonline
有很多“C +”程序员会做出非常“聪明”的事情。我经常看到这样的恐怖场景,感觉自己就像生活在一个低成本的血腥电影中。 - James Schek
@Tadeusz:编写(a=b)=c这样的代码会受到惩罚,因为对于内置类型来说这是未定义行为。但如果operator=是函数调用,则不会受到惩罚。 - Steve Jessop
《Effective C++》的第10条建议,即从operator=()中返回*this的原因是允许链式赋值。 - Graphics Noob
1
@图形学新手:是的,我看过了。但它没有确切地说这就是原因。即使按值返回实现,你仍然可以说a = b = c; 它仍然可以工作。 - jasonline
@GraphicsNoob 那是对那里所写内容的误读。作者陈述这是因为内置类型的行为。 - C S

2

如果您的赋值运算符没有使用const引用参数:


A& A::operator=(A&); // unusual, but std::auto_ptr does this for example.

如果类 A 有可变成员(引用计数?),那么赋值运算符可能会改变被赋值的对象以及被赋值的对象。因此,如果你有以下代码:

a = b = c;
b = c 赋值将首先发生,并通过值返回一个副本(称之为 b'),而不是返回对 b 的引用。当完成 a = b' 赋值时,变异赋值运算符将更改 b' 副本而不是真正的 b
另一个潜在的问题是,如果您有虚拟赋值运算符,则按值而不是按引用返回可能会导致切片问题。我并不是说这是一个好主意,但这可能是一个问题。
如果您打算执行类似于 (a = b).f() 的操作,则需要按引用返回,以便如果 f() 改变对象,则不会改变临时对象。

1

通过引用返回可以减少执行链接操作的时间。例如:

a = b = c = d;

让我们看一下如果operator=返回值的情况下会调用哪些操作。
  1. c的复制赋值运算符使c等于d,然后创建一个临时匿名对象(调用复制构造函数)。让我们称之为tc
  2. 然后调用b的operator=。右侧对象是tc。调用了移动赋值运算符。 b变成等于tc。然后函数将b复制到临时的匿名对象,让我们称之为tb
  3. 同样的事情再次发生,a.operator=返回a的临时副本。在运算符;之后,所有三个临时对象都被销毁
总共:3个复制构造函数,2个移动运算符,1个复制运算符
让我们看看如果operator=返回引用会发生什么变化:
  1. 调用复制赋值运算符。 c 变为等于 d,返回左值对象的引用。
  2. 同上。 b 变为等于 c,返回左值对象的引用。
  3. 同上。 a 变为等于 b,返回左值对象的引用。

总之:仅调用了三个复制运算符,没有调用任何构造函数!

此外,我建议您通过 const 引用返回值,这样可以防止编写棘手和不明显的代码。使用更干净的代码将更容易找到错误 :) ( a = b ).f(); 最好拆分成两行 a=b; a.f();

P.S.: 复制赋值运算符: operator=(const Class& rhs)

移动赋值运算符: operator=(Class&& rhs)


1

如果你担心返回错误的内容可能会悄悄地导致意外的副作用,你可以编写你的operator=()返回void。我见过很多这样的代码(我认为是因为懒惰或者不知道应该返回什么类型而不是出于“安全”考虑),并且它几乎没有问题。通常需要使用operator=()返回的引用的表达式非常少见,而且几乎总是可以轻松编写替代方案。

我不确定我是否支持返回void(在代码审查中,我可能会指出它是不应该做的事情),但我提出这个选项是为了考虑一下,如果你想不必担心赋值运算符的奇怪用法如何处理。


最后编辑:

另外,我一开始应该提到你可以通过让你的operator=()返回一个const&来折中解决 - 这仍然允许赋值链:

a = b = c;

但会禁止一些更不寻常的用法:

(a = b) = c;

请注意,这使得赋值运算符的语义类似于C语言中的语义,其中由=运算符返回的值不是左值。在C++中,标准将其更改为=运算符返回左操作数的类型,因此它是左值,但正如Steve Jessop在对另一个答案的评论中指出的那样,虽然这使得编译器接受了这种写法,但它并没有真正地将右侧的值分配给左侧的变量。
(a = b) = c;

即使对于内置函数,由于在没有中间序列点的情况下两次修改了 a,结果是未定义的行为。对于非内置函数,通过使用 operator=() 避免了这个问题,因为 operator=() 函数调用是一个序列点。

@Michael:感谢您对C和C++之间的差异以及序列点的额外(并且清晰)解释。我从未想过它们有所不同。无论如何,我只是关心如何正确实现它(就像原语一样),以及为什么要以那种方式实现它。我没有任何意图使它返回void,因为这会禁用链接,而正常情况下应该允许链接。 - jasonline

1
在实际编程中(即不是像(a=b)=c这样的情况),返回一个值不太可能导致任何编译错误,但是返回副本是低效的,因为创建副本通常很昂贵。
显然可以想出需要引用的情况,但这些情况很少——如果有的话——在实践中出现。

1

这是Scott Meyers的优秀书籍《Effective C++》中的第10项。从operator=返回引用只是一种约定,但它是一个好习惯。

这只是一种约定;不遵循它的代码也会编译。然而,所有内置类型以及标准库中的所有类型都遵循这个约定。除非你有充分的理由采取不同的做法,否则不要这样做。


是的,我已经了解了这个问题。实际上,我正在寻找一些可能导致错误值的情况,但我想大多数答案都是效率问题。 - jasonline

0

如果它返回一个副本,那么几乎所有非平凡对象都需要实现复制构造函数。

此外,如果您将复制构造函数声明为私有但将赋值运算符声明为公共,则会出现问题...如果您尝试在类或其实例之外使用赋值运算符,则会得到编译错误。

更不用说已经提到的更严重的问题了。你不想它是对象的副本,你真的想让它引用同一个对象。对一个对象的更改应该对两个对象都可见,如果返回一个副本,这是行不通的。


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