目录
前言:
1. 构造函数
1.1 概念
1.2 特性
1)
2)
2. 析构函数
2.1 概念
2.2 特性
3. 拷贝构造
3.1 概念
3.2 特征
4. 赋值运算符重载
4.1 运算符重载
4.2 赋值运算符重载
5.3 前置++和后置++的重载
前言:
问:当我们构建了一个类,里面什么都没有写时,也就是空类,它里面是否真的什么都没有?
答案是否,当任何一个类在没有写东西时,编辑器会为我们生成6个成员函数,至于是那四个请看我下面的介绍。
1. 构造函数
1.1 概念
我想大家在平时练习代码时,或者在写项目的时候总是会出现一个情况——那就是不喜欢初始化,或者是忘记了初始化,比如在写我们常用的数据结构栈或者队列、链表之类的。我猜测不只是我们认为初始化这种东西麻烦,就连C++的祖师爷也认为这玩意不符合我们喜欢偷懒的习惯,所以才增加了这样一个函数。
通过上面的讲诉,我想大家也能够推断出构造函数的作用,那就是在我们实例化对象的时候由编辑器自动为我们初始化。当然也并不是说编辑器能够为我们做到任何事情,部分功能还是需要我们自己来实现,这些我后面会讲解。
下面先看一下没有构造函数实例化一个对象并初始化有多麻烦。
class Date
{
public:
void Init(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 d1; //实例化
d1.Init(2022, 7, 5); //初始化
d1.Print();
Date d2; //实例化
d2.Init(2022, 7, 6); //初始化
d2.Print();
return 0;
}
1.2 特性
1)
1. 函数名于类名相同。
2. 无返回值(字面意思)。
3. 构造函数支持函数重载。
4.对象实例化时编译器自动调用对应的构造函数。
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而是没有。
构造函数被分为了无参构造函数和有参构造函数,因为C++支持函数重载的原因,所以编辑器能够通过我们不同的实例化方式进行初始化。
void TestDate()
{
Date d1; // 调用无参构造函数
Date d2(2015, 1, 1); // 调用带参的构造函数
//Date d3(); //错误
}
注意看无参构造函数的调用方式和我们的有参构造函数是有区别的,并且最大的区别并不是在带不带参数的原因,而是当我们想要调用无参构造函数的时候,在实例化的后面不能添加()。不仅仅是不能添加,而是我写上去了还会报错。这个时候有的小伙伴可能就有点疑惑了,这不是有病吗?保持一致不好吗?
事实上我们的祖师爷考虑得还是很齐全的,而是朋友们忘记了一件事情,那就是函数声明的书写方式。
我们仔细观察一下无参构造函数的调用加上()。
Date d3(); //错误的无参构造函数调用
int Function(); //函数声明
首先Date是不是我们自定义的的一个类型?d3是不是一个函数名?加上括号是不是整个句子就变成了一个函数的调用?只不过这个函数没有参数罢了。
通过编辑器的报错提示,我们就清楚了编辑器是分不清楚我们这个地方到底是想要实例化一个对象还是对函数进行声明。这么看来不是我们觉得编辑器有病,而是编辑器觉得我们有病了,哈哈。
对于无参构造函数的理解大家不能仅仅将其想为没有参数的函数,还记得C++中新添加的一个东西——缺省参数吗?当我们定义的构造函数中函数里的参数是全缺省,那么他就是无参构造函数。
class Date
{
public:
// 1.无参构造函数
Date()
{}
//全缺省
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
注意看上面的报错信息,结合我所讲解的内容,没有写参数的构造函数和全缺省没有形成函数重载,反而造成了重定义的错误,也就证明了我们的全缺省也是无参构造函数的一种形势。
由上述问题我们也得知了一个信息,那就是有参构造函数只要参数不同可以不断的定义,但是无参构造函数由且只能有一个。
2)
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
这句话的意思就是我们如果没有写任何的构造函数,那么编辑器会自动为我们生成一个无参构造函数,当我们自定义了任意一种构造函数,那么编辑器都不会生成。
class Date
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
按照我们的特性4,编辑器会在实例化时自动调用它的构造函数。
但是当我们生成之后,编辑器却并没有报错,我们也没有写构造函数啊?这也就证明了编辑器会自动为我们生成一个构造函数。
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,double、char)等等它不会做任何处理,但是对于我们的自定义类型,它是会去调用那个自定义类型的构造函数的。
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++没有设计得很好,同时也不止是我,很多大佬也认为这一点设计得有问题,对于内置类型,说实话直接初始化为0也行啊,直接不进行初始化但是对自定义类型做初始化不是相当于把亲儿子往外丢嘛。
所以说,基于这一点,C++在C11这个版本为其添加了一个补丁,也就是在成员变量声明的时候写一个缺省参数,也就是内置类型成员变量在类中声明时可以给默认值。
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;
};
注意,这个功能是C11添加的一个补丁,所以不能保证它能在所有版本的编辑器下都能够实现编译,所以说,我们平时写还是写下类的构造函数更好。
2. 析构函数
2.1 概念
如果说我之前写的构造函数相当于malloc、new等等,那么这里谈论的析构函数就相当于free、delete释放内存这一个过程,当然我只是描述这一个过程,实际上二者不是同一个东西。
它的产生一是为了方便我们使用,因为懒惰的我们总是会忘记free或者delete,那么这个时候析构函数的产生就尤为的重要,虽然对于我们平时写的小项目没有什么影响,但是对于工程当中产生的内存泄漏可就是一个大问题了。
析构函数和构造函数一样,不需要我们调用,并且每一个对象只能调用一次析构函数,因为调用析构函数的原因只有一个,那就是这个对象的生命周期已经结束。
2.2 特性
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 3)
{
_array = (DataType*)malloc(sizeof(DataType)* capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
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);
}
上方是一个栈类,有它的构造函数Stack(),也有析构函数~Stack(),同时也有普通的成员函数,由此代码我们也能清晰的知道析构函数的写法就是在类名的前面加上~表示,和构造函数一样,它也是没有返回值的。不过他与构造函数不同,它不支持重载,主要是也没有办法实现重载,因为整个对象的使用中,我们都没有办法调用到析构函数,它只能由编辑器调用。
但是我觉得析构函数不支持重载的原因是没有重载的必要,难道你释放空间还会用不同的方式释放?想来点花活?要是不信非要写几个析构函数你就等哭吧。
同样,析构函数也是默认函数之一,所以就算我们不写,编辑器还是会为我们这些小蠢蛋写上的,不过它也是一个空的构造函数,它对于内置类型不做处理,但是对于自定义成员类型会调用它的析构函数。看下代码和图。
class Time
{
public:
~Time()
{
cout << "~Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour = 1;
int _minute = 1;
int _second = 1;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
当然,既然它是系统为我们写的要求就别那么高,他一定是不能完成所有的释放功能的,就比如说需要释放掉重堆上申请来的空间就不行。
综上,我们可以知道其实析构函数并不是每一个类都必须写上,只有内置类型的类就不用写,这样想更容易理解一点,我反正都不要它了,我留他干嘛,而且内置类型开的空间又不是我申请的而是编辑器自己申请的,就没有释放的必要了,随着它的自然周期结束就行。
但是对于有从堆上申请的空间时,析构函数就必须出场了,防止内存泄漏。
3. 拷贝构造
3.1 概念
拷贝构造其实就是一个对象生成另外一个对象,按照现实生活中来说的话也就是双胞胎之类的,不是让你女朋友给你找个女朋友哈,别乱想哦。
拷贝构造函数的写法和构造函数一样,只不过他只有单个形参,并且该形参一般是该类型对象的const引用,一般是在创建新对象时由编译器自动调用。
想想看为什么拷贝一个对象只需要一个参数呢?一般来说不应该是两个参数吗,一个用于被拷贝,一个用于拷贝?
这就需要我们前一篇的知识啦,那就是每一个对象里面悄悄藏了一个this指针,也就不需要两个指针咯。
3.2 特征
1. 拷贝构造函数是构造函数的一个重载形式。
2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
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;
};
大伙对于这里的无穷递归可能有一些问题,所以我决定为大家讲解一下。
提问:平时我们的传参数传参,函数接收到的是拷贝对象还是传参本身?
答:是传参的拷贝对象。
提问:那么对于自定义类型的传参是否有区别?
答:没有任何区别。
提问:当拷贝对象中有申请的空间,编辑器应该如何拷贝?
答:对于C语言来说,直接按字节进行拷贝,不管是否是自定义类型还是内置类型,但是C++中进行了优化,对于内置类型编辑器也是按字节拷贝,但是对于自定义类型,编辑器会去调用该类型的拷贝构造。
提问:C语言中直接对任意类型按字节拷贝是否会有问题?
答:问题很大,因为按字节拷贝也就证明了拷贝的对象会指针指向被拷贝对象的从堆上申请的空间,如果我们不清楚这个问题,那么务必会产生对于数据的紊乱,甚至释放两次该空间。所以说,这一部分其实是C语言没有考虑清楚的一个点,是错误的。
提问:那么C++是如何做的?
答:如刚才的答案,对于任意一次自定义类型拷贝,编辑器都会调用该类型的拷贝构造,也就是说,就算是作为函数的形参他也会去调用拷贝构造。但是拷贝函数必须用引用传参,否则会产生无穷递归,产生原因请看下图。
相信大家看了这一问一答也能明白为什么在写拷贝构造函数的时候需要用引用传参的原因了,因为引用传参是不会产生备份的。至于里面的const是为了防止某些粗心蛋乱改,写不写都是一样的,只不过写上更加的完美。
当然拷贝构造也是默认函数之一,所以就算我们不写,编辑器也会生成一个,不过默认生成的拷贝函数就会回归C语言的模式,按字节拷贝,也就是浅拷贝。
至于使用条件相信大家也能分辨出来,也就是当类里面只有内置类型时可以不写,但是有从堆上申请的空间时,就必须写上拷贝构造函数,实现深拷贝。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
cout << "Time::Time(const Time&)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
// 用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数
// 但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构
//造函数
Date d2(d1); //拷贝构造
Date d3 = d2; //拷贝构造
return 0;
}
像上述代码这样的类,我们就算不写拷贝构造也是没有任何问题的。
但是请看下面的代码。
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_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);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
看到了吗?朋友们,编辑器对于同一片重堆上申请的空间进行了析构2次,也就造成了此次程序崩溃的一点,要是不知道这一个知识点,估计找bug的找到猴年马月去。这也就是浅拷贝的危害之一,所以要不要写拷贝函数请各位朋友考虑清楚哦。
图解:
4. 赋值运算符重载
4.1 运算符重载
在了解赋值运算符重载之前我们得先了解运算符重载是怎样的才行。
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)。
注意:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类类型参数
用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
藏的this。
.* :: sizeof ?: . 注意以上5个运算符不能重载。
为什么会出现运算符重载呢?
事实上,我们学习C++,里面会包含各种各样的类,有的类之间呢有需要有比较之类的函数操作,但是每一次比较都要去写整个函数调用感觉太麻烦了,所以就重新对运算符附加新的比较类型,让我们能够方便的对类于类之间进行交互使用。
类外的操作符重载:
// 全局的operator==
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//private:
int _year;
int _month;
int _day;
};
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
void Test()
{
Date d1(2018, 9, 26);
Date d2(2018, 9, 27);
cout << (d1 == d2) << endl;
}
对于有多个运算符重载的问题其实很简单,编辑器会自动会我们解决这个问题,所以不需要我们担心,因为这个运算符重载实际上也就是函数重载,就算函数名相同,但是参数类型不同也是构成重载的。
但是朋友们发现了没,当我们将运算符重载写为全局的,它就变成公有的了,那么此时,封装性如何保证?
其实解决方案有两个,一个是通过写友元函数解决,而是直接将我们的函数写为类内的成员函数就能解决。
类内的操作符重载:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// bool operator==(Date* this, const Date& d2)
// 这里需要注意的是,左操作数是this,指向调用函数的对象
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
private:
int _year;
int _month;
int _day;
};
当我们定义为类内的成员函数时,就必须将操作数对应,this指针永远是左操作数,另外一个类是右操作数。
4.2 赋值运算符重载
赋值运算符重载是类中的默认成员函数之一,也就是说当我们实例化了两个对象,例如我们实例化了像个对象d1、d2。我们可以直接d2 = d1,直接上d1里面的数据拷贝到d2当中去。
当然也和我之前所说的一样,这个过程是一个浅拷贝过程,所以在有深拷贝的时候需要自己在类中或者类外写下赋值运算符重载。
赋值运算符重载格式:
参数类型:const T&,传递引用可以提高传参效率。
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值。
检测是否自己给自己赋值。
返回*this :要复合连续赋值的含义。
例子:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
值得注意的一点是,其它的操作符重载可以定义在类外或者是类里面,但是对于赋值运算符重载只能实现在类里面作为成员函数,不信的话请复制下方代码进行编译。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现
一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值
运算符重载只能是类的成员函数。
用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注
意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符
重载完成赋值。
5.3 前置++和后置++的重载
本来来说,这个重载不应该放到这里来讲,但是他又十分的有用,所以我提前讲解了。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
Date& operator++()
{
_day += 1;
return *this;
}
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
Date d1(2022, 1, 13);
++d1;
d1++;
return 0;
}
大家看出二者的区别了吗? 那就是后置++的运算符重载的参数里面有一个int,只能是int不能是其它的任何类型,可以不要任何的变量接收,这就是C++的规定,其余的问题请别问我,我不知道。
其中有几个需要注意的点:
前置++:返回+1之后的结果
注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
后置++:
前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载
C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器
自动传递
注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存
一份,然后给this+1,而temp是临时对象,因此只能以值的方式返回,不能返回引用
以上就是我本节想讲解的全部内容啦,有不周到的地方请多多包涵啦,也请大家多多支持我啦!