拷贝控制
内容概览
本章的主要内容是类中的5个函数:
- 拷贝构造函数 (copy constructor)
- 拷贝赋值运算符 (copy-assignment operator)
- 移动构造函数 (move constructor)
- 移动赋值运算符 (move-assignment operator)
- 析构函数 (destructor)
拷贝、赋值和销毁
拷贝构造函数
拷贝构造函数第一个参数必须是引用类型,且不应该是explicit的。
为什么参数必须是引用类型呢?
因为如果拷贝构造函数形参不是引用的话,在实参向形参传值的时候要进行拷贝,这样就会调用拷贝构造函数,形成递归调用,无限循环。
拷贝初始化=和直接初始化的区别:
直接初始化就是普通的函数调用,使用与给定初始化参数最匹配的构造函数。
拷贝初始化一般使用拷贝构造函数来完成,但是如果一个类定义有移动构造函数且参数是一个右值,那么拷贝初始化调用的则是移动构造函数。
- 拷贝初始化的常见形式
- 直接使用=定义变量。
- 将一个对象作为实参传递给非引用类型的形参。
- 从一个返回类型为非引用类型的函数返回一个对象。
- 花括号初始化列表初始化一个数组或者聚合类的成员。
练习13.4
假定Point是一个类类型,有一个public的拷贝构造函数,指出下面程序中那些地方使用了拷贝构造函数?
Point global;
Point foo_bar(Point arg){ //1形参拷贝
Point local = arg, *heap = new Point(global); //2: Point local = arg, 3: Point *heap = new Point(global)
*heap = local; //这是赋值
Point pa[4] = {local, *heap}; // 4, 5
return *heap; //6
};
练习13.5 给定下面一个框架类,编写一个拷贝构造函数。应动态分配一个新的string,并将对象拷贝到ps指向的位置,而不是拷贝ps的值。
class HasPtr{
public:
HasPtr(const std::string &s = std::string()) : ps(new string(s)), i(0) {};
//拷贝构造函数
HasPtr(HasPtr& origin);
private:
std::string *ps;
int i;
};
//将origin的ps指向的对象拷贝到新的ps ,注意,获取一个实例的成员指针指向的对象,使用*instance.ptr
HasPtr::HasPtr(HasPtr& origin) : ps(new std::string(*origin.ps)), i(origin.i) {}
拷贝赋值运算符
重载赋值运算符。某些运算符,包括赋值运算符,必须声明为成员函数。如果一个运算符是一个成员函数,其左侧运算对象就绑定到隐式的this指针。
class Foo {
public:
Foo& operator = (const Foo& rhs); //赋值运算符
//。。。。
};
赋值运算符返回值类型为什么是引用呢?
书上说是为了与内置类型的赋值运算符保持一致,赋值运算符通常返回一个指向其左侧运算对象的引用。
析构函数
负责释放对象的资源,就是销毁对象的非static数据成员。没有返回值,没有参数,所以不能重载,也就是说一个类只有一个析构函数。
构造函数中,类成员的初始化是在执行函数体之前完成的,且成员的初始化顺序按照在类中出现的先后顺序进行初始化。
而在析构函数中,先执行析构函数体,再销毁成员,按成员的初始化顺序逆序销毁。其实这样很有道理,有时候需要先在析构函数体中手动释放指针等指向的内存,执行完函数体后才销毁指针等成员。
三/五法则
一般来说,需要自定义析构函数的类也需要自定义拷贝构造函数和拷贝赋值运算符。
因为一般来说需要自定义析构函数的类都含有动态分配的内存,需要手动定义析构函数来释放内存。这就是为什么需要同时定义拷贝构造函数和拷贝赋值运算符的原因,不然默认的拷贝构造和拷贝赋值只是简单地拷贝动态内存的地址,而不是创建新的内存来复制内容。
没有自定义的拷贝构造和拷贝赋值运算符会导致一块内存被多次释放。
同时,需要拷贝构造的类几乎可以肯定也需要拷贝赋值操作,反之亦然!
关于使用default的问题:
与构造函数一样,可以显式将拷贝控制成员定义成=default来生成默认的版本。
如果=default在类内,则该成员函数就是内联的,在=default在类外成员函数就不是内联的。
阻止拷贝
如果不想类的成员被拷贝构造或者拷贝赋值,那么可以将拷贝构造函数或拷贝赋值运算符定义成delete的。
class NoCopy{
NoCopy() = default;
NoCopy(const NoCopy& ) = delete; //阻止了拷贝操作,也就是不能使用该类的一个对象来直接初始化另一个对象
NoCopy &operator = (const NoCopy& ) = delete; //阻止了对该类的=操作
~NoCpoy() = default; //使用合成的析构函数
};
与=default不同,=delete必须出现在函数第一次出现的地方。
需要注意的是,析构函数在语法上可以被定义为删除函数,但是这样就会导致该类的对象无法被销毁,所以编译器就不允许定义该类型的变量或者创建该类的临时对象。
但是可以动态分配这种类型的对象,但是又不能释放这些对象。。。(???)
class NoDtor{
NoDtor() = default;
~NoDtor() = delete; //不能销毁该类型对象
}
NoDtor nd; // 错误,不能声明该对象
NoDtor *p = new NoDtor(); //正确,动态分配NoDtor内存。。
delete p; // 错误, 不能释放这些对象。(那不是就肯定存在内存泄露了???)
合成的拷贝控制成员可能是删除的情况
如果一个类有数据成员不能默认构造、拷贝、赋值或者销毁,对应的成员函数将被定义为删除的。(具体看书)
private拷贝控制
在新标准之前,可以通过将拷贝控制成员声明为private的来禁止拷贝赋值,但最好使用新版本的操作。
class PrivateCopy{
//class 默认的权限是private的,与struct相反
PrivateCopy(const PrivateCopy& ); //这里的拷贝构造函数和赋值运算符都是私有的,所以无法进行拷贝构造和赋值
PrivateCopy& operator = (const PrivateCopy& );
public:
PrivateCopy() = default;
~PrivateCopy() = default;
};
试图拷贝该类的对象的用户代码在编译阶段会被标记错误,类的成员函数和友元函数进行拷贝操作会在链接时错误。
拷贝控制和资源管理
一般来说,如果一个类需要手动定义析构函数,那么它基本上可以肯定也需要手动定义一个拷贝构造函数和拷贝赋值运算符,因为需要释放动态分配的内存。
行为像值的类:副本和原对象完全独立,改变其中一个不会影响另一个。
行为像指针的类:副本和原对象使用相同的底层数据,改变其中一个会影响另外一个。
行为像值的类
拷贝类中指针指向的对象,而不是拷贝指针。
class HasPtr{
public:
HasPtr(const std::string& s = std::string()) : ps(new std::string(s)), i(0) {}
//拷贝构造函数,拷贝p.ps指针指向的对象到this.ps中
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;
};
赋值运算符=通常组合了析构函数和拷贝构造函数的作用。自定义赋值运算符要满足:
- 能够实现对象自身给自身赋值
拷贝赋值的步骤:
- 先拷贝右侧的运算对象。
- 释放左侧运算对象的资源。
- 更新指针等,令其指向拷贝得到的对象。
所以上面的operator=的实现如下:
HasPtr& HasPtr:: operator = (const HasPtr& rhs){
auto newp = new string(*rhs.ps); //先拷贝右侧运算对象,顺序不能错
delete ps; //释放旧的内存
ps = newp; // 指向新内存
i = rhs.i;
return this;
}
行为像指针的类
可以用shared_ptr实现,也可以自定义引用计数来实现。
引用计数实现的一个方法是将计数器保存在一个动态内存中。(不能使用static变量)
创建一个新的对象时,分配一个新的计数器,当拷贝或者赋值时,拷贝指向计数器的指针。这样副本和原对象都会指向相同的计数器。
class HasPtr{
public:
HasPtr(const std::string &s = new std::string()) : ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
//拷贝构造函数拷贝3个成员,并递增计数器
HasPtr(const HasPtr& p) : ps(p.ps), i(p.i), use(p.use) { ++*p; } //这样子一来ps指向了p.ps所指向的内存,计数器+1
HasPtr operator=(const HasPtr& );
~HasPtr()
private:
std::string s;
int i;
std::size_t *use;
};
HasPtr::~HasPtr(){
if(--*use == 0){ //如果引用计数变为0,释放ps和use指向的内存
delete use;
delete ps;
}
}
HasPtr& HasPtr::operator=(const HasPtr& rhs){
++*rhs.use; //递增右侧运算对象的引用计数
if(--*use == 0){
delete ps;
delete use;
}
ps = rhs.ps;
use = rhs.use;
i = rhs.i;
return *this; //返回本对象
}
交换操作swap
管理资源的类通常还自己定义一个swap函数(非必须,而且标准库还默认定义了一个swap)。
高效的swap操作应该是交换指向对象的指针,而不是交换对象的值。
class HasPtr{
public:
//其他成员与上面以一样
friend void swap(HasPtr&, HasPtr&);
};
inline void swap(HasPtr& lhs, HasPtr& rhs){ //优化声明为内联函数
using std::swap;
swap(lhs.ps, rhs.ps);
swap(lhs.i, rhs.i);
}
对象移动
如果拷贝一个对象后,这个对象就被销毁了(如return),在这种情况下,将对象进行移动就会大幅提升性能。
还有,如IO类和unique_ptr等不能进行拷贝,但是可以进行移动。
右值引用
必须绑定到右值的引用,通过&&获得右值引用。
性质:只能绑定到一个将要销毁的对象。
左值引用不能将其绑定到要求转换的表达式、字面常量或者返回右值的表达式。
右值引用与左值引用刚好相反。
int i = 42;
int &r = i; //正确,将r绑定到i
int &&rr = i; //错误,不能将右值引用绑定到左值上。
int &r2 = i * 42; //错误,i*42是一个右值
const int &r3 = i * 42; //正确,可以将const引用绑定到右值上。
int &&rr2 = i * 42; //正确,将右值引用绑定到乘法结果上。
返回左值的表达式:
- 返回左值引用的函数
- 赋值运算符
- 下标运算符
- 解引用运算符
- 前置递增递减运算符
返回右值的表达式:
- 返回非引用类型的函数
- 算术运算符
- 关系运算符
- 位运算符
- 后置递增递减运算符
不可以将左值引用绑定到这类表达式中,但是可以将右值引用和const的左值引用绑定到这类表达式上。
左值持久,右值短暂
由于右值引用只能绑定到临时对象,所以该对象应该是:
将要被销毁。
没有其他的地方使用到该对象。
变量是左值,不能将右值引用绑定到一个变量上,即使这个变量本身是右值引用也不行,因为这个右值引用它本身首先是一个变量。
int &&rr1 = 42; //右值引用绑定到字面值常量
int &&RR2 = rr1; //错误,不能将右值引用绑定到一个变量上。
std::move()
我们可以通过使用定义在utility的std::move来获得一个绑定到左值的右值引用,也就是说可以通过这个函数将右值引用绑定到一个左值上,比如将绑定到一个变量上。就是告诉编译器,我们有一个左值,但我们想像一个右值一样来处理它。
int &&rr3 = std::move(rr1); //将右值引用绑定到了一个变量上。
除了对移动后的原对象(如,rr1)进行销毁和赋值外,我们不能再使用它。
移动构造函数和移动赋值运算符
移动构造函数
原则:经过移动构造函数移动后,销毁一个源对象是无害的。我的理解就是移动后,源对象和之前它所指向的内存没有任何关系了,销毁源对象不会影响移动后的那个内存。
//该移动构造函数接管s中的内存
StrVec::StrVec(StrVec &&s) noexcept //移动操作不应抛出任何异常,注意s是一个右值引用
: element(s.element), first_free(s.first_free), cap(s.cap){
s.element = s.first_free = s.cap = nullptr;
}
一般来说,移动构造函数这是“窃取”别的对象的资源,不分配任何新的资源,所以一般不会抛出任何异常。所以应该使用noexcept通知标准库这段程序不会抛出异常,否则编译器可能会做一些额外的工作。。
为什么移动构造函数建议使用noexcept?
比如vector中,如果元素的移动构造函数不是显式声明不会抛出异常,那么在分配内存的时候,vector会使用拷贝构造函数而非移动构造函数来对对象进行拷贝而非移动,这样效率就会下降。所以建议移动构造函数标记为noexcept。
移动赋值运算符
与移动构造函数执行相同的操作,并且能够处理自赋值的情况。
StrVec& StrVec::operator = (StrVec &&rhs) noexcept{
if (this != rhs){ //如果不是自赋值,那么就执行常规赋值操作
free(); //释放本对象的原来的资源,free函数是之前自定义的函数
elements = rhs.elelments;
first_free = rhs.first_free;
cap = rhs.cap;
//将rhs置于可析构状态,也就是删除它不会影响到this对象内存
rhs.element = rhs.first_free = rhs.cap;
}
return *this; //返回赋值后的本对象,如果是自赋值则直接返回自身即可。
}
关于合成的移动操作:
如果一个类定义了自己的拷贝构造函数、拷贝赋值运算符或者析构函数,编译器就不会再合成移动构造函数和移动赋值运算符。只有一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都能移动时,才会合成移动构造函数和移动赋值运算符。可以移动的数据成员指的是内置类型和定义了对应移动操作的类类型。
class X{
int i; //内置类型可以移动
std::string s; //string定义了自己的移动构造函数
};
class hasX{
X mem; //X有合成的移动操作
};
X x;
X x2 = std::move(x); //使用X合成的移动构造函数
hasX hx;
hasX hx2 = std::move(hx); //使用hasX合成的移动构造函数
与拷贝操作不同,移动操作永远不会隐式地定义为删除的函数。
移动构造函数会被定义为删除的函数的情况:
- 与拷贝构造函数不同,如果有类成员定义了自己的拷贝构造函数且为定义移动构造函数,或者类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数,则该类的移动构造函数是删除的。
- 类成员的移动构造函数或者移动赋值运算符被定义为删除的或者不可访问的,则该类的移动构造函数和移动赋值运算符被定义为删除的。
- 类似拷贝构造函数,如果类的析构函数被定义为删除的或者不可访问的,则该类的移动构造函数被定义为删除的。
- 类似拷贝赋值运算符,如果类有const或者引用成员,则类的移动赋值运算符被定义为删除的。
struct hasY{
hasY() = defauult;
hasY(hasY&&) = default;
Y mem; ///Y 是一个定义了自己的拷贝构造函数但未定义自己得移动构造函数的类
};
hasY hy;
hasY hy2 = std::move(hy); //错误,hasY的移动构造函数是删除的
如果一个类定义了移动构造函数或者移动赋值运算符,那么该类合成的拷贝构造函数和拷贝赋值运算符会被定义为删除的。
如果没有移动构造函数,那么右值也会进行拷贝。
class Foo{
public:
Foo() = defualt; //
Foo(const Foo&);
};
Foo x;
Foo y;
Foo z(std::move(x)); //这里使用的是拷贝构造函数,因为未定义移动构造函数
用拷贝构造函数来代替移动构造函数几乎肯定是安全的。其实我觉得移动构造函数就是适用于右值版本的拷贝构造函数。
移动迭代器
一般的迭代器的解引用运算符返回一个指向给定元素的左值,而移动迭代器的解引用运算符返回一个右值引用。
可以使用make_move_iterator来将一个普通迭代器转换成移动迭代器。原迭代器的所有其他操作在移动迭代器中都能正常工作。
在类外不要随意使用移动操作,只有移动后源对象不在使用的情况下才能使用移动操作。
右值引用和成员函数
成员函数也可以有拷贝形式和移动形式。例如定义了push_back的标准库容器提供两个版本:右值引用版本和const左值引用版本。
void push_back(const X&); //拷贝,绑定到任意的X类型
void push_back(X&&); //移动,绑定到X的可修改右值
一般来说拷贝函数参数应该是const T&,而移动函数形参应该是T&&。
定义拷贝和移动形式的成员函数
class StrVec{
public:
void push_back(const std::string&); //拷贝形式
void push_back(std::string&&); //移动形式
//其他成员。。。
};
void StrVec::push_back(const std::string &s){
chk_n_alloc(); //检查空间是否足够,不够扩容
//在first_free的指向的元素中构造s的一个副本
alloc.construct(first_free++, s);
}
void push_back(std::string&& s){
chk_n_alloc();
alloc.construct(first_free, std::move(s)); //使用string的移动构造函数来构造新元素
}
StrVec vec;
string s = "some string or another.";
vec.push_back(s); //调用的是push_back(const std::string&s)
vec.push_back("done"); //调用的是push_back(string&&)
左值、右值引用成员函数
注意,新标准库仍然允许向右值赋值。例如:
string s1 = "s1", s2 = "s2";
auto n = (s1 + s2).find('s');
s1 + s2 = "hhhhhh"; //很奇怪的方式
如果要强制左侧运算对象是一个左值,可以在this的参数列表后放置一个引用限定符&。
class Foo{
public:
Foo& operator = (const Foo&) &; //只能向可修改的左值赋值
Foo mem() const &; //引用限定符须在const之后
};
Foo& Foo::operator = (const Foo &rhs){
//将rhs赋值给this
return *this;
}
其中引用限定符可以是&和&&,与const类似,引用限定符只能用于非static成员函数。
还有,如果一个成员函数有引用限定符,那么具有相同参数列表的所有版本都必须加上引用限定符。