更多 C++ 惯用法/移动构造函数
在 C++03 中将一个对象持有的资源的所有权转移到另一个对象。注意:在 C++11 中,移动构造函数是使用内置的右值引用功能实现的。
- Colvin-Gibbons 技巧
- 源和汇技巧
C++ 中的一些对象表现出所谓的移动语义。例如,std::auto_ptr
。在下面的代码中,auto_ptr b
在创建对象 a
后不再有用。
std::auto_ptr <int> b (new int (10));
std::auto_ptr <int> a (b);
auto_ptr
的复制构造函数修改了它的参数,因此它不接受 const
引用作为参数。它在上面的代码中没有问题,因为对象 b
不是 const
对象。但它在涉及临时对象时会产生问题。
当一个函数按值返回一个对象,并且该返回的对象用作函数的参数(例如,构造另一个相同类的对象)时,编译器会创建一个返回对象的临时对象。这些临时对象的生命周期很短,一旦语句执行完毕,临时对象的析构函数就会被调用。因此,临时对象在其非常短的生命周期内拥有其资源。问题是,临时对象非常类似于 const
对象(修改临时对象几乎没有意义)。因此,它们不能绑定到非 const
引用,因此不能用于调用接受非 const
引用的构造函数。移动构造函数可用于此类情况。
namespace detail {
template <class T>
struct proxy
{
T *resource_;
};
} // detail
template <class T>
class MovableResource
{
private:
T * resource_;
public:
explicit MovableResource (T * r = 0) : resource_(r) { }
~MovableResource() throw() { delete resource_; } // Assuming std::auto_ptr like behavior.
MovableResource (MovableResource &m) throw () // The "Move constructor" (note non-const parameter)
: resource_ (m.resource_)
{
m.resource_ = 0; // Note that resource in the parameter is moved into *this.
}
MovableResource (detail::proxy<T> p) throw () // The proxy move constructor
: resource_(p.resource_)
{
// Just copying resource pointer is sufficient. No need to NULL it like in the move constructor.
}
MovableResource & operator = (MovableResource &m) throw () // Move-assignment operator (note non-const parameter)
{
// copy and swap idiom. Must release the original resource in the destructor.
MovableResource temp (m); // Resources will be moved here.
temp.swap (*this);
return *this;
}
MovableResource & operator = (detail::proxy<T> p) throw ()
{
// copy and swap idiom. Must release the original resource in the destructor.
MovableResource temp (p);
temp.swap(*this);
return *this;
}
void swap (MovableResource &m) throw ()
{
std::swap (this->resource_, m.resource_);
}
operator detail::proxy<T> () throw () // A helper conversion function. Note that it is non-const
{
detail::proxy<T> p;
p.resource_ = this->resource_;
this->resource_ = 0; // Resource moved to the temporary proxy object.
return p;
}
};
移动构造函数/赋值惯用法在接下来的代码片段中扮演着重要角色。函数 func
按值返回对象,即返回一个临时对象。虽然 MovableResource
没有任何复制构造函数,但 main
中的局部变量 a
的构造成功,同时将所有权从临时对象移走。
MovableResource<int> func()
{
MovableResource<int> m(new int());
return m;
}
int main()
{
MovableResource<int> a(func()); // Assuming this call is not return value optimized (RVO'ed).
}
该惯用法结合了 C++ 的三个有趣且标准的特性。
- 复制构造函数不必按
const
引用接受其参数。C++ 标准在 12.8.2 节中提供了复制构造函数的定义。引用--类 X 的非模板构造函数是复制构造函数,如果它的第一个参数是X&
、const X&
、volatile X&
或const volatile X&
类型,并且要么没有其他参数,要么所有其他参数都有默认参数。--结束引用。 - 编译器会自动识别通过
detail::proxy<T>
对象进行的一系列转换。 - 非
const
函数可以在临时对象上调用。例如,转换函数operator detail::proxy<T> ()
不是const
。此成员转换运算符用于修改临时对象。
编译器会寻找一个复制构造函数来初始化对象 a
。有一个复制构造函数已定义,但它按非 const
引用接受其参数。非 const
引用不会绑定到函数 func
返回的临时对象。因此,编译器会寻找其他选项。它发现提供了一个接受 detail::proxy<T>
对象的构造函数。因此它尝试识别一个将 MovableResource
对象转换为 detail::proxy<T>
的转换运算符。事实上,也提供了这样的转换运算符(operator detail::proxy<T>()
)。还要注意,转换运算符不是 const
。这不是问题,因为 C++ 允许在临时对象上调用非 const
函数。调用此转换函数后,局部 MovableResource
对象 (m) 会失去其资源所有权。只有 proxy<T>
对象在很短的时间内知道指向 T
的指针。随后,转换构造函数(接受 detail::proxy<T>
作为参数的构造函数)成功地获得了所有权(main
中的 a
对象)。
让我们深入了解一下临时对象是如何创建和使用的。事实上,上面的步骤不是执行一次,而是以完全相同的方式执行两次。首先是创建一个临时 MovableResource
对象,然后是创建 main
中的最终对象 a
。当从临时 MovableResource
对象创建 a
时,C++ 的第二个例外规则开始发挥作用。转换函数 (operator detail::proxy<T>()
) 会在临时 MovableResource
对象上调用。注意,它不是 const
。与真正的 const
对象不同,非 const
成员函数可以在临时对象上调用。C++ ISO/IEC 14882:1998 标准的 3.10.10 节明确提到了这个例外。有关此例外的更多信息,请参见这里。转换运算符恰好是一个非 const
成员函数。因此,临时 MovableResource
对象也会像上面描述的那样失去所有权。当编译器找出正确的转换函数序列时,也会创建和销毁几个临时 proxy<T>
对象。编译器可能会使用返回值优化 (RVO) 来消除某些临时对象。要观察所有临时对象,请对 gcc 编译器使用 --no-elide-constructors 选项。
这种移动构造函数惯用法的实现对于在函数内外转移资源(例如,堆分配的内存)非常有用。接受 MovableResource
按值作为参数的函数充当汇函数,因为所有权被转移到调用堆栈中更深层的函数,而按值返回 MovableResource
的函数充当源,因为所有权被移动到外部范围。因此,名称为源和汇惯用法。
安全移动构造函数
虽然源和汇惯用法在函数调用的边界处非常有用,但上述实现有一些不希望有的副作用。特别是,看起来像复制赋值和复制初始化的简单表达式根本不会进行复制。例如,考虑以下代码
MovableResource mr1(new int(100));
MovableResource mr2;
mr2 = mr1;
MovableResource
对象 mr2
看起来是从 mr1
复制赋值的。但是,它会悄无声息地从 mr1
窃取底层资源,使其像默认初始化一样被遗留下来。mr1
对象不再拥有堆分配的整数。这种行为让大多数程序员感到惊讶。此外,假设常规复制语义编写的通用代码很可能会产生非常不希望有的后果。
上面提到的语义通常称为移动语义。它是一个强大的优化。但是,这种优化以上述形式实现的部分问题在于,对于看起来像复制但实际上执行了移动的赋值和复制初始化表达式,缺乏编译期间的错误信息。具体来说,隐式地从另一个命名 MovableResource
(例如,mr1
)复制一个 MovableResource
是不安全的,因此应该以错误的形式表示。
请注意,从临时对象中窃取资源是完全可以接受的,因为它们的持续时间不长。这种优化是为了在右值(临时对象)上使用。安全移动构造函数惯用法是在没有语言级支持(C++11 中的右值引用)的情况下实现这种区别的一种方法。
安全移动构造函数惯用法通过声明私有构造函数和赋值运算符函数来防止从命名对象中隐式移动语义。
private:
MovableResource (MovableResource &m) throw ();
MovableResource & operator = (MovableResource &m) throw ();
这里需要注意的关键是参数类型是非 const
的。它们只绑定到命名对象,而不是临时对象。仅此一项小小的改变就足以禁用从命名对象的隐式移动,但允许从临时对象的移动。该惯用法还提供了一种方法,如果需要,可以从命名对象进行显式移动。
template <class T>
MovableResource<T> move(MovableResource<T> & mr) throw() // Convert explicitly to a non-const reference to rvalue
{
return MovableResource<T>(detail::proxy<T>(mr));
}
MovableResource<int> source()
{
MovableResource<int> local(new int(999));
return move(local);
}
void sink(MovableResource<int> mr)
{
// Do something with mr. mr is deleted automatically at the end.
}
int main(void)
{
MovableResource<int> mr(source()); // OK
MovableResource<int> mr2;
mr2 = mr; // Compiler error
mr2 = move(mr); // OK
sink(mr2); // Compiler error
sink(move(mr2)); // OK
}
上面的 move
函数也是非常惯用的。它必须接受参数作为对象的非 const 引用,以便它仅绑定到左值。它通过转换为 detail::proxy
将非 const MovableResource
左值(命名对象)转换为右值(临时对象)。在 move
函数内部需要进行两次显式转换以避免 某些语言怪癖。理想情况下,move
函数应该与 MovableResource
类定义在相同的命名空间中,这样就可以使用参数依赖查找 (ADL) 查找该函数。
函数 source
返回一个局部 MovableResource
对象。因为它是一个命名对象,所以在返回之前必须使用 move
将其转换为右值。在 main
中,首先 MovableResource
直接从 func
返回的临时对象初始化。简单地赋值给另一个 MovableResource
会导致编译器错误,但显式移动可以。类似地,隐式复制到 sink
函数不起作用。程序员必须明确表达将对象移动到 sink
函数的意愿。
最后,重要的是关键函数(从 detail::proxy
构建以及到 detail::proxy
构建)必须是非抛出函数,以保证至少基本的异常保证。在此期间不应该抛出任何异常,否则会导致资源泄漏。
C++11 提供了 右值引用,它支持语言级的移动语义,消除了对移动构造函数模式的需要。
历史上的替代方案
一个老旧的(但较差的)替代方案是使用一个 mutable
成员来跟踪所有权,从而绕过编译器的 const 正确性检查。以下代码展示了如何以另一种方式实现 MovableResource
。
template<class T>
class MovableResource
{
mutable bool owner;
T* px;
public:
explicit MovableResource(T* p=0)
: owner(p), px(p) {}
MovableResource(const MovableResource& r)
: owner(r.owner), px(r.release()) {}
MovableResource & operator = (const MovableResource &r)
{
if ((void*)&r != (void*)this)
{
if (owner)
delete px;
owner = r.owner;
px = r.release();
}
return *this;
}
~MovableResource() { if (owner) delete px; }
T& operator*() const { return *px; }
T* operator->() const { return px; }
T* get() const { return px; }
T* release() const { owner = false; return px; } // mutable 'ownership' changed here.
};
然而,这种技术有几个缺点。
- 复制构造函数和复制赋值运算符不会进行逻辑上的 **复制**。相反,它们将所有权从右侧对象转移到
*this
。MovableResource
的第二个实现的接口中没有反映这一点。第一个MovableResource
类通过非 const 引用接受MovableResource
对象,这会阻止在预期复制的上下文中使用该对象。 - Const auto_ptr 模式是 C++03 中防止所有权转移的方式,在
MovableResource
类的第二个实现中根本不可能使用。这种模式依赖于const
auto_ptr 对象不能绑定到移动构造函数和移动赋值运算符的非 const 引用参数这一事实。将参数设为const
引用会违背目的。 boolean
标志 'owner' 会增加结构的大小。对于像std::auto_ptr
这样的类来说,大小的增加是相当大的(实际上是两倍),而这些类本身只包含一个指针。大小翻倍是由于编译器强制执行的数据对齐。
已知用途
[edit | edit source]- Boost.Move 库
- std::auto_ptr 使用移动构造函数的“不安全”版本。
- Howard Hinnant 的 unique_ptr C++03 模拟 使用安全的移动构造函数模式。