文章目录
简介
class Date {};
我们看上面这个类。如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成6个默认成员函数,它们分别是:构造函数、析构函数、拷贝构造函数、赋值重载函数、普通对象的取地址重载函数和const对象的取地址重载函数。
(默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。)
我们下面先从构造函数开始:
构造函数※※※
当初我们在学习链表、栈和队列等数据结构时,我们使用C语言会写两个相关函数,分别是初始化函数(Init)和销毁函数(Destroy),但是我们总会忘掉其中的某个,导致程序崩溃或者是内存泄漏,所以C++为了解决这个问题,直接要求我们直接实现这两个功能并自动调用,让我们不会再忘记,他们俩就是构造函数和析构函数。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载。
这里我们用见过的日期类进行展示,代码如下:
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数,因为它支持重载
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用无参构造函数
Date d2(2015, 1, 1); // 调用带参的构造函数
// 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
// 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
// warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
Date d3();
}
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。:
class Date
{
public:
/*
// 如果用户显式定义了构造函数,编译器将不再生成
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
*/
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
// 将Date类中构造函数屏蔽后,代码可以通过编译,因为编译器生成了一个无参的默认构造函数
// 将Date类中构造函数放开,代码编译失败,因为一旦显式定义任何构造函数,编译器将不再生成
// 无参构造函数,放开后报错:error C2512: “Date”: 没有合适的默认构造函数可用
Date d1;
return 0;
}
我们知道C++有一个特性就是可以给函数参数以缺省值,所以对于构造函数来说,我们就可以给它定义一个全缺省的构造函数,这样也可以不传参实现默认的初始化:
class Date
{
public:
// 1.无参构造函数
Date()
{}
// 2.带参构造函数,因为它支持重载
Date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用全缺省构造函数,其日期为1970-1-1
}
此外,默认构造函数一共包括三种,分别是:
- 我们不进行定义系统自动生成的
- 我们所定义的无参构造函数
- 我们所定义的全缺省的构造函数
也就是不需要传参就能调用的构造函数就是默认构造函数。
每个类中都只能有一个默认构造函数,并且我们上面提到了无参的构造函数和全缺省的构造函数都是默认构造函数,那么如果我们在类中既定义一个无参的构造函数又定义一个全缺省的构造函数,虽然二者构成了重载,但是在调用的时候会因为二义性而出现错误。
系统生成的默认构造函数的特性
前面我们提到,当我们没有显式的去定义构造函数的时候,系统会默认生成一个构造函数,那它对于成员变量是如何进行初始化的呢?
class Date
{
public:
void Print()
{
cout << _year << _month << _day << endl;
}
//Date(int year = 1970, int month = 1, int day = 1)
//{
//_year = year;
//_month = month;
//_day = day;
//}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1; // 调用全缺省构造函数,其日期为1970-1-1
d1.Print();
}
可以看到,系统生成的构造函数将类属性初始化成了随机值,这看起来确实初始化了,但是好像又没初始化?d1对象调用了编译器生成的默认构造函数,但是d1对象的_year/_month/_day,依旧是随机值。也就说在这里编译器生成的默认构造函数并没有什么用??
解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型,看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
所以在一个类中定义的内置类型只会用随机值进行初始化,而类中的自定义类型又会调用它的构造函数进行初始化,比如我们在栈和队列部分曾经做过一道用两个栈来实现队列的题目:当时的实现思路是用一个栈来调换顺序,另一个栈来进行输出,我们在写栈并给他初始化的时候,是肯定要写构造函数的,目的是给它分配好空间、并将栈顶指针和栈的容量改好,这样我们在写两栈实现队列的代码的时候,在类属性中定义两个栈就不用再考虑他俩的初始化问题了,直接交给栈类中的构造函数即可。
那么还有个问题,自定义类型可以调用它从老家带来的构造函数,那内置类型咋办,自定义类型就不配被初始化么?
所以C++的设计者在C++11中针对内置类型成员不初始化的缺陷打了补丁,即:内置类型成员变量在类中声明时可以给缺省值,也就是写成这样:
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
这样如果构造函数并没有给某个类属性进行赋值,他们就会在生成时将缺省值直接导入,完成初始化。
构造函数的初始化列表
初始化列表: 以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。代码如下:
class Date
{
public:
Date(int year, int month, int day)
: _year(year)
, _month(month)
, _day(day)
{}
private:
int _year;
int _month;
int _day;
};
我们之前在研究类属性中内置类型的初始化的时候,纠结了很久,最后是C++11的补丁给我们提供了缺省值的初始化方法,但是在那之前内置类型的就不能初始化了么?显然不是,这就需要用到我们的初始化列表,而且对于某些特定类型的类属性,我们必须要用初始化列表来实现初始化:
- 引用成员变量
- const成员变量
- 自定义类型成员(且该类没有默认构造函数时)
引用和const成员变量都很好理解,因为他们俩一辈子就一个时间点能够初始化,就是定义的时候,那自定义类型成员该如何理解呢?
我们括号中提到了该类是没有默认构造函数的,那他作为一个类属性就无法自身完成初始化。与此同时,他如果没有默认构造函数,说明类里面肯定是有人为写的不是无参也不是全缺省的构造函数,这时候我们想要对类对象进行初始化,就需要在初始化列表中初始化对应的对象,并在对象名后的括号里加上初始化的参数。(人为定义的构造函数的参数)
初始化列表会在类这个模板进行实例化的时候最优先走一遍,完成其类属性的初始化,而且不管你是否在初始化列表里提到某个类属性,它都会走一遍初始化列表:对于内置类型有缺省值就用缺省值,否则就是随机值(没写进初始化列表的话);对于自定义类型,会调用它得默认构造函数,如果没有就会报错。并且每个类属性只能初始化一次。
此外,我们前面提到的缺省值进行初始化,如果某个类属性在初始化列表进行了初始化,并且它有缺省值,那么这个缺省值是不起作用的。
因为初始化列表是必定会被走一遍的,所以我们在初始化的时候能用初始化列表还是要尽量用初始化列表。
如果真的出现在初始化列表中不方便进行初始化的变量,我们再在构造函数体内进行初始化工作,也就是混着来。此外,因为对于自定义类型在初始化列表中会直接调用其默认构造函数,所以我们推荐每个类都最好有默认构造函数,会很方便,也不容易出错。
最后我们再看这样一段代码:
class A
{
public:
A(int a)
:_a1(a)
,_a2(_a1)
{}
void Print() {
cout<<_a1<<" "<<_a2<<endl;
}
private:
int _a2;
int _a1;
};
int main() {
A aa(1);
aa.Print();
}
如果这段代码能够正常运行,那么输出结果是什么?
答案是:1 随机值
解答:因为成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。声明时a2在前,a1在后,所以初始化列表中的a2要先被初始化,但是此时a1还没被a赋值,它是一个随机值,所以a2就被赋成了一个随机值,而后a1正常接收a传过来的1被初始化为1。
析构函数※
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?这里我们自然就需要用到析构函数。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值类型。
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
这里我们用以前学过的栈来举个例子:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 3)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc failed");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
~Stack()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s;
s.Push(1);
s.Push(2);
}
int main()
{
TestStack();
return 0;
}
因为我们构建栈的时候在堆上申请了空间,因此我们在析构函数中就必须完成空间的回收,也就是free,然后将指针置空即可。
在上面讲默认构造函数的时候我们提到,对于自定义类型,会直接调用其构造函数,对于自定义类型我们只能通过缺省值来进行初始化,析构函数也是如此,也有默认析构函数。如果类属性为自定义类型,那么在其生命周期结束时就会调用其析构函数,而内置类型则不会进行处理。
总结一下:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
class Time
{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 2022;
int _month = 10;
int _day = 12;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
拷贝构造函数※※※
在现实生活中,可能存在两个长相相同的人,我们称其为双胞胎。那在创建对象时,可否创建一个与已存在对象一某一样的新对象呢?
这里我们就引入了拷贝构造函数这个概念:
拷贝构造函数只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。它包含以下几个特征:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
这里我们针对两个特性举个例子:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// Date(const Date& d) // 正确写法
Date(const Date d) // 错误写法:编译报错,会引发无穷递归
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
首先,我们可以看到拷贝构造函数是构造函数的一个重载,所以和构造函数函数名相同而参数不同。但是拷贝构造函数仍然是构造函数,他的使命依然是给某个类对象进行初始化,只不过初始化方式不再是通过定义来进行赋值,而是通过某个已经创建好的类对象来进行拷贝初始化,从而生成两个一模一样的类对象。其次,这里如果不用引用传值,用普通的传值为什么会出现无穷递归的问题呢?我们知道函数参数的传递是通过拷贝实现的,内置类型的拷贝很简单,编译器自己就能解决(比如利用memcpy),但自定义类型作为形参想要拷贝相对来说就复杂得多,编译器不敢也没办法直接进行拷贝,所以编译器就要回头来找此类的拷贝构造函数来完成传参,结果过来一找发现,这哥们也要我拷贝一份,不让我传引用,那我就得接着再找他,这样就形成了闭环,也就是我们前面说的无穷递归。因此如果拷贝构造函数我们不将参数定为引用,那就无法通过编译。
此外,我们还可以看到拷贝构造函数的参数前面是用const进行修饰了的,这能够防止在书写函数的时候写反了从而修改old_menber的值导致意料之外的错误。
默认的拷贝构造函数
在了解了拷贝构造函数的性质之后,我们开始应用阶段。拷贝构造函数作为一个默认成员函数,我们不去写编译器自己也会去生成一个,但是生成的这个有时候能用,有时候就不能用,下面举个两个例子:
- 日期类
class Date
{
public:
Date(int year = 1970, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1(2022,10,12);
Date d2(d1);//正确
}
对于日期类来说,我们并没有专门去写它的拷贝构造函数,但是上面的代码是没有问题的,d2会接收到d1的数据并完成拷贝,但并不是所有类都能这样解决,比如下面这个栈类:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc failed");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType *_array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
Stack s2(s1);
return 0;
}
对于这个类,如果我们不写它的拷贝构造函数,那他运行起来就会在free处崩掉:
这是为什么呢?
对于一个栈来说,它的类属性包括三个,分别是一个动态分配的数组,一个栈顶指针和一个容量值。他们虽然都是内置类型,但是这里仍然存在一些问题:栈类自己生成的拷贝构造函数去拷贝栈顶指针(int)、容量值(int)是没有问题的,但是当拷贝栈的动态数组的时候也是原封不动的拷贝过去的,这样就会导致两个栈对象的指针指向了同一块内存区域,当两个栈的生命周期结束时,要分别调用析构函数。因为栈类是肯定要向堆申请内存的,所以我们自己写析构函数,并且肯定是要调用free函数的。然后s1和s2都在函数栈帧中,因为s2后定义的,所以它先被析构(压栈顺序),释放掉之后s1中的指针就指向了野指针,此时s1再调用析构函数,free一个野指针,程序自然也就崩掉了。
这个问题就是我们常说的浅拷贝问题。在没有在向堆进行空间申请的情况下,浅拷贝一般能够解决问题,但是当我们申请内存之后,如果仍进行浅拷贝,就会导致两个类对象都指向了同一块内存,这样在析构环节就必然会出问题,解决这个问题的办法就是在出现空间申请的类时,我们要自行实现其拷贝构造函数,并用深拷贝来实现:
Stack(const Stack& st)
{
DataType* tmp = (DataType*)malloc(st._capacity * sizeof(DataType));
if (nullptr == tmp)
{
perror("malloc failed");
exit(-1);
}
_array = tmp;
memcpy(_array, st._array, sizeof(int) * st._size);
_size = st._size;
_capacity = st._capacity;
}
将这段代码加入上面的栈类中就可以解决这个问题,而以上实现就是深拷贝。
这里也总结一下什么样的类需要自己实现拷贝构造:
需要写析构函数的类,就需要自己写深拷贝的拷贝构造函数;
不需要写析构函数的类,类自己的浅拷贝的默认拷贝构造就能用。
赋值重载函数
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
运算符重载有专门的博客进行了讲解,这里我们主要讲赋值重载函数这个默认成员函数。
超链接:C++运算符重载
赋值重载函数之所以紧跟着拷贝构造函数,正是因为二者有很多的相似之处,我们一点点看:
赋值运算符的重载格式
- 参数类型:const classname&,传递引用可以提高传参效率
- 返回值类型:classname&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
一般形式如下:
Date& operator=(const Date& d)
{
if(this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
赋值运算符只能重载成类的成员函数
赋值运算符是不能重载成全局函数的,因为赋值重载函数本身就是一个默认成员函数,如果我们不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载发生冲突,故赋值运算符重载只能是类的成员函数。
有关默认生成的赋值重载函数
当我们没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
下面贴出两个类:
//日期类
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1(2022,10,12);
Date d2;
d2 = d1; //right
}
//栈类
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)//构造函数
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc failed");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
Stack(const Stack& st)//拷贝构造函数
{
DataType* tmp = (DataType*)malloc(st._capacity * sizeof(DataType));
if (nullptr == tmp)
{
perror("malloc failed");
exit(-1);
}
_array = tmp;
memcpy(_array, st._array, sizeof(int) * st._size);
_size = st._size;
_capacity = st._capacity;
}
void Push(const DataType& data)//压栈函数
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()//析构函数
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
Stack s2;
s2 = s1;//error
return 0;
}
我们刚才说赋值运算符重载和拷贝构造函数相似就体现在这里:对于日期类来说,编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,所以我们没必要给日期类专门写一个赋值运算符重载函数;但是对于涉及到资源管理的Stack类的对象,我们就不能再使用默认的赋值运算符重载函数了,因为和拷贝构造函数一样,会使两个栈的指针指向同一块空间,会发生free野指针的问题(浅拷贝问题)。(同一块空间free两次)此外,我们用这里的代码举例子,因为我们让s2也指向了s1,所以原来s2指向的空间我们也找不到了,也就发生了内存泄漏。所以栈类的赋值运算符重载怎么写呢?其实和他的拷贝构造函数区别不大:
Stack& Stack(const Stack& st)
{
if(this != &st)
{
free(_array);
DataType* tmp = (DataType*)malloc(st._capacity * sizeof(DataType));
if (nullptr == tmp)
{
perror("malloc failed");
exit(-1);
}
_array = tmp;
memcpy(_array, st._array, sizeof(int) * st._size);
_size = st._size;
_capacity = st._capacity;
}
return *this;
}
这里主要的变化就是在拷贝之前将左值的空间先free掉了,这里也有个方案是将左值的空间进行realloc,但是当左右值的空间差别较大的时候代价很大,所以我们这里就考虑直接将它free掉然后重新申请相同的空间,最后返回左值的引用即可。并且为了增加赋值重载函数的健壮性,防止有老六让此运算符的左右值为一个对象,我们再加一层判断,两侧的对象相同时,我们直接返回*this。
这里总结一下,赋值运算符重载函数和拷贝构造函数一样:
需要写析构函数的类,就需要自己写深拷贝的赋值运算符重载函数;
不需要写析构函数的类,类自己的浅拷贝的默认赋值运算符重载函数就能用。
普通对象和const对象的取地址重载函数(非重点)
这两个默认成员函数为什么放在一起了呢?因为它们两个确实不怎么需要我们来写,如果非要写,他俩一般是这样实现的:
Date* operator&()
{
return this;
}
const Date* operator&() const
{
return this;
}
如果非要找一种应用场景的话,我们可以假设某个类要求其对象不能够被取地址,我们就可以将这两个函数进行一定的修改:
Date* operator&()
{
return nullptr;
}
const Date* operator&() const
{
return nullptr;
}
也就是这些内容了…
结束语
到此为止,关于C++类和对象的默认成员函数就全部讲完了,希望能够对你有所帮助。如文章有不足或遗漏之处还请大家指正,笔者感激不尽;同时也欢迎大家在评论区进行讨论,一起学习,共同进步!