通过类和对象上篇的学习,我们知道知道如果一个类中没有成员变量,也没有成员函数,啥也没有,那我们把它叫做空类。
比如 :class Date {};
那么空类中真的什么都没有吗?
并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:即用户没有显式实现,编译器自动生成的成员函数称。
现在有这样一个类:
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;
Date d2;
d1.Init(2023,9,5);
d1.Print();
d2.Init(2023,8,18);
d2.Print();
return 0;
}
那对于一个类来说,我们实例化出来对象之后一般会对其进行一个初始化,但是有时候我们可能会忘记初始化,直接就对对象进行一些操作,不初始化直接用可能就会出现问题。
这个方法就是构造函数。构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证 每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
也就是说定义好一个类,它的构造函数的函数名就已经确定,跟当前类的类名是相同的。
要注意这里说的无返回值不是说返回类型是
void
,而是根本就不写返回类型。
通过构造函数我们初始化对象就不用再手动初始化了,实例化对象时编译器会自动调用其对应的构造函数。
那么接下来我们就给上面的Date类写一个构造函数。
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
通过上图运行结果我们看出:这次我们并没有调用初始化函数,但是打印出来不是随机值,而是我们在构造函数中给定的值,说明我们实例化对象的时候确实自动调用构造函数进行初始化了。
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
注意: 如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明.
也就是说,构造函数不一定非要自己写,如果我们自己没有定义构造函数,编译器会自动生成一个。只不过是无参的。
那编译器会自动生成的话,我们以后是不是就不用自己写构造函数了?
答案是不可以。
实际上这个地方大家可以认为是C++在设计时出现了问题。
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:
int/char...
(包括各种指针类型),自定义类型就是我们使用class/struct/union等自己定义的类型
而编译器自动生成的构造函数不会对内置类型进行处理,对于自定义类型会处理,怎么处理?会去调用该自定义类型对应的默认构造函数
我们再看一个例子:
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;
}
这里的Date类它的成员变量里既有内置类型又有自定义类型。
但是我们现在并没有给Date类写构造函数,那我们在main函数里直接拿Date去创建一个对象,它自然就会去调用编译器自动生成的构造函数,那内置类型不做处理,这里还有一个自定义类型Time _t;
,对于自定义类型,编译器会自动去调用它对应的默认构造函数。
我们运行一下验证一下结果:
那难道说内置类型不写构造函数就没法初始化了吗?
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数: 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
下面我们举一个例子来帮助大家理解:
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc fail");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
return 0;
}
这里的对象s需要我们自己去销毁吗?
答案是不需要,因为s是定义在栈区上的局部变量,程序结束,它就随着main函数的栈帧自动销毁。
那析构函数的作用是啥呢?完成对象中资源的清理工作,什么意思?
像栈这样的对象,它里面是有在堆上动态开辟的空间,那经过C语言的学习我们都知道,这些空间是需要我们手动去释放的,否则可能会导致内存泄漏。
所以说,析构函数就是来帮我们干这件事情的。
析构函数是特殊的成员函数,其特征如下:
一个类定义好之后,它的析构函数的函数名也是确定的,即在类名前面加上“~”。
“~”在C语言中是按位取反,表示它的功能和构造函数是相反的。
下面我们就给刚才的栈写一个析构函数
~Stack()
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
为了方便观察是否自动调用了析构函数我们可以在代码中加入一行打印:
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc fail");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
cout << "~Stack" << endl;
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Stack _s;
};
int main()
{
// Stack s;
// s.Push(1);
// s.Push(2);
Date d1;
return 0;
}
这里我们没有给Date显式定义析构函数,d1声明周期结束时,就会调用编译器自己生成的默认析构函数,那里面的内置类型不做处理,而自定义类型
Stack _s;
申请的资源需要清理,编译器自己生成的默认析构函数会调用Stack 类的析构函数:
我们再来回顾一下之前创建的日期类:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1;
return 0;
}
现在有这样一个问题:如果我们现在想再创建一个对象,使这个对象和d1一样,或者说是d1的一份拷贝,应该怎么实现呢?
经过上面的学习,相信老铁们很容易想到,我们想创建一个和d1一样的新对象,可以用d1去初始化创建出来的新对象啊。是不是把构造函数的参数类型设置成类对象的类型就行了。
这就是拷贝构造。
拷贝构造函数: 只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数也是特殊的成员函数,其特性如下:
那么我们现在先来写一个拷贝构造函数:
Date(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
那为什么必须是类类型对象的引用呢?
此外对一个对象拷贝构造也可以这样写:
除此之外,我们还需要注意:
拷贝构造函数形参一般用const修饰:
相信这一点大家不难理解,形参是用来初始化我们新创建的对象的,加个const修饰形参d不会被修改。此外加上const 若传来的参数是const修饰的,我们依然可以接收。
那么默认生成的拷贝构造函数是否可靠呢?
这里我们再来看一下日期类:
首先我们将刚才实现的拷贝构造函数注释掉:
class Date
{
public:
Date(int year = 1, 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;
// }
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
// Date d1(2023,9,5);
// Date d2(2023,8,18);
//d1.Init(2023,9,5);
Date d1;
Date d2(d1);
Date d3 = d1;
d1.Print();
//d2.Init(2023,8,18);
d2.Print();
d3.Print();
return 0;
}
看到这里拷贝构造函数能一直可靠吗?
下面我们再来看一下Stack类:
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc fail");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
cout << "~Stack" << endl;
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
private:
DataType* _array;
int _capacity;
int _size;
};
int main()
{
Stack s;
s.Push(1);
s.Push(2);
Stack s2(s);
return 0;
}
其实这里根本原因就是出现在特性3上。
我们来对比一下Date类和Stack这两个类的拷贝:
对于Stack类浅拷贝是存在问题的:
在这里出了作用域就会调用析构函数,而两个s1和s2中指针指向的空间就会被free两次,同样的我们在s1
中入栈数据,s2
里面就也有数据了(因为它俩用的是同一块空间),然后如果我们再用st2去入栈数据,此时s1
的_size
前面已经++
过,但是s2
的_size
前面还是0,这样s2入的数据就把之前s1入的数据给覆盖了。注意:
这里是s2先析构,我们知道s1和s2都是在栈上的(栈区),那栈区之所以叫栈区就是因为它在这个地方栈帧的建立也是遵循先进后出的这个顺序的,即后定义的会先进行析构。s2先析构,那堆上的这块空间就被释放了,但是接下来st也会进行它的析构,而此时虽然s1还保留了这块空间的地址,但是这块空间已经被释放,所以s1就是个野指针了。
所以为什么程序崩溃了,就是我们这里对野指针进行free了。
因此:
类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
使用已存在对象创建新对象
函数参数类型为类类型对象
函数返回值类型为类类型对象
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其 返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为: 关键字operator后面接需要重载的运算符符号。
函数原型: 返回值类型 operator操作符(参数列表)
下面我们依旧以日期类为例:
class Date
{
public:
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
};
int main()
{
Date d1(2023,9,5);
Date d2(2023,8,18);
d1.Print();
d2.Print();
return 0;
}
现在有两个对象d1,d2,大家思考一个问题,现在我们想比较这两个对象是否相等,要怎么实现呢?相信大家很容易想到用一个函数实现:
bool Equal(const Date& x1, const Date& x2)
{
//...
}
但是在C++引入运算符重载之后呢,就使得我们还能够这样去实现:
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1.day == d2.day;
}
这里会有一个小问题:
造成此问题的原因是:
Date类的这3个成员变量是私有的(private),所以在类外面是不能访问的。
那怎么解决?
我们可以在类里写一个Get方法(函数),通过Get方法来访问,或者直接把private访问限定符去掉。
我们这里先把private注释下:
现在我们来调用一下:
但是这里直接重载到了全局,我们把成员变量全部公有了,封装性又如何体现呢?
所以这里比较好的一种方法是:我们直接重载到类里面,即重载成成员函数。
但是这里又出现了一个小问题如果直接把函数封装在类里:
这里我们重载的是==
运算符,正常情况下只有两个操作数,所以只需要两个参数就够了。
那这里不就是两个参数嘛?
不要忘了,这里还有一个隐藏参数。什么隐藏参数,就是this指针。
C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象)
所以我们这里只需给一个参数。
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
注意:
- 不能连接其他符号来创建新的操作符:比如operator@
- 重载操作符至少有一个类类型的参数
- 用于内置类型的运算符,其含义不能重载改变,例如:内置的整型+,不能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1个,因为成员函数的第一个参数为隐藏的this
.* :: sizeof ?: .
注意这5个运算符不能重载,这个经常在笔试选择题中出现。
参数类型:const 类对象的引用,传递引用可以提高传参效率
返回值类型:类类型&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
最好检测一下是否是自己给自己赋值,并进行一下处理
返回*this:返回的结果用于支持连续赋值
那么日期类的赋值重载就是:
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
但是有时候呢不排除有人可能会把自己赋值给自己,于是它调用函数白白进行了一次拷贝,我们对此进行改进
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
注意:默认生成的赋值重载对于内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用其对应类的赋值运算符重载完成赋值。
那么下面这种情况会调用拷贝构造还是赋值重载?
这里用了赋值=
,但是是拷贝构造。
什么时候是调赋值重载呢?
是我们用已经实例化出来的对象进行相互赋值的时候,调用赋值重载。而当我们用一个已经实例化出来的对象去初始化一个新对象的时候,调的是拷贝构造。
原因:赋值重载如果在类里不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数 隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
以刚才的赋值重载函数为例:
Date& operator=(const Date& d) const
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!