300字范文,内容丰富有趣,生活中的好帮手!
300字范文 > 面向对象和C++基础—面向对象(构造与析构函数篇)

面向对象和C++基础—面向对象(构造与析构函数篇)

时间:2021-09-17 06:09:49

相关推荐

面向对象和C++基础—面向对象(构造与析构函数篇)

文章目录

8.面向对象—构造与析构函数篇(1). 为什么要单独设计构造函数与析构函数?#1.Default—自动生成构造函数与析构函数代码#2.那析构函数呢? (2). 构造函数的分类(3). 一般有参/无参构造函数#1.一个简单的有参构造函数定义#2.参数为std::initializer_list的情况 (4). 拷贝构造函数#1.深拷贝与浅拷贝#2.实现一个拷贝构造函数 (5). 移动语义和移动构造函数(6). 列表初始化#1.什么是列表初始化#2.为什么要列表初始化#3.列表初始化的顺序 (7). 析构函数总结

8.面向对象—构造与析构函数篇

  什么东西都不是一蹴而就的,我们需要产生一个对象就一定首先需要创建它,在C++中我们使用构造函数(Constructor)来创建对象,当一个对象使用完毕之后,我们需要把它销毁掉以节省内存,这就要用到析构函数(Destructor),这两个东西在面向对象的程序设计中起着举足轻重的作用。

(1). 为什么要单独设计构造函数与析构函数?

  事实上如果你在设计一个类的时候可以让C++帮你生成一个默认的构造函数与析构函数,例如:

class A{public:A() = default;~A() = default;};

#1.Default—自动生成构造函数与析构函数代码

  上面的代码中我们定义了一个叫做A的类,它具有两个函数—A()和~A(),这两个函数是没有返回类型的,前者就是构造函数,而后者是析构函数,构造函数是可以有参数的,这我们在之后会提到,而析构函数不能有参数,因此一个类只会有一个析构函数

  我们在这两个函数后加上了default,那么编译器就会帮助我们自动生成,不过编译器只能生成无参构造函数,也正常,有参的构造函数的需求并不明确,因此编译器无法帮你自动生成。

  因此,假设我们需要在初始化的时候对对象的属性做一些操作的时候,我们就必须单独设计构造函数了。

#2.那析构函数呢?

  析构函数貌似并不需要我们重载啊,毕竟编译器都帮我们自动生成了。其实不然,析构函数的作用还是相当重要的,C++语言被用于相当多的工程当中,假设对于这样一个需求:一个长期运行的服务器,会不定期有用户连接,服务器需要对客户端的请求作出相应的操作,确认连接之后会定期发送心跳连接包确认连接没有断开,假设确认已经断开,需要尽快析构掉这样一个客户对象以节省空间。

  这个需求用面向对象的方式设计是相当合适的,对于大量具有共同特性和类似请求的东西我们采取类的方式进行设计,我们设计了一个Client类,对于以上的需求我们都写出对应的方法,接下来就有一个比较重要的问题:如何设计析构函数以节省空间呢?我们或许在这个类中写了一些需要动态内存分配的属性,在析构函数中我们必须将他们进行手动释放才能真正节省空间,而默认生成的析构函数并不会做这件事情。

  我们试着用以下的例子证明一下这件事情:

#include <iostream>class Test{public:int* p = nullptr;Test(){this->p = new int[5]{20};}~Test() = default;};int main(){Test t;int* p = t.p;t.~Test();for (int i = 0; i < 5; i++) {std::cout << p[i] << " ";}std::cout << std::endl;return 0;}

  我们在调用了Test类的默认析构函数之后,p这个指针依旧没有被释放,这也就是为什么在有动态内存分配需求的情况一定要注意析构函数的重载

(2). 构造函数的分类

  构造函数也是函数,它和一般函数最大的区别就在于没有返回类型,对于不同的需求,我们可以比较自由的编写,例如对于std::vector类(C++ STL中的动态数组类型,之后会细讲),我们有以下几种构造函数:

vector<T>(); // 无参构造函数vector<T>(size_t i); // 初始分配i个空间的构造函数vector<T>(size_t i, T _val); // 初始分配i个空间,且赋初始值为_valvector<T>(std::initializer_list<T> l); // 用std::initializer_list<T>中的元素进行初始化vector<T>(const vector<T>& vec); // 拷贝构造函数...

  以上是我们用的比较多的一些构造函数,在C++11中还有一个全新的构造函数——移动构造函数,一般形式为:

vector<T>(vector<T>&& vec);

  这里参数的类型就是vector&&右值引用类型了,它被称为移动构造函数是因为我们需要配合std::move()——移动语义一并使用,这个我们马上会来具体介绍。

  所以综合以上的各种构造函数,我们可以将其分为一般的有参/无参构造函数、拷贝构造函数以及移动构造函数

(3). 一般有参/无参构造函数

  这一部分还是很简单的,我们只需要根据自己的需求去写这个函数,例如前文中提到的vector的以下构造函数:

vector<T>(); // 无参构造函数vector<T>(size_t i); // 初始分配i个空间的构造函数vector<T>(size_t i, T _val); // 初始分配i个空间,且赋初始值为_valvector<T>(std::initializer_list<T> l); // 用std::initializer_list<T>中的元素进行初始化

#1.一个简单的有参构造函数定义

  假设对于vector<T>(size_t i, T_val);这个函数,我们进行以下定义:

template<typename T>vector<T>(size_t i, T _val){this->_data = new T[i];for (size_t j = 0; j < i; j++) {this->_data[j] = _val;}}

  这里用到了一个叫做this的东西,this是一个指向自己这个对象本身的一个指针,在传入参数的名字与对象成员的名字不冲突的时候,这么写也是可以的:

template<typename T>vector<T>(size_t i, T _val){_data = new T[i];for (size_t j = 0; j < i; j++) {_data[j] = _val;}}

  因为_data本身是处于本对象的作用域内的,可以直接访问,不过我个人比较习惯用this,在Java中,this也是可选使用的,不过Java中的this不是指针,而就是本身这个对象。在Python中,与this对应的就是self,在Python的成员函数设计中,要访问属性一定需要通过self.xxx进行访问。

#2.参数为std::initializer_list的情况

  假设你要写一个保存数据的函数,重载一个参数为std::initializer_list<T>类型的构造函数还是相当有必要的,比如我们在初始化一个std::vector<int>对象时,可以使用以下语句进行初始化:

std::vector<int> vec{1, 2, 3, 4};

  vec后面跟着的{1, 2, 3, 4}这个列表一样的东西就是std::initializer_list,我们可以通过重载对于这个类型的构造函数来完成一个vector的初始化,我们可以这么完成:

template<typename T>vector<T>(std::initializer_list<T> l){for (auto it = l.begin(); it != l.end(); it++) {this->push_back(*it);}}

  我们需要用迭代器的方式进行遍历,然后用*it来访问每个元素,真正的vector可能并不是这么实现这个函数的,在这里我只是用来举个例子

(4). 拷贝构造函数

#1.深拷贝与浅拷贝

  拷贝是Copy的音译,你也可以将其译作"复制",这完全看你的个人喜好。要完成对于类的拷贝操作,实际上并不一定需要自己重写一个拷贝构造函数,例如:

#include <iostream>using namespace std;class A{public:int a = 0;};int main(){A a{10};A b = a;cout << b.a << endl;return 0;}

  是吧,我们没有写类A的任何一种拷贝构造函数,就完成了拷贝的过程,在C++中,拷贝操作实际上会由编译器自动生成一部分代码完成这个工作,但是这个过程是机械的,也就是说:默认的拷贝构造函数会原封不动地把所有数据拷贝到需要被拷贝到的对象当中去,我们来看下面这个例子:

#include <iostream>using namespace std;class A{public:int a = 0;int* p = nullptr;};int main(){int d{20};A a{10, new int[20]};A b = a;cout << "a.a = " << a.a << ", a.p = " << a.p << endl;cout << "b.a = " << b.a << ", b.p = " << b.p << endl;return 0;}

  我们打印出两个对象的a和p属性,结果发现他们是完全一致的,a属性倒无所谓,但是这个p属性有点麻烦,它原封不动的把a.p赋值给了b.p,这样一来,假设我在A的析构当中delete掉了这个a.p,那么b.p的内存也就一并被释放掉了,如果再对b.p进行写入,很有可能会出现其他的问题。

  这样对于属性为指针的值进行简单拷贝的操作,就叫做浅拷贝,这看起来真的是很有危害啊,不过实际上这样的操作不是完全没有好处,如果我们的类中只有简单的几个非指针类型的属性,我们拷贝的时候就不需要自己写拷贝构造函数了,但是一旦涉及到指针等的数据,那就一定要自己重写拷贝构造函数了。

  与浅拷贝相对的,就是深拷贝,对于形如指针的属性,只拷贝地址指向的值,不拷贝地址本身的值

#2.实现一个拷贝构造函数

  接下来的问题就是如何实现拷贝构造函数了,其实还是比较简单的。

#include <iostream>#include <cstring>using namespace std;class Info{public:int* scores = nullptr;int size = 0;size_t ID = 0;public:Info() = default; // 自动生成无参构造函数Info(const Info& info); // 拷贝构造函数~Info(); // 析构函数void print();};int main(){Info i1;i1.scores = new int[3]{99, 90, 85};i1.ID = 121001030;i1.size = 3;i1.print();Info i2(i1);i2.print();return 0;}Info::Info(const Info& info){this->scores = new int[info.size];memcpy(this->scores, info.scores, info.size * sizeof(int));this->ID = info.ID;this->size = info.size;}Info::~Info(){delete[] this->scores;this->scores = nullptr;this->ID = 0;this->size = 0;}void Info::print(){cout << this->ID << " : " << this->scores << ", ";for (int i = 0; i < this->size; i++) {cout << this->scores[i] << " ";}cout << endl;}

  这就完成了拷贝,并且两个对象的scores的地址并不一样,而且保证了两个对象的值是一样的,这就完成了一次深拷贝的操作。

(5). 移动语义和移动构造函数

  C++11中出现了type&&这样的新特性,称为右值引用,这使得我们可以对于一个临时对象(右值)取引用,并进行一定的操作,那这有什么好处吗?让我们来设想这样一个情景:

  我们有这样一个类:

class A{public:int* a = nullptr;int cnt = 0;A(){std::cout << "Default Ctor" << std::endl;this->a = new int[100000]{0 };}A(const A& a){this->a = new int[100000]{0 };memcpy(this->a, a.a, 100000 * sizeof(int));std::cout << "Memcpy finished!" << std::endl;this->cnt = t;}~A(){delete[] a;a = nullptr;cnt = 0;}};

  它的属性中包含一个有十万个int元素的数组,我们重载了一个拷贝构造函数,那么我们每次通过这个方式构造新对象都要进行一次memcpy,例如:

int main(){A a{};memset(a.a, 2, 100000 * sizeof(int));t = 10;A b{a };return 0;}

  正常运行之后,完成了十万个元素的拷贝,我们在写代码的过程中可能需要一个工厂函数用于批量生成符合需要的对象,或许我们会这么写:

#include <iostream>#include <cstring>int size{0 };class A{public:int* pa = nullptr;int cnt = 0;A(){std::cout << "Default Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;}A(const A& a){std::cout << "Copy Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;memcpy(this->pa, a.pa, 100000 * sizeof(int));std::cout << "Memcpy finished!" << std::endl;this->cnt = t;}~A(){delete[] this->pa;this->pa = nullptr;cnt = 0;}};A Factory(){return A();}int main(){A b(Factory());std::cout << "Now size is " << size << std::endl;return 0;}

  以上是理想情况,整个操作会调用一次默认构造函数以及两次拷贝构造函数,过程是这样的:首先进入Factory()函数调用一次默认构造函数,在函数返回时,在main函数的作用域内利用拷贝构造函数生成一个临时对象,之后再利用拷贝构造函数根据临时对象生成对象b,因此size的值也是300000,很符合我们的预期。

  那么从这个角度上来看,整个过程的开销很大啊,一次通过工程函数生成对象赋值给一个正常的对象甚至需要使用两次拷贝构造函数,我们应该得要优化一下这个过程。

  但这是理想情况,假设你试图去跑一跑这个代码就会发现,无论你是g++,clang++还是msvc,跑出来的结果都是下图:

  这很奇怪,实际上我自己写出来的时候也很奇怪,因为下面这种结果size只有100000,并且从输出上看,甚至根本没有调用过拷贝构造函数,这太奇怪了。实际上,这是C++编译器的RVO(Return Value Optimization,返回值优化) ,编译器会自动对源代码进行转换,消除对象的创建来实现加速程序、提升性能的措施,简单来说,现代C++编译器足够智能,能够确定通过函数返回创建的临时对象,从而减少拷贝构造的次数。

  但是说到底,RVO优化是编译器完成的操作,从C++11起,我们可以从代码的层面上完成这个过程的优化了。

  在A这个类中,属性a的内存分配在堆上,理论上讲如果我们不手动释放,在程序结束之前这一部分内存都是可以在任何地方访问的,因此我们或许可以尝试直接把创建的临时对象里分配的这部分内存拿过来,这样一来不仅不用分配空间,也不用把临时对象的数据数据复制到新的对象数组中了

  没错,这样一来我们就要用到移动语义了,我们来看下面这一段代码:

#include <iostream>#include <cstring>int size{0 };class A{public:int* pa = nullptr;int cnt = 0;A(){std::cout << "Default Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;}A(const A& a){std::cout << "Copy Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;memcpy(this->pa, a.pa, 100000 * sizeof(int));this->cnt = t;}A(A&& a){std::cout << "Move Ctor" << std::endl;this->pa = a.pa;a.pa = nullptr;this->cnt = t;}~A(){std::cout << "Dtor" << std::endl;delete[] this->pa;this->pa = nullptr;cnt = 0;}};A Factory(){A temp{};std::cout << "temp.pa = " << temp.pa << std::endl;return temp;}int main(){A b(std::move(Factory()));std::cout << "b.pa = " << b.pa << std::endl;std::cout << "Now size is " << size << std::endl;return 0;}

  我们在构造b的过程当中不是单纯的用Factory()作为实参,而是附加了一个std::move()在外面,这就是C++11中引入的移动语义(Move Semantic),先不说那么多,我们来尝试运行一下上述的代码:

  我们先根据运行结果推断一下这个过程:首先在Factory函数中通过默认构造函数构造了一个对象temp,然后打印出来了temp对象的pa值,之后退出了Factory函数之后,进入了A(A&& a),即移动构造函数,之后发生了一次析构,这一次析构的是temp这个对象,之后回到main函数中,我们把已经构造好的对象b的pa打印出来,它的值和先前的temp的pa值完全一致,最后再打印出来size,然后程序结束,把对象b也析构掉。

  这一回输出的size是100000,也就是说,过程当中没有发生多次拷贝甚至没有发生过拷贝,并且我们把工厂函数中构造的临时对象的pa打印出来,和b的pa是完全一致的,这太棒了,直接拿过来之后不用多余的构造了。

  我们将这一种构造函数称为移动构造函数,对应的,std::move()称为移动语义,std::move()这个函数的工作相当简单,就是将一个值(无论左右值均可)转换为一个右值,之后再配合移动构造函数就可以在小开销的前提下完成新对象的构造。并且这个被转换为右值的对象会在移动构造函数之后被析构掉

  C++在引用类型方面还有相当多的讲究,你可以去网上查一查完美转发(Perfect Forward)通用引用(Universal Reference)以及引用折叠等等内容。

  所以要写好一个类,构造函数还是相当重要的,通过更加完善的拷贝、移动和默认构造函数的组合实现对象构造更低的开销,对于未来的你将会是一个很大的挑战。

(6). 列表初始化

#1.什么是列表初始化

  我们再拿类A来做个例子:

class A{public:int* pa;int cnt;A(){std::cout << "Default Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;}A(const A& a){std::cout << "Copy Ctor" << std::endl;this->pa = new int[100000]{0 };size += 100000;memcpy(this->pa, a.pa, 100000 * sizeof(int));this->cnt = t;}A(A&& a){std::cout << "Move Ctor" << std::endl;this->pa = a.pa;a.pa = nullptr;this->cnt = t;}~A(){std::cout << "Dtor" << std::endl;delete[] this->pa;this->pa = nullptr;cnt = 0;}};

  我们说构造函数是对对象的初始化,实际上是不严谨的,因为在初始化只能发生一次,而赋值可以发生任意多次,我们在构造函数内对于属性的操作只能算是赋初始值而并非初始化,因此在C++中还有一个叫做列表初始化的特性,它是这么做的:

A():pa(new int[100000]{0}), cnt(0){std::cout << "Default Ctor" << std::endl;size += 100000;}

  就是在构造函数后与大括号之前用属性名(初始化值)的方式完成属性的初始化操作,如果有多个,就用逗号进行分割。

#2.为什么要列表初始化

  那么你看完了之后发现,这不也没啥区别吗?跟我直接在构造函数里面写不是差不多吗?回归到本质,我们要注意一件事情:构造函数内只能算赋初始值,而列表初始化是真正的初始化,因此我们来看下面这个例子:

class A{public:const int ca;A(){ca = 1;std::cout << "Default Ctor" << std::endl;}A(const A& a){ca = a.ca;std::cout << "Copy Ctor" << std::endl;}};

  首先有个问题,这段代码能够通过编译吗?答案当然是不能,因为如我们前面所说,构造函数内的操作是赋值,而不是初始化,假设类的属性中包含一个常量,那我们用构造函数内赋值的办法就不能对这个属性进行初始化。

  如果你对属性ca赋了初始值,例如:

class A{public:const int ca = 3;A(){ca = 1;std::cout << "Default Ctor" << std::endl;}A(const A& a){ca = a.ca;std::cout << "Copy Ctor" << std::endl;}};

  这段代码依旧不能正常通过编译,因为我们在构造函数内试图对已经初始化的常量赋值,这是不允许的操作,那怎么办呢?当然是直接用列表初始化啦:

class A{public:const int ca;A() : ca(1){std::cout << "Default Ctor" << std::endl;}A(const A& a) : ca(a.ca){std::cout << "Copy Ctor" << std::endl;}};

  提到常量,我相信你也能很快想到引用,引用的本质是指针常量,如果类的属性中包含一个引用,我们也只能通过初始化的方式对引用进行修改,例如:

class A{public:int& a;A() = delete;A(int& b) : a(b) {}};

  很奇妙的写法,我们居然能只写int& a。在这里的A() = delete;就是强调禁止编译器自动生成默认构造函数。还有一种情况就是:类的其中一个属性为自定义的类型,且这个类型没有默认构造函数,细想一下其实是合理的,对于一个没有默认构造函数的类,我们不能在无参的情况下生成一个默认的对象,因此只能在构造过程当中利用其他的构造函数初始化这个类的对象,这里就不在演示了。

  所以如果你的类中包含以上三种类别的元素:常量类型引用类型以及没有无参构造函数的自定义类型,你的构造函数就一定要使用初始化列表,其他情况下你可以按照自己的喜好来完成。

#3.列表初始化的顺序

  顺序这个事情也还是挺重要的,比如我们有下面这个例子:

#include <iostream>class A{public:int xa;A() : xa(0) {}A(int a) : xa(a) {}A(const A& a) : xa(a.xa) {}};class B{public:A A1_, A2_;B() = delete;B(int a, int b): A1_(a), A2_(b) {}B(const B& b): A1_(b.A1_), A2_(A1_) {}};int main(){B b1{1, 2};B b2{b1};std::cout << b2.A1_.xa << " " << b2.A2_.xa << std::endl;return 0;}

  这段代码中,类B的拷贝构造函数中使用A1_完成了A2_的拷贝构造,如果顺序是先A1_,再A2_,那么运行结果就应该是1 1,否则的话就是1 0(因为A1_先用无参构造函数生成的对象中xa的值为0),跑一下!

  所以结果如第一种情况,假设我们把B的属性从A1_, A2_改为A2_, A1_呢?

#include <iostream>class A{public:int xa;A() : xa(0) {}A(int a) : xa(a) {}A(const A& a) : xa(a.xa) {}};class B{public:A A2_, A1_;B() = delete;B(int a, int b): A1_(a), A2_(b) {}B(const B& b): A1_(b.A1_), A2_(A1_) {}};int main(){B b1{1, 2};B b2{b1};std::cout << b2.A1_.xa << " " << b2.A2_.xa << std::endl;return 0;}

  这就变成第二种情况了,所以你知道了,初始化的顺序与初始化列表中的顺序是无关的,其顺序与类本身的属性声明顺序相关

(7). 析构函数

  讲完了构造函数,就要讲讲析构函数了,构造是对象的起点,而析构是对象的终点,二者是同样重要的。一般来说如果你不自行重载析构函数,C++编译器会自动生成一个默认的析构函数,那么对于下面这个类来说,有没有自行重载析构函数都是无所谓的:

#include <iostream>#include <stdexcept>#include <cstring>class A{private:int a[100]{0};int cnt{0};public:A() = default;A(const A& a){memcpy(this->a, a.a, 100 * sizeof(int));this->cnt = t;}int& operator[](size_t index) // 重载类的中括号运算符{if (index < 100) return a[index];else throw std::out_of_range{"Index out of range!"}; // 一旦超出上限,就抛出异常}};int main(){A a{};std::cout << a[10] << std::endl;return 0;}

  这次的C++教程当中可能也会有一些内容是与之后相关联的,我一般会特别指出,如果给你的学习造成了一些不便,敬请谅解。

  这段实例代码中的类A有两个属性,一个是数组a,还有一个是cnt,这两者都是分配在栈空间上的,理论上讲,这一部分内存的管理是由系统自动完成的,与我们无关,因此我们可以不用重载这个类的析构函数。

  在这段代码中包含的stdexcept头文件中包含了一系列std命名空间中的标准异常类,在A的中括号运算符重载中,我们用到的std::out_of_range()就属于其中之一,构造异常类时如果我们传入一个字符串,则会在程序抛出异常时自动显示出字符串的内容,例如我们将a[10]改为a[120],就会出现以下情景:

  这个不知道也没事,我们之后会有一个小节专门来讲异常处理机制

  那么我们回到析构函数上来,上面的情况是,内存不需要我们自行管理的情况,但是假设我们有了下面这个类,我们可能就不能不重载析构函数了:

#include <iostream>#include <stdexcept>#include <cstring>int size{0};class A{private:int* a;int cnt;public:A() : a(new int[100]) {size += 100;}A(const A& a) : a(new int[100]){size += 100;memcpy(this->a, a.a, 100 * sizeof(int));this->cnt = t;}int& operator[](size_t index) // 重载类的中括号运算符{if (index < 100) return a[index];else throw std::out_of_range{"Index out of range!"}; // 一旦超出上限,就抛出异常}};int main(){A a{};std::cout << a[10] << std::endl;return 0;}

  这个类中我们手动分配了堆内存上的一段空间,如果不进行手动释放,就可能会造成内存泄漏,所以我们需要给它补上这么一个析构函数:

~A(){delete[] a;a = nullptr;}

  要手动释放掉这块内存,而且还要记得把a指向空指针,这样才能避免对野指针进行读写操作。析构函数的调用一般是自动的,当然你也可以手动调用,例如在B中有一个A的对象,你可以这么写:

class A{...~A() ...};class B{...~B(){...a.~A();}};

  有一个比较重要的点是,在释放内存的时候一定要小心小心再小心,一旦在释放内存方面不小心,你就可能会delete一个栈空间上的数组,这就会引发编译错误,有的时候可能B类的属性a被别的对象所引用了,你不能直接析构掉a,否则别的对象在调用的时候也可能会出错。

总结

  类的构造函数与析构函数是相当重要的,一个好的类首先要具备合理的构造函数和析构函数,C++将内存的管理权交给了我们,我们就要尽可能地去保证内存安全,不过很多人做不到这一点,因此才会有异常强调内存安全的Rust出现。

  比较抱歉,这一篇拖了将近两个月才发出来,因为最近一段时间真的相当相当忙,下一篇我们会讲讲类当中一般的成员函数的设计

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。