为什么在C++中,内置赋值返回一个非const引用而不是const引用?

3

请注意原问题标题中为“代替rvalue”而非“代替const引用”。下面的一个答案是针对旧标题而给出的。这已经被修复以提高清晰度。

C和C++中的一种常见结构是链式赋值,例如:

    int j, k;
    j = k = 1;

第二个 = 先执行,表达式 k=1 的副作用是将 k 设置为 1,而表达式本身的值是 1。
然而,在 C++ 中合法但不在 C 中的一种构造是以下形式,适用于所有基本类型:
    int j, k=2;
    (j=k) = 1;

在这里,表达式j=k的副作用是将j设置为2,而表达式本身变为对j的引用,然后将j设置为1。据我所知,这是因为表达式j=k返回一个-constint&,通常是左值。
通常也建议为用户定义的类型使用此约定,如《Effective C++》中的“第10项:使赋值运算符返回(非const)引用*this”,其中我的括号补充说明。该书的这一部分并没有尝试解释为什么引用是非const的,甚至没有在经过之前注意到非const
当然,这确实增加了功能,但语句(j=k) = 1;至少似乎有些尴尬。
如果约定改为内置赋值返回const引用,则自定义类也将使用此约定,并且C中允许的原始链式构造仍将正常工作,而无需任何多余的复制或移动。例如,以下内容可以正确运行:
#include <iostream>
using std::cout;

struct X{
  int k;
  X(int k): k(k){}
  const X& operator=(const X& x){
  // the first const goes against convention
    k = x.k;
    return *this;
  }
};

int main(){
  X x(1), y(2), z(3);
  x = y = z;
  cout << x.k << '\n'; // prints 3
}

优点是所有3种类型(C内置、C++内置和C++自定义类型)都具有一致性,不允许出现像(j=k) = 1这样的习惯用法。

在C和C++之间增加这种习惯用法是否是有意为之?如果是,什么情况下会使用它呢?换句话说,这种扩展功能提供了什么非虚假的好处呢?


你是否满意于因为标准如此而这么做的回答?还是你想知道为什么标准这么规定? - NathanOliver
NathanOliver,我无法解析您的问题;您能重新表述一下吗? - xdavidliu
1
@xdavidliu,你是否接受这样的答案:允许这样做的原因是因为C++标准的某个部分规定了它可以这样做,或者你想要一个回答,解释为什么该标准部分规定必须返回左值引用。 - NathanOliver
1
啊,好的,在这种情况下,我肯定想知道为什么标准会这样规定。由于该功能适用于C++而不是C,我猜这是一个有意识的决定,因此应该有一些情况可以证明它是合理的。 - xdavidliu
你的问题标题有误导性。返回引用的常量性和它是左值还是右值是两个不同的问题。 - kraskevich
显示剩余8条评论
3个回答

6

根据设计,C和C++之间的一个基本区别是,C是一种“左值丢弃”的语言,而C++是一种“左值保留”的语言。

在C++98之前,Bjarne添加了引用(reference)到语言中,以使操作符重载成为可能。而为了让引用有用,在表达式中必须保留左值性。

这种保留左值性的想法直到C++98才被真正形式化。在C++98标准之前的讨论中,注意到了引用需要保留表达式的左值性并予以规范化,这就是C++与C之间做出的一个主要而有意的突破,使其成为一种保留左值的语言。

C++努力保留任何表达式结果的“左值性”,只要这是可能的。它适用于所有内置操作符,也适用于内置赋值运算符。当然,这不是为了启用编写像 (a = b) = c 这样的表达式,因为它们的行为将是未定义的(至少在最初的C++标准下)。但由于C++的这个特性,您可以编写如下代码:

int a, b = 42;
int *p = &(a = b);

它的实用性是另一个问题,但这只是C ++表达式的lvalue保留设计的一个结果。

至于为什么它不是const lvalue……坦白说,我不明白为什么它应该是。与C ++中任何其他lvalue保留内置运算符一样,它仅保留给定的类型。


然而,事实是原始规范在左值丢弃上下文(在C中)是可以的,但在左值保留上下文(在C ++中)没有意义(有缺陷)。这基本上触发了C++11中C++序列模型的完全重新设计。在现代C++中,(a = b) = c是定义良好的。这就是为什么你看到编译器表现得“如预期”的原因。 - AnT stands with Russia
我可能在这里误解了什么,但根据lvalue的定义为“我们可以取其地址的东西”,如果我们在问题末尾的示例中尝试X *p = &(y = z);,代码将无法编译,因为它试图获取返回的const引用的地址,因此(y = z)不是一个lvalue。 - xdavidliu
@xdavidliu:是的,lvalue 是我们可以取地址的东西(不是100%准确,但足够好)。您使用 X 的代码之所以无法编译,仅仅是因为它违反了基本的 const-correctness。您需要 const X *p = &(y = z);。这将完美地编译。错误与 const 引用所谓的“不是 lvalue”无关。(y = z) 一个 lvalue。在您的情况下,它只是恰好是一个带有 const 限定符的 lvalue。 - AnT stands with Russia
1
@Omnifarious 很有用的是条件语句能够返回一个左值,例如(foo ? a : b) = 5;或者func(foo ? a : b),其中func以非const引用作为参数。这并不是新功能,因为你本来就可以写成*(foo ? &a : &b) = 5;,但这样看起来更整洁。 - M.M
我认为C++11的排序更改是为了支持线程(C11遵循相同的模型)。 - M.M
显示剩余11条评论

1
我会回答标题中的问题。
假设它返回了一个右值引用。这样就不可能通过这种方式返回对新分配对象的引用(因为它是左值)。如果无法返回对新分配对象的引用,则需要创建副本。对于重型对象,例如容器,这将非常低效。
考虑一个类似于std::vector的类的示例。
使用当前的返回类型,分配的工作方式如下(我故意没有使用模板和复制-交换惯用语来保持代码尽可能简单):
class vector {
     vector& operator=(const vector& other) {
         // Do some heavy internal copying here.
         // No copy here: I just effectively return this.
         return *this;
     }
};

假设它返回一个右值:
class vector {
     vector operator=(const vector& other) {
          // Do some heavy stuff here to update this. 
          // A copy must happen here again.
          return *this;
      }
};

你可能会考虑返回右值引用,但这也行不通:你不能仅仅移动*this(否则,一连串的赋值a = b = c将运行b),所以需要第二个副本来返回它。在你的帖子中,问题是不同的:返回一个const vector&确实是可能的,而且没有上面展示的任何复杂性,所以对我来说更像是一种约定。注意:问题的标题涉及内置类型,而我的答案涵盖了自定义类。我认为这是关于一致性的问题。如果它对内置和自定义类型有不同的作用,那将是非常令人惊讶的。

好的,但是问题的标题提到的是内置对象,而不是用户定义的对象? - xdavidliu
@xdavidliu 我认为这是关于一致性的问题。我不希望内置类型和自定义类型的行为有所不同。 - kraskevich
就一致性而言,当前的约定导致 C++ 内置类型与 C++ 自定义类型一致,但两者都与 C 不一致。然而,如果约定返回 const 引用,则三者将保持一致,因为它们都不允许像 (j = k) = 3 这样的表达式。 - xdavidliu
@xdavidliu 为什么要与C保持一致?C不是C++的子集。它们是完全不同的语言,彼此没有任何关系。你也不会期望它与Java保持一致,对吧? - kraskevich
2
就内置类型(如int、double、数组、指针等)而言,C的许多语义直接转移到了C++,因为C++最初是在C的基础上构建的,而不是在Java或任何其他语言的基础上构建的。当然,C++是一种非常不同的语言,具有大量新功能,但与C的兼容性在C++设计过程中可以说是一个非零的关注点。任何习惯用法的更改都应该有充分的理由。这里的差异似乎只允许我提供的不太吸引人的习惯用法,这似乎并不构成差异的充分理由,因此我提出了问题。 - xdavidliu
显示剩余2条评论

0

内置运算符不会“返回”任何东西,更不用说“返回引用”了。

表达式主要由两个方面来描述:

  • 它们的类型
  • 它们的值类别。

例如,k + 1 的类型为 int,值类别为“prvalue”,但是 k = 1 的类型为 int,值类别为“lvalue”。lvalue 是指定内存位置的表达式,而由声明 int k; 分配的位置就是由 k = 1 指定的位置。

C 标准只有值类别“lvalue”和“非 lvalue”。在 C 中,k = 1 的类型为 int,类别为“非 lvalue”。


您好像在暗示k = 1应该有类型const int和值类别lvalue。也许可以这样,但这会使语言略有不同。它会禁止混淆的代码,但也可能会禁止有用的代码。对于语言设计者或设计委员会来说,这是一个难以评估的决策,因为他们无法考虑到语言的每种可能使用方式。

他们在不引入可能出现问题的限制方面犯错误。一个相关的例子是隐式生成的赋值运算符是否应该是& ref-qualified?

一个可能的情况是:

void foo(int& x);

int y;
foo(y = 3);

这将设置y3,然后调用foo。在您的建议下,这是不可能的。当然,您可以认为y = 3; foo(y);更清晰,但这是一个很棘手的问题:也许增量运算符不应该允许在较大的表达式中等等。


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