在类和对象三部曲(上)文章中,我们知道了类和对象的基本定义、访问限定符、封装和this指针的概念,但是上面的知识点只是类和对象中很小的一部分,今天我们给大家讲解一下类的6个默认成员函数及他们的模拟实现过程,帮助大家更好的理解类的概念。
类和对象(中)
类的6个默认成员函数
在类和对象三部曲(上)中有计算类的大小的方法,其中我们介绍了空类的概念:类中什么也没有的类。 但是空类中真的什么都不存在吗?-----答案是否定的,任何一个类在我们什么成员都不写的情况下,都会自动生成下面的6个默认成员函数。
比如下面的Person类,即使我们没有写成员变量和成员函数,其内部仍存在6个成员函数。
class Person
{};
1、构造函数
1.1 构造函数的引出
我们在没有学习C++之前,对于结构体的初始化工作,是通过调用专门的初始化函数Init来完成的,代码和运行结果如下:
class Date
{
public:
//可能我们会忘记调用初始化函数,所以C++引用构造函数,来进行初始化
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(2021,5,25);
d1.Print();
return 0;
}
这种情况下,我们每次实例化对象进行初始化的时候都要调用Init函数,这样会很麻烦,所以C++引出了构造函数的概念,每次对象实例化的时候,系统会自动调用构造函数来完成对象的初始化工作,这样就保证了对象一定会初始化。
1.2 构造函数的特性
构造函数的特性:
构造函数是特殊的成员函数,需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象。其特征如下:
1. 函数名与类名相同。
2. 无返回值。
3. 对象实例化时编译器自动调用对应的构造函数。
4. 构造函数可以重载。 – 即可以有多种初始化方式
5.如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
6.无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
注意:无参、构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。
1.3 构造函数的实现
在我们引出构造函数的概念后,上面的代码可以作出修改,其代码和运行结果如下:
class CSDN_Date
{
public:
//构造函数 - 对象实例化的时候自动调用,这样就保证对象一定初始化
class CSDN_Date
(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//构造函数可以重载
class CSDN_Date
()
{
_year = 0;
_month = 1;
_day = 1;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1(1998, 07, 18);
d1.Print();
CSDN_Date d2;
d2.Print();
return 0;
}
我们可以发现,当我们在实例化对象的时候,如果给定初始值,对象就会被初始化为我们给定的初始值,如果没给定,就会调用调用默认值的初始化构造函数,这样会让代码变得冗余,我们将以上两种情况进行合并,利用缺省参数,将其合二为一。
class CSDN_Date
{
public:
//我们把上面两种情况合成一种:一般情况,对象初始化惯例都分两种,默认值初始化和给定值的初始化
//合二为一,给一个全缺省参数,这样定义的全缺省函数,给一个就ok了,好用,当然也可以写其他的,但要避免坑,不知道调用那个构造函数的坑
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1(1998, 07, 18);
d1.Print();
CSDN_Date d2;
d2.Print();
return 0;
}
1.4 什么是默认构造函数
对于默认构造函数,许多人的理解是:我们自己不写,编译器自动生成的那个。这样的理解是不全面的,默认构造函数分为三种:
a:我们不写,编译器自动生成的
b:我们自己写的无参的
c:我们自己写的全缺省的
当然上面三种情况中的bc是不能同时存在的,因为其不构成函数重载,所以,判断是否是默认构造函数,本质是在是否进行传参调用,不用传参数就可以调用的构造函数就是默认构造函数。
默认构造函数都做了什么?
看到这里,许多同学可能会发出这样的疑问,既然编译器自动生成和我们写的全缺省的都叫做默认构造函数,那我们干脆不写,系统自己生成就可以了,何必多此一举,我们下面将用代码测试一下,我们不写,系统自动生成的默认构造函数都干了什么,它对内置类型和自定义类型的作用是否是一样的。
a:内置类型(基本类型):语言原生定义的类型,如:int、char、double等,还有指针。
b:自定义类型:我们使用class、struct等定义的类型。
a:内置类型
class CSDN_Date
{
public:
//我们不写,编译器会生成一个默认的构造函数,我们写了编译器就不会生成了。所以说,构造函数叫默认成员函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1;
d1.Print();
return 0;
}
b:自定义类型
class A
{
public:
A(int a = 1)
{
cout << "A(int a = 0)构造函数" << endl;
_a = a;
}
A(const A& a)
{
_a = a._a;
cout << "A(const A& a)" << endl;
}
void Print()
{
cout << _a << endl;
}
private:
int _a;
};
class CSDN_Date
{
public:
//我们不写,编译器会生成一个默认的构造函数,我们写了编译器就不会生成了。所以说,构造函数叫默认成员函数
void Print()
{
//cout << _aa.a << endl;
_aa.Print();
}
private:
A _aa;
};
int main()
{
CSDN_Date d1;
d1.Print();
return 0;
}
c:结论
我们不写构造函数的时候,编译器此时会自动生成一个构造函数,但是这个默认构造函数会对不同类型的对象进行区分:
a:内置类型(基本类型):语言原生定义的类型,如:int、char、double等,还有指针,编译器不会对这些内置类型进行初始化。
b:自定义类型:我们使用class、struct等定义的类型,编译器会去调用他们的默认构造函数进行初始化。
所以我们在使用构造函数的过程中,一般要写一个全缺省的默认构造函数,其能满足大部分场景的使用。因为系统生成的默认构造函数对内置类型不处理,而是赋予随机值,只对自定义类型进行处理,所以我们应该手动写一个全缺省构造函数,对内置类型和自定义类型的值都进行初始化。
2、析构函数
2.1 析构函数的概念
构造函数不是完成对象的构建,析构函数也不是完成对象的销毁。对象的创建和销毁是编译器自己完成的,当开辟空间的时候就构建完成,出了作用域自动销毁。
析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
2.2 析构函数的特性
析构函数是特殊的成员函数。
其特征如下:
1. 析构函数名是在类名前加上字符 ~。
2. 无参数无返回值。 --所以他不能重载,所以一个类有且只有一个析构函数
3. 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
5. 关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对会自定类型成员调用它的析构函数。
2.3 析构函数的实现
并不是所有的类都需要析构函数的,这也是析构函数和构造函数的区别之一,对于下面的CSDN_Date的类,系统自动生成的析构函数足够完成需求,并不需要我们自己书写,而对于CSDN_Stack 类,需要我们自己写析构函数来完成资源的清理。
a:无意义的析构函数
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//有同学就会想,CSDN_Date的析构函数好像没啥意义? -->是的
~CSDN_Date()
{
//完成资源的清理
cout << "~CSDN_Date()" << endl;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1;
d1.Print();
return 0;
}
正如运行结果所显示,虽然CSDN_Date类的对象在实例化完成后,在销毁的时候没有资源需要清理,但是系统还是会调用其析构函数,这是编译器默认的工作方式,在对象销毁前调用其析构函数。
但是对于下面的类来说,析构函数是必须要存在的,如果不存在会导致后续的野指针等问题的发生。
b:意义重大的析构函数
struct CSDN_Stack
{
public:
CSDN_Stack(int capacity = 4)
{
if (capacity == 0)
{
_a = nullptr;
_size = _capacity = 0;
}
else
{
_a = (int*)malloc(sizeof(int)* capacity);
_size = 0;
_capacity = capacity;
}
}
//像Stack这样的类,析构函数具有重大意义
~CSDN_Stack()
{
cout << "~CSDN_Stack()" << endl;
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
void Push(int x){}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
CSDN_Stack st1;
st1.Push(1);
st1.Push(2);
st1.Push(3);
return 0;
}
对于上面的CSDN_Stack类来说,在对象销毁前编译器同样调用了其析构函数,但是它的析构函数是具有重大意义的,如果没有析构函数,在对象销毁后,指针_a仍然指向某一块内存空间,而这一块内存空间并不属于这个对象,如果后续继续对指针_a进行解引用或者其他操作,则会导致野指针等问题,这是非常危险的,会导致编译器崩溃。
2.4 系统自动生成的析构函数都做了什么?
同构造函数一样,析构函数对成员变量的处理也是分两种:
a:内置类型
b:自定义类型
下面我们通过测试用例来研究。
a:内置类型
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//不写,编译器会生成默认的析构函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1;
d1.Print();
return 0;
}
b:自定义类型
struct CSDN_Stack
{
public:
CSDN_Stack(int capacity = 4)
{
cout << "Stack()构造函数" << endl;
if (capacity == 0)
{
_a = nullptr;
_size = _capacity = 0;
}
else
{
_a = (int*)malloc(sizeof(int)* capacity);
_size = 0;
_capacity = capacity;
}
}
//像Stack这样的类,析构函数具有重大意义
~CSDN_Stack()
{
cout << "~Stack()析构函数" << endl;
//清理资源
free(_a);
_a = nullptr;
_size = _capacity = 0;
}
void Push(int x){}
private:
int* _a;
int _size;
int _capacity;
};
class CSDN_Date
{
public:
//不写,编译器会生成默认的狗杂函数
//不写,编译器会生成默认的析构函数
private:
CSDN_Stack _st;
};
int main()
{
CSDN_Date d1;
return 0;
}
c:总结
c-1:内置类型成员,不处理
c-2:自定义类型成员,它会去调用他的析构函数
3、拷贝构造函数
拷贝构造函数是用来拷贝初始化的,但是其只能拷贝同类型的,即完成同类之间的拷贝初始化。
3.1 拷贝构造函数的特性
拷贝构造函数的特征:
拷贝构造函数也是特殊的成员函数,其特征如下:
1. 拷贝构造函数是构造函数的一个重载形式。
2. 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。
3. 若未显示定义,系统生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做浅拷贝,或者值拷贝。
3.2 拷贝构造函数的实现
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
CSDN_Date(const CSDN_Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Stack d1(2020, 5, 27);
CSDN_Stack d2;
d1.Print();
d2.Print();
//我们想再定义一个d4,但是想让d4的值和d1一摸一样,总不能还是传一样的参数,如果d1修改了,d4也得修改
CSDN_Stack d4(d1);//这就叫做:拷贝构造函数
d4.Print();
d1.Print();
return 0;
}
有细心的同学发现我们的拷贝构造函数在形参的位置采用了传引用的方式,这是为什么呢?
答:要调用拷贝构造,就要先传参,传参采用传值的方式,又是对象拷贝构造,循环往复的过程,就会让程序崩溃。如果是引用作为形参,d1传过来后,d是d1的别名,就会进行赋值操作。
这个地方还推荐+const,如果不加,把左右写反了,这样就会改变d1,然后d1和d4都成了随机值,加了const后,写反会报错。
3.3 系统自动生成的拷贝构造函数都做了什么?
拷贝构造函数也是默认成员函数之一,当我们不写构造函数时,系统自动生成的又干了什么呢?我们同样分内置类型和自定义类型变量来进行剖析。
a:内置类型
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Stack d1(2020, 5, 27);
CSDN_Stack d2;
d1.Print();
d2.Print();
//我们想再定义一个d4,但是想让d4的值和d1一摸一样,总不能还是传一样的参数,如果d1修改了,d4也得修改
CSDN_Stack d4(d1);//这就叫做:拷贝构造函数
d4.Print();
d1.Print();
return 0;
}
显而易见,系统自动生成的拷贝构造函数仍然完成了对内置类型的拷贝,我们称这种拷贝为浅拷贝,或者值拷贝。
b:自定义类型
struct CSDN_Stack
{
public:
CSDN_Stack(int capacity = 4)
{
if (capacity == 0)
{
_a = nullptr;
_size = _capacity = 0;
}
else
{
_a = (int*)malloc(sizeof(int)* capacity);
_size = 0;
_capacity = capacity;
}
}
void Push(int x){}
private:
int* _a;
int _size;
int _capacity;
};
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
CSDN_Stack _st;
};
int main()
{
CSDN_Stack st;
CSDN_Stack copy(st);
return 0;
这时,程序崩溃,这是为什么呢?
答:我们在main函数中定义了CSDN_Stack类的st,然后又定义了copy,调用其拷贝构造函数,这时候程序崩了。
1、系统执行CSDN_Stackst;调用默认构造函数,malloc了一块内存空间,然后有_a,_size,_capacity
2、调用拷贝构造函数,将_a,_size,_capacity又重新生成了一份,这两份(st和copy)都指向malloc那块空间,st先构造,copy后构造,copy先析构,st后析构
3、copy析构的时候,把malloc释放掉了,然后st又去free,所以崩溃,因为malloc和free是对应的,一块malloc出来的空间只能free一次,虽然copy析构的时候,将_a=nullstr,但是这只是将自己置空,对st没影响,这是两个空间
4、因为共有一块空间,当其中一个对象插入删除数据,都会导致另一个对象也插入删除了数据
所以像CSDN_Stack这样的类,编译器默认生成的拷贝构造函数完成的是浅拷贝,不能满足我们的需求,需要自己实现深拷贝。
4、赋值运算符重载
4.1 赋值运算符重载的引出
赋值运算符函数也是一个默认成员函数,也就是说我们不写,编译器也会自动生成一个。编译器默认生成赋值运算符跟拷贝构造的特性是一致的。
a:针对内置类型,完成浅拷贝,也就是说像CSDN_Date这样的类,不需要我们自己写赋值运算符重载,像CSDN_Stack就得自己写;
b:针对自定义类型,也一样,它会调用他的赋值运算符重载,完成拷贝。
赋值拷贝也是拷贝行为,但是不一样的是,拷贝构造是创建一个对象时,拿同类对象初始化的拷贝,这里的赋值拷贝时两个对象已经都存在了,都被初始化过了,现在想把一个对象,复制拷贝给另一个对象。
我们现在有一个类CSDN_Date,我们已经实例化了两个对象d1和d2,并将他们赋值,现在我们想让d1 = d2,应该怎么做呢?此时就用到了赋值运算符重载。
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1(2021, 1, 1);
CSDN_Date d2(1998, 7, 18);
d1.Print();
d2.Print();
//怎么做到d1=d2??
//d1 = d2;
}
这里是跟拷贝构造不一样的,拷贝构造是定义了d1,然后在实例化d2的时候,拿同类对象d1的值拷贝给d2进行初始化。我们这里虽然也是拷贝,但是d1和d2两个对象都已经初始化过了,现在想把一个对象复制拷贝给另一个对象。
4.2 运算符重载
在讲解赋值运算符重载之前我们先给各位读者说明一下运算符重载的规则和概念。
运算符重载的目的:为了让自定义类型可以像内置类型一样使用运算符,需要哪个运算符就重载哪个运算符。其本质是为了增加代码的可读性。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意:
不能通过连接其他符号来创建新的操作符:比如operator@
重载操作符必须有一个类类型或者枚举类型的操作数
用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不能改变其含义
作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的
操作符有一个默认的形参this,限定为第一个形参
.* 、:: 、sizeof 、?: 、. 注意以上5个运算符不能重载
4.3 赋值运算符的重载
class CSDN_Date
{
public:
CSDN_Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
//这里如果不采用传引用返回,会去调用拷贝构造函数,将*this拷贝返回回去。
//因为这里的this指代的是d1,出了这个作用域,d1仍存在,所以这里可以用传引用返回。
CSDN_Date& operator=(const CSDN_Date& d)
{
//这里的&d是取地址,而形参里面的Date& d是引用
if (this != &d)
//检查左操作数的地址和右操作数的地址是否相同,不是自己给自己赋值,才进行拷贝
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
CSDN_Date d1(2021, 1, 1);
CSDN_Date d2(1998, 7, 18);
d1.Print();
d2.Print();
d1 = d2;
d1.Print();
d2.Print();
return 0;
}
❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️❤️
5、6、取地址及const取地址操作符重载
对于取地址重载和const对象取地址,这两个我们很少自己实现,使用系统自己生成的即可。
class Date
{
public:
Date(int year = 0, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//这两个的价值:如果你不想让别人取你的地址,就把里面的返回置为空
Date* operator&()
{
return this;
//return nullptr;
}
const Date* operator&()const
{
return this;
}
private:
int _year;
int _month;
int _day;
};
//这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
int main()
{
Date d1(2021, 5, 27);
const Date d2(2022, 5, 4);
cout << &d1 << endl;
cout << &d2 << endl;
return 0;
}