目录
- 1.类的6个默认成员函数
- 2.构造函数
- 2.1概念
- 2.2特性
- 3.析构函数
- 3.1概念
- 3.2特性
- 4.拷贝构造函数
- 4.1概念
- 4.2 特性
- 5.赋值运算符重载
- 5.1运算符重载
- 5.2赋值重载
- 5.3赋值运算符重载特性
- 6.const成员
- 7.取地址及const取地址操作符重载
1.类的6个默认成员函数
通过类和对象上篇的学习,我们知道知道如果一个类中没有成员变量,也没有成员函数,啥也没有,那我们把它叫做空类。
比如 :class Date {};
那么空类中真的什么都没有吗?
并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数:即用户没有显式实现,编译器自动生成的成员函数称。
2.构造函数
2.1概念
现在有这样一个类:
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;
}
那对于一个类来说,我们实例化出来对象之后一般会对其进行一个初始化,但是有时候我们可能会忘记初始化,直接就对对象进行一些操作,不初始化直接用可能就会出现问题。
那么针对以上情况,C++给我们提供一种方法解决这个问题。
这个方法就是构造函数。构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证 每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
2.2特性
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
其特征如下:
- 函数名与类名相同。
也就是说定义好一个类,它的构造函数的函数名就已经确定,跟当前类的类名是相同的。
- 无返回值。
要注意这里说的无返回值不是说返回类型是
void
,而是根本就不写返回类型。
- 对象实例化时编译器自动调用对应的构造函数。
通过构造函数我们初始化对象就不用再手动初始化了,实例化对象时编译器会自动调用其对应的构造函数。
- 构造函数可以重载。
那么接下来我们就给上面的Date类写一个构造函数。
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
- 无参构造函数
通过上图运行结果我们看出:这次我们并没有调用初始化函数,但是打印出来不是随机值,而是我们在构造函数中给定的值,说明我们实例化对象的时候确实自动调用构造函数进行初始化了。
- 带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
注意: 如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明.
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
也就是说,构造函数不一定非要自己写,如果我们自己没有定义构造函数,编译器会自动生成一个。只不过是无参的。
那编译器会自动生成的话,我们以后是不是就不用自己写构造函数了?
答案是不可以。
将上文中自己写的构造函数注释掉,直接运行程序:
我们可以看到调用编译器自动生成的构造函数是随机值。
实际上这个地方大家可以认为是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;
,对于自定义类型,编译器会自动去调用它对应的默认构造函数。
我们运行一下验证一下结果:
那难道说内置类型不写构造函数就没法初始化了吗?
注意: C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。 注意: 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数。
3.析构函数
3.1概念
通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?
析构函数: 与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
下面我们举一个例子来帮助大家理解:
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语言的学习我们都知道,这些空间是需要我们手动去释放的,否则可能会导致内存泄漏。
所以说,析构函数就是来帮我们干这件事情的。
3.2特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上字符 ~。
一个类定义好之后,它的析构函数的函数名也是确定的,即在类名前面加上“~”。
“~”在C语言中是按位取反,表示它的功能和构造函数是相反的。
- 无参数无返回值类型
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
下面我们就给刚才的栈写一个析构函数
~Stack()
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
为了方便观察是否自动调用了析构函数我们可以在代码中加入一行打印:
此时我们main函数里并没有显示调用
~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;
};
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 类的析构函数:
- 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
4.拷贝构造函数
4.1概念
我们再来回顾一下之前创建的日期类:
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修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
4.2 特性
拷贝构造函数也是特殊的成员函数,其特性如下:
- 拷贝构造函数是构造函数的一个重载形式。
那么我们现在先来写一个拷贝构造函数:
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;
}
对于Stack类我们也没有写拷贝构造函数,我们运行一下看一下结果:
会发现这里程序挂了, 那这里挂了的原因是什么呢?
其实这里根本原因就是出现在特性3上。
在特性3中有这样一句话:
在这里其实就是对逐个成员变量依次进行拷贝,里面存的是啥就把啥拷过去。
我们来对比一下Date类和Stack这两个类的拷贝:
对于Date类,浅拷贝是没有问题的。
一共12字节的内容依次拷贝过去就行
对于Stack类浅拷贝是存在问题的:
在这里出了作用域就会调用析构函数,而两个s1和s2中指针指向的空间就会被free两次,同样的我们在s1
中入栈数据,s2
里面就也有数据了(因为它俩用的是同一块空间),然后如果我们再用st2去入栈数据,此时s1
的_size
前面已经++
过,但是s2
的_size
前面还是0,这样s2入的数据就把之前s1入的数据给覆盖了。注意:
这里是s2先析构,我们知道s1和s2都是在栈上的(栈区),那栈区之所以叫栈区就是因为它在这个地方栈帧的建立也是遵循先进后出的这个顺序的,即后定义的会先进行析构。s2先析构,那堆上的这块空间就被释放了,但是接下来st也会进行它的析构,而此时虽然s1还保留了这块空间的地址,但是这块空间已经被释放,所以s1就是个野指针了。
所以为什么程序崩溃了,就是我们这里对野指针进行free了。
因此:
类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
- 拷贝构造函数典型调用场景:
使用已存在对象创建新对象
函数参数类型为类类型对象
函数返回值类型为类类型对象
5.赋值运算符重载
5.1运算符重载
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个运算符不能重载,这个经常在笔试选择题中出现。
5.2赋值重载
参数类型: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;
}
5.3赋值运算符重载特性
- 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝(浅拷贝)。
注意:默认生成的赋值重载对于内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用其对应类的赋值运算符重载完成赋值。
那么下面这种情况会调用拷贝构造还是赋值重载?
这里用了赋值=
,但是是拷贝构造。
什么时候是调赋值重载呢?
是我们用已经实例化出来的对象进行相互赋值的时候,调用赋值重载。而当我们用一个已经实例化出来的对象去初始化一个新对象的时候,调的是拷贝构造。
- 赋值运算符只能重载成类的成员函数不能重载成全局函数
原因:赋值重载如果在类里不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
6.const成员
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数 隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
以刚才的赋值重载函数为例:
Date& operator=(const Date& d) const
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
7.取地址及const取地址操作符重载
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!