文章目录
一、构造函数
1.认识构造函数
当创建一个类类型对象时,类通过一个或者几个特殊的成员函数来控制对象的初始化,这种函数就是构造函数。它的任务就是用来初始化类对象的成员的,所以当创建类对象或者类对象被创建就会调用构造函数。构造函数的几个特点:①函数名和类名必须一样,没有返回值;②当没有显式的定义构造函数时,系统会自己生成默认的构造函数;③构造函数可以重载(可以带多个参数,析构函数不可以重载,因为析构函数无参),不可以为虚函数。
class Date {
public:
Date() {}
Date(int day) {
_year = 1949;
_month = 10;
_day = day;
}
void print() { cout << _year << "-" << _month << "-" << _day << endl; }
private:
int _year = 1990;
int _month;
int _day;
};
在上面的代码中,定义了一个简单的Date类类型,可以看到有显式的给出了构造函数,第一个是没有参数列表且函数不做任何事的,还有一个是有一个整型参数day的,就是当传了一个day参数,在函数内部把它的year和month初始化为1994和10。这样的两个构造函数就构成了重载,因为能够重载,所以在写构造函数的时候要保证只有一个缺省的构造函数(参数列表为空或者参数全缺省称为缺省构造函数)。当不传参的定义一个Date类型对象,会调用显式定义的缺省构造函数,在没有初始化列表的情况下采取类内初始化或默认初始化,上面的程序中,如果不传参,那么构建的对象的_year成员为1990,另外两个值为随机值。牢记: 在没有显式的定义构造函数时,系统会自动生成一个默认构造函数。当定义了一些其他的构造函数时,这个类就将没有默认构造函数。所以当显式的定义了其他构造函数,最好把默认构造函数也显式的定义一遍。这样也有好处,就是系统生成的默认构造函数有可能执行错误的操作或者无法完成类成员的初始化(例如有一个成员是类类型的对象且它没有缺省的构造函数)。当定义的默认构造函数并不需要干什么事情,只是因为上面的情况才显式的定义它,那么此时的默认构造函数等同于系统生成的默认构造函数,那么可以这么定义:Date() = default;
,因为在新标准中,如果需要系统默认的行为,就可以通过在参数列表后加上=default
来使编译器生成构造函数。
2.初始化列表
如下图所示,在冒号和花括号之间的代码部分称为构造函数的初始值列表,它的作用是给创建的对象的某些成员赋初值。这种是在构建对象的时候的初始化,是在对象创建成功之前完成的,和在函数体内赋值是不一样的,函数体内赋值是对象成员都已经创建好后对成员进行的赋值。
那么可以看到,这种初始化并不是必须的。但是在以下几种情况时是必须进行初始化的:①成员是const
类型;②成员是引用类型;③有一个成员是类类型的对象,且它没有缺省的构造函数。
class Time {
public:
Time(int hour) {
_hour = hour;
}
private:
int _hour;
};
class Date {
public:
Date(int year = 1990, int month = 1, int day = 1) : _year(year), _month(month), _day(day), t(10) {}
void print() { cout << _year << "-" << _month << "-" << _day << endl; }
private:
int _year;
int _month;
int _day;
Time t;
};
解释:①对于const
和引用类型,必须要进行初始化,所以它们必须在初始化列表中进行初始化;②当类类型成员有缺省的构造函数时,在创建对象的时候会默认调用,因为不用传参。当构造函数不是缺省的,如果不在初始化列表中调用构造函数,系统就无法知道怎么调用t
的构造函数,那么就无法创建t
了。如上代码中,需要在参数列表中调用t
的构造函数才不会出错。成员初始化顺序:在上面的初始化列表中,每个成员只能出现一次,因为一个变量多次初始化是无意义的,还有重要的一点,初始化列表的顺序并不限定初始化的执行顺序,成员的初始化顺序是与类中定义的顺序保持一致。可以看看下面的初始化列表:
在这里的意思是想要用1来初始化_month
,再用_month
初始化_year
。但其实是_year
被先初始化,而此时_month
并没有初始化,所以最后的结果是_year
是一个随机值。所以,最好让构造函数初始值的顺序与成员声明的顺序保持一致。
二、拷贝构造函数
1.类对象的拷贝
对于普通类型的对象来说,它们之间的复制是很简单的,例如:
int a = 88;
int b = a;
而类对象与普通对象不同,类对象内部结构一般较为复杂,存在各种成员变量。下面看一个类对象拷贝的简单例子:
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
CExample(int b) {
a = b;
}
void Show() {
cout << a << endl;
}
};
int main() {
CExample A(100);
CExample B = A;
B.Show();
return 0;
}
运行程序,屏幕输出100。从以上代码的运行结果可以看出,系统为对象B分配了内存并完成了与对象A的复制过程。就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的。下面举例说明拷贝构造函数的工作过程:
#include <iostream>
using namespace std;
class CExample {
private:
int a;
public:
CExample(int b) { a = b; }
CExample(const CExample& C) { a = C.a; }
void Show() { cout << a << endl; }
};
int main() {
CExample A(100);
CExample B = A;
B.Show();
return 0;
}
CExample(const CExample& C)
就是我们自定义的拷贝构造函数。可见,拷贝构造函数是一种特殊的构造函数,函数的名称必须和类名称一致,它的唯一的一个参数是本类型的一个引用变量,该参数是const
类型,不可变的。例如:类X的拷贝构造函数的形式为X(X& x)
。当用一个已初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用。也就是说,当类的对象需要拷贝时,拷贝构造函数将会被调用。以下情况都会调用拷贝构造函数:①一个对象以值传递的方式传入函数体;②一个对象以值传递的方式从函数返回;③一个对象需要通过另外一个对象进行初始化。如果在类中没有显式地声明一个拷贝构造函数,那么,编译器将会自动生成一个默认的拷贝构造函数,该构造函数完成对象之间的位拷贝。位拷贝又称浅拷贝,后面将进行说明。自定义拷贝构造函数是一种良好的编程风格,它可以阻止编译器形成默认的拷贝构造函数,提高源码效率。
2.浅拷贝和深拷贝
在某些状况下,类内成员变量需要动态开辟堆内存,如果实行位拷贝,也就是把对象里的值完全复制给另一个对象,如A=B
。这时,如果B中有一个成员变量指针已经申请了内存,那A中的那个成员变量也指向同一块内存。这就出现了问题:当B把内存释放了(如:析构),这时A内的指针就是野指针了,出现运行错误。深拷贝和浅拷贝可以简单理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,资源重新分配,这个过程就是深拷贝,反之,没有重新分配资源,就是浅拷贝。下面举个深拷贝的例子:
#include <iostream>
using namespace std;
class CA {
public :
CA(int b, char* cstr) {
a = b;
str = new char[b];
strcpy(str, cstr);
}
CA(const CA& C) {
a = C.a;
str = new char[a]; // 深拷贝
if (str != 0)
strcpy(str, C.str);
}
void Show() {
cout << str << endl;
}
~CA() {
delete str;
}
private :
int a;
char* str;
};
int main() {
CA A(10, "Hello!");
CA B = A;
B.Show();
return 0;
}
深拷贝和浅拷贝的定义可以简单理解成:如果一个类拥有资源(堆,或者是其它系统资源),当这个类的对象发生复制过程的时候,这个过程就可以叫做深拷贝,反之对象存在资源,但复制过程并未复制资源的情况视为浅拷贝。浅拷贝资源后在释放资源的时候会产生资源归属不清的情况导致程序运行出错。CA(const CA& C)
是自定义的拷贝构造函数,拷贝构造函数的名称必须与类名称一致,函数的形式参数是本类型的一个引用变量,且必须是引用。当用一个已经初始化过了的自定义类类型对象去初始化另一个新构造的对象的时候,拷贝构造函数就会被自动调用,如果没有自定义拷贝构造函数的时候,系统将会提供一个默认的拷贝构造函数来完成这个过程,上面代码的复制核心语句就是通过CA(const CA& C)
拷贝构造函数内的语句完成的。
三、赋值函数
当一个类的对象向该类的另一个对象赋值时,就会用到该类的赋值函数,当没有重载赋值函数(赋值运算符)时,通过默认赋值函数来进行赋值操作。
A a;
A b;
b = a;
强调: 这里a和b对象是已经存在的,是用a对象来赋值给b的。赋值运算的重载声明如下:
A& operator = (const A& other)
通常会混淆拷贝构造函数和赋值函数,仔细比较两者的区别:①拷贝构造函数是一个对象初始化一块内存区域,这块内存就是新对象的内存区,而赋值函数是对于一个已经被初始化的对象来进行赋值操作:
class A;
A a;
A b = a; // 调用拷贝构造函数(b不存在)
A c(a); // 调用拷贝构造函数
/****/
class A;
A a;
A b;
b = a; // 调用赋值函数(b存在)
②一般来说,在数据成员包含指针对象的时候,需要考虑两种不同的处理需求,一种是复制指针对象,另一种是引用指针对象,拷贝构造函数大多数情况下是复制,而赋值函数是引用对象;③实现不一样,拷贝构造函数首先是一个构造函数,它调用的时候是通过参数的对象初始化产生一个对象,赋值函数则是把一个新的对象赋值给一个原有的对象,所以如果原来的对象中有内存分配要先把内存释放掉,而且还要检察一下两个对象是不是同一个对象,如果是,不做任何操作,直接返回。(这些要点会在下面的String
实现代码中体现)。如果不想写拷贝构造函数和赋值函数,又不允许别人使用编译器生成的缺省函数,最简单的办法是将拷贝构造函数和赋值函数声明为私有函数,不用编写代码。如:
class A {
private:
A(const A& a); // 私有拷贝构造函数
A& operate=(const A& a); // 私有赋值函数
}
如果程序这样写就会出错:
A a;
A b(a); // 调用了私有拷贝构造函数,编译出错
A b;
b = a; // 调用了私有赋值函数,编译出错
所以如果类定义中有指针或引用变量或对象,为了避免潜在错误,最好重载拷贝构造函数和赋值函数。下面以String
类的实现为例,完整的写了普通构造函数、拷贝构造函数、赋值函数的实现:
String::String(const char* str) { // 普通构造函数
cout << construct << endl;
if (str == NULL) { // 如果str为NULL,就存一个空字符串“”
m_string = new char[1];
*m_string = '\0';
} else {
m_string = new char[strlen(str) + 1]; // 分配空间
strcpy(m_string, str);
}
}
String::String(const String& other) { // 拷贝构造函数
cout << "copy construct" << endl;
m_string = new char[strlen(other.m_string) + 1]; // 分配空间并拷贝
strcpy(m_string, other.m_string);
}
String& String::operator=(const String& other) { // 赋值运算符
cout << "operator =funtion" << endl;
if (this == &other) { // 如果对象和other是用一个对象,直接返回本身
return *this;
}
delete[] m_string; // 先释放原来的内存
m_string = new char[strlen(other.m_string) + 1];
strcpy(m_string, other.m_string);
return *this;
}
一句话记住三者:对象不存在,且没用别的对象来初始化,就是调用了构造函数;对象不存在,且用别的对象来初始化,就是拷贝构造函数(上面说了三种用它的情况);对象存在,用别的对象来给它赋值,就是赋值函数。
四、析构函数
1.认识析构函数
类的析构函数,它是类的一个成员函数,名字由波浪号加类名构成,是执行与构造函数相反的操作:释放对象使用的资源,并销毁非static
成员。同样的,来看看析构函数的几个特点:①函数名是在类名前加上~
,无参数且无返回值;②一个类只能有且有一个析构函数,如果没有显式的定义,系统会生成一个缺省的析构函数(合成析构函数);③析构函数不能重载,每有一次构造函数的调用就会有一次析构函数的调用。拿程序说话:
class Date {
public:
Date(int year = 1990, int month = 1, int day = 1) : _month(year), _year(month), _day(day) {}
~Date() { cout << "~Date()" << this << endl; }
private:
int _year = 1990;
int _month;
int _day;
};
void test() {
Date d1;
}
int main() {
test();
return 0;
}
在test()
函数中构造了对象d1,那么在出test()
作用域d1应该被销毁,此时将调用析构函数,下面是程序的输出(当然在构建对象时是先调用构造函数的,在这里就不加以说明了):
在构造函数中,成员的初始化是在函数体执行前完成的,并按照成员在类中出现的顺序进行初始化,而在析构函数中,首先执行函数体,然后再销毁成员,并且成员按照初始化的逆序进行销毁。
2.销毁,清理?
析构函数的作用是在类对象离开作用域后释放对象使用的资源,并销毁成员。那么这里所说的销毁到底是什么?继续往下看:
void test () {
int a = 10;
int b = 20;
}
回想在一个函数体内定义一个变量的情况,在test()
函数中定义了a和b两个变量,那么在出这个函数之后,a和b就会被销毁(栈上的操作)。如果是一个指向动态开辟的一块空间的指针,我们知道需要自己进行free
,否则会造成内存泄漏。其实在类里面的情况也是一样的,这就是析构函数体为空的原因,函数并不需要做什么,当类对象出作用域时系统会释放内置类型的那些成员。但是像上面说的一样,如果成员里有一个指针变量并且指向了一块动态开辟的内存,那么也需要自己来释放,此时就需要在析构函数内部写释放代码,这样在调用析构函数的时候就可以把所有的资源进行释放(其实这才是析构函数有用的地方)。还有一点,当类类型对象的成员还有一个类类型对象,那么在析构函数里也会调用这个对象的析构函数。
3.析构函数来阻止该类型对象被销毁?
如果不想要析构函数来对对象进行释放该怎么做呢?不显式的定义显然是不行的,因为编译器会生成默认的析构函数。如果想让系统默认生成自己的构造函数可以利用default
,其实还有一个东西叫做delete
:
class Date {
public:
Date(int year = 1990, int month = 1, int day = 1)
: _year(year), _month(month), _day(day) {}
~Date() = delete;
private:
int _year = 1990;
int _month;
int _day;
};
如果这么写了,又在底下创建Date类型的对象,那么这个对象将是无法被销毁的,其实编译器并不允许这么做,会直接报错。但是允许动态创建这个类类型对象,像这样:Date* p = new Date;
,虽然这样是可行的,但当delete p
的时候依然会出错。所以既然这样做的话既不能定义一个对象也不能释放动态分配的对象,所以还是不要这么用为好。
4.注意
一般在显式的定义了析构函数的情况下,也应该把拷贝构造函数和赋值操作显式的定义。看下面的改动:
class Date {
public:
Date(int year = 1990, int month = 1, int day = 1) : _year(year), _month(month), _day(day) { p = new int; }
~Date() { delete p; }
private:
int _year = 1990;
int _month;
int _day;
int *p;
};
成员中有动态开辟的指针成员,在析构函数中对它进行了delete
,如果不显式的定义拷贝构造函数,当这样:Date d2 (d1);
来创建d2,我们都知道默认的拷贝构造函数是浅拷贝,那么这么做的结果就会是d2的成员p
和d1的p
是指向同一块空间,那么调用析构函数的时候会导致一块空间被释放两次,程序会崩溃。