C++类的拷贝销毁与赋值(上)
文章目录
定义一个类需要的操作:对象 拷贝,移动,赋值,和销毁。
五种特殊的成员函数来控制这些操作:
拷贝构造函数, 拷贝赋值运算符,移动构造函数,移动赋值运算符,析构函数。
我们称这些操作为拷贝控制操作。
ps:如果我们忽略这些操作,编译器也会生成默认的这些操作,但是这些操作通常都不合我们的意思。
拷贝销毁与赋值
拷贝构造函数
构造函数的参数是自身类型的引用,并且任何其他参数都有默认值:这个构造函数就是拷贝构造函数:
class Foo { public: Foo(const Foo& f); //拷贝构造函数声明 };
拷贝构造函数不应该是explicit的,即它允许隐式调用。
若在类内未显式定义,则编译器会自动生成合成拷贝构造函数,它主要是将其参数的成员逐个拷贝到正在创建的对象中。或用来阻止拷贝该类类型的对象。
拷贝初始化
直接初始化和拷贝初始化的区别:
直接初始化:由编译器提供参数最符合的构造函数。
string s1(10, '*'); string s2("woaini");
拷贝初始化:将右侧运算对象拷贝到正在创建的对象中,如果需要的话还需要类型转换
string s1 = "***"; string s2 = "123456";
拷贝初始化一般会使用拷贝构造函数,除非它具有移动构造函数。
拷贝初始化发生的情况:
- 使用 = 定义变量。
- 将一个对象作为实参传递给一个非引用类型的形参。
- 从返回类型为非引用类型的函数返回一个对象。
- 用花括号列表初始化一个数组中的元素或者聚合类的成员。
- 标准库容器的insert和push成员使用 拷贝初始化,emplace 使用直接初始化。
为什么拷贝构造函数的实参一定是引用类型:
为了调用拷贝构造函数,我们必须拷贝它的实参,因此必须采用引用类型来拷贝实参。
示例:将对象拷贝到动态分配的内存,而不是string本身:
class HasPtr { public: HasPtr(const string& s = string()) :ps(new string(s)), i(0) {} //拷贝构造函数 HasPtr(const HasPtr& h) { this->ps = new string(*h.ps); this->i = h.i; } private: string* ps; int i; };
拷贝赋值运算符
首先来了解重载赋值运算符。
重载赋值运算符
名字由operator加上一个运算符组成。具有返回类型和参数类型:
class Foo { public: Foo& operator=(const Foo& f); //重载赋值运算符 };
返回一个指向其左侧运算对象的引用。
若在类内未显式定义,则编译器会自动生成合成拷贝赋值运算符,它主要是将运算符右侧的所有非static成员赋给左侧元算对象对应成员(或是用来禁止该类型对象的赋值)
//拷贝赋值运算符 HasPtr& operator=(const HasPtr& h) { string* str = new string(*h.ps); delete this->ps; //删除原内存 this->ps = str; //赋值 this->i = h.i; //赋值 return *this; //返回 }
析构函数
执行与析构函数相反的操作:即释放对象使用的资源。
由一个波浪号组成:
~Foo();
析构顺序:按照在构造函数中初始化成员的顺序的逆序销毁。
销毁类类型成员,需要该成员有自己的析构函数;销毁内置类型,则什么也不需要做。
ps:隐式销毁(即在析构中不显式使用delete)一个内置指针成员不会delete它所指向的对象
隐式销毁一个智能指针则会被delete。
- 什么时候会调用析构函数?
- 变量离开作用域时会自动销毁
- 对象被销毁,成员也被销毁
- 容器被销毁,成员也被销毁
- 对动态分配的指针应用delete
- 临时对象结束时,被销毁
当指向一个对象的引用或指针离开作用域时,析构函数不会执行。
若在类内未显式定义,则编译器会自动生成合成析构函数。
三者相结合
关于默认构造函数,拷贝构造函数,拷贝赋值运算符,析构函数在何时会调用?
class Foo { public: //默认构造函数 Foo(const int& data = 5) :a(new int(data)) { cout << "Foo(cnst int& data)" << endl; } //拷贝构造函数 Foo(const Foo& foo) { cout << "Foo(const Foo& foo)" << endl; this->a=make_shared<int>(*foo.a); } //析构函数 ~Foo() { cout <<*a<< " ~Foo()" << endl; } //拷贝赋值运算符 Foo& operator=(const Foo& foo) { shared_ptr<int> temp = make_shared<int>(*foo.a); this->a = temp; cout << "Foo& operator=(const Foo& foo)" << endl; return *this; } private: //使用智能指针shared_ptr shared_ptr<int> a; };
将测试分为三个部分:
- 普通变量的赋值与拷贝
- 容器类对象
- 动态内存分配的对象
int main() { //普通变量的赋值与拷贝 Foo a = 10; //调用默认构造函数 Foo b = 5; //调用默认构造函数 Foo c(2); //调用默认构造函数 c = b; //调用拷贝赋值运算符 cout << endl; cout << endl; //容器 vector<Foo> f; f.push_back(10); //调用默认构造函数->拷贝构造函数->析构函数 cout << endl; cout << endl; //动态分配 Foo* g = new Foo(15); //调用默认构造函数 shared_ptr<Foo> h = make_shared<Foo>(b); //调用拷贝构造函数 //注意动态分配的内置指针,需要使用delete来调用析构函数 delete g; //智能指针,不需要delete,在最后销毁时会调用析构函数 cout << endl; cout << endl; return 0; }
三五法则
如前所示,有三个基本操作,就可以控制类的拷贝操作:拷贝构造函数,拷贝赋值运算符,析构函数
通常要把他们看作一个整体。
-
需要析构函数的类也需要拷贝构造函数和拷贝赋值运算符
-
如果我们定义了一个析构函数,但是使用合成版本的拷贝构造和拷贝赋值,会发生什么?
A text(A hp) { //传入对象 A temp = hp; //处理此对象... //返回对象 return temp; //(int* p) temp进入析构函数被释放,此时temp连接hp,值传递,因此hp原对象也被删除了 } 合成版本的拷贝构造和拷贝赋值会自动采用非引用版本,即值传递的形式,因此会有多个A对象指向相同内存,当在函数返回对象时,会被析构函数释放,导致原对象也被释放,如果后续还有对于原对象的操作,会直接失败:
A a(15); A b(a); text(a); //a被释放 //a b 都指向了被释放的内存 -
需要拷贝构造函数一定需要拷贝赋值运算符,反之亦然;但是他们两个都不必需要析构函数。
使用=default
显式的要求编译器生成合成的默认构造函数和拷贝控制操作(即默认版本)。
class A { public: A() = default; //内联 A(const A& a) = default; //内联 ~A() = default; //内联 A& operator=(const A& a); //非内联(类外使用=dafault) private: int p; }; A& A::operator=(const A& a) = default;
阻止拷贝
有时候我们的确不需要定义拷贝构造函数和拷贝赋值运算符,但即使我们不定义,编译器也会为我们自动生成合成版本的拷贝操作。
定义删除的函数
在函数后面加上 =delete 指明我们希望让他们被删除。
class A { public: A() = default; //合成默认构造函数 A(const A& a) = delete; //删除拷贝 ~A() = default; //合成的析构函数 A& operator=(const A& a)=delete; //删除赋值 private: int p; };
可以看到当我们想要执行拷贝操作时,会报错:它是已删除的函数。
ps:可以对任何函数指定 =delete ;但是只能对默认构造函数和拷贝控制操作指定 =default
析构函数不能是删除的
如果我们将一个析构函数删除了,则我们将不能创建该类的对象:
~A() = delete; //合成的析构函数 //... A a; //定义:出错,无法定义,因此该类已经没有了能销毁此类型的函数了,它属于不完整的类 A* b=new A(); //可以,但是其无法释放 delete b; //出错,使用delete会调用析构,但是没有析构函数 //正确,仍然有效,因为智能指针对象在销毁时,计数为零自动释放。 shared_ptr<int> c=make_shared<int>();
合成的拷贝控制成员可能是删除的
含义:如果有一个类的数据成员不能默认构造,拷贝,赋值,销毁,则对应的成员函数被定义为删除的。
- 以前阻止拷贝的操作:
使用private将拷贝控制操作定义为私有的,则使得对象无法复制。
但是使用友元函数仍可以访问,如何解决?
使用声明但是不定义的private成员函数,来彻底拒绝任何拷贝该类对象的企图:试图拷贝时会在编译时被视为错误,成员和友元函数的拷贝操作会导致连接错误。
ps:判断一个类是否需要拷贝控制函数成员,首先判断其是否需要自定义版本的析构函数,如果需要,则拷贝控制成员函数都需要。如果类中有智能指针,可以自动控制内存的释放,所以使用类的合成析构函数即可。另外类默认的拷贝控制成员对于智能指针的拷贝也不需要自定义版本来修改,所以全部定义为 =default 即可,如果是普通指针,则需要定义。
拷贝控制和资源管理
-
管理类外资源的类必须定义拷贝控制成员。
-
为了定义拷贝控制成员,我们可以定义拷贝操作,使得类的行为看起来像是一个值或者一个指针。
-
类的行为像一个值,拷贝发生时,副本和原对象是完全独立的,改变副本不会对原对象产生影响。
-
类的行为像一个指针,拷贝发生时,副本和原对象共用底层数据,改变副本也会改变原对象。
定义类值版本的类
//拷贝赋值运算符 HasPtr& operator=(const HasPtr& h) { //防止delete了底层数据 string* str = new string(*h.ps); //临时拷贝底层数据 delete this->ps; //删除原内存 this->ps = str; //赋值 this->i = h.i; //赋值 return *this; //返回 }
-
类值版本,类的构造函数需要可能需要动态分配其成员的副本。
-
类值版本,类的拷贝赋值运算符相当于结合了构造函数和析构函数的操作,首先销毁左侧运算对象的资源,再从右侧运算符对象拷贝资源,注意顺序。
-
由于有上述的顺序存在,所以我们必须保证这样的拷贝赋值运算符是正确的:首先将右侧运算对象拷贝到一个临时的对象中,再销毁左侧的运算对象的现有成员,之后将临时对象中的数据成员拷贝至左侧对象中(防范自赋值的情况发生—首先就销毁了自身的成员,再进行拷贝自身则会访问到已经释放的内存中)
定义指针版本的类
引用计数
工作方式:
- 每个构造函数创建一个引用计数,用来记录有多少对象与正在创建的对象共享状态,创建一个对象时,只有一个共享状态,引用计数为1。
- 拷贝构造函数递增共享的计数器,指出给定对象的状态被一个新用户共享。
- 析构函数递减计数器,共享的对象减少一个,计数为0,析构函数释放状态。
- 拷贝赋值运算符递增右侧运算对象,递减左侧运算对象。
class HasPtr { public: HasPtr(const string& s = string(),int i=0) //初始化引用计数为1 :ps(new string(s)), i(i),use(new size_t(1)) {} //析构函数 ~HasPtr(); //拷贝构造函数 HasPtr(const HasPtr& h) :ps(h.ps), i(h.i), use(h.use) {++* use;} //拷贝赋值运算符 HasPtr& operator=(const HasPtr& h); private: string* ps; int i; size_t* use; //记录有多少个对象共享*ps的成员 };
引用计数的拷贝构造函数:拷贝ps本身,而不是ps所指向的string,共享ps及计数器,同时递增与string相关联的计数器。
引用计数的析构函数:递减引用计数,指出共享的string对象少了一个,当计数为0,说明无共享对象,释放ps和use的内存。
HasPtr::~HasPtr() { if (-- * use == 0) { delete ps; delete use; } }
引用计数的拷贝赋值运算符:递增右侧对象引用计数,递减左侧引用计数,释放左侧对象的原始内存,赋予新的内存数据。
inline HasPtr& HasPtr::operator=(const HasPtr& h) { //递增右侧 ++* h.use; //递减左侧 if (-- * use) { delete ps; delete use; } //数据赋值 ps = h.ps; i = h.i; use = h.use; return *this; }
本文来自博客园,作者:hugeYlh,转载请注明原文链接:https://www.cnblogs.com/helloylh/p/17209731.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)