《C++ Primer》学习笔记(十三):拷贝控制
拷贝、赋值与销毁
拷贝构造函数
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。虽然可以定义一个接受非const
引用的拷贝构造函数,但是一般都是使用const
引用作为参数。拷贝构造函数在几种情况下都会被隐式调用,因此一般不应是explicit
的。
拷贝赋值运算符
class Foo{
public:
Foo& operator=(const Foo&);//赋值运算符
}
赋值运算符通常应该返回一个指向其左侧运算对象的引用。
析构函数
析构函数释放对象使用的资源,并销毁对象的非static
数据成员。
三/五法则
如果一个类需要一个析构函数时,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符,反之亦然。但是并不意味着需要自定义析构函数。
使用=default
可以通过将拷贝控制成员定义为=default
来显式地要求编译器生成合适的版本。当我们在类内用=default
修饰成员的声明时,合成的函数将隐式地声明为内联的(就像其他任何类内声明的成员函数一样)。如果我们不希望合成的成员是内联函数,应该只对成员的类外定义使用=default
。
class Sales_data{
public:
Sales_data() = default;
Sales_data(const Sales_data&) = default;
Sales_data& operator=(const Sales_data &);
~Sales_data() = default;
}
//在类外定义使用=default合成的成员函数就不是默认内联的
Sales_data& Sales_data::operator=(const Sales_data&) = default;
阻止拷贝
对某些类来说,拷贝操作没有合理的意义。例如iostream
类阻止了拷贝,以避免多个对象写入或读取相同的IO缓冲。
可以通过在函数的参数列表后面加上=delete
来指出我们希望将该函数定义为删除的。
struct NoCopy{
NoCopy() = default; //使用合成的默认构造函数
NoCopy(const NoCopy&) = delete; //阻止拷贝
NoCopy &operator=(const NoCopy&) = delete; //阻止赋值
~NoCopy() = default; //使用合成的析构函数
}
与=default
不同,=delete
必须出现在函数第一次声明的时候。
不能删除析构函数。
拷贝控制和资源管理
行为像值的类
为了提供类值的行为,对于类管理的资源,每个对象都应该拥有一份自己的拷贝。
类值版本的HasPtr
如下所示:
class HasPtr{
public:
HasPtr(const std::string &s = std::string()):
ps(new std:string(s)), i(0) { }
//对ps指向的string,每个HasPtr对象都有自己的拷贝
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;
};
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); //拷贝底层string
delete ps; //释放旧内存
ps = newp; //从右侧运算对象拷贝数据到本对象
i = rhs.i;
return *this; //返回本对象
}
定义行为像指针的类
除了使用shared_ptr
类来设计外,还可以设计自己的引用计数,将计数器保存在动态内存中。
class HasPtr{
public:
//构造函数分配新的string和新的计数器,将计数器置为1
HasPtr(const std::string &s = std::string()):
ps(new std:string(s)), i(0), use(new std::size_t(1)) { }
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; //用来记录有多少个对象共享*ps的数据
}
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use;//递增右侧运算对象的引用计数
if(--*use == 0){ //然后递减本对象的引用计数
delete ps; //如果没有其他用户
delete use; //释放本对象分配的成员
}
ps = rhs.ps;
i = rhs.i;
use = rhs.use;
return *this;//返回本对象
}
HasPtr::~HasPtr()
{
if(--*use == 0){//如果引用计数变为0
delete ps; //释放string内存
delete use; //释放计数器内存
}
}
交换操作
可以在自己的类上定义自己版本的swap
,典型实现如下:
class HasPtr{
friend void swap(HasPtr& , HasPtr&);
//其他成员不变
}
inline void swap(HasPtr &lhs, HasPtr &rhs)
{
using std::swap;
//如果存在类型特定的swap版本,其匹配程度会优于std中定义的版本
swap(lsh.ps, rhs.ps); //交换指针,而不是string数据
swap(lhs.i, rhs.i);//交换int成员
}
定义了swap
的类通常用swap
来定义它们的赋值运算符。使用了一种名为拷贝并交换 的技术,将左侧对象与右侧对象的一个副本进行交换。通过在改变左侧对象之前拷贝右侧运算对象保证了自赋值的正确。
//注意rhs是按值传递的,意味着HasPtr的拷贝构造函数
//将右侧运算对象中的`string`拷贝到rhs
HasPtr& HasPtr::operator=(HasPtr rhs)
{
//交换左侧运算对象和局部变量rhs的内容
swap(*this, rhs);//rhs现在指向本对象曾经指向的内存
return *this; //rhs被销毁,从而delete了rhs中的指针
}
对象移动
在某些情况下,对象拷贝后就立刻被销毁了,这时移动而非拷贝对象能够大幅度提高性能。使用移动的另一个原因源于IO类或unique_ptr
这样的类,这些类都包含不能被共享的资源(如指针或IO缓冲)。
标准库容器、string
和shared_ptr
类既支持移动也支持拷贝。IO类和unique_ptr
类可以移动但是不能拷贝。
右值引用
为了支持移动操作,引入了一种新的引用类型–右值引用。通过&&
来获得右值引用。右值引用只能绑定到一个将要销毁的对象。右值引用必须绑定到一个右值上。
int i = 42;
int &r = i;//正确
int &&rr = i; //错误:不能将一个右值引用绑定到一个左值上
int &r2 = i*42; //错误:i*42是一个右值
const int &r3 = i * 42; //正确:可以将一个const的引用绑定到一个右值上
int &&rr2 = i * 42; //正确:右值引用绑定到右值上
虽然不能将右值引用直接绑定到一个左值上,但可以显式地通过move
来将一个左值转换为对应的右值引用,move
定义在头文件utility
中。
int &&rr3 = std::move(i); //ok
调用move
就意味着我们将不再使用它,我们不能对移后源对象的值做任何假设。我们可以销毁一个移后源对象,也可以赋值它新值,但不能使用一个移后源对象的值。
注意:使用到move
的代码应该使用std::move
而不是move
,这样可以避免潜在的命名冲突。
移动构造函数和移动赋值运算符
移动构造函数需要确保移后源对象处于这样一个状态:销毁它是无害的。
StrVec::StrVec(StrVec &&s) noexcept //移动操作不应抛出任何异常
:elements(s.elements), first_free(s.first_free), cap(s.cap)
{
//令s进入这样的状态:对其运行析构函数是安全的
s.elements = s.first_free = s.cap = nullptr;
}
注意:不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept
。
StrVec &StrVec::operator=(StrVec &&rhs) noexcept
{
//直接检测自赋值
if(this != &rhs)
{
free(); //释放已有元素
elements = rhs.elements;//从rhs接管资源
first_free = rhs.first_free;
cpa = rhs.cap;
//令rhs进入这样的状态:对其运行析构函数是安全的
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static
数据成员都可以移动时,编译器才会为它合成移动构造函数或移动赋值运算符。
注意:定义了一个移动构造函数或移动赋值运算符的类必须也定义自己的拷贝操作。否则,这些成员默认地被定义为删除的。
建议::不要随意使用移动操作。通过在类代码中小心地使用move
,可以大幅度提升性能。但是如果随意在普通用户代码(与类实现代码相对)中使用移动操作,很可能导致莫名其妙的难以查找的错误。
右值引用和成员函数
除了构造函数和赋值运算符外,如果一个成员函数同时提供拷贝和移动版本,它也能从中受益。
区分移动和拷贝的重载函数通常有一个版本接受一个const T&
,而另一个版本接受一个T&&
。
练习
- 解释下面的声明为什么是非法的:
Sales_data::Sales_data(Sales_data rhs);
此为一个类的拷贝构造函数,作为函数其非引用类型的参数需要进行拷贝初始化,但拷贝初始化又要调用拷贝构造函数以拷贝实参,但为了拷贝实参又需要调用拷贝构造函数,无限循环。
- 假定
Point
是一个类类型,它有一个public
的拷贝构造函数,指出下面程序片段中哪些地方使用了拷贝构造函数:
Point global;
Point foo_bar(Point arg)
{
Point local = arg, *heap = new Point(global);
*heap = local;
Point pa[4] = {local, *heap};
return *heap;
}
-
foo_bar
函数的参数为非引用类型,会用到拷贝构造函数。
- 函数的返回类型为非引用类型,也需要使用拷贝构造函数。
- 在函数体中
local = arg
,*heap = new Point(global)
,Point pa[4] = {local, *heap}
皆会使用到拷贝构造函数。
-
*heap = local
会用到拷贝赋值运算符。
- 编写标准库
string
的简化版本,命名为String
。你的类应该至少有一个默认构造函数和一个接受C风格字符串指针参数的构造函数。使用allocator
为你的String
类分配所需内存。
#include <iostream>
#include <algorithm>
#include <memory>
using namespace std;
class String{
public:
String(): elements(nullptr), end(nullptr) {};
String(const char *p)
{
auto p1 = const_cast<char *>(p);
while(*p1){
++p1;
}
alloc_n_copy(p, p1);
}
String(const String&);
String& operator=(const String&);
~String(){
if (elements)
{
for_each(elements, end, [this](char &rhs){alloc.destroy(&rhs);});
alloc.deallocate(elements,end-elements);
}
}
private:
static std::allocator<char> alloc; //分配内存的方法
char *elements; //首指针
char *end;
std::pair<char*, char*> alloc_n_copy(const char*a, const char*b)
{
auto p1 = alloc.allocate(b-a);//allocate参数为分配内存的大小,这里分配b-a个char类型所占用的内存大小,返回首指针
auto p2 = uninitialized_copy(a, b, p1);//将a到b之间的元素拷贝至p1,返回的是最后一个构造元素之后的位置
return make_pair(p1, p2); //返回分配空间的首尾指针
}
void range_initializer(const char*c, const char*d) //初始化
{
auto p = alloc_n_copy(c,d);//拷贝并初始化新的string
elements = p.first;
end = p.second;
}
};
- 如果
sorted
定义如下,会发生什么?
Foo Foo::sorted() const &{
Foo ret(*this);
return ret.sorted();
}
sorted
不断调用自身,无限循环,造成堆栈溢出。
- 如果
sorted
定义如下,会发生什么?
Foo Foo::sorted() const & {return Foo(*this).sorted();}
会调用Foo Foo::sorted() &&
。
- 编写新版本的
Foo
类,其sorted
函数中有打印语句,测试这个类,验证前两题的答案
#include <vector>
#include <iostream>
#include <algorithm>
using std::vector;
using std::sort;
class Foo {
public:
Foo sorted()&&;
Foo sorted() const&;
private:
vector<int> data;
};
Foo Foo::sorted() &&
{
sort(data.begin(), data.end());
std::cout << "&&" << std::endl; // debug
return *this;
}
Foo Foo::sorted() const &
{
// Foo ret(*this);
// sort(ret.data.begin(), ret.data.end());
// return ret;
std::cout << "const &" << std::endl; // debug
// Foo ret(*this);
// ret.sorted(); //13.56
// return ret;
return Foo(*this).sorted(); //13.57
}
int main()
{
Foo().sorted(); // call "&&"
Foo f;
f.sorted(); // call "const &"
system("pause");
return 0;
}