C++Primer第十三章 拷贝控制
拷贝控制
13.1 拷贝、赋值与销毁
13.1.1 拷贝构造函数
class Foo{
public:
Foo(); //默认构造函数
Foo(const Foo&); //拷贝构造函数
}
拷贝构造函数的第一个参数自身类型的引用,任何额外参数都有默认值。
合成拷贝构造函数
合成拷贝构造函数,无论是否有自定义的拷贝构造函数,编译器都会合成拷贝构造函数。一般情况,合成的拷贝构造函数会将其参数的成员逐个拷贝到正在创建的对象中,成员的类型决定了拷贝的方式。
class Sales_data {
public:
//与合成的拷贝构造函数等价的拷贝构造函数
Sales_data(const Sales_data&);
private:
std::string bookNo;
int units_sold = 0;
double revenue = 0.0;
};
Sales_data::Sales_data(const Sales_data &orig) :
bookNo(orig.bookNo);
units_sold(orig.units_sold);
revenue(orig.revenus);
{}
拷贝构造函数
直接初始化和拷贝初始化之间的差异:
直接初始化是编译器使用函数匹配来选择与我们提供的参数匹配的构造函数。
拷贝初始化将右侧运算对象拷贝到正在创建的对象中。
拷贝初始化发生的情况有:
- 用“=”定义变量
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或聚合类中的成员。
- 调用
insert
或push/push_back
成员,容器会对元素进行拷贝初始化,调用emplace
成员会进行直接初始化。
拷贝构造函数自己的参数必须是引用类型?
函数调用的过程中,对非引用类型的参数进行拷贝初始化。如果拷贝构造函数自己的参数不是引用类型,为了调用拷贝构造函数,就要拷贝其实参,然后有需要调用拷贝构造函数,无限循环。
13.1.2拷贝赋值运算符
重载运算符的参数表示运算符的运算对象。赋值运算符必须定义为成员函数。如果一个运算符为成员函数,左侧运算对象绑定到隐式的this参数,二元运算符右侧运算对象作为显示参数传递。
class Foo{
public:
Foo& operator=(const Foo&); //赋值运算符
};
赋值运算符通常返回一个指向其左侧运算对象的引用。
合成拷贝赋值运算符
Salse_data& Sales_data::operaotr=(const Sales_data &rhs) {
bookNo = rhs.bookNo; //调用string::operator =
units_sold = rhs.units_sold;//使用内置的Int赋值
revenue = rhs.revenue; //使用内置的double赋值
return *this;
}
13.1.3 析构函数
析构函数释放对象使用的资源,并销毁对象的非static成员。
class Foo{
~Foo(); //析构函数
};
析构函数不接受任何参数,无法重载。
析构函数先执行函数体,然后按照初始化顺序的逆序销毁成员。隐式销毁一个内置指针类型的成员不会delete它所指向的对象。
什么时候调用析构函数
- 变量在离开作用域时被销毁
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时,其元素被销毁
- 对于动态分配的对象,当对指向它的指针使用delete时被销毁
- 对于临时对象,当创建它的完整表达式结束时被销毁
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
合成析构函数
当一个类未定义自己的析构函数时,编译器会为它定义一个析构函数。
class Sales_data {
public:
~Sales_data() {}
};
析构函数体执行完毕后成员会被自动销毁。析构函数体不直接销毁成员,成员在析构函数体之后隐含的阶段中被销毁。
13.1.4 三/五法则
需要析构函数的类也需要拷贝和赋值操作
给HasPtr定义一个析构函数,但是使用合成的拷贝构造函数和赋值函数。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
~HasPtr() {
delete ps;
}; //析构函数
private:
std::string *ps;
int i;
};
HasPtr f(HasPtr hp) {
HasPtr ret = hp; //拷贝给定的HasPtr;
return ret; //ret和hp销毁
}
HasPtr p("some values");
f(p); //f结束的时候1,p.ps指向的内存被释放
HasPtr q(p); //p和q指向无效内存
合成构造函数和赋值函数只进行简单拷贝指针成员,多个HasPtr对象指向相同的内存。f返回的时候hp和ret销毁,调用~HasPtr()会Delete ret和Hp的指针成员,但是两个指针成员指向同一个地址,指针会delete两次。
一个类需要自定义析构函数,几乎可以肯定需要自定义拷贝赋值运算符和拷贝构造函数
需要拷贝操作的类也需要赋值操作,反之亦然,但是需要拷贝和赋值的类不一定需要析构函数
13.1.5 使用=default
将拷贝控制成员定义为=default
来显式的要求编译器生成合成的版本。
13.1.6 阻止拷贝
对于某些类来说无法执行拷贝和赋值操作,比如iostream
类阻止了拷贝,避免多个对象写入或读取相同的IO缓冲,需要一种机制来阻止拷贝。
定义删除函数
参数列表后面加上=delete
表示定义删除函数。
struct Nocopy{
Nocopy() = default; //默认的构造函数
Nocopy(const Nocopy&) = delete; //阻止拷贝
Nocopy& operator=(const Nocopy&) = delete; //阻止赋值
~Nocopy() = default; //使用合成的析构函数
};
析构函数不能是删除的成员
如果删除了析构函数,我们就无法销毁这个对象。因此对于析构函数已删除的类型,不能定义该类型的变量或释放指向该类型的动态指针。
合成的拷贝控制成员可能是删除的
如果一个类有数据成员不能默认构造、拷贝、复制或销毁,则对应的成员函数将被定义为删除的。这一类数据成员有:
- 一个成员有删除的或不可访问的析构函数(比如是private)会导致合成的默认和拷贝构造函数定义为删除的。
- 对于具有引用成员或无法默认构造的const成员的类。不可能给一个const 成员赋新值,对于引用的成员,如果我们修改了引用的值相当于是修改了引用对象的值。
希望阻止拷贝的类应该使用=delete
来定义它们自己的拷贝构造函数和拷贝赋值函数,而不应该将它们声明为private的
13.2 拷贝控制和资源管理
13.2.1 行为像值的类
类的行为像值,说明类有自己的状态,当拷贝一个像值的对象时,副本和原对象是完全独立的。改变副本不会改变原对象的值。为了提供类值的行为,每个对象都应该有一个自己的拷贝。
类值拷贝赋值运算符
赋值运算符组合了拷贝构造函数和析构函数的工作。向左侧对象赋值时需要释放左侧运算对象的资源,还需要从右侧运算对象中拷贝数据。操作要按照正确的顺序执行,保证处理好异常的情况。
class HasPtr {
public:
HasPtr(const std::string &s = std::string()) : ps(new std::string(s)), i(0) {}
HasPtr(const HasPtr &p) : ps(new std::string(*p.ps)), i(p.i) {}
HasPtr &operator=(const HasPtr &hp) {
auto new_p = new std::string(*hp.ps); //先拷贝底层的string
delete ps; //释放旧内存
//执行拷贝构造函数相似的操作。
ps = new_p;
i = hp.i;
return *this;
}
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
编写赋值运算符时,先将右侧运算对象拷贝到一个临时对象中,然后释放左侧运算对象现有成员,最后将临时对象的拷贝到左侧运算对象的成员中。
13.2.2 定义行为像指针的类
定义行为指针的类可以采用shared_ptr
,如果想直接管理资源,最好采用引用计数。
引用计数的工作方式:
- 每个构造函数创建一个对象时还要创建一个引用计数,并初始化为1
- 拷贝构造函数不分配新的计数器,拷贝给定对象的数据成员包括计数器。
- 析构函数递减计数器
- 拷贝赋值运算符递增右侧对象的计数器,递减左侧对象的计数器。
我们可以采用动态内存的方式存放引用计数器
13.6 对象移动
使用移动而不是拷贝可以大幅度提高性能,而且有些类比如iostream
,unique_ptr
不包含共享的资源无法拷贝,只能采用移动的方式。
13.6.1 右值引用
右值引用:绑定到右值的引用,使用"&&"来获取右值引用,右值引用只能绑定到一个将要销毁的对象,与左值引用完全相反,右值引用可以绑定到要求转换的表达式、字面常量、返回右值的表达式。
int i = 42;
int &r = i; //right
int &&rr = i; //error,不能用右值引用绑定到一个左值上
int &r2 = i * 42; //错误,i * 42是一个右值
const int &r3 = i * 42; //正确,const的引用可以绑定右值
int &&rr2 = i * 42; //right,右值引用可以绑定表达式
右值引用只能绑定到临时对象:
- 所引用的对象将要被销毁
- 该对象没有其他用户
说明右值引用可以自由接管所引用对象的资源。
变量表达式都是左值,不能将右值引用绑定到一个右值引用类型的变量上。
int &&rr1 = 42 //right,字面常量是一个右值
int &&rr2 = rr1; //error,rr1是一个左值
可以显示的将一个左值转换为对应的右值引用类型。
int &&r3 = std::move(rr1);
move告诉编译器,有一个左值希望像一个右值一样处理它,但是对于移后源对象我们可以销毁或者赋新值,但是不能再使用移后源对象的值。
13.6.2 移动构造函数和移动赋值运算符
移动构造函数的第一个参数为右值引用,使用移动构造函数必须确保移后源对象处于销毁它是无害的状态,而且一旦完成资源的移动移动源对象就不能再指向被移动的资源。移动操作是窃取资源而非拷贝资源。
StrVec::StrVec(StrVec &&s) noexcept : elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = nullptr;
}
由上面代码可知,在我们移动资源过后,我们可以确保移动过后移动源对象的销毁都是无害的。
移动操作、标准库容器和异常
通过在移动构造函数的参数列表后面加上noexcept来告诉标准库,移动构造函数没有任何异常。
class StrVec{
public:
StrVec(StrVec &&) noexcept; //移动构造函数
};
StrVec::StrVec(StrVec &&s) noexcept;
不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept
移动赋值运算符
StrVec &StrVec::operator = (StrVec &&rhs) noexcept {
//检测自赋值
if (this != &rhs) {
free();
elements = rhs.elements;
first_free = rhs.first_free;
cap = rhs.cap;
rhs.elements = rhs.first_free = rhs.cap = nullptr;
}
return *this;
}
移后源对象必须可析构
移后源对象必须保持有效的、可析构的状态,用户不能对其值做任何假设。
合成的移动操作
只有当一个类没有定义任何自己版本的拷贝控制成员,且他的所有数据成员都能移动构造函数或移动赋值时,编译器才会合成移动构造函数或移动赋值运算符。
struct X{
int i;
std::string s;
};
struct hasX {
X mem; //X有合成的移动操作
};
X x, x2 = std::move(x);
hasX hx, hx2 = std::move(hx); //使用合成的移动构造函数
移动函数永远不会被定义为隐式的函数,如果显式地要求编译器生成=default
移动操作,但是编译器无法移动所有成员,移动操作就会被定义为删除的移动函数。
移动函数被定义为删除的移动函数的条件:
- 类成员定义了自己的拷贝构造函数但是未定义移动构造函数,或类成员未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。
- 类成员的移动构造函数或移动赋值运算符被定义为删除的或是不可访问的。
- 类的析构函数被定义为删除的或不可访问的,移动构造函数被定义为删除的
- 有类成员是const或是引用,类的移动赋值运算符被定义为删除的。
//假定y为定义了拷贝构造函数但是未定义自己的拷贝构造函数
struct hasY {
hasY() = default;
hasY(hasY &&) = default;
Y mem;
};
hasY hy, hy2 = std::move(hy); //error,移动构造函数为删除的
如果一个类定义了移动构造函数/移动赋值运算符,则合成的拷贝构造函数/合成的赋值运算符被定义为删除的
移动右值,拷贝左值
一个类既有拷贝构造函数又有移动构造函数,按照函数匹配规则来选择。
StrVec v1, v2;
v1 = v2 ; //v2为左值,调用拷贝赋值
StrVec getVec(istream &); //getVec返回一个右值
v2 = getVec(cin); //使用移动赋值
```c++
如果没有移动构造函数,右值也会被拷贝.
```c++
class FOO{
public:
Foo() = default;
Foo(const Foo&);
};
Foo x;
Foo y(x);
Foo z(std::move(x)); //拷贝构造函数,因为未定义移动构造函数
13.6.3 右值引用和成员函数
区分移动和拷贝的重载函数通常有一个版本接受一个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();
alloc.construct(first_free++, s);
}
void StrVec::push_back(std::string &&s) {
chk_n_alloc();
alloc.construct(first_free++, std::move(s));
}
StrVec vec;
string s = "string";
vec.push_back(s); //调用push_back(const std::string &);
vec.push_back("c++") //调用push_back(std::string &&);
右值和左值引用成员函数
string s1 = "a value", s2 = "another";
auto n = (s1 + s2).find('a'); //一个右值string调用find成员函数
s1 + s2 = "wow"; //对右值进行赋值
新标准库中允许对右值进行赋值,但是有时候想要阻止自己的类使用这种方法,可以在参数列表后面加上引用限定符来强制左侧运算对象是一个左值/右值。
class Foo {
public:
Foo &operator=(const Foo&) &; //只能向可修改的左值赋值
};
Foo &Foo::operator=(const Foo &rhs) & {
return *this;
}
引用限定符(&和&&),只能用于成员函数,且必须同时出现在函数的声明和定义中。
&
限定的函数只能用于左值,对于&&
限定的函数只能用于右值。
Foo &retFoo(); //返回一个引用,retFoo调用是一个左值
Foo retVal(); //返回一个值,retVal调用是一个右值
Foo i, j; //i 和 j是一个左值
i = j; //right
retFoo() = j; //retFoo返回一个左值
retVal() = j; //error, retVal返回一个右值
i = retVal(); //right
一个函数可以同时使用const限定符和引用限定符,但是引用限定符必须在const限定符之后。
重载和引用函数
class Foo {
public:
Foo sorted() &&;
Foo sorted() const &;
private:
vector<int> data;
};
//对象为一个右值,可以对原址排序
Foo Foo::sorted() && {
sort(data.begin(), data.end());
return *this;
}
//对象为const或是一个左值,不能对原址进行排序需要拷贝
Foo Foo::sorted() const & {
Foo ret(*this);
sort(ret.data.begin(), ret.data.end());
return ret;
}
retVal().sorted(); //retVal()为一个右值,调用Foo::sorted() &&
retFoo().sorted(); //retFoo()为一个左值,调用Foo::sorted() const &
定义两个或两个以上具有相同名字和相同参数列表的成员函数,必须对所有函数都加上引用限定符,或者所有都不加。
class Foo {
public:
Foo sorted() &&;
Foo sorted() const ; //error,必须加上引用限定符
using Comp = bool(const int &, const int &);
Foo sorted(Comp*);
Foo sorted(Comp*) const ; //right,两个版本都没有引用限定符
};
如果一个成员函数具有引用限定符,则具有相同参数列表的所有版本都必须具有引用限定符。