【问题标题】:Is this a known pitfall of C++11 for loops?这是 C++11 for 循环的已知缺陷吗?
【发布时间】:2012-05-22 13:10:06
【问题描述】:

假设我们有一个结构,用于保存 3 个带有一些成员函数的双精度:

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 替换为例如字符串):

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 表达式中构造的临时对象在循环期间存在?

【问题讨论】:

  • 出于某种原因,我记得之前有人问过一个非常相似的问题,但忘记了它叫什么。
  • 我认为这是一种语言缺陷。临时对象的生命周期不会扩展到整个 for 循环体,而只是用于设置 for 循环。不仅范围语法受到影响,经典语法也受到影响。在我看来,init 语句中的临时变量的生命周期应该延长到循环的整个生命周期。
  • @edA-qamort-ora-y:我倾向于同意这里潜藏着一个轻微的语言缺陷,但我认为特别是当你直接绑定一个临时的时,生命周期的延长会隐含地发生参考,但在任何其他情况下都没有 - 这似乎是临时生命周期潜在问题的半生不熟的解决方案,尽管这并不是说更好的解决方案是显而易见的。在构建临时文件时,可能是一个明确的“生命周期扩展”语法,这使得它一直持续到当前块的末尾——你怎么看?
  • @edA-qamort-ora-y: ...这相当于将临时对象绑定到引用,但优点是对读者来说更明确,即“终身延长”正在发生,内联(在表达式中,而不需要单独声明),并且不需要您命名临时。

标签: c++ for-loop c++11 language-lawyer foreach


【解决方案1】:

这是否正确并受到广泛赞赏?

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

以上哪一部分是“坏”的部分,应该避免?

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

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

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

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

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

它会防止人们在临时变量上使用链式表达式或对表达式使用各种惰性求值方法而造成某些问题吗?是的。但它也需要特殊情况的编译器代码,并且会混淆为什么它不能与 other 表达式构造一起使用。

更合理的解决方案是通知编译器函数的返回值始终是对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();

但你也不能做错事。

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

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);。命名的返回值优化使得这在性能方面达到了合理的最优。

【讨论】:

  • 可能使这更像一个“陷阱”的是,新的 for 循环在语法上隐藏了引用绑定在幕后进行的事实 - 即它比你的“就像上面一样糟糕”的例子。这就是为什么建议额外的生命周期延长规则似乎是合理的,只是为了新的 for 循环。
  • @ndkrempel:是的,但是如果你打算提出一个语言特性来解决这个问题(因此至少要等到 2017 年),我希望它更全面,一些可以解决无处不在的临时扩展问题。
  • +1。在最后一种方法中,而不是delete,您可以提供一个返回右值的替代操作:Vector normalize() && { normalize(); return std::move(*this); }(我相信函数内部对normalize 的调用将调度到左值重载,但有人应该检查它: )
  • 我从未见过这种&/&& 限定方法。这是来自 C++11 还是一些(可能是普遍存在的)专有编译器扩展。提供有趣的可能性。
  • @ChristianRau:它是 C++11 的新功能,类似于 C++03 非静态成员函数的“const”和“volatile”限定,因为它限定了“this”一些意义。但是 g++ 4.7.0 不支持它。
【解决方案2】:

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

这不是语言的限制,而是您的代码的问题。表达式Vector{1., 2., 3.} 创建一个临时的,但normalize 函数返回一个lvalue-reference。因为表达式是一个左值,编译器假定对象是活着的,但是因为它是一个临时对象的引用,所以对象在完整的表达式被求值后就死了,所以你只剩下一个悬空参考。

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

【讨论】:

  • 在这种情况下,const 引用会延长对象的生命周期吗?
  • 这会破坏 normalize() 作为现有对象上的变异函数的明显期望语义。因此问题。当用于迭代的特定目的而不是其他用途时,临时具有“延长的生命周期”,我认为这是一个令人困惑的错误特征。
  • @AndyRoss:为什么? 任何临时绑定到 r 值引用(或 const&)都会延长其生命周期。
  • @ndkrempel:尽管如此,不是基于范围的 for 循环的限制,如果您绑定到引用:Vector & r = Vector{1.,2.,3.}.normalize();,也会出现同样的问题。你的设计有这个限制,这意味着你要么愿意按值返回(这在许多情况下可能是有意义的,对于 rvalue-referencesmove 更是如此),否则您需要在调用位置处理问题:创建一个适当的变量,然后在 for 循环中使用它。另请注意,表达式 Vector v = Vector{1., 2., 3.}.normalize().negate(); 创建 两个 对象...
  • @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+ 对值进行操作,则它更安全;那么编译器可能会优化复制出来(想要速度?按值传递),但这是一个奖励。我允许的唯一临时绑定是绑定到 r-values 引用,但是为了安全起见,函数应该返回值并依赖 Copy Elision / Move Semantics。
【解决方案3】:

恕我直言,第二个例子已经有缺陷了。修改运算符返回 *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 个双精度数的数组(未分配堆)。效率、易用性和使用安全性之间的权衡。
  • 是的,但无论如何,在堆栈上拥有大小 > ≈100 的结构很少有用。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2014-11-27
  • 1970-01-01
  • 1970-01-01
  • 2012-05-10
  • 2015-09-08
  • 2020-04-10
  • 1970-01-01
相关资源
最近更新 更多