C++ Primer 笔记 第十三章
当一个类被定义的时候,有几种针对于该类型的操作需要被指定:
我们通过如下类型的构造函数来决定这些操作如何进行:
定义以上这些操作的过程,被称为拷贝控制(Copy Control)。这些操作通常由编译器隐式的实现。但在某些类中,编译器的实现是无法满足我们的要求的;这些情况下需要显式的定义如上的操作。
拷贝构造函数(copy constructor)具有如下的特点:
class Foo {
public:
Foo(); // default constructor
Foo(const Foo&); // copy constructor
// ...
};
为了同时支持常量 / 非常量应用,绝大部分情况下,第一个 parameter 的类型是 const classType&
。由于拷贝构造函数经常用于不同的情况,因此不应该是 explicit
类型。
拷贝构造函数之所以接收 const classType& 类型作为参数,是为了避免陷入逻辑上的死循环。如果拷贝构造函数接收 const classType 作为参数,那么就会出现先有鸡还是先有鸡蛋的问题:拷贝构造函数需要类对象的拷贝进行初始化;但类对象反过来又需要拷贝构造函数进行初始化。
编译器会在拷贝构造函数没有被显式定义的情况下提供一个合成拷贝构造函数(synthesized copy constructor)。合成拷贝构造函数使用 memberwise copies 的策略对类对象进行初始化,也就是:
以 Sales_data 类为例,其拷贝构造函数实际上等同于:
class Sales_data {
public:
// other members and constructors as before
// declaration equivalent to the synthesized copy constructor
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
// equivalent to the copy constructor that would be synthesized for Sales_data
Sales_data::Sales_data(const Sales_data &orig):
bookNo(orig.bookNo), // uses the string copy constructor
units_sold(orig.units_sold), // copies orig.units_sold
revenue(orig.revenue) // copies orig.revenue
{ } // empty body
拷贝初始化与直接初始化的区别
=
拷贝右边算子的内容对对象进行初始化。该过程会有潜在的类型转换。
拷贝初始化存在两种方式
Copy-assignment 和 Move-assignment。在存在移动构造函数的情况下,拷贝初始化会使用移动构造函数取代拷贝构造函数。
拷贝初始化应用的场景
=
进行初始化
直接初始化与拷贝初始化是否合法是根据构造函数是否是 explicit
来决定的。下面的例子中,vector 中单参数的构造函数是 explicit
的,因此该构造函数不能被用于带有隐式转换的过程。比如 f(10)
,将 int 类型的参数传递给 vector<int> 的 parameter 存在隐式转换,因此不合法。
vector<int> v1(10); // ok: direct initialization
vector<int> v2 = 10; // error: constructor that takes a size is explicit
void f(vector<int>); // f's parameter is copy initialized
f(10); // error: can't use an explicit constructor to copy an argument
f(vector<int>(10)); // ok: directly construct a temporary vector from an int
当然也存在一种情况,编译器会优先选择对对象进行直接初始化,而不是使用拷贝/移动构造函数。比如:
string null_book = "9-999-99999-9"; // copy initialization
结果被编译器转换为:
string null_book("9-999-99999-9"); // compiler omits the copy constructor
不过,即便是编译器绕过了拷贝构造函数,我们也需要保证拷贝构造函数是存在并可访问的。
拷贝赋值运算符(copy-assignment operator)定义了类的拷贝方式。其核心是一个名叫 operator= 的,可被重载的函数。该函数获取两个参数:this
和 右边的 operand ,作为该函数的与运算对象。
需要注意的是:
class Foo {
public:
Foo& operator=(const Foo&); // assignment operator
// ...
};
为了与默认的赋值运算保持一致,拷贝赋值运算符通常被定义为:接收一个 const classtype& 的参数,并返回当前类(左算子)的引用。
合成拷贝赋值运算符的功能与合成拷贝构造函数类似,也是使用 memberwise coping 的策略来进行赋值;并返回左算子的引用。还是以 Sales_data 为例:
// equivalent to the synthesized copy-assignment operator
Sales_data&
Sales_data::operator=(const Sales_data &rhs)
{
bookNo = rhs.bookNo; // calls the string::operator=
units_sold = rhs.units_sold; // uses the built-in int assignment
revenue = rhs.revenue; // uses the built-in double assignment
return *this; // return a reference to this object
}
析构函数(Destructor)与构造函数的操作正好相反:
析构函数没有参数,也不可以被重载。一个类只能有一个析构函数。其写法如下:
~DSTR();
析构函数的作用分两部分诠释:
析构的部分是隐式的进行的。由于 bulit-in type 无需析构,因此该部分主要用于销毁类型为 class type 的成员。销毁的过程通过调用该成员自身的析构函数进行。
析构部分不会对普通指针类型成员指向的对象进行释放。该部分必须手动释放(通常放到函数体中处理)。
delete
释放动态分配的对象时(delete 其对应指针)下面是一些详细的例子:
{ // new scope
// p and p2 point to dynamically allocated objects
Sales_data *p = new Sales_data; // p is a built-in pointer
auto p2 = make_shared<Sales_data>(); // p2 is a shared_ptr
Sales_data item(*p); // copy constructor copies *p into item
vector<Sales_data> vec; // local object
vec.push_back(*p2); // copies the object to which p2 points
delete p; // destructor called on the object pointed to by p
} // exit local scope; destructor called on item, p2, and vec
// destroying p2 decrements its use count; if the count goes to 0, the object is freed
// destroying vec destroys the elements in vec
上述的所有的对象都包含了一个动态分配的 string 成员,但只有 p
这个直接进行动态内存申请的对象需要手动释放。这是因为:
item
,vec
在销毁的过程中调用了自身的析构函数,该析构函数负责销毁了其所有的成员。p2
是 shared_ptr,在引用计数归零时会自动 delete。指针和引用对象离开作用域时不会调用析构函数。
当我们没有自定义析构函数的时候,编译器会提供一个合成析构函数(synthesized destructor)。合成析构函数的函数体为空:
class Sales_data {
public:
// no work to do other than destroying the members, which happens automatically
~Sales_data() { }
// other members as before
};
由于函数体为空,合成析构函数只会执行析构的部分,也就是调用类成员自身的析构函数对其销毁并释放。
函数体中的内容与析构的部分是分开的,属于额外的操作。析构的部分通过 member-wise 的方式来执行成员的销毁与释放。
三规则(The Rule of Three)指控制拷贝行为的三种操作:拷贝构造函数,拷贝赋值运算符和析构函数(Big three)应该以绑定的形式出现,也就是:
该规则在 C++ 11 中被延伸到了 “五”,因为移动构造函数与移动赋值运算符也加入到了 copy-control 成员中。
通常情况下,类对析构函数的要求更明显。如果析构函数是必要的,那么拷贝构造函数与拷贝赋值运算符也是必要的。
比如我们之前的习题例子 HasPtr,由于该类在构造函数中有分配动态内存,因此需要在析构函数中自定义释放。来看看下面的实现,以及不提供配套的其他两样会造成什么效果:
class HasPtr {
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) { }
~HasPtr() { delete ps; }
// WRONG: HasPtr needs a copy constructor and copy-assignment operator
// other members as before
};
如果使用合成版本的构造函数
HasPtr f(HasPtr hp) // HasPtr passed by value, so it is copied
{
HasPtr ret = hp; // copies the given HasPtr
// process ret
return ret; // ret and hp are destroyed
}
很明显,合成构造函数并不能处理拷贝初始化的需求。由于 ps
成员是指针,此时 ret
与 hp
实际上指向的是同一个对象。当 f() 返回, ret
会被首先销毁;此时自定义的析构函数会使用 delete ret
。由于 hp
也是局部变量,因此也会被销毁;此时自定义的析构函数会再次被调用,并使用 delete hp
。由于 ret
与 hp
指向同一份内存,因此上述的程序实际上对同一片内存进行了双重释放,结果是未定义的。f
内部的 ret
,指向 Hasptr
对象的指针 p
也能作为参数传递给 f()
。p
指向的资源会在 f
调用完毕时被释放掉。而接下来是可以利用 p
进行构造新的对象的;但此时无论是 p
还是 q
,都是悬挂指针。
HasPtr p("some values");
f(p); // when f completes, the memory to which p.ps points is freed
HasPtr q(p); // now both p and q point to invalid memory!
一个例子:假设我们有个产品类;其他的成员都相同,唯独序列号不同。可知:
可见拷贝构造函数与拷贝赋值运算符是一定会配套出现的。
需要拷贝构造函数与拷贝赋值运算符的地方不一定需要析构函数。
C++ 11 中允许我们使用 = default
来显式告诉编译器生成对应函数的合成版本:
// copy control; use defaults
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
需要注意的是,在类中使用 = default
的函数都隐含了 inline
的意思。如果不希望合成的函数是 inline
,可以对该函数进行类外定义,并在类外定义的时候使用 = default
。
= default 只能应用于有合成版本的成员函数。
大多数类都(显式或隐式)的定义了拷贝三大件。但有些情况下也需要禁止其使用,比如某些无法被拷贝的类( iostream
类)。由于编译器会自动的为我们合成这三个函数,我们需要显式的禁止这些函数的使用。
C++11 中提供了一种函数的种类:被删除的函数(deleted function)。该类函数只能被声明,而不能用于其他地方。我们通过 = delete
的形式来声明 deleted 函数。
truct 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
};
=deleted
必须出现在第一次声明中,因为编译器需要第一时间知道哪些函数需要被禁止使用;而 =default
可以出现在任意位置的声明中,因为其只影响编译器生成的结果。= deleted
可以应用于任意函数。=deleted
不仅适用于 copy-control members, 也适用于引导函数的匹配。而 =default
只能应用于可以被合成的默认构造函数和拷贝控制成员。析构函数不能是被删除函数。如果析构函数被删除,那么
需要注意的是,动态分配对象的创建不受被删除的析构函数的限制,但是释放该对象受限制:
struct NoDtor {
NoDtor() = default; // use the synthesized default constructor
~NoDtor() = delete; // we can't destroy objects of type NoDtor
};
NoDtor nd; // error: NoDtor destructor is deleted
NoDtor *p = new NoDtor(); // ok: but we can't delete p
delete p; // error: NoDtor destructor is deleted
在如下情形中,合成拷贝控制成员会被定义为被删除的函数:
const
/ 引用成员,则类自身的拷贝赋值运算符是被删除的
那么该类的默认构造函数是被删除的。
这样做是基于逻辑上的原因:
总的来说,如果某个类的类成员不能被默认初始化,拷贝,赋值,销毁,则其对应类的拷贝控制成员则为被删除的。
在 = delete
提出之前,C++ 通过将拷贝构造函数与拷贝赋值运算符定义为私有成员防止拷贝:
class PrivateCopy {
// no access specifier; following members are private by default; see § 7.2 (p. 268)
// copy control is private and so is inaccessible to ordinary user code
PrivateCopy(const PrivateCopy&);
PrivateCopy &operator=(const PrivateCopy&);
// other members
public:
PrivateCopy() = default; // use the synthesized default constructor
~PrivateCopy(); // users can define objects of this type but not copy them
};
这个结构中:
这种实现方式中,声明但不定义某个成员是合法的。这样的声明会有以下的效果:
推荐使用新标准 = delete 来完成该类需求。
某些获取了动态资源的类(通常带指针)需要通过析构函数来进行资源释放。根据三/五规则,我们需要同时定义其他的拷贝控制成员。根据拷贝成员的定义方式,类会呈现出两种行为方式:
string
类某些不能拷贝的类,比如 uniuqe_ptr 和 stream 类,都不属于这两种类。
当一个类的行为与值相同时,其拷贝控制成员需要保证每个类对象都有一份独立的资源。比如之前提到 HasPtr
:
<html>
<img src=“/_mediaprogramming/cpp/cpp_primer/valuelike_class_3_.svg” width=“550”> </div> </html>
实现如下:
<code cpp>
class HasPtr {
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) { }
each HasPtr has its own copy of the string to which ps points
HasPtr(const HasPtr &p): ps(new std::string(*p.ps)), i(p.i) { } HasPtr& operator=(const HasPtr &); ~HasPtr() { delete ps; }
private:
std::string *ps; int i;
}; </code>
值类中的拷贝赋值运算符的功能是将 =
右边对象中的内容拷贝到左边对象中。与拷贝构造函数不同的是,左边的对象中很可能已经分配了资源,并存在着数据。因此,拷贝赋值运算符的运行逻辑应该如下:
鉴于这样的运行顺序,我们可以写出一个实现版本:
HasPtr&
HasPtr::operator=(const HasPtr &rhs)
{
delete ps; // frees the string to which this object points
// if rhs and *this are the same object, we're copying from deleted memory!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}
但这样的实现有一个很严重的问题。如果我们使用该运算符进行对象的自我赋值的话:
delete ps
释放了 ps
指向的内存rhs
同样也指向这该片内存,因此 rhs
此时称为了悬挂指针 ps = new string(*(rhs.ps))
,会使 ps
指向一片无效的内存
因此,我们会得到一个未定义的结果。
解决这个问题的办法是,先分配空间进行拷贝,再释放掉原有的空间:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // copy the underlying string
delete ps; // free the old memory
ps = newp; // copy data from rhs into this object
i = rhs.i;
return *this; // return this object
}
上述代码可以很好的处理自我赋值的问题。我们通过创建新的空间进行拷贝,避免了释放原有空间导致指针失效的问题。与值类不同,指针类的拷贝意味着共享;因此,拷贝成员的操作对象应该是指向资源的指针,而不是资源本身。当前最好的办法是使用 shared_ptr 代为管理资源,拷贝工作只要基于 shared_ptr 实现即可。如果需要直接管理,那么保险的解决方案是仿照 shared_ptr 的原理来进行资源管理,也就是说,使用引用计数。
根据 shared_ptr 的原理,我们应该对类中的成员做如下设计:
1
;也就是说,一个对象创建时,自身计数为 1
1
1
,当计数为 0
的时候对资源进行释放1
的计数;左边的对象被覆盖,因此需要减 1
的计数。0
时,意味着该对象没有别的使用者,此时释放该对象。由于引用计数需要在所有的拷贝中共享,因此引用计数的存储需要放置到动态内存中。引用计数的分配与创建对象同时进行,而拷贝的时候只需要拷贝引用计数的指针即可实现当前计数的共享。
还是以之前的 HasPtr
作为例子。首先是整个类的实现:
class HasPtr {
public:
// constructor allocates a new string and a new counter, which it sets to 1
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// copy constructor copies all three data members and increments the counter
HasPtr(const HasPtr &p):
ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // member to keep track of how many objects share *ps
};
这里 use
作为计数成员加入了类中,而拷贝构造函数则添加了计数加一的功能。0
,也就是对象没有用户的情况下才能进行资源的释放:
HasPtr::~HasPtr()
{
if (--*use == 0) { // if the reference count goes to 0
delete ps; // delete the string
delete use; // and the counter
}
}
接下来是拷贝赋值运算符。根据之前的设计,拷贝赋值运算符需要同时对右边的对象进行计数自增,并且对左边的对象进行计数自减,并在左边对象计数为 0
时释放该资源:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // increment the use count of the right-hand operand
if (--*use == 0) { // then decrement this object's counter
delete ps; // if no other users
delete use; // free this object's allocated members
}
ps = rhs.ps; // copy data from rhs into this object
i = rhs.i;
use = rhs.use;
return *this; // return this object
}
如果类会使用到包含排序的算法,那么定义一个交换函数 swap() 是非常重要的(比如快速排序就会用到交换的手法)。
标准库中已经定义了 std::swap() 函数。如果类中没有定义自己的 swap(),则排序算法会默认使用标准库的版本。
默认情况下,swap() 可以通过值类的拷贝方式实现:
HasPtr temp = v1; // make a temporary copy of the value of v1
v1 = v2; // assign the value of v2 to v1
v2 = temp; // assign the saved value of v1 to v2
不过,这样做开销很大。上例中, 单单 v1
中的 string 被拷贝了两次:从 v1
到 temp
,再从 temp
到 v2
。实际上,通过交换指针来代替交换值,会极大的提高交换的效率:
string *temp = v1.ps; // make a temporary copy of the pointer in v1.ps
v1.ps = v2.ps; // assign the pointer in v2.ps to v1.ps
v2.ps = temp; // assign the saved pointer in v1.ps to v2.ps
以 HasPtr 类为例子,自定义的 swap() 的实现如下:
class HasPtr {
friend void swap(HasPtr&, HasPtr&);
// other members as in § 13.2.1 (p. 511)
};
inline
void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
swap(lhs.ps, rhs.ps); // swap the pointers, not the string data
swap(lhs.i, rhs.i); // swap the int members
}
有两点要注意:
inline
上面的例子中,我们在外部的 swap() 中调用了另外的 swap() 实现了交换。但需要注意的是,外部 swap() 函数中调用的 swap() 实际上是 std::swap()
。这是由于我们交换的成员是 built-in type, 而类中并没有处理对应类型的成员 swap()。
如果我们在类中实现了针对类成员类型的 swap(),则应该避免显式的使用 std::swap
。这是因为 std::swap
通常不会针对特殊的类型进行优化(比如使用 std::swap
交换 string,交换的过程是直接拷贝 string)。
下面是一个比较好的写法:
void swap(Foo &lhs, Foo &rhs)
{
using std::swap;
swap(lhs.h, rhs.h); // uses the HasPtr version of swap
// swap other members of type Foo
}
这种写法下,如果类中存在着更匹配的 swap()
,则该版本会被优先选择。比如我们在 HasPtr
类中定义了针对 string h
的指针交换版本,上面的 swap() 调用会优先选择我们定义的版本。而对于其他的类型,如果类中没有对应的 swap(),由于存在 std::swap
的 using
声明,这些类型的交换会调用 std::swap
。
上面的例子中,using std::swap 并不会隐藏类成员 swap() 的声明。
赋值运算符有另外一个版本:该版本结合 swap 来定义赋值。这种技术被称为 Copy and Swap,也就是将左边算子的内容与右边算子内容的拷贝进行交换:
// note rhs is passed by value, which means the HasPtr copy constructor
// copies the string in the right-hand operand into rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
// swap the contents of the left-hand operand with the local variable rhs
swap(*this, rhs); // rhs now points to the memory this object had used
return *this; // rhs is destroyed, which deletes the pointer in rhs
}
这个版本的赋值运算符的实现逻辑如下:
rhs
,由于 rhs
的 string 成员 ps
是存储在动态内存里的,因此使用值传递相当于为 rhs.ps
分配了新的动态内存。*this
)和 rhs
进行交换。交换完毕之后,rhs.ps
指向了 *this
中的 string,而 this.ps
指向了 rhs.ps
的 string。rhs
是传递到赋值运算函数中的临时对象,因此会调用析构函数进行内存释放。由于此时 rhs.ps
指向了之前左边对象的 string
,因此通过 rhs.ps
释放操作实际上就是释放了之前左边的对象。<html>
<img src=“/_media/programming/cpp/cpp_primer/cp-swap-assign_2_.svg” width=“500”>
</html>
这种实现实际上也能处理自我赋值:对象的自我赋值实际上实在与自己的拷贝做交换。
本实例需要实现邮件投递的功能。这种投递的关系是多对多的,比如一个文件夹可以有多封不同的邮件,而一封邮件可以投递到多个不同的文件夹中。下面是一个简单的两封邮件与两个文件夹多对多对应的关系图:
<html>
<img src=“/_media/programming/cpp/cpp_primer/message_n_folder.svg” width=“400”>
</html>
清楚结构以后,我们还需要对整个系统设计一些功能:
上述的需求可以简单的分析一下:
Message 类用于实现邮件。大致的实现如下:
class Message {
friend class Folder;
public:
// folders is implicitly initialized to the empty set
explicit Message(const std::string &str = ""):
contents(str) { }
// copy control to manage pointers to this Message
Message(const Message&); // copy constructor
Message& operator=(const Message&); // copy assignment
~Message(); // destructor
// add/remove this Message from the specified Folder's set of messages
void save(Folder&);
void remove(Folder&);
private:
std::string contents; // actual message text
std::set<Folder*> folders; // Folders that have this Message
// utility functions used by copy constructor, assignment, and destructor
// add this Message to the Folders that point to the parameter
void add_to_Folders(const Message&);
// remove this Message from every Folder in folders
void remove_from_Folders();
};
Message 中包含了两个成员:
contents
:用于存储邮件的内容folders
: 存储了一系列指向 Folder 对象的指针的 set
。该 set 用于表示当前邮件处于哪些文件夹内。
save
与 remove
实现的是邮件的存储于删除功能。按照之前的分析,存储于删除的过程实际上就是邮件与文件夹建立相互的,一一对应的过程。实现上,则是双方互相添加 / 删除对方的指针:
<html>
<img src=“/_media/programming/cpp/cpp_primer/save_remove.svg” width=“300”>
</html>
假设文件夹类 Folder
中使用类型为 set<Message*>
的 set msgs
来存储对应邮件的信息,那么:
void Message::save(Folder &f)
{
folders.insert(&f); // add the given Folder to our list of Folders
f.msgs.insert(this); // add this Message to f's set of Messages
}
void Message::remove(Folder &f)
{
folders.erase(&f); // take the given Folder out of our list of Folders
f.msg.earse(this); // remove this Message to f's set of Messages
}
上述的过程可以通过定义私有成员 addMsg(), remMsg(),addFdr(),remFdr() 进行简化:
/* In Message class*/
void addFdr(Folder *f) { folder.insert(f); } // equivalent to folder.insert(this);
void remFdr(Folder *f) { folder.insert(f); } // equivalent to msg.earse(this);
/* In Folder class */
void addMsg(Message *m) { msgs.insert(m); } // equivalent to msgs.insert(this);
void remMsg(Message *m) { msgs.insert(m); } // equivalent to msg.earse(this);
虽然 save() 与 remove() 成员需要同时建立双边的关系,但从字面上是对邮件的操作,因此定义于 Message() 类中即可。至于实现指针添加删除功能的私有成员,则定义于对应指针 set 的所在类中。比如 Message 类中的 set 管理的是 Folder*
,则对 Folder*
操作的 addFdr()
成员应该在 Folder 类的内部实现。
根据之前的分析,所有的拷贝控制成员都需要批量的的更新某个添加或者移除的类对象。比如我拷贝了一份邮件,那么被拷贝的邮件之前在哪些文件夹,该邮件也应该在;文件夹同理:拷贝后的文件夹应该拥有拷贝前的文件夹一样的文件。因此,设计批量建立关系的成员函数可以让关系更新工作更加方便。
以 Message 类为例。之间我们为单个邮件关联到目标文件夹的功能设计了私有函数 addMsg() 和 remMsg()。这对函数在 Folder
类中实现,会接收一个 Message*
,并存储到 Folder 的指针 set 中。我们可以通过以下步骤来实现遍历当前所有文件夹,并依次添加该指针到对应的 Folder 对象中:
<html>
<img src=“/_media/programming/cpp/cpp_primer/add_rm_folder.svg” width=“400”>
</html>
实现如下:
// add this Message to Folders that point to m
void Message::add_to_Folders(const Message &m)
{
for (auto f : m.folders) // for each Folder that holds m
f->addMsg(this); // add a pointer to this Message to that Folder
}
同理,批量删除邮件关系的成员函数也可以以相同的方式实现:
void Message::remove_from_Folders()
{
for (auto f : folders) // for each pointer in folders
f->remMsg(this); // remove this Message from that Folder
folders.clear(); // no Folder points to this Message
}
需要注意上述最后一行,对 folders
进行了清空操作。该操作逻辑上是很清晰的,一个即将被销毁的邮件不但需要自身从文件夹中移除,也需要清理自身与对应文件夹的关系。但该操作会对拷贝赋值运算符的实现产生很大的影响,之后我们会看见。
通过之前的分析,Message 类的拷贝构造函数只需要做两件事:
folders
包含的文件夹中这里我们使用之前定义的 add_to_Folders 函数进行批量的邮件注册:
Message::Message(const Message &m):
contents(m.contents), folders(m.folders)
{
add_to_Folders(m); // add this Message to the Folders that point to m
}
析构函数 ~Message() 除了对邮件对象的内容进行销毁,还需要从当前文件夹中挨个把该邮件的信息清理掉。这里我们使用之前定义的 remove_from_Folders() 进行清理:
Message::~Message()
{
remove_from_Folders();
}
由于 Message 中,类的成员都是自动存储对象,因此无需像在堆上的对象一样申请一块临时空间进行先行存放。但拷贝赋值的过程中,我们需要对当前的对象进行类似的关系更新。按照 copy-assignment 的 idiom,应该先将旧的邮件从各个文件夹中删除,然后使用新的邮件内容对其进行覆盖,最后在注册新的邮件到各个文件夹:
Message& Message::operator=(const Message &rhs)
{
// handle self-assignment by removing pointers before inserting them
remove_from_Folders(); // update existing Folders
contents = rhs.contents; // copy message contents from rhs
folders = rhs.folders; // copy Folder pointers from rhs
add_to_Folders(rhs); // add this Message to those Folders
return *this;
}
需要注意的是,书上声称这样的顺序解决了自我赋值的问题,但实际上,由于 folder.clear()
的存在,自我赋值依然会出现严重的问题。设想一下如果 this
与 rhs
是同一个地址,则 remove_from_Folders();
上来就会将 rhs
中的文件夹信息全部清空;这会导致我们失去之后注册该邮件到新文件夹的所有信息。Message& Message::operator=(const Message &rhs)
{
// handle self-assignment
if(this == &rhs)
return *this;
remove_from_Folders(); // update existing Folders
contents = rhs.contents; // copy message contents from rhs
folders = rhs.folders; // copy Folder pointers from rhs
add_to_Folders(rhs); // add this Message to those Folders
return *this;
}
根据之前的分析,swap() 函数需要做四件事:
也就是说,交换邮件,不但交换了邮件的内容,也交换了邮件所处的文件夹位置:
void swap(Message &lhs, Message &rhs)
{
using std::swap; // not strictly needed in this case, but good habit
// remove pointers to each Message from their (original) respective Folders
for (auto f: lhs.folders)
f->remMsg(&lhs);
for (auto f: rhs.folders)
f->remMsg(&rhs);
// swap the contents and Folder pointer sets
swap(lhs.folders, rhs.folders); // uses swap(set&, set&)
swap(lhs.contents, rhs.contents); // swap(string&, string&)
// add pointers to each Message to their (new) respective Folders
for (auto f: lhs.folders)
f->addMsg(&lhs);
for (auto f: rhs.folders)
f->addMsg(&rhs);
}
除此之外,swap() 需要同时成为 Message 类与 Folder 类的友元函数,因为其实现同时使用了两个类的成员函数。
Folder 类实际上与 Message 类基本上相同,唯一的不同有两点:
本节的实例的内容是实现一个元素为 string 的 vector: StrVec 类。该类与 vector 的功能基本一致。大致的设计如下:
element
,指向 StrVec 的头部位置first_free
, 指向 StrVec 当前元素的 off-the-end 位置cap
,指向 StrVec 当前已分配内存的 off-the-end 位置alloc
,存储 StrVec 的数据。该成员为 static
类成员。* 私有功能函数:
alloc_n_copy
:分配空间,并将指定范围的元素拷贝到当前空间。reallocate
:当 StrVec 对象空间不足时,重新分配空间的策略。chk_n_alloc
:检查当前空间是否足够,不够则调用 reallocate()
函数获取更多空间。除此之外还会一些常用的功能函数。
一个简单的 StrVec 类的定义如下:
// simplified implementation of the memory allocation strategy for a vector-like class
class StrVec {
public:
StrVec(): // the allocator member is default initialized
elements(nullptr), first_free(nullptr), cap(nullptr) { }
StrVec(const StrVec&); // copy constructor
StrVec &operator=(const StrVec&); // copy assignment
~StrVec(); // destructor
void push_back(const std::string&); // copy the element
size_t size() const { return first_free - elements; }
size_t capacity() const { return cap - elements; }
std::string *begin() const { return elements; }
std::string *end() const { return first_free; }
// ...
private:
static std::allocator<std::string> alloc; // allocates the elements
void chk_n_alloc() // used by functions that add elements to a StrVec
{ if (size() == capacity()) reallocate(); }
// utilities used by the copy constructor, assignment operator, and destructor
std::pair<std::string*, std::string*> alloc_n_copy
(const std::string*, const std::string*);
void free(); // destroy the elements and free the space
void reallocate(); // get more space and copy the existing elements
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
};
// alloc must be defined in the StrVec implmentation file
allocator<string> StrVec::alloc;
alloc_n_copy() 函数的目标是完成两个功能:分配一块新的空间,并将指定的内容拷贝过来。设计上,该函数会接收表示被拷贝内容的头尾指针,并返回新创建内容的头尾指针。该函数的逻辑如下:
由于我们需要同时返回两个指针,因此需要使用 std::pair
类型作为返回类型。整个函数的实现代码如下:
pair<string*, string*>
StrVec::alloc_n_copy(const string *b, const string *e)
{
// allocate space to hold as many elements as are in the range
auto data = alloc.allocate(e - b);
// initialize and return a pair constructed from data and
// the value returned by uninitialized_copy
return {data, uninitialized_copy(b, e, data)};
}
两个要点:
e - b
确定了被拷贝内容需要的空间长度uninitialized_copy
会返回被拷贝元素的 off-the-end 指针
拷贝构造函数的逻辑与 alloc_n_copy()
的实现是完全一致的;因此只需要调用该函数,并使用其返回值更新指针内容即可:
StrVec::StrVec(const StrVec &s)
{
// call alloc_n_copy to allocate exactly as many elements as in s
auto newdata = alloc_n_copy(s.begin(), s.end());
elements = newdata.first;
first_free = cap = newdata.second;
}
free() 函数用于清理 alloc
分配的空间。根据 allocator 的清理顺序,free() 函数的逻辑如下:
alloc
分配的空间(capacity,不是使用的空间)进行释放。需要注意的是,销毁的顺序与构造的顺序是相反的;因此需要从 off-the-end 的位置开始销毁,到头元素的位置结束。实现代码:
void StrVec::free()
{
// may not pass deallocate a 0 pointer; if elements is 0, there's no work to do
if (elements) {
// destroy the old elements in reverse order
for (auto p = first_free; p != elements; /* empty */)
alloc.destroy(--p);
alloc.deallocate(elements, cap - elements);
}
}
如果使用算法 for_each()
,可以在不需要考虑顺序的情况下对已有元素进行销毁:
for_each(element, first_free,
[](cosnt string &s)
{ alloc.destroy(&s); };
两个要点:
–p
先往左移动一位,在进行元素的销毁。
析构函数只需要对 alloc
成员进行释放,因此直接调用 free() 函数即可。
StrVec::~StrVec() { free(); }
根据拷贝赋值运算 的 idiom,可知拷贝赋值运算符通过组合使用 alloc_n_copy()
与 free()
即可实现。其中:
alloc_n_copy
负责申请空间并拷贝传递的 StrVec 对象free()
负责释放被赋值的对象实现如下:
StrVec &StrVec::operator=(const StrVec &rhs)
{
if(this == &rhs)
return *this;
// call alloc_n_copy to allocate exactly as many elements as in rhs
auto data = alloc_n_copy(rhs.begin(), rhs.end());
free();
elements = data.first;
first_free = cap = data.second;
return *this;
}
当然,只要分配了新的空间,指针是一定要更新的。同时,自我赋值的检测也不能忘记。
reallocate() 函数的主要的功能之一将旧的 StrVec 对象中的 string 拷贝到新分配的空间中。由于 std::string
是值类,该拷贝的过程是拷贝值方式,并且会产生两份相同且独立的数据。由于此时只有调用者在使用数据,因此存在两份数据是没有必要的;也就是说,此处的拷贝操作是不必要的。
我们可以通过 “移动” 而不是拷贝的方式来优化该过程的性能。C++11 提供了移动构造函数(Moving Constructor)以及其配套的 std::move
函数。与拷贝不同的是,该构造函数会以 “移动” 的方式来处理之前目标拷贝对象与源拷贝对象之间的数据转移问题。这两者被定义于 <utility>
头文件内。
对于 string, 这里的 “移动” 可以想象成使用拷贝 const char* 取代直接拷贝 string 的过程。
需要注意的是:
std::move
的参数生效。没有被 std::move
修饰的表达式,会默认调用拷贝构造函数。std::move
的时候,不需要要提供 using
。调用 move
会直接调用 std::move
。reallocate() 实现了 StrVec 的空间增长功能。其逻辑如下:
0
则设置为 1
,如果不为 0
则按 size()
翻倍,并将翻倍后的值作为新的 capacity。size()
为范围,循环的移动所有的元素<html>
<img src=“/_media/programming/cpp/cpp_primer/reallocate.svg” width=“400”>
</html>
实现如下:
void StrVec::reallocate()
{
// we'll allocate space for twice as many elements as the current size
auto newcapacity = size() ? 2 * size() : 1;
// allocate new memory
auto newdata = alloc.allocate(newcapacity);
// move the data from the old memory to the new
auto dest = newdata; // points to the next free position in the new array
auto elem = elements; // points to the next element in the old array
for (size_t i = 0; i != size(); ++i)
alloc.construct(dest++, std::move(*elem++));
free(); // free the old space once we've moved the elements
// update our data structure to point to the new elements
elements = newdata;
first_free = dest;
cap = elements + newcapacity;
}
由于 reallocate() 实现了 StrVec 的空间增长功能,我们可以将其用于 push_back() 的空间判断中。鉴于 push_back() 是按位进行空间的申请与构造,我们只需要判断当前 size()
与 capacity()
是否相等来决定是否进行空间增长。我们将这一切全部打包到 chk_n_alloc()
中:
void StrVec::chk_n_alloc()
{
if (size() == capacity())
reallocate();
}
保证了足够空间后,我们只需要在 off-the-end 位置进行新元素构造,即可达到 push_back() 的效果:
void StrVec::push_back(const string& s)
{
chk_n_alloc(); // ensure that there is room for another element
// construct a copy of s in the element to which first_free points
alloc.construct(first_free++, s);
}
这里需要先构造再位移,因此需要使用后置版本的自增。不然 off-the-end 位置的元素将不会被构造。
之前在实现 reallocate() 函数时,我们已经使用了 “移动” 代替了拷贝操作。总的来说,在某个对象即将失效(在拷贝后就会失效)时,可以通过使用 “移动” 的方式取代拷贝,来获得更好的性能。
有两种典型的情况可以使用移动:
C++ 11 之前,并没有提供直接移动的功能。传递很多较大的对象,或是需要分配内存的对象时,都会进行非常多的,但又不必要的拷贝。
为了支持移动操作,新标准提供了一种被称为右值引用(Rvalue Reference)的新类型引用。右值引用指与 Rvalue 绑定的引用,使用 &&
(Double Ampersands)进行定义。总的来说,右值引用只能与即将销毁的对象绑定,这也满足哟东操作的使用前提。
为了说明右值引用的功能,这里需要再次回顾一下左值(Lvalue)与右值(Rvalue)。总的来说:
左值引用只能绑定与自身类型相同的对象,不能绑定需要转换类型的对象,literal 和返回右值的表达式。右值引用可以绑定上述的这些表达式,但不能直接绑定左值:
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 &T 是种例外。该引用是左值引用,但可以绑定能转换到 T 类型的右值。一般来说,该类引用绑定的是一个临时对象,临时对象中装载了对应的右值。
从生存周期上来说,左值是长期持续的,而右值是临时(短暂)的。由于右值引用只能与右值(临时对象)绑定,因此右值引用指向的是一个将要被销毁的,不能再被重复利用的对象。利用这种性质,我们可以使用接管(或者说 steal)的方式来获取右值引用指向的资源,而不是拷贝。也就是说,右值引用可以使用在任何具有移动意义的数据传递中使用。由于该过程使用接管代替了拷贝,因此效率会大大提升。
变量可以被视作表达式,因此也具有左右值的属性。总的来说,变量是左值:
int &&rr1 = 42; // ok: literals are rvalues
int &&rr2 = rr1; // error: the expression rr1 is an lvalue!
可以注意到,上例中,即便是变量被定义为了右值引用,而其自身仍然是一个左值。因此,该变量不能绑定到另外一个右值引用上。
std::move
函数实际上将指定的参数(通常是左值)转换为了对应的右值引用:
int &&rr1 = 42; // ok: literals are rvalues
int &&rr3 = std::move(rr1); // ok
注意第一行,rr1
是右值引用;但由于 rr1
是变量,因此是左值。为了继续将其作为右值引用使用,我们使用 std::move
对其进行转换。但这里需要注意,std::move
的一层隐含意思是不再使用 rr1
;因此,在调用 std::move
之后,被移动的对象中的值将不再可知。
std::move()
只是一个辅助函数。为了获得右值引用带来的性能提升,我们需要定义移动构造函数(Move Constructor)与移动赋值运算符(Move-Assignment Operator)。新标准下,这两者与 Big Three 被统称为 Five Rule,被视作 C++ 11 下的拷贝控制标准。
移动构造函数具有以下特点:
典型的移动构造函数的操作步骤:
nullptr
)一个 StrVec 的移动构造函数定义如下:
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;
}
由于移动操作并不参与分配空间,理论上来说该操作不应该抛出分配异常。但在实际操作中,我们需要“通知”标准库我们的移动构造函数不会抛出异常,从而避免移动构造函数的异常处理带来的额外开销。
通常,我们使用 noexcept
关键字来指定我们的移动构造函数不会抛出异常;需要注意,如果构造函数存在于类外定义, noexcept
不能省略。
StrVec(StrVec&&) noexcept;
StrVec::StrVec(StrVec &&s) noexcept { /* constructor body */ }
为什么需要手动的阻止移动构造函数抛出异常?以 vector 作为例子。如果调用 push_back() 成员时发生异常, vector 的异常管理会将该 vector 对象会回滚到未改变的状态。假设这个过程发生在 reallocate() 的阶段;reallocate() 会新申请一片内存空间,之后会将原有的元素“移动”到该新区域中,并使用新的 vector 对象进行管理。如果此过程中抛出异常,那么:
当然我们可以通过拷贝而不是移动的方式来确保原来的对象的安全性;但如果希望仍然使用移动操作的话,我们必须确保移动操作不会抛出任何异常。因此,这种情况下必须手动的添加 noexcept
关键字来确保这一点。
移动赋值运算符也是接收一个当前类对象的右值引用,返回当前类类型的引用。其逻辑与拷贝赋值运算符类似,也需要首先清空当前对象:
StrVec 的实现:
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
// direct test for self-assignment
if (this != &rhs) {
free(); // free existing elements1
elements = rhs.elements; // take over resources from rhs
first_free = rhs.first_free;
cap = rhs.cap;
// leave rhs in a destructible state
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
如果是自我赋值,则直接返回当前对象。
我们注意到在移动版本的拷贝控制成员中,最后都将被移动对象(右手边)所有的指针设为了 nullptr
。这样做是为了保证被移动的对象可以被析构。该做法是基于逻辑上的原因:被移动的对象从逻辑上来说是即将被销毁的,因此必须确保其处于可以被析构的状态。这种状态隐含着两个事实:
诸如此类的原因,我们需要将被移动的对象空置,保证该对象处于一个 “可以被析构” 的状态;但该对象所管理的资源(值)是不可控的,因此不应该对这些资源做任何假设。以 string 为例,在左边的对象接管了资源后,右边的对象任然可以使用 std::string 的成员函数;但其结果是无法保证的。
编译器在某些情况下也会为我们合成移动成员。但该合成的条件与 Big Three 有些不同:
编译器才会合成移动拷贝控制成员,比如 :
// the compiler will synthesize the move operations for X and hasX
struct X
{
int i; // built-in types can be moved
std::string s; // string defines its own move operations
};
struct hasX
{
X mem; // X has synthesized move operations
};
X x, x2 = std::move(x); // uses the synthesized move constructor
hasX hx, hx2 = std::move(hx); // uses the synthesized move constructor
默认情况下,移动成员不会被隐式的定义为被删除的函数。只有在以下条件下,才会被定义为被删除的移动成员:
= default
修饰了移动成员,但类中存在无法移动的成员(存在例外,见 p508)
// assume Y is a class that defines its own copy constructor but not a move constructor
// Y has a deleted move constructor (rule 2)
struct hasY {
hasY() = default;
hasY(hasY&&) = default;
Y mem; // hasY will have a deleted move constructor (rule 4)
};
hasY hy;
hasY hy2 = std::move(hy); // error: move constructor is deleted
除此之外,定义移动成员也对拷贝成员有影响。如果类中定义了任意一个移动成员(移动构造函数或移动赋值运算符),则合成的拷贝成员则是被删除的。
定义了移动成员的类也应该定义其拷贝成员。
当类中同时存在移动版本与拷贝版本的拷贝控制成员时,编译器会通过函数匹配来决定构造函数的使用。比如下面的例子:
StrVec v1, v2;
v1 = v2; // v2 is an lvalue; copy assignment
StrVec getVec(istream &); // getVec returns an rvalue
v2 = getVec(cin); // getVec(cin) is an rvalue; move assignment
上例中:
v2
是左值,且右值引用无法绑定到左值上,因此选择拷贝构造函数v2 = getVec(cin);
,由于 getVec(cin);
返回一个右值,此处的操作是将右值赋值给一个左值。此时拷贝版本与移动版本都是候选:copy-swap 赋值运算符也使用右值作为参数。如果类中有移动赋值运算符,会导致二义性。该版本通常使用移动 + 拷贝成员替代,后文有介绍。
当类中只有拷贝构造函数,而没有移动构造函数时:
class Foo {
public:
Foo() = default;
Foo(const Foo&); // copy constructor
// other members, but Foo does not define a move constructor
};
Foo x;
Foo y(x); // copy constructor; x is an lvalue
Foo z(std::move(x)); // copy constructor, because there is no move constructor
上例中完成了 Foo&&
到 const Foo&
的转换。
使用拷贝构造函数取代移动构造函数进行构造是安全的;因为拷贝构造的过程不会改变被拷贝对象的状态;这与移动构造的要求(被拷贝对象是有效的)是一致的。
如果类中定义了拷贝交换赋值运算符,同时定义移动构造函数也可以很好的提升该运算符的性能,比如下例:
class HasPtr {
public:
// added move constructor
HasPtr(HasPtr &&p) noexcept : ps(p.ps), i(p.i) {p.ps = 0;}
// assinment operator is both the move- and copy-assignment operator
HasPtr& operator=(HasPtr rhs)
{ swap(*this, rhs); return *this; }
// other members as in § 13.2.1 (p. 511)
};
上例中,我们为 HasPtr
类添加了一个移动构造成员。来看看下面的使用:
hp = hp2; // hp2 is an lvalue; copy constructor used to copy hp2
hp = std::move(hp2); // move constructor moves hp2
可见,当参数类型是右值时,参数的传递会使用移动构造函数进行构造;当参数是左值时,则会调用拷贝构造函数进行构造。因此该 copy-swap 运算符实际上可以同时接收左值与右值参数,即左值拷贝构造,右值移动构造。
本例中的移动构造函数通过 p.ps = 0; 来确保被移动对象处于可以被析构的状态。
推荐使用拷贝赋值运算符 + 移动赋值运算符的重载组合来代替 copy-swap。copy-swap 赋值运算符在将参数传递至内部 swap() 函数的过程中会不可避免的使用一次拷贝,这使得其效率会大大降低。课后题 13.53 为专门针对该问题的讨论。
新版本下的五个拷贝控制成员应该被视为一个整体来设计。如果某个类定义了拷贝操作,则应该定义所有的五个操作。
Message 类中包含了 string 类型的类容和用于存储文件夹关系的 set,因此实现移动成员可以有效的避免拷贝带来的额外开销。与其拷贝成员一致,移动成员的设计也需要维护当前邮件与文件夹的关系网。我们使用 move_Folders()
私有成员来完成该功能,实现如下:
// move the Folder pointers from m to this Message
void Message::move_Folders(Message *m)
{
folders = std::move(m->folders); // uses set move assignment
for (auto f : folders)
{ // for each Folder
f->remMsg(m); // remove the old Message from the Folder
f->addMsg(this); // add this Message to that Folder
}
m->folders.clear(); // ensure that destroying m is harmless
}
该函数的逻辑为:
几个要点:
move_Folders()
属于移动成员功能的一部分。但由于该函数需要通过更新关系 set 中的元素来维护关系网,我们需要允许该过程中存在异常的抛出。因此,该实现中的移动成员不能被声明为 noexcept
对应的移动构造函数定义如下:
Message::Message(Message &&m): contents(std::move(m.contents))
{
move_Folders(&m); // moves folders and updates the Folder pointers
}
对应的移动赋值运算符定义如下:
Message& Message::operator=(Message &&rhs)
{
if (this != &rhs) { // direct check for self-assignment
remove_from_Folders(); //destroy the left-hand operand
contents = std::move(rhs.contents); // move assignment
move_Folders(&rhs); // reset the Folders to point to this Message
}
return *this;
}
在 StrVec 类中,我们使用 reallocate()
函数实现 vector 的自我增长。其中,我们使用了 allocator
的成员 uninitialized_copy
将旧的 vector 拷贝到新申请的 vector 中。可以发现的是,该过程也可以用移动操作取代;但问题在于,uninitialized_copy
并没有移动的功能。
比起使用 uninitialized_copy
来实现移动功能,我们可以利用算法的迭代器来实现按位的移动。C++11 标准中提供了移动迭代器(Move Iterator)来支持这种操作。对该类型的迭代器的解引用会返回一个右值引用。
实现上,我们使用标准库函数 make_move_iterrator()
来将普通迭代器转化为移动迭代器,再将其作为 uninitialized_copy
的参数即可:
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
会按元素进行构造。而在构造时,由于我们使用了移动迭代器,构造得到的参数是右值引用类型;因此 construct
成员会使用移动构造函数进行元素的构造。std::move 隐含了参数对象即将消亡的意思。确保该对象在移动后不会被用于其他地方是符合逻辑的,也是必要的。
除了拷贝成员之外,成员函数也可以通过重载接收右值引用的版本来获得移动的性能提升。设计的方法与拷贝成员类似:
const T&
T&&
T
为对应的 class type。以 push_back() 为例:
void push_back(const T&); // copy: binds to any kind of T
void push_back(T&&); // move: binds only to modifiable rvalues of type T
该设计中,任何可以修改的右值参数都可以使用移动构造函数来完成 push_back 的操作;任意 non-const rvalue 对于移动版本的 push_back() 都是最佳匹配。const T&&
和 T&
类型参数的的函数版本:
T&&
const T&
类型作为参数简单的实现如下:
class StrVec
{
public:
void push_back(const std::string&); //copy construct element
void push_back(std::string&&); //move construct element
//....
};
void
StrVec::push_back(const std::string &s)
{
chk_n_alloc();
alloc.construct(first_free++, s);
}
void
StrVec::push_back(std::string &&s)
{
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
上述两种重载的实现中,唯一不同在于 s
的传递方式不同。以移动方式传递的 s
使用了 std::move
进行修饰。根据以上实现,当使用右值作为 push_back() 的参数时,会以移动的方式构造元素:
StrVec vec; // empty StrVec
string s = "some string or another";
vec.push_back(s); // calls push_back(const string&)
vec.push_back("done"); // calls push_back(string&&)
早前版本的 C++ 中,成员函数的调用并不会考虑类对象是左值还是右值,比如如下的写法,右值对象既可以调用成员函数,也可以放到等号的左边:
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a');
s1 + s2 = "wow!";
这种用法在 C++11 之前是没有办法禁止的。因此,C++11 中提供了引用限定符 Reference Qualifier,通过强制 this
的指向类型为左值,来避免这样的用法:
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;
}
除此之外,引用限定符也提供了右值的选项,来限定 this
指向的是右值。对右值的修饰时,在参数列表的后面添加 &&
。如果需要添加 const,则 const 需要添加到引用限定符与参数列表之间。需要注意的是,引用限定符修饰的对象需要满足以下要求:
class Foo {
public:
Foo rvalueMem() &&; //may assign only to modifiable rvalues
Foo someMem() & const; // error: const qualifier must come first
Foo anotherMem() const &; // ok: const qualifier comes first
};
根据之前的设定,&
限定符应该只用于限定接受左值参数的函数;而 &&
则应该只应用于右值的函数版本:
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 进行重载;比如下例:
class Foo {
public:
Foo sorted() &&; // may run on modifiable rvalues
Foo sorted() const &; // may run on any kind of Foo
// other members of Foo
private:
vector<int> data;
};
// this object is an rvalue, so we can sort in place
Foo Foo::sorted() &&
{
sort(data.begin(), data.end());
return *this;
}
// this object is either const or it is an lvalue; either way we can't sort in place
Foo Foo::sorted() const &
{
Foo ret(*this); // make a copy
sort(ret.data.begin(), ret.data.end()); // sort the copy
return ret; // return the copy
}
第一个版本的 sorted() 限定为被右值对象调用,因为直接修改右值是安全的,逻辑正确的;而第二个版本的 sorted() 被定义为接收 const T&
类型,根据隐式转换:
这意味着该版本可以接收所有类型的数据。这也很符合逻辑:因为拷贝版本的 sorted() 并不会修改任何原有的数据:
retVal().sorted(); // retVal() is an rvalue, calls Foo::sorted() &&
retFoo().sorted(); // retFoo() is an lvalue, calls Foo::sorted() const &
需要注意的是,如果引用限定符与 const 在某个函数中出现,则所有名称 & 参数列表与该函数相同的合法重载版本都必须提供引用限定符。也就是说,以 const 区分带引用限定符的重载版本,都需要带上引用限定符:
class Foo {
public:
Foo sorted() &&;
Foo sorted() const; // error: must have reference qualifier
// Comp is type alias for the function type (see § 6.7 (p. 249))
// that can be used to compare int values
using Comp = bool(const int&, const int&);
Foo sorted(Comp*); // ok: different parameter list(comparing with Foo sorted() &&)
Foo sorted(Comp*) const; // ok: neither version is reference qualified
};
上例中: