89

假设我们有一个结构,用于保存 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 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 表达式中构造的临时对象在循环期间存在?
4

3 回答 3

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);改用。命名的返回值优化使得这在性能方面相当优化。

于 2012-05-15T03:50:36.310 回答
25

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

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

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

于 2012-05-15T03:24:48.373 回答
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 移动操作中完成。

于 2012-05-15T13:34:09.237 回答