移动语义和函数顺序评估

52

假设我有以下内容:

#include <memory>
struct A { int x; };

class B {
  B(int x, std::unique_ptr<A> a);
};

class C : public B {
  C(std::unique_ptr<A> a) : B(a->x, std::move(a)) {}
};

如果我正确理解了关于"C++函数参数未指定顺序"的规则,那么这段代码是不安全的。如果使用移动构造函数先构造B的构造函数的第二个参数,则a现在包含一个nullptr,并且表达式a->x将触发未定义行为(很可能是段错误)。如果先构造第一个参数,则一切都会按预期工作。

如果这是一个普通的函数调用,我们可以创建一个临时变量:

auto x = a->x
B b{x, std::move(a)};

但是在类的初始化列表中,我们没有自由创建临时变量。

假设我不能改变 B,那么有没有可能实现上述操作?也就是在同一个函数调用表达式中取消引用并移动一个 unique_ptr,而不创建临时变量?

如果您能更改 B 的构造函数,但不能添加新方法(例如 setX(int)),那会有帮助吗?

谢谢


1
如果您可以更改B的构造函数,则无需执行任何操作。只需使用单个参数unique_ptr<A>,并在构造函数的初始化列表中复制a->x即可。 - Praetorian
我不想以这种方式更改B的接口来支持此特定用法。使用a->x初始化x可能不是一件预期的事情,因此不应该需要来自B的特殊情况。这取决于上下文,但对于仅采用unique_ptr的构造函数,将x初始化为某个默认常量可能更自然,而不是a->x。如果我们将B更改为通过右值引用获取unique_ptr,我们可以免费为调用者提供更多灵活性,而不更改接口。我不认为在这里传递unique_ptr参数的原因。 - Matthew Fioravante
没错,在这里通过 rvalue 引用传递没有什么不好的。另一个可能性是保留现有的 B 构造函数,并添加一个只接受 unique_ptr<A> 的重载版本。在这种情况下,暗示着 B 将从 a->x 初始化 x。你选择哪一个取决于类的预期用途。 - Praetorian
4
请看Scott Meyers的帖子,这个问题启发了他:http://scottmeyers.blogspot.com.au/2014/07/should-move-only-types-ever-be-passed.html - Jon
4个回答

47
使用列表初始化构造B。元素保证从左到右进行评估。
C(std::unique_ptr<A> a) : B{a->x, std::move(a)} {}
//                         ^                  ^ - braces

来自§8.5.4/4 [dcl.init.list]

花括号初始化列表中,包括任何因参数包展开(14.5.3)而产生的初始化子句,都按照它们出现的顺序进行求值。也就是说,与给定初始化子句相关联的每个值计算和副作用都在初始化列表中逗号分隔的任何跟随它的初始化子句的所有值计算和副作用之前。


不知道这个规则。这是来自C语言吗?也就是说,在C语言中,结构体初始化是否保持了这种质量? - Ryan Haining
1
在POD结构体的情况下,使用大括号进行初始化是聚合初始化,这一特性一直是C++(从C语言继承而来)的一部分。C++11添加了列表初始化(这也是我在这里使用的),它也包含聚合初始化。我非常确定聚合初始化始终具有从左到右的指定评估顺序,因为structclass成员始终按照定义顺序进行初始化。 - Praetorian
我将研究C99中的指定初始化器,它们应该按照定义的顺序进行评估,但如果标准规定“出现的顺序”,那么我的理解可能是错误的。 - Ryan Haining
@RyanHaining 关于指定初始化程序的好点子,我不知道C99对它们的规定。无论如何,我所说的一切只适用于C++。它可能也适用于C,但我不能确定。 - Praetorian
9
很久以来,gcc 在大括号初始化列表内一直存在 评估顺序违规 的问题;该漏洞于 2014-07-01(trunk)被修复。 - Filip Roséen - refp

33
作为Praetorian的回答的替代方案,您可以使用构造函数委托:
class C : public B {
public:
    C(std::unique_ptr<A> a) :
        C(a->x, std::move(a)) // this move doesn't nullify a.
    {}

private:
    C(int x, std::unique_ptr<A>&& a) :
        B(x, std::move(a)) // this one does, but we already have copied x
    {}
};

1
第一步为什么不能使a无效?这是因为std::move基本上只是一个转换吗? - Chris Drew
1
@ChrisDrew 是的,你所做的只是将它绑定到一个引用。当构造函数参数被构造时,实际的内部移动将在 C 的私有构造函数初始化列表中完成。 - Praetorian

11

Praetorian建议使用列表初始化似乎可以解决问题,但它存在一些问题:

  1. 如果unique_ptr参数放在第一位,我们将无法使用列表初始化
  2. B的客户端很容易无意中忘记使用{}而不是()B接口的设计者给我们带来了这种潜在的错误。

如果我们可以更改B,则构造函数的一个更好的解决方案可能是始终通过rvalue引用传递unique_ptr,而不是按值传递。

struct A { int x; };

class B {
  B(std::unique_ptr<A>&& a, int x) : _x(x), _a(std::move(a)) {}
};

现在我们可以安全地使用std::move()。

B b(std::move(a), a->x);
B b{std::move(a), a->x};

@Deduplicator:没有人传递a.get()(即unique_ptr包含的原始指针)。 - Ben Voigt
1
@Jarod42:这是提问者自己的答案。 - Peter O.
2
如果我们被允许更改 B,我建议采用(务实的)解决方案,即向 B 添加一个仅接受 unique_ptr<A> 的构造函数,该构造函数从 a 内部设置 x - Chris Drew

0
代码不包含未定义的行为。这是一个常见的误解,即std::move()实际上执行移动操作,但实际上并不是这样的。std::move()只是将输入强制转换为r-value引用,这是一种语义上的编译时改变,并没有运行时代码。因此,在下面的语句中:
B(a->x, std::move(a))

'a'的状态并没有被std::move()调用所修改,因此不管求值顺序如何,都不会出现未定义行为。


1
再想一想。即使 move(a) 本身不移动任何东西,B 的第二个参数的初始化确实是从 a 移动的。如果这发生在评估 a->x 之前,那么就存在未定义的行为。 - j6t

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