引言:在我们学习了类和对象(上)的基础知识后,我们就需要进入类和对象(中)的学习。本篇博客将会介绍C++的几个默认成员函数,它们的存在虽然难以理解,但也是C++如此简单实用的原因之一。相信在你看完这篇博客之后一定会对C++有新的认识!
更多有关C语言和数据结构的知识详解可前往个人主页:计信猫
一,类的六个默认成员函数
假如当我们定义一个如下的空类:
class Date {};
那么此时这个类里边真的什么都没有吗?答案是否定的,不管这个类里边是否有任何成员,编译器在编译时都会生成六个默认成员函数,他们的作用如下图所示:
其中默认成员函数的定义是用户不显式实现,编译器会自动生成的成员函数。
二,构造函数
1,概念
我们先来看下面一段代码:
class Date
{
public:
//初始化函数
void Init(int year, int month, int date)
{
_year = year;
_month = month;
_date = date;
}
int _year;
int _month;
int _date;
};
int main()
{
Date d1;
//初始化d1对象
d1.Init(2024, 7, 11);
return 0;
}
在该段代码中,我们为Date类设计了一个成员类型的初始化函数,当我们以这个类创建了一个对象d1时,就需要再调用一次Init函数为它初始化。那有没有什么方法可以让我们在创建对象的同时就将信息设置进去呢?
当然有咯,答案就是我们的默认成员函数之一——构造函数。构造函数是一个特殊的成员函数,它的作用就是初始化对象。
2,特性
我们的构造函数有以下几条特性:
1,构造函数的名字和类名相同。
2,构造函数无返回值(就连void也不需要写)。
3,类的对象实例化时会自动被调用。
4,构造函数可以被重载。
实例:
class Date
{
public:
//构造函数,函数和类同名,并且没有返回值
Date()
{
_year = 2024;
_month = 7;
_date = 11;
}
int _year;
int _month;
int _date;
};
int main()
{
Date d1;
return 0;
}
此时,我们就定义了一个构造函数Date()给类的成员类型进行初始化,当我们代码以走起来,那么这个构造函数就会被自动调用,此时我们观察d1的值就会发现d1中的三个成员类型都被自动初始化了。
当然,构造函数还有以下几点特性:
5,类中若无显式定义的构造函数,C++会自动生成一个无参数的构造函数。
6,无参、全缺省、编译器自动生成的构造函数,都被称作默认构造函数,但是三个函数不可以同时存在于类中。
7,编译器生成的构造函数对内置类型成员变量无要求。
看下面的示例,都是一个合格的构造函数,并且附带有它们对应正确的对象创建方式:
//示例一
Date()
{
_year = 2024;
_month = 7;
_date = 11;
}
//于main中
Date d1;
//示例二
Date(int year, int month, int date)
{
_year = year;
_month = month;
_date = date;
}
//于main中
Date d1(2024, 7, 11);
//实例三
Date(int year = 2024, int month = 7, int date = 11)
{
_year = year;
_month = month;
_date = date;
}
//于main中
Date d1;
当然,对于当类中存在其他类型并且其他类型已有对应的构造函数,那么此时我们就不需要自己再写构造函数了。例子如下:
class stack
{
//stack已存在构造函数
};
class queue
{
private:
stack pushst;
stack popst;//此时编译器就会调用stack的构造函数,就不需要专门为queue创建构造函数了
};
3,小总结
在大多数情况下,都需要我们自己写构造函数,养成一个好习惯,除了刚刚上面讲的情况以外,构造函数我们还是应写尽写吧。
三,析构函数
1,概念
当我们创建完一个对象,对象的生命周期结束之后,那么这个对象是怎样消失的呢?则此时就不得不提到一个和构造函数相对的函数——析构函数了。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
2,特性
我们的析构函数有以下几条特性:
1,析构函数的函数名是在类名之前加上“~”。
2,析构函数没有参数,也没有返回值。
3,若析构函数没有被显式定义,那么系统会自动生成。
4,当对象的生命周期结束时,系统会自动调用析构函数。
那我们就自己来定义一个简单的析构函数吧!
class Stack
{
public:
//构造函数
Stack(int capacity=4)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
perror("malloc fail!");
return;
}
_top = 0;
_capacity = capacity;
}
//析构函数
~Stack()
{
free(_a);
_a = NULL;
_top = _capacity = 0;
}
private:
int* _a;
int _top;
int _capacity;
};
此时,当类创建的对象生命周期为零时,那么系统就会自动调用这个析构函数对资源进行销毁。
当然,我们的析构函数还具有以下的性质:
5,如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
让我们再继续看一个小例子:
class Time
{
public:
~Time()
{
cout << "~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 d;
return 0;
}
当我们代码一走,会出现什么结果呢?
由此结果我们可以发现,Time中的销毁函数被调用,但是在main()中我们并没有定义有关Time的对象啊,这是为什么呢?
其实答案很简单,因为我们创建了一个Date类的对象d1,而d1当中存在着内置类型Time。当我们想要销毁d1时,则d1的成员类型_year,_month,_date三个成员不需要销毁,编译器回收即可,但是Time类型成员_t却需要被销毁,而此时编译器就会自动生成一个析构函数,而这个析构函数就会调用Time类中的析构函数也就是~Time(),以此达到销毁_t变量的目的。
3,小总结
所以我们现在可以知道,析构函数也就是一个帮助销毁变量的函数。当没有显式定义时编译器会自动生成。如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,而一旦有申请资源时,就最好还是自己将析构函数写出来,防止内存泄漏。
四,拷贝构造函数
1,概念
拷贝构造函数其实是特殊的构造函数。它的第一个参数为自身类型的引用,并且额外参数都有对应的缺省值。
2,特性
那么我们就像来写一段拷贝构造函数并且试着在main函数中使用。
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;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
//使用拷贝构造函数
//写法一:
Date d2(d1);
//写法二:
Date d2 = d1;
return 0;
}
此时我们代码一走,我们就会发现系统创建了d1和d2两个变量,并且d2的值由d1拷贝而来。
那么我们的拷贝构造函数拥有着一下几点特性:
1,拷贝构造函数和构造函数构成函数重载。
2,自定义类型的传值传参和传值返回都由拷贝构造函数完成。
3,拷贝构造函数第一个参数必须为类型对象的引用,不然会因为造成的无限递归而报错。
4,若无显式定义拷贝构造函数,编译器会自动生成。但该函数只会对内置类型完成浅拷贝。
当然,文字固然枯燥难懂,现在我将举例对其中几点特性进行说明:
Ⅰ,第二点,第三点
其实第二点的含义很简单,我们看如下的一张图示:
这张图就可以很好地解释第二点,当一个函数的参数里边含有自定义类型的时候,那么此时编译器就会调用拷贝构造函数,将该类的对象的数据通过拷贝构造函数传递给该函数,再由该函数进行使用。同理,如果这个函数的返回值是自定义类型,此时返回值也会通过拷贝构造函数传递给接收值。
有了这个知识做准备,我们就可以对第三点进行讲解了。那么我们就先设计一个第一个参数不为类类型对象的引用的例子,看看会发生什么状况。
如图所示,如果不使用对象的引用的话,那么编译器会一直调用拷贝构造函数,无限递归造成错误。
Ⅱ,第四点
在要搞懂第四点之前,我们就需要首先搞懂浅拷贝和深拷贝分别的含义。浅拷贝就是拷贝构造函数对象按内存存储按字节序完成拷贝,也就是差不多一个一个字节挨着拷贝,适合于没有空间资源申请的时候的拷贝,而深拷贝就是在浅拷贝的基础上,还将申请的资源空间等全部内容一并拷贝。
所以,当我们没有显式定义一个拷贝构造函数时,编译器会自动生成一个默认的拷贝构造函数,但这个函数只能进行浅拷贝。
推导:像Date类型中,全为内置类型,就可以不用写拷贝构造函数,但类似Stack类,有空间资源的申请时,我们就需要自己写一个拷贝构造函数来完成深拷贝。
那下面我们就来写一个Stack类的拷贝构造函数吧!
class Stack
{
public:
//拷贝构造函数
Stack(const Stack& s1)
{
_a = (int*)malloc(sizeof(int) * s1._capacity);
if (_a == NULL)
{
perror("malloc fail!");
return;
}
memcpy(_a, s1._a, sizeof(int) * s1._top);
_top = s1._top;
_capacity = s1._capacity;
}
private:
int* _a;
int _top;
int _capacity;
};
3,小提醒
让我们来看下面两段代码,观察他们是否有问题:
//代码一:
Stack func1()
{
Stack st;
return st;
}
//代码二:
Stack& func1()
{
Stack st;
return st;
}
那么答案很简单,第一段代码是正确的,而第二段代码存在错误。在第二段代码中,Stack&为返回值,表示这个函数返回的是st变量的别名,但是一旦我们出了这个函数,那么这个st就会被系统自动删除,那么此时就相当于指针知识里边的野指针了。
但是想改正也很简单,只需要像如下改正就可以了:
//代码二:
Stack& func1()
{
static Stack st;
return st;
}
4,小总结
若一个类显式实现了析构函数并且析构函数中释放了资源,那么这个类也需要写一个拷贝构造函数了。
五,赋值运算符重载
1,引入
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
所以,运算符重载代表的是一类函数,由C++的关键字operator和运算符构成,针对于自定义类型。格式如下:
返回值类型 operator操作符(参数列表)
那么此时就让我们来试着构造一个比较两个类是否相等的运算符重载函数吧。
bool operator==(Date& d)
{
return _year == d._year && _month == d._month && _day == d._day;
}
此时这个函数就在Date类中构造好了,于是我们便可以如下使用这个函数:
int main()
{
Date d1;
Date d2(d1);
if (d1 == d2)//或者写为d1.operator==(d2)
{
cout << "两个对象相等" << endl;
}
return 0;
}
此时代码走起来,那么结果如下,说明运算符重载函数设计成功!
2,特性
当然,我们的运算符重载函数具有以下几点特性:
1,不能通过连接其他符号来创建新的操作符:比如operator@。
2,重载操作符必须有一个类类型参数。
3,用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义。
4,作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。(函数参数按从左往右的顺序依次放入操作符需要值的地方)
5,.* 、::、 sizeof 、?:、 . 注意以上5个运算符不能重载。
3,小例子
那我们现在来试着实现一个用于Date类的对象赋值的运算符重载函数吧!
//赋值运算符重载
void operator=(Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
int main()
{
//调用构造函数
Date d1(1999,10,1);
//调用拷贝构造函数
Date d2;
d2.Print();
return 0;
}
此时我们实现了该函数,在main中代码一走,结果如下,说明实现成功。
但是现在就出现了一个小问题,该函数无法进行连续赋值的操作,并且函数的参数设计得不完美,但我们可以对其进行如下改变:
//赋值运算符重载
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
此时无法连续赋值的问题就被解决了,如下图所示:
而此时我们将函数的返回值和参数都设置为类的对象的引用,那么就省去了调用拷贝构造函数的那一步,这时候程序的效率再一次被提高。
4,小总结
若一个类显式实现了析构函数时,就需要显式实现赋值运算符重载函数。
六,取地址及const取地址运算符重载
1,const
其实在之前我们就已经学到过const修饰的变量会改变该变量的权限,并且权限只可以缩小,不可以扩大。那么在这个雷打不动的规定之下,我们定义的重载函数的参数和返回值就有了一定的要求。
我们首先看一个Date类成员函数的声明:
void Print();
然后我们将这个函数隐藏的this指针也写出来会发现其实这个函数是如下这样的:
//打印函数
void Print(Date* const this);
在使用这个函数的时候,如果我们类的对象的类型为Date时,还可以使用,可一旦我们的变量类型为const Date时,那么此时再使用该函数,就会造成权限的放大问题而无法使用,所以我们需要对该函数的参数做一些改变,如下:
//打印函数
void Print(const Date* const this) => void Print()const;
那么此时这个函数就可以适用于const Date类型的对象了。
2,const取地址和取地址
那么有了上面的知识的辅助,学这里的知识点就非常简单了。这两个函数其实不用自己显式定义,因为编译器会自动生成。
//取地址运算符重载
Date* operator&()
{
return this;
}
//const取地址运算符重载
const Date* operator&()const
{
return this;
}
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
七,结语
现在我们学习到的知识都是在为了之后学习C++的起飞知识做的铺垫,这个章节可能只是比较多比较难,但是相信我们只要认真学习,仔细理解,还是很容易听懂的,有什么问题欢迎找我沟通或者在评论区讨论。
接下来我将更新类和对象(下)的知识点,困难的知识都差不多在本章节讲完了,下篇博客也就是收尾知识了,加油学习!相信我们的能力会在这个暑假突飞猛进!!