对拷贝控制成员使用= default
我们可以通过将拷贝控制成员定义为= default
,显示地要求编译器生成它们的合成版本:
Code:
class Sales_data {
public:
// copy control; use defaults
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
// other members as before
};
Sales_data& Sales_data::operator=(const Sales_data&) = default;
当我们在类体内的成员声明中指定= default
时,编译器生成的合成函数是隐式内联的(就像在类体中定义的任何其他成员函数一样)。如果我们不希望合成函数是内联函数,我们可以在该函数的定义上指定= default
,就像在重载=
运算符的定义中那样。
注:我们只能对具有合成版本的成员函数使用= default
(即默认构造函数或拷贝控制成员)。
使用= delete来阻止拷贝类对象
在新标准下,我们可以通过将拷贝构造函数和赋值运算符定义为已删除函数来阻止复制。已删除的函数是已声明的函数,但不能以任何其他方式使用。我们使用= delete
跟随想要删除的函数的参数列表来将函数定义为已删除:
Code:
struct NoCopy {
NoCopy() = default; // use the synthesized default constructor
NoCopy(const NoCopy&) = delete; // no copy
NoCopy &operator=(const NoCopy&) = delete; // no assignment
~NoCopy() = default; // use the synthesized destructor
// other members
}
= delete
关键字即告诉编译器又告诉代码阅读者,我们故意没有定义这些成员。
与= default
不同,= delete
必须出现在已删除函数的第一个声明中。从逻辑上讲,这种差异源自这些声明的含义。默认成员只影响编译器生成的代码;因此在编译器生成代码之前不需要= default
。另一方面,编译器需要知道一个函数被删除,以禁止试图使用它的操作。
与= default
不同,我们可以在任何函数上指定= delete
(我们可以在默认构造函数或编译器可以合成的拷贝控件成员上使用= default
)。虽然删除函数的主要用途是抑制拷贝控制成员,但是当我们想要引导函数匹配过程时,删除函数有时也很有用。
移动而不是复制类对象
我们可以使用新标准库引入的两个工具来避免复制字符串。首先,包括字符串在内的几个库类定义了所谓的“移动构造函数”。字符串移动构造函数如何工作的详细信息与其他任何有关实现的详细信息都没有公开。但是,我们知道移动构造函数通常通过将资源从给定对象“移动”到正在构造的对象来操作。我们也知道库保证“移动”字符串保持有效,可破坏的状态。对于字符串,我们可以想象每个字符串都有一个指向char
数组的指针。据推测,字符串移动构造函数复制指针而不是为字符本身分配空间和复制字符。
我们将使用的第二个工具是名为move
的库函数,它在头文件<utility>
中定义(关于utility
更多的信息可参考header <utility>)。目前,关于move
有两点需要了解。首先,当reallocate
在新内存中构造字符串时,它必须调用move
来表示它想要使用字符串移动构造函数。如果省略了调用move
来移动字符串,则将使用拷贝构造函数。其次,我们通常不提供move
的使用说明。当我们使用move
时,我们调用std::move
,而不是move
。关于move
的更多的信息可参考std::move。
右值引用
为了支持move
操作,新标准引入了一种新的引用,即右值应用。右值引用是必须绑定到右值的引用。通过使用&&
而不是&
获得右值引用。正如我们将看到的,右值引用具有重要的属性,它们可能只绑定到即将被销毁的对象上。因此,我们可以自由地将资源从一个右值引用“移动”到另一个对象。
左值和右值是表达式的属性。有些表达式产生或需要左值;另一些表达式产生或需要右值。一般来说,左值表达式指的是对象的标识,而右值表达式指的是对象的值。
与任何引用一样,右值引用只是对象的另一个名称。我们知道,当我们需要将它们与右值引用区分开时,我们将其称为左值引用,我们不能将常规引用绑定到需要转换的表达式,文本或返回右值的表达式。右值引用具有相反的绑定属性:我们可以将右值引用绑定到这些类型的表达式,但是我们不能直接将右值引用绑定到左值:
Code:
int i = 42;
int &r = i; // ok: r refers to i
int &&rr = i; // error: cannot bind an rvalue reference to an lvalue
int &r2 = i * 42; // error: i * 42 is an rvalue
const int &r3 = i * 42; // ok: we can bind a reference to const to an rvalue
int &&rr2 = i * 42; // ok: bind rr2 to the result of the multiplication
返回左值引用的函数以及赋值,下标,解引用和前缀增量/减量运算符都是返回左值的表达式的示例。我们可以将左值引用绑定到任何这些表达式的结果。
返回非引用类型的函数,以及算术,关系,按位和后缀增量/减量运算符,都产生右值。我们不能将左值引用绑定到这些表达式,但我们可以绑定对const
的左值引用或对这些表达式的右值引用。
库函数move
虽然我们不能直接将右值引用绑定到左值,但我们可以显式地将左值转换为其对应的右值引用类型。我们还可以通过调用名为move
的新标准库函数来获取绑定到左值的右值引用,该函数在utility
头文件中定义。move
函数的定义如下:
Code:
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type&&>(t);
}
我们可以以如下方式使用move
函数;
Code:
int &&rr3 = std::move(rr1); // ok
调用move
告诉编译器我们有一个左值,我们要将它视为右值。必须认识到move
承诺,我们不打算再次使用rr1
,除非对它赋值或销毁它。在调用move
之后,我们无法对被操作对象的值做出任何假设。
注:我们可以销毁一个被move
操作的对象,也可以为它分配一个新值,但是我们不能使用它的值。
注:使用move
的代码应该使用std::move
,而不是move
。这样做可以避免潜在的名称冲突。
移动构造和移动赋值
我们可以在自己的类中定义移动构造和移动赋值函数,这种函数与拷贝构造函数类型,但它们从给定对象中“窃取资源”而不是复制资源。
与拷贝构造函数一样,移动构造函数具有一个初始参数,该参数是对类类型的引用。与拷贝构造函数不同,移动构造函数中的引用参数是右值引用。与拷贝构造函数一样,任何其他参数都必须具有默认参数。
除了移动资源之外,移动构造函数还必须确保移动的对象处于一种合法状态,以便无影响地销毁该对象。特别是,一旦移动其资源,原始对象就不能再指向那些被移动的资源,而新创建的对象负责这些被移动的资源。
作为一个例子,我们将定义StrVec
移动构造函数来移动而不是将元素从一个StrVec
复制到另一个:
Code:
/***********************
class StrVec{
private:
std::string *elements; // pointer to the first element in the array
std::string *first_free; // pointer to the first free element in the array
std::string *cap; // pointer to one past the end of the array
};
***********************/
StrVec::StrVec(StrVec &&s) noexcept // move won't throw any exceptions
// member initializers take over the resources in s
: elements(s.elements), first_free(s.first_free), cap(s.cap)
{
// leave s in a state in which it is safe to run the destructor
s.elements = s.first_free = s.cap = nullptr;
}
与复制构造函数不同,移动构造函数不分配任何新内存;它接管给定StrVec
中的内存。从其参数接管内存后,构造函数体将给定对象中的指针设置为nullptr
。 移动对象后,该对象继续存在。最终,移动的对象将被销毁,这意味该对象将运行析构函数。StrVec
析构函数在first_free
上调用deallocate
。如果我们忽略了改变s.first_free
,那么销毁给定对象将删除我们刚刚移动的资源。
移动构造函数通常应该是noexcept
因为移动操作通过“窃取”资源来执行,所以它通常不会自己分配任何资源。 因此,移动操作通常不会引发任何异常。当我们编写一个不能抛出异常的移动操作时,我们应该告知库这个事实。正如我们所看到的,除非库知道移动构造函数不会抛出异常,否则它将做额外的工作,以满足移动类类型的对象可能抛出异常的可能性。
通知库的一种方法是在构造函数上指定noexcept
。noexcept
是我们承诺函数不会抛出任何异常的一种方式。我们在函数的参数列表之后指定noexcept
。在构造函数中,noexcept
出现在参数列表和以:
开始的成员初始化列表之间:
Code:
class StrVec {
public:
StrVec(StrVec&&) noexcept; // move constructor
// other members as before
};
StrVec::StrVec(StrVec &&s) noexcept : // member initializers
{ /* constructor body */ }
如果函数的定义出现在类之外,我们必须在类头中的声明和定义上都指定noexcept
。
理解为什么需要noexcept
可以帮助加深我们对库如何与我们编写的类型的对象交互的理解。我们需要指出一个移动操作不会抛出异常,因为有两个相互关联的事实:第一,尽管移动操作通常不抛出异常,但允许它们这样做。第二,库容器提供了在发生异常时所做操作的保证。例如,vector
保证如果在我们调用push_back
时发生异常,那么向量本身将保持不变。
现在让我们考虑一下push_back
内部会发生什么。与相应的StrVec
操作一样,vector
上的push_back
可能需要重新分配向量。当重新分配向量时,它会将元素从其旧空间移动到新内存。
正如我们之前所见,移动一个对象通常会更改被移动的对象的值。如果重新分配使用移动构造函数,并且该构造函数在移动一些但不是所有元素后抛出异常,则会出现问题。旧空间中的已经移动的元素将被更改,新空间中未构造的元素将不存在。在这种情况下,vector
将无法满足其保持向量不变的要求。
另一方面,如果vector
使用复制构造函数并发生异常,则可以轻松满足此要求。在这种情况下,当元素在新内存中构造时,旧元素保持不变。如果发生异常,vector
可以释放它已经分配的但无法成功构造的空间并返回。原始的vector
元素仍然存在。
为了避免这个潜在的问题,vector
在重新分配期间必须使用拷贝构造函数而不是移动构造函数,除非它知道元素类型的移动构造函数不能抛出异常。如果我们希望移动我们类型的对象而不是在vector
重新分配等情况下复制,我们必须明确告诉库我们的移动构造函数是安全的。我们通过标记移动构造函数(和移动赋值运算符)noexcept
来实现。
移动迭代器
我们在重新分配成员时可以使用for
循环来调用构造函数以将元素从旧内存复制到新内存。作为编写该循环的替代方法,如果我们可以调用uninitialized_copy
来构造新分配的空间,那将会更容易。但是uninitialized_copy
只做复制工作。没有类似的库函数可以将对象“移动”到未构造的内存中。
相反,新标准库定义了一个移动迭代器适配器。移动迭代器通过更改迭代器的解引用运算符的行为来调整其给定的迭代器。通常,迭代器解引用运算符返回对该元素的左值引用。与其他迭代器不同,移动迭代器的取消引用运算符产生右值引用。
我们通过调用库make_move_iterator
函数将普通迭代器转换为移动迭代器。此函数接收迭代器并返回移动迭代器。
返回的移动迭代器的所有操作都和原始迭代器一样。因为这些迭代器支持常规迭代器操作,所以我们可以将一对移动迭代器传递给算法。特别是,我们可以将移动迭代器传递给uninitialized_copy
:
Code:
void StrVec::reallocate()
{
// allocate space for twice as many elements as the current size
auto newcapacity = size() ? 2 * size() : 1;
auto first = alloc.allocate(newcapacity);
// move the elements
auto last = uninitialized_copy(make_move_iterator(begin()),
make_move_iterator(end()),
first);
free(); // free the old space
elements = first; // update the pointers
first_free = last;
cap = elements + newcapacity;
}
uninitialized_copy
调用输入序列中每个元素的构造函数,以将该元素“复制”到目标中。该算法使用迭代器解引用运算符从输入序列中获取元素。因为我们传递了移动迭代器,所以解引用运算符产生一个右值引用,这意味着构造将使用移动构造函数来构造元素。
值得注意的是,标准库不保证哪些算法可以与移动迭代器一起使用,哪些算法不能。因为移动对象可以消除源对象,所以只有当我们确信算法在分配给该元素或将该元素传递给用户定义的函数后才访问该元素时,才应将移动迭代器传递给算法。
因为移动的对象具有不确定的状态,所以在对象上调用std::move
是一种危险的操作。当我们调用move
时,我们必须绝对确定不能有其他用户移动对象。
在类代码中明智地使用move
可以提供显着的性能优势。随便在普通用户代码中使用(与类实现代码相对),移动对象更有可能导致神秘且难以发现的错误,而不是应用程序性能的任何改进。
在类实现代码(例如移动构造函数或移动赋值运算符)之外,只有当我们确定需要执行移动并且保证移动是安全的时,才使用std::move
。
引用限定成员函数
我们以与定义const
成员函数相同的方式指示this
的左右值属性。我们在参数列表后面放置一个引用限定符:
Code:
class Foo {
public:
Foo &operator=(const Foo&) &; // may assign only to modifiable lvalues
// other members of Foo
};
Foo &Foo::operator=(const Foo &rhs) &
{
// do whatever is needed to assign rhs to this object
return *this;
}
引用限定符可以是&
或&&
,表示这可以分别指向右值或左值。与const
限定符一样,引用限定符只能出现在(非static
)成员函数上,并且必须出现在函数的声明和定义中。
我们可以只在左值上运行由&
限定的函数,也可以只在右值上运行由&&
限定的函数:
Code:
Foo &retFoo(); // returns a reference; a call to retFoo is an lvalue
Foo retVal(); // returns by value; a call to retVal is an rvalue
Foo i, j; // i and j are lvalues
i = j; // ok: i is an lvalue
retFoo() = j; // ok: retFoo() returns an lvalue
retVal() = j; // error: retVal() returns an rvalue
i = retVal(); // ok: we can pass an rvalue as the right-hand operand to assignment
函数可以是const
和引用限定的。在这种情况下,引用限定符必须遵循const
限定符:
Code:
class Foo {
public:
Foo someMem() & const; // error: const qualifier must come first
Foo anotherMem() const &; // ok: const qualifier comes first
};
参考文献
[1] Lippman S B , Josée Lajoie, Moo B E . C++ Primer (5th Edition)[J]. 2013.