目录
13.1 拷贝、赋值与销毁
- 对初学c++的程序员来说,必须定义对象拷贝,移动,赋值或销毁时做什么
13.1.1 拷贝构造函数
- 拷贝构造函数:如果一个构造函数的参数是本身的引用,且其他的参数都有默认值,此构造函数是拷贝构造函数。拷贝构造函数在几种情况下都会被隐式调用,因此不能是explicit类型。
- 合成拷贝构造函数:在我们没有为类定义时,编译器会帮我们定义一个。编译器从给定对象中一次将每个非static成员拷贝到正在创建的对象中。
#include <iostream>
#include <string>
using namespace std;
class A
{
friend std::ostream& print(std::ostream &os, A &a);
public:
A() = default;
A(int a,string b):i(a),s(b) { c++; }
private:
int i;
string s;
static int c; //所有类的对象都能访问,只能在类外初始化
//const static int c = 10; //const int 可以在类内初始化,尽量将静态数据成员在类外初始化
//const static double c = 10.1; //error:初始化只能在类外初始化, 在类外加上const double A::c = 10.1就对了
};
int A::c = 10;
std::ostream& print(std::ostream &os, A &a)
{
os << a.i << " " << a.s << " " << a.c << endl;
}
int main()
{
A a1(10,"123");
A a2(a1); //没有定义拷贝构造函数,编译器自己合成了一个,叫合成拷贝构造函数。不拷贝static成员。
print(std::cout, a2);
A a3(10,"123");
print(std::cout, a3);
}
- 拷贝初始化不仅在我们用=定义变量时会发生,在下列情况也会发生
- 将一个对象作为实参传递给一个非引用对象
- 从一个返回类型为非引用类型的函数返回一个对象
- 用花括号列表初始化一个数组中的元素或者一个聚合类的成员
- 使用insert或者push成员(使用implace创建元素是直接初始化)
- 拷贝构造函数必须是引用类型,如果不是会进入无限递归
Point global;
Point foo_bar(Point arg) //调用1次
{
Point local = arg, *heap = new Point(global);//调用两次
*heap = local; //不调用,对指针赋值不调用拷贝构造函数
Point pa[4] = {local, *heap};//调用2次
return *heap; //调用1次,返回的是对象
}
13.1.2 拷贝赋值运算符
Sales_data s1,s2;
s1 = s2;
- 和拷贝构造函数一样,如果类未定义拷贝赋值运算符,编译器会默认合成一个。合成拷贝赋值运算符会将右侧的除了非static的成员全部拷贝过去。
- 赋值运算符重载要返回类的引用是this,因为赋值有可能是a= b = c;这种赋值,所以必须返回它自己的指针,那么也就是this。所以必须定义成成员函数,和输出函数返回流的引用是一样的,可能os << a << b;
13.1.3 析构函数
- 析构函数执行的于构造函数执行的相反,析构函数释放所使用的资源,并释放非static数据成员。析构函数不接受参数,不能被重载,对于一个类只有一个析构函数。析构函数销毁的顺序是构造函数构造顺寻的逆序。
- 隐式销毁一个内置指针类型的成员不会delete它所指向的对象
- 与内置普通指针不同,智能指针是类类型,具有析构函数。因此,智能指针成员会在析构阶段被自动销毁
- 什么时候调用析构函数
- 变量在离开其作用域的时候
- 当一个对象被销毁时,其成员被销毁
- 容器被销毁时
- 对于临时对象,当创建它的完整表达式结束时被销毁
- 当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
- 编译器会默认合成一个合成析构函数,析构函数是在合成析构函数的补充,比如堆上申请的对象,必须我们手动释放,就写在析构函数里面。知道析构函数体自身并不直接销毁成员是非常重要的,成员是在析构函数体之后隐含的析构阶段被销毁的。
13.1.4 三/五法则
- 拷贝构造函数,拷贝赋值函数,析构函数是一个整体。需要析构函数的类也需要拷贝和赋值操作,需要拷贝操作的类也需要赋值操作,反之亦然。
- 默认合成的构造函数是不会帮我们new一个对象或者是其他有关于分配内存的,指针它只会拷贝指针,如果只定义了析构函数那么就会new了一次但是析构了多次。
- 如果一个类需要自定义析构函数,那么可以肯定它也需要定义拷贝赋值运算符和拷贝构造函数。
13.1.5 使用=default
- 我们可以通过=default让编译器来为我们生成默认版本
13.1.6 阻止拷贝
- 加上=delete
- 新标准下,我们可以通过将拷贝构造函数和赋值运算符定义为删除的函数来阻止拷贝和赋值。
- 与=delete不同的是我们可以指定任何函数为=delete,(只能对编译器可以默认合成的使用=default)
- 析构函数不能是删除的成员
- 虽然删除函数主要用途是禁止拷贝控制成员,但当我们希望引导函数匹配的时候,删除函数也是有用的。
- 删除一个类的析构函数或者类的某个成员的类型删除了析构函数,我们都不能定义该类的变量或临时对象。但可以动态分配这种类型,不能释放。
- 如果一个类有数据成员不能被默认构造、拷贝、复制或销毁。则对应的成员函数将被定义为删除的。
- 希望阻止拷贝的类应该使用=delete来定义他们自己的拷贝构造函和拷贝赋值运算符,而不应该将他们声明为private类型。
13.2 拷贝控制和资源管理
- 我们如何拷贝指针成员决定了像HasPtr这样的类是具有类值行为还是类指针行为。
13.2.1 行为像值的类
- 实现类值行为的操作:
- 定义一个拷贝构造函数,完成string的拷贝,而不是拷贝指针
- 定义一个析构函数来释放string
- 定义一个拷贝赋值运算符来释放对象当前的string,并从右侧运算符对象拷贝string
- 编写赋值运算符时要注意,先将右侧运算对象拷贝到局部临时变量中
- 拷贝赋值运算符一定要处理析构函数的操作。
#include <iostream>
#include <string>
class HasPtr
{
friend std::ostream& print(std::ostream &os, const HasPtr &hp);
public:
//HasPtr():p(nullptr),i(0) { } //加上会有二义性ambiguous
HasPtr(const std::string s = std::string()):
p(new std::string(s)), i(0) { std::cout << "调用单参或无参构造函数" << std::endl; }
HasPtr(const std::string s, int t):
p(new std::string(s)), i(t) { std::cout << "调用两参构造函数" << std::endl; }
HasPtr(const HasPtr &hp):
p(new std::string(*hp.p)), i(hp.i)
{
std::cout << "调用拷贝构造函数" << std::endl;
}
HasPtr& operator=(const HasPtr &hp)
{
std::cout << "调用赋值运算符" << std::endl;
auto temp = new std::string(*hp.p);//考给临时变量,万一=左值是自己就释放了。
if(p != nullptr)
delete p;
p = temp;
i = hp.i;
return *this;
}
~HasPtr()
{
delete p;
}
private:
std::string *p;
int i;
};
std::ostream& print(std::ostream &os, const HasPtr &hp)
{
std::cout << "string:" << *hp.p << " int:" << hp.i << std::endl;
return os;
}
int main()
{
HasPtr p1;
HasPtr p2("hehe");
print(std::cout, p1);
print(std::cout, p2);
p1 = p2;
print(std::cout, p1);
HasPtr p4 = p2;
}
13.2.2 定义行为像指针的类
- 将计数器保存在动态内存中,当创建一个对象时,分配一个新的计数器,当拷贝或赋值对象时,我们拷贝指向计数器的指针,这样副本和原对象会指向相同的计数器。
#include <iostream>
#include <string>
class HasPtr
{
friend std::ostream& print(std::ostream &os, const HasPtr &ps);
public:
HasPtr(const std::string &s = std::string()):
p(new std::string(s)), i(0), use(new std::size_t(1)) { }
//HasPtr(const std::string &s = std::string(), std::size_t t):
//error: 列表中带有默认初始值的要写在后面
HasPtr(std::size_t t, const std::string &s = std::string()):
p(new std::string(s)), i(t), use(new std::size_t(1)) { }
HasPtr(const HasPtr& hp)
{
use = hp.use;
p = hp.p;
++*use;
i = hp.i;
}
HasPtr& operator=(HasPtr &ps)
{
//*ps.use++ error: ++和*优先级相同,从右向左进行,先加了地址在解引用。
if(--*use == 0)
{
delete use;
delete p;
}
p = ps.p;
use = ps.use;
i = ps.i;
++*ps.use;
return *this;
}
~HasPtr()
{
if(--*use == 0)
{
delete use;
delete p;
}
}
private:
std::string *p;
int i;
std::size_t *use; //控制
};
//流要输出不能是const类型
std::ostream& print(std::ostream& os, const HasPtr &ps)
{
os << "string:" << *ps.p << " int:" << ps.i << " use:" << *ps.use;
return os;
}
int main()
{
HasPtr p1;
HasPtr p2(99, "hehe");
print(std::cout, p1) << std::endl;
print(std::cout, p2) << std::endl;
HasPtr p3 = p2;
print(std::cout, p3) << std::endl;
HasPtr p4;
print(std::cout, p4) << std::endl;
p4 = p3;
print(std::cout, p4) << std::endl;
}
13.3 交换操作
- 和拷贝控制成员不同,swap并不是必要的手段,但是对于分配了资源的类,定义swap可能是一种很重要的优化手段。
- 我们更希望swap交换指针,而不是分配string的的新副本
- 在赋值运算符中使用swap,使用拷贝和交换的赋值运算符自动就是异常安全的,且能够正确处理自赋值。
string *temp = v1.ps;
v1.ps = v2.ps;
v2.ps = temp;
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
class HasPtr
{
friend bool compare(HasPtr &hp1, HasPtr &hp2); //compare二元谓词函数,在sort中使用。也可使使用lambda
friend void swap(HasPtr &hp1, HasPtr &hp2); //自定义版本的swap
friend std::ostream& print(std::ostream &os, const HasPtr &hp);
public:
//HasPtr():p(nullptr),i(0) { }//加上会有二义性ambiguous
HasPtr(const std::string s = std::string()):
p(new std::string(s)), i(0) { std::cout << "调用单参或无参构造函数" << std::endl; }
HasPtr(const std::string s, int t):
p(new std::string(s)), i(t) { std::cout << "调用两参构造函数" << std::endl; }
HasPtr(const HasPtr&hp)
{
std::cout << "调用拷贝构造函数" << std::endl;
p = new std::string(*hp.p);
i = hp.i;
}
//!!在赋值运算符中使用swap
//!!拷贝并交技术
HasPtr& operator=(HasPtr hp)
{
swap(*this, hp);//!!使用我们自定义的版本。 这里是个小技巧,传参时传的是一份拷贝,然后我们交换*this和拷贝,此时已经“赋值”完成,*this已经改变
return *this; //!!而且在作用域结束的时候,hp就会销毁,且hp的任务已经完成。
}
~HasPtr()
{
delete p;
}
private:
std::string *p;
int i;
};
bool compare(HasPtr &hp1, HasPtr &hp2) //<来排序
{
return hp1.i < hp2.i;
}
void swap(HasPtr &hp1, HasPtr &hp2)
{
std::cout << "调用自定义版本swap" << std::endl;
using std::swap; //为了避免使用成我们自己的版本从而引起无限递归。
swap(hp1.i, hp2.i); //系统版本
swap(hp1.p, hp2.p); //系统版本
}
std::ostream& print(std::ostream &os, const HasPtr &hp)
{
std::cout << "string:" << *hp.p << " int:" << hp.i << std::endl;
return os;
}
int main()
{
std::vector<HasPtr>vec_hp;
HasPtr hp1("wang", 2);
HasPtr hp2("wei", 3);
HasPtr hp3("hao", 1);
vec_hp.push_back(hp1);
vec_hp.push_back(hp2);
vec_hp.push_back(hp3);
std::sort(vec_hp.begin(), vec_hp.end(), compare);
for(const HasPtr&hp : vec_hp)
{
print(std::cout, hp);
}
}
13.4 拷贝控制示例
13.5 动态内存管理类
- 某些类需要自己进行内存分配,这些类要自己定义拷贝控制成员来管理分配的内存。
13.6 对象移动
- 对于对象拷贝后就立即被销毁的类,用移动可以大大提高性能
- 标准库容器、string和shared_ptr类即支持移动也支持拷贝,IO类和unique_ptr类可以移动但不能拷贝
13.6.1 右值引用
- 为了支持移动操作,新标准引入了右值引用&&,右值引用只能绑定在将要销毁的对象上。
- 一般而言,一个左值表达式表示的是一个对象的身份,一个右值表达式表示的是对象的值。
- 右值引用绑定到要求转换的表达式、字面值常量或是返回右值的表达式上,但不能将一个右值引用直接绑定到一个左值上。
- 返回非引用类型的函数,连同算数,关系,位以及后置递增递减运算符(可以发现这些值都是临时的,即将销毁的),都生成右值。 我们不能将左值引用绑定到这类运算符上,但是可以将const的左值引用或者一个右值引用绑定到这类表达式上。
- 简单的说左值引用生存期长,右值引用生存期短。右值引用只能绑定到临时对象,该对象没有其他用户。使用右值引用的代码可以自由的接管所引用对象的资源。
- 不能将一个右值引用直接绑定到一个左值上,但是可以显式地将一个左值转换为对应的右值引用类型。调用move函数意味着承诺,除了对rr1赋值或者销毁他,我们将不再使用他。
int &&rr3 = std::move(rr1); // 正确
- 使用move的代码应该使用std::move,这样做可以避免潜在的名字冲突
13.6.2 移动构造函数和移动赋值运算符
- 要求:类支持拷贝和赋值
- 移动构造函数的第一个参数是该类类型的一个右值引用,和拷贝构造函数一样,其他任何参数都必须有默认的实参。
- 移动构造函数确保移动后源对象处于这样一个状态,销毁它是无害的,一旦资源完成移动,源对象就必须不再指向被移动的资源,这些资源的所有权已经归属新创建的对象。
- 和构造函数不同,移动构造函数不创建任何新的资源。
- 移动操作不应该抛出异常,所以我们应该用noexcept来显示的指定。(即移动构造函数和移动赋值运算符必须标记为noexcept)
StrVec::StrVec(StrVec &&s) noexcept //noexcept指名不抛出异常
:elements(s.elements), first_free(s.first_free), cap(s.cap)
{
s.elements = s.first_free = s.cap = nullptr; //销毁地动源后的对象
// 这样执行的销毁操作删除的是空指针的内存,对新对象不产生影响
}
- noexcept是C++11引入的,在一个构造函数中,noexcept出现在参数列表和初始化列表开始的冒号之间。
- 之所以不允许抛出异常是因为在移动过程中抛出异常会产生问题。移动源改变了,新创建的对象也改变了,源对象不能保证在失败的情况下自身不变的要求。
StrVec& StrVec::operator=(StrVec &&s) noexcept //不抛出任何异常
{
if(this != &s) //检测是否是自己给自己赋值,
{
free(); //释放this本身资源
elements = s.elements;
first_free = s.first_free;
cap = s.cap;
s.elements = s.cap = s.first_free = nullptr;
}
return *this;
}
- 在移动操作后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。
- 只有当一个类没有定义任何自己版本的拷贝控制成员,且它的所有数据成员都能移动构造或移动赋值时,编译器才会为它合成移动构造函数或移动赋值运算符。
struct X{
int i; // 内置类型可以移动
std::string s; // string定义了自己的移动操作
}
struct hasX{
X mem; // X有合成的移动操作
}
X x, x2 = std::move(x); // 使用合成的移动构造函数
hasX hx, hx2 = std::move(hx); // 使用合成的移动构造函数