深入剖析类和对象中六大成员函数不为人知的一面。
如果觉得有所收获,或者觉得作者写的还行,希望给作者点点赞,能评论几句就更好了,欢迎各位大佬对我的文章提出建议。
如果文章有错误,还请各位指出,感谢。
如果一个类中什么成员都没有,简称为空类。
但是空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
默认成员函数就是:如果用户没有自己去编写如何实现这几个函数,那么编译器就会自动帮你生成。如果用户写了,那编译器就不会自动生成了。
我们先实现一个日期类,用这个日期类来说明。
#include
using namespace std;
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 t1;
t1.Init(2024, 1, 21);
t1.Print();
Date t2;
t2.Init(2024, 1, 23);
t2.Print();
return 0;
}
我们可以看到,每设置一个对象比如t1、t2,想要初始化对象中的内容是每次都得自己手动调用成员函数Init(),是不是很烦呢?那有没有什么方法可以让事情变得简单一点,比如在对象创建时,就将信息设置进去呢?
当然有,那就是接下来要说的构造函数。
构造函数是一个特殊的成员函数
构造函数的意义:能够保证对象被 初始化 (init) 。
构造函数是特殊的成员函数,主要任务是初始化,而不是开空间(虽然构造函数的名字叫构造)。
可以看到我们完成了初始化工作
构造函数的定义
类名(形参)
{
内容
}
形参可以有好几个也可以没有,会构成函数重载。
上图中我们定义了两个构造函数一个无参数,一个三个参数,它们之间构成函数重载。
使用方法
由上图可得,我们想要调用无参的构造函数那就直接实例化对象就行,啥也不用管。如果你想要调用三参数的构造函数,那就在实例化对象之后加个(),里面填上你想要的数据即可。
#include
using namespace std;
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类中是没有写构造函数的,这里运行代码使用过的是编译器生成的默认构造函数,可见这个默认构造函数调用的_t的构造函数。而_t的数据类型是自定义类型。而对内置类型没有任何处理,这里的内置类型是随机值。
注意:C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。
这样就解决了调用编译器生成的构造函数内置类型是随机值的问题。
总结:
- C++中将数据类型分为内置类型如:int/char…,和使用class/struct/union等自己定义的类型。
- 对于编译器生产的默认构造函数来说,它会调用自定义类型的构造函数,但不会对内置类型进行处理,这也是内置类型是随机值的原因。
- C++11中内置类型成员变量在类中声明时可以给默认值来解决针对内置类型成员不初始化的缺陷
构造函数是可以全缺省的。意思就是在函数形参中给定一个初始值,在调用这个函数是,不过不传参数,那就使用默认的缺省值,如果传参了,那就使用传的参数。
如图我们实现了一个全缺省的构造函数,如果没有传参,那就默认year=2000, month=2, day=2,如果传了参数t2(2024,1,23)那就使用传的参数而不是缺省值。
在C++中类的默认构造函数有三个。
解答:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型,看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数。
通过构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么销毁的呢?
比如在构造函数中malloc了一段内存,最后要释放的话,我们还得专门写一个Destroy函数来free它,还得记得调用这个函数。
**有没有一个函数像构造函数一样编译器会自动调用来帮助我们实现清理资源的工作呢?**当然有,那就是析构函数。
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
外貌特征:
为了验证在对象生命周期结束时,编译器会自动调用析构函数,我们可以让对象叫一声,即在析构函数中写个输出语句,只要析构函数被调用了,那就会在控制台上打印出一段话。
#include
using namespace std;
class Date
{
public:
Date(int year=2024, int month=1, int day=24)//全缺省的构造函数
{
_year = year;
_month = month;
_day = day;
}
~Date()// 析构函数
{
//Date类没有要清理的资源,所以可以什么都不写
cout << "调用了析构函数" << endl;//叫一声
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year = 2024;
int _month = 1;
int _day = 23;
};
int main()
{
Date t1;
return 0;
}
用Date类没办法向大家清晰的展示析构函数的清理工作,这里我们使用Stack来展示析构函数是如何清理资源的。
因为Stack要用malloc在堆区开空间的,所以最后就一定要将这部分空间释放掉,这就要靠析构函数来实现了。
如此一来就保证了初始化有构造函数,清理资源有析构函数,再也不用担心最后因为没有手动调用destroy函数释放资源了,析构函数会被编译器自动调用完成这件事。
代码演示
#include
#include
using namespace std;
typedef int StackDataType;
class Stack {
public:
// 构造函数 - StackInit
Stack(int capacity = 4) { // 这里只需要一个capacity就够了,默认给4(利用缺省参数)
_array = (StackDataType*)malloc(sizeof(StackDateType) * capacity);
if (_array == NULL) {
cout << "Malloc Failed!" << endl;
exit(-1);
}
_top = 0;
_capacity = capacity;
}
// 析构函数 - StackDestroy
~Stack() {
// 这里就用的上析构函数了,我们需要清理开辟的内存空间(防止内存泄漏)
free(_array); //释放内存空间
_array = nullptr; //置空
_top = _capacity = 0;
}
private:
int* _array;
size_t _top;
size_t _capacity;
};
int main(void)
{
Stack s1;
Stack s2(20); // s2 栈 初始capacity给的是20
return 0;
}
注释:
在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month, _day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁。main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数
注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
总结:
一、如果不自己写构造函数,让编译器自动生成,那么这个自动生成的 默认构造函数:
- 对于 “内置类型” 的成员变量:不会做初始化处理。
- 对于 “自定义类型” 的成员变量:会调用它的默认构造函数(不用参数就可以调的)初始化,如果没有默认构造函数(不用参数就可以调用的构造函数)就会报错!
二、如果我们不自己写析构函数,让编译器自动生成,那么这个 默认析构函数:
- 对于 “内置类型” 的成员变量:不作处理 (不会帮你清理的.)
- 对于 “自定义类型” 的成员变量:会调用它对应的析构函数。
在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎。
相当于就是把自己复制一遍,内置类型如int,char这些要实现复制很简单只需要:
int a = 10;
int b = a;
那在创建类对象时,如何创建一个与已存在对象一摸一样的新对象呢?
答案是拷贝构造。
class Date
{
public:
Date(int year = 2024, int month = 1, int day = 25)//全缺省构造函数
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)//拷贝构造
{
_year = d._year;
_month = d._month;
_day = d._day;
}
~Date()//析构函数
{
//cout << "~Time()" << endl;
}
void show()//普通函数
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date a(2000, 1, 1);
Date b = a; //注意这里的对象初始化要调用拷贝构造函数,而非赋值
b.show();
return 0;
}
从以上代码可以看出系统为对象 B 分配了内存并完成了与对象 A 的复制过程。就类对象而言,相同类型的类对象是通过拷贝构造函数来完成整个复制过程的。
具体过程是当编译器执行到Date b = a这一行代码时会自动调用b的拷贝构造函数,拷贝构造的参数为对象a,并在函数中完成赋值操作。
同一个类的对象在内存中有完全相同的结构,如果作为一个整体进行复制或称拷贝是完全可行的。==这个拷贝过程只需要拷贝数据成员,==而函数成员是共用的(只有一份拷贝)。在建立对象时可用同一类的另一个对象来初始化该对象的存储空间,这时所用的构造函数称为拷贝构造函数。
拷贝构造函数本质上来说也是构造函数,是构造函数的一个重载。
拷贝构造函数概念:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
Date a;
Date b=a;//用已存在的对象去创建一个新对象,这里是拷贝构造不是复制重载
Date c(a);//拷贝构造
//下面这个是复制重载为了你们区分就都写出来对比一下
Date d;
d=a;
class Date
{
public:
Date(int year = 2024, int month = 1, int day = 25)//全缺省构造函数
{
cout << "Date(int,int,int):" << this << endl;
}
Date(const Date& d)//我们自定义的拷贝构造
{
cout << "Date(const Date& d):" << this << endl;
}
~Date()//析构函数
{
cout << "~Date():" << this << endl;
}
private:
int _year;
int _month;
int _day;
};
Date Test(Date d)
{
Date temp(d);
return temp;
}
int main()
{
Date d1(2022, 1, 13);
Test(d1);
return 0;
}
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
浅拷贝就是在对象复制时**,仅仅只对对象中的数据成员进行简单的赋值**,默认的拷贝构造就是浅拷贝,虽然大多数情况下浅拷贝就已经够用了,但如果出现资源申请的情况(申请内存),浅拷贝就会出现一些问题。
以下程序是我们自定义实现了一个栈,其中有个成员变量是指针DataType* _array;,在构造函数中我们在堆区申请了内存,让指针_array指向了内块内存。我们在主函数中写了Stack s2(s1);利用构造函数构造了一个s2,但默认的构造函数时是浅拷贝,在s2的构造函数中相当于有这么一句代码_array=s1._array;。这造成了两个指针指向了同一块内存空间,但这俩指针在不同的对象s1,s2中,在俩对象析构的时候会导致同一块内存空间释放两次,导致错误。
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2(s1);
return 0;
}
为了解决上述问题 我们就需要给s2中的_array也开辟和s1中的_array一样大小的空间,所以我们就需要深拷贝 。
在“深拷贝”的情况下,对于对象中动态成员,就不能仅仅简单地赋值了,而应该重新动态分配空间,如上面的例子就应该按照如下的方式进行处理:
手动写一个深拷贝的拷贝构造,各自指向一段内存空间,但它们指向的空间具有相同的内容,这就是所谓的“深拷贝”。
Stack(const Stack& s)
{
//s1空间有多大就申请多大的空间
_array= (DataType*)malloc(s._capacity * sizeof(DataType));
if (_array == NULL)
{
exit(-1);
}
for (int i = 0; i < s._size; i++)
{
_array[i] = s._array[i];
_size++;
}
_capacity = s._capacity;
}
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
//函数原型:返回值类型 operator操作符(参数列表)
返回值 operator 操作符(参数列表)
{
}
注意:
在讲拷贝构造的时候,我们说明了初始化和赋值的区别:在定义的同时进行赋值叫做初始化(Initialization),定义完成以后再赋值(不管在定义的时候有没有赋值)就叫做赋值(Assignment)。初始化只能有一次,赋值可以有多次。
//Date为一个类
Date a;
Date b(a);//拷贝构造
Date c=a;//拷贝构造
Date d;
d=a;//复制重载
当以拷贝的方式初始化一个对象时,会调用拷贝构造函数;当给一个对象赋值时,会调用重载过的赋值运算符。
1. 赋值运算符重载格式
//Date& operator=(const Date& d)a、b、c、d都是Date类对象
a=b=c=d;//由于有返回值Date&,所以能连续赋值
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;
}
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
private:
int _year;
int _month;
int _day;
};
2. 赋值运算符只能重载成类的成员函数不能重载成全局函数
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
if (&left != &right)
{
left._year = right._year;
left._month = right._month;
left._day = right._day;
}
return left;
}
// 编译失败:
// “operator =”必须是成员函数
原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。
3. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time& operator=(const Time& t)
{
if (this != &t)
{
_hour = t._hour;
_minute = t._minute;
_second = t._second;
}
return *this;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d1;
Date d2;
d1 = d2;
return 0;
}
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
// 这里会发现下面的程序会崩溃掉?这里就需要我们以后讲的深拷贝去解决。
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 10)
{
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
return;
}
_size = 0;
_capacity = capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
size_t _size;
size_t _capacity;
};
int main()
{
Stack s1;
s1.Push(1);
s1.Push(2);
s1.Push(3);
s1.Push(4);
Stack s2;
s2 = s1;
return 0;
}
对于简单的类,默认的赋值运算符一般就够用了,我们也没有必要再显式地重载它。但是当类持有其它资源时,例如动态分配的内存、打开的文件、指向其他数据的指针、网络连接等,默认的赋值运算符就不能处理了,我们必须显式地重载它,这样才能将原有对象的所有数据都赋值给新对象。
这两个默认成员函数一般不用重新定义 ,编译器默认会生成。
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容!
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。