类和对象三部曲(中)
Posted 做1个快乐的程序员
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了类和对象三部曲(中)相关的知识,希望对你有一定的参考价值。
在类和对象三部曲(上)文章中,我们知道了类和对象的基本定义、访问限定符、封装和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这样的类&
以上是关于类和对象三部曲(中)的主要内容,如果未能解决你的问题,请参考以下文章