title: 析构/构造/拷贝构造函数
date: 2020-08-24 19:29:09
tags: c++
categories: c++
在C++中对象的初始化和清理是两个非常重要的安全问题,一个对象或者变量没有初始状态,对其使用后果是未知的,同理使用完一个对象或者变量,没有及时清理,也会造成一定的安全问题。在C++中利用了构造函数和析构函数解决了上述问题,这两个函数将会被编译器自动调用,完成对象初始化和清理工作。
1、构造函数
构造函数它的名字和类名相同,没有返回值,不需要用户显式调用(用户也不能调用),而是在创建对象时自动执行。构造函数的主要作用在于创建对象时为对象的成员属性赋值。如果程序员不提供构造函数,编译器会提供,只不过是空实现。如果一旦用户自己定义了构造函数,不管有几个,也不管形参如何,编译器都不再自动生成
下面构造函数的使用方法:
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
string name;
int age;
float high;
//默认(无参)构造函数
Persion()
{
cout<<"默认构造函数"<<endl;
}
//有参构造函数
Persion(string m_name, int m_age, float m_high)
{
name = m_name;
age = m_age;
high = m_high;
cout<<"有参构造函数"<<endl;
}
};
int main(int argc, const char** argv) {
Persion p; //调用无参构造
Persion p1("张三",18,1.72); //调用有参构造
cout<<p1.name<<p1.age<<p1.high<<endl;
return 0;
}
1.1 构造函数的重载
和普通成员函数一样,构造函数是允许重载的。一个类可以有多个重载的构造函数,创建对象时根据传递的实参来判断调用哪一个构造函数。上例中的Persion
构造函数就是重载的。构造函数的调用是强制性的,一旦在类中定义了构造函数,那么创建对象时就一定会调用。如果有多个重载的构造函数,那么创建对象时提供的实参必须和其中的一个构造函数匹配;反过来说,创建对象时只有一个构造函数会被调用。
1.2 构造函数的初始化列表
构造函数的一项重要功能是对成员变量进行初始化,为了达到这个目的,可以在构造函数的函数体中对成员变量一一赋值,还可以采用初始化列表。
语法:构造函数():属性1(值1),属性2(值2)... {}
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
string name;
int age;
float high;
//普通成员变量赋值
Persion(string m_name, int m_age, float m_high)
{
name = m_name;
age = m_age;
high = m_high;
}
//使用初始化列表辅助
Persion(string m_name, int m_age, float m_high):name(m_name),age(m_age),high(m_high)
{
}
};
int main(int argc, const char** argv) {
Persion p1("张三",18,1.72);
cout<<p1.name<<p1.age<<p1.high<<endl;
return 0;
}
使用构造函数初始化列表并没有效率上的优势,仅仅是书写方便,不在对成员变量一一赋值,尤其在成员变量较多时,这种写法非常简单明了。
1.3初始化const成员变量
const 成员的初始化: 不能在定义处初始化,只能在构造函数列表中初始化,而且必须要有构造函数
原因:const数据成员只在某个对象生存期内饰常量,而对于整个类而言是可变的。 因为类可以创建多个对象,不同的对象其const数据成员的值可以不同。所以不能在类声明中初始化const数据成员,因为类的对象未被创建时,编译器不知道const数据成员的值是什么。
错误
class Persion
{
public:
string name;
int age;
const float high = 1; //可以这么用,但是错误的。使用const只是想修饰某个对象为常量。如果在声明时直接赋初值那么所有的对象都成一个常量值,这并不是我们想要的结果。
};
class Persion
{
public:
string name;
int age;
const float high;
//使用初始化列表赋值
Persion(string m_name, int m_age, float m_high)
{
name = m_name;
age = m_age;
high = m_high; //报错
}
};
正确用法是类中const修饰的成员变量只能使用初始化列表的形式赋值
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
string name;
int age;
const float high;
//使用初始化列表为每个对象的const成员赋值。
Persion(string m_name, int m_age, float m_high):name(m_name),age(m_age),high(m_high)
{
}
};
int main(int argc, const char** argv) {
Persion p1("张三",18, 17.5);
cout<<p1.name<<p1.age<<p1.high<<endl;
return 0;
}
1.4 类成员变量初始化顺序
#include <iostream>
using namespace std;
class Student
{
public:
int m_a;
int m_b;
void ShowAll(void)
{
cout << "m_a="<<m_a<<' '<< "m_b="<<m_b<< ' '<<"m_c="<<m_c<< ' '<<"m_d="<<m_d<<endl;
}
Student(int a,int b,int c, int d):m_b(b),m_d(m_b+1),m_c(m_d+1),m_a(m_c+1)
{
}
private:
int m_c;
int m_d;
};
int main(void)
{
Student st1(1,2,3,4);
st1.ShowAll();
return 0;
}
在不看运行结果前我们分析分析,按照正常思维st1对象被创建后会调用Student类的构造函数。在构造函数中使用了初始化列表的形式对各个属性进行赋值,m_b=2,m_d=3,m_c=4,m_a=5,但是这个结果是错的。下面看看正确答案。
为什么结果和我们预想的不一样呢?这是因为成员变量在使用初始化列表初始化时,与构造函数中初始化成员列表的顺序无关,只与定义成员变量的顺序有关。因为成员变量的初始化次序是根据变量在内存中次序有关,而内存中的排列顺序早在编译期就根据变量的定义次序决定了。
在本例中我们定义成员变量的顺序是m_a, m_b, m_c, m_d而初始化列表的顺序是m_b,m_d,m_c,m_a。因此初始化的顺序和列表中排列的顺序无关,仅仅与定义的顺序有关。由于m_a先定义因此会对m_a先赋值m_a=m_c+1,m_c是个垃圾值,因此m_a也是个垃圾值。接下来是对m_b进行赋值m_d=b,也就是2。下面是m_c赋值,m_c =m_d+1,m_d还未赋值是个垃圾值,因此m_c也是垃圾值。最后是m_d,m_d=m_b+1,m_b刚刚被赋值为2,因此m_d就等于3。
如果不使用初始化列表初始化,在构造函数一个一个对其赋值初始化时,此时与成员变量定义的顺序无关。和在构造函数中赋值初始化的顺序有关
2、析构函数
创建对象时系统会自动调用构造函数进行初始化工作,同样,销毁对象时系统也会自动调用一个函数来进行清理工作,例如释放分配的内存、关闭打开的文件等,这个函数就是析构函数。析构函数也是一种特殊的成员函数,没有返回值,不需要程序员显式调用,而是在销毁对象时自动执行。构造函数的名字和类名相同,而析构函数的名字是在类名前面加一个~
符号。
析构函数没有参数,不能被重载,因此一个类只能有一个析构函数。如果用户没有定义,编译器会自动生成一个默认的析构函数。
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
int *m_age = NULL;
Persion(int age) //构造函数
{
int *m_age = new int;
*m_age = age;
}
~Persion() //析构函数
{
delete m_age;
cout<<"析构函数"<<endl;
}
};
int main(int argc, const char** argv) {
Persion p1(25);
return 0;
}
3、拷贝构造函数
C++中还有一类特殊的构造函数,他就是拷贝构造函数。拷贝构造函数的常见形式如下:
classname (const classname &obj)
{
// 构造函数的主体
}
如果在类中没有显式定义拷贝构造函数,编译器会自行定义一个。编译器定义的拷贝构造函数会带来浅拷贝的问题。稍后再做说明,先看看拷贝构造函数用在什么地方。
拷贝构造函数常常用于以下三个地方
-
(1)使用一个已经创建完毕的对象来初始化一个新对象时
class Persion { public: Persion(int age):m_age(age) //构造函数 { } ~Persion() //析构函数 { } Persion(const Persion &p) //拷贝构造函数 { this->m_age = p.m_age; cout<<"拷贝构造函数的调用"; } int m_age; }; int main(int argc, const char** argv) { Persion p1(20); //p1已经创建完毕的对象 Persion p2(p1); //使用一个创建完毕的对象p1,初始化一个新对象p2时,对调用拷贝构造函数 cout<<p2.m_age<<endl; return 0; }
-
(2)对象作为参数以值传递的方式给函数传参时
class Persion { public: Persion(int age,string name):m_age(age),m_name(name) //构造函数 { } ~Persion() //析构函数 { } Persion(const Persion &p)//拷贝构造函数 { this->m_age = p.m_age; this->m_name = p.m_name; cout<<"调用"<<endl; } int m_age; string m_name; }; void DoingSomething(Persion p) { cout<<p.m_name<<"DoingSomething"<<endl; } int main(int argc, const char** argv) { Persion p1(18,"张三");//建立一个对象 DoingSomething(p1);//对象作为参数,以值传递方式传递给函数时,会调用拷贝构造函数 return 0; }
-
(3)以值方式返回局部对象
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
Persion(int age, string name) :m_age(age), m_name(name) //构造函数
{
}
~Persion() //析构函数
{
}
Persion(const Persion &p) //拷贝构造函数
{
this->m_age = p.m_age;
this->m_name = p.m_name;
cout << "调用" << endl;
}
int m_age;
string m_name;
};
Persion test(void)
{
Persion p(25, "李四"); //新建栈区局部对象
return p; //返回局部对象。会调用拷贝构造函数
}
int main(int argc, const char** argv) {
Persion p1 = test(); //接收返回的局部对象
cout << p1.m_name << p1.m_age << endl;
return 0;
}
3.1深拷贝与浅拷贝问题
如果用户没有自己编写拷贝构造函数,使用编译器默认提供的拷贝构造函数,那么就会带来浅拷贝的问题。看下面这个例子:
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
Persion(int age, string name) //构造函数
{
m_name = name;
m_age = new int(age); //在堆区开辟内存
}
~Persion() //析构函数
{
delete m_age; //对象销毁前释放对象所占用的资源
}
int * m_age;
string m_name;
};
void Dosomething(Persion p)
{
;
}
int main(int argc, const char** argv) {
Persion p(17, "张三");
Dosomething(p); //触发调用拷贝构造函数
return 0;
}
上述代码在Visual Studio 2017中运行出错。原因看下图,当触发调用拷贝构造函数时,由于程序员自己未定义拷贝构造函数,所以使用了编译器默认提供的拷贝构造函数。而编译提供的拷贝构造函数类似于下
Persion(const Persion &obj) { m_age = obj.m_age; m_name = obj.m_name; }
它只是简单的进行了赋值操作,而类中int * m_age指向的是堆区申请的空间,这就导致对象P和拷贝的对象P~中int * m_age成员都指向了同一块内存空间。而当这个对象销毁时,会执行析构函数,在析构函数中我们对堆区申请的内存进行释放。这就导致当对象P~执行析构时释放完堆区内存后,对象P在执行析构时又对堆区申请的这片内存进行了释放,但是第一次p~释放完这片内存后用户对这片内存已经没有访问操作的权限,P再次去释放时就属于非法操作。因此程序会出异常。这就是浅拷贝带来的问题。
3.2 解决浅拷贝带来的问题
下面针对上面的例子重新修正后再看。
#include <iostream>
#include <string>
using namespace std;
class Persion
{
public:
Persion(int age, string name) //构造函数
{
m_name = name;
m_age = new int(age); //在堆区开辟内存
}
~Persion() //析构函数
{
delete m_age;
}
Persion(const Persion &obj) //显示定义拷贝构造函数
{
m_age = new int(*obj.m_age);
m_name = obj.m_name;
}
int * m_age;
string m_name;
};
void Dosomething(Persion p)
{
;
}
int main(int argc, const char** argv) {
Persion p(17, "张三");
Dosomething(p); //触发调用拷贝构造函数
return 0;
}
本例中仅仅添加了17-21行的代码,这里我们显式地定义了拷贝构造函数,它除了会将原有对象的所有成员变量拷贝给新对象,还会为新对象再分配一块内存,并将原有对象所持有的内存也拷贝过来。这样做的结果是,原有对象和新对象所持有的动态内存是相互独立的,更改一个对象的数据也不会影响另外一个对象。
我们必须显式地定义拷贝构造函数才能达到深拷贝的目的。如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深拷贝,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅拷贝足以。
总结:如果属性有在堆区开辟的,一定要自己提供拷贝构造函数,防止浅拷贝带来的问题