这是C ++ 11 for循环的已知陷阱吗?


89

让我们想象一下,我们有一个结构可以容纳带有某些成员函数的3个double:

struct Vector {
  double x, y, z;
  // ...
  Vector &negate() {
    x = -x; y = -y; z = -z;
    return *this;
  }
  Vector &normalize() {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  // ...
};

为了简化起见,这有点人为设计,但是我敢肯定您同意类似的代码已经存在。这些方法使您可以方便地进行链接,例如:

Vector v = ...;
v.normalize().negate();

甚至:

Vector v = Vector{1., 2., 3.}.normalize().negate();

现在,如果我们提供了begin()和end()函数,则可以在一种新的for循环中使用Vector,例如在3个坐标x,y和z上循环(您无疑可以构造更多“有用”的示例通过将Vector替换为例如String):

Vector v = ...;
for (double x : v) { ... }

我们甚至可以:

Vector v = ...;
for (double x : v.normalize().negate()) { ... }

并且:

for (double x : Vector{1., 2., 3.}) { ... }

但是,以下(在我看来)被破坏了:

for (double x : Vector{1., 2., 3.}.normalize()) { ... }

虽然这似乎是前两种用法的逻辑组合,但我认为最后一种用法会创建一个悬空的引用,而前两种用法完全可以。

  • 这是正确的并且受到广泛赞赏吗?
  • 上面的哪一部分是“不良”部分,应避免?
  • 是否可以通过更改基于范围的for循环的定义来改进语言,以使在for表达式中构造的临时对象在循环持续时间内存在?

由于某种原因,我记得以前曾问过一个非常类似的问题,但忘记了它的名字。
2012年

我认为这是语言缺陷。临时对象的寿命不会扩展到for循环的整个主体,而仅适用于for循环的设置。不仅范围语法受到影响,经典语法也是如此。我认为init语句中的临时对象的寿命应延长到循环的整个寿命。
edA-qa mort-ora-y

1
@ edA-qamort-ora-y:我倾向于同意这里潜伏着轻微的语言缺陷,但是我认为这是这样的事实,即只要您将临时对象直接绑定到引用,生命周期扩展就会隐式发生。其他情况-这似乎只是暂时解决潜在生命周期问题的半成品,尽管这并不是说显然会有更好的解决方案。构造临时文件时,可能会使用显式的“生命周期扩展”语法,这会使它持续到当前块结束时-您认为呢?
ndkrempel,2012年

@ edA-qamort-ora-y:...相当于将临时对象绑定到引用,但具有以下优点:对于读者来说,“生命周期延长”的发生是内联的(在表达式中) ,而不需要单独的声明),并且不需要您为临时名称命名。
ndkrempel

Answers:


64

这是正确的并且受到广泛赞赏吗?

是的,您对事物的理解是正确的。

上面的哪一部分是“不良”部分,应避免?

不好的部分是对函数返回的临时值使用左值引用,并将其绑定到右值引用。就像这样糟糕:

auto &&t = Vector{1., 2., 3.}.normalize();

临时对象Vector{1., 2., 3.}的生存期无法延长,因为编译器不知道其返回值normalize引用了它。

是否可以通过更改基于范围的for循环的定义来改进语言,以使在for表达式中构造的临时对象在循环持续时间内存在?

那将与C ++的工作方式高度不一致。

是否可以防止人们使用临时表上的链式表达式或各种表达式的惰性计算方法而产生的某些陷阱?是。但这还需要特殊情况的编译器代码,以及为什么它不能与其他表达式构造一起使用时感到困惑。

一种更合理的解决方案是通过某种方式通知编译器,函数的返回值始终是对的引用this,因此,如果将返回值绑定到临时扩展结构,则它将扩展正确的临时结构。不过,这是语言级别的解决方案。

当前(如果编译器支持的话),您可以使其normalize 无法在临时调用:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector &normalize() && = delete;
};

这将导致Vector{1., 2., 3.}.normalize()产生编译错误,同时v.normalize()可以正常工作。显然,您将无法执行以下纠正操作:

Vector t = Vector{1., 2., 3.}.normalize();

但是您也将无法做不正确的事情。

或者,如注释中所建议,您可以使右值引用版本返回一个值而不是引用:

struct Vector {
  double x, y, z;
  // ...
  Vector &normalize() & {
     double s = 1./sqrt(x*x+y*y+z*z);
     x *= s; y *= s; z *= s;
     return *this;
  }
  Vector normalize() && {
     Vector ret = *this;
     ret.normalize();
     return ret;
  }
};

如果Vector该类型具有可移动的实际资源,则可以Vector ret = std::move(*this);改用。命名的返回值优化使其在性能方面达到合理的最佳状态。


1
可能更像“陷阱”的是,新的for循环在语法上隐藏了引用绑定在幕后进行的事实-即,与上面的“同样糟糕”的示例相比,它公然得多。这就是为什么为新的for循环建议额外的生命周期延长规则似乎是合理的。
ndkrempel

1
@ndkrempel:是的,但是如果您要提出一种语言功能来解决此问题(因此至少要等到2017年),我希望它更全面,可以解决所有地方的临时扩展问题。
尼科尔·波拉斯

3
+1。在最后的办法,而不是delete你能提供一个替代操作,返回一个右值:Vector normalize() && { normalize(); return std::move(*this); }(我相信调用normalize的函数内就会分派到左值过载,但有人应该看看:)
大卫·罗德里格斯- dribeas

3
我从来没有见过这种&/ &&方法资格。这是来自C ++ 11还是这是(也许是广泛的)专有编译器扩展。给人以有趣的感觉。
Christian Rau 2012年

1
@ChristianRau:它是C ++ 11的新功能,类似于非静态成员函数的C ++ 03“ const”和“ volatile”限定,在某种意义上,它限定了“ this”。但是g ++ 4.7.0不支持它。
ndkrempel

25

for(double x:Vector {1。,2.,3。}。normalize()){...}

那不是语言的限制,而是您的代码有问题。该表达式Vector{1., 2., 3.}创建一个临时normalize变量,但函数返回一个左值引用。由于该表达式是一个左值,因此编译器会假定该对象仍处于活动状态,但是由于它是对临时对象的引用,因此该对象在对整个表达式求值后会死亡,因此您将获得一个悬空的引用。

现在,如果您更改设计以按值返回新对象,而不是返回对当前对象的引用,那么将没有问题,并且代码将按预期工作。


1
将一个const参考延长对象的生命周期在这种情况下?
大卫·斯通

5
这将破坏normalize()作为现有对象上的变异函数的明确期望的语义。这样的问题。我认为,当临时对象用于迭代的特定目的时具有“延长的使用寿命”,而并非如此,这是一个令人困惑的错误特征。
安迪·罗斯

2
@AndyRoss:为什么?绑定到r值参考(或)的任何临时对象const&的寿命都会延长。
尼科尔·波拉斯

2
@ndkrempel:仍然,这不是对基于范围的for循环的限制,如果绑定到引用,也会出现相同的问题:Vector & r = Vector{1.,2.,3.}.normalize();。您的设计有此限制,这意味着您要么愿意按值返回(这在许多情况下可能是有意义的,在使用rvalue-referencesmove时更是如此),否则您需要在调用:创建一个适当的变量,然后在for循环中使用它。还要注意的是表达Vector v = Vector{1., 2., 3.}.normalize().negate();创造2个物体...
dribeas大卫-罗德里格斯

1
@DavidRodríguez-dribeas:绑定到const-reference的问题是:T const& f(T const&);完全可以。T const& t = f(T());完全可以 然后,在另一个TU中,您发现T const& f(T const& t) { return t; }并哭泣...如果operator+对值进行操作,则更安全;那么编译器可能会优化复制输出(Want Speed?Pass by Values),但这是一个好处。我唯一允许的临时绑定是绑定到r值引用,但是函数为了安全起见应该返回值,并依赖于复制省略/移动语义。
Matthieu M.

4

恕我直言,第二个例子已经存在缺陷。修改运算符返回*this的方式很方便,就像您提到的那样:它允许链接修饰符。它可以用于简单地传递修改的结果,但是这样做很容易出错,因为它很容易被忽略。如果我看到类似的东西

Vector v{1., 2., 3.};
auto foo = somefunction1(v, 17);
auto bar = somefunction2(true, v, 2, foo);
auto baz = somefunction3(bar.quun(v), 93.2, v.qwarv(foo));

我不会自动怀疑这些功能会被修改v为副作用。当然可以,但是会造成混乱。因此,如果我要编写这样的内容,请确保它v保持不变。例如,我将添加免费功能

auto normalized(Vector v) -> Vector {return v.normalize();}
auto negated(Vector v) -> Vector {return v.negate();}

然后写循环

for( double x : negated(normalized(v)) ) { ... }

for( double x : normalized(Vector{1., 2., 3}) ) { ... }

这是IMO更好的可读性,并且更安全。当然,它需要一个额外的副本,但是对于堆分配的数据,这很可能可以通过廉价的C ++ 11移动操作来完成。


谢谢。和往常一样,有很多选择。您的建议可能不可行的一种情况是,例如,Vector是1000个双精度数组(未分配堆)。在效率,易用性和使用安全性之间进行权衡。
ndkrempel

2
是的,但是无论如何,在堆栈上具有尺寸大于等于≈100的结构很少有用。
左转
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.