当我们定义一个类,内部不声明成员,难道里面真的什么都没有吗?其实每个类都自带六个默认成员函数。分别是构造函数,析构函数,拷贝构造函数,赋值运算符重载函数以及取地址重载函数。
构造函数用于帮助我们初始化类的成员,我们以前写动态开辟的结构体时,会有个指针指向动态开辟的空间,如果我们忘记初始化这个指针,会导致后面的代码崩盘,即使我们正确初始化了指针,也会经常忘记释放内存,造成内存泄露,而c++考虑到这些问题后也就设计出了构造函数和析构函数,当对象实例化的时候就会调用构造函数,帮助我们初始化,当程序结束时,就会调用自动析构函数,清理空间(当然构造函数和析构函数在对象生命周期内只会被调用一次)函数介绍具体如下。
一 构造函数
构造函数特点:
1 函数名和类名相同2 无返回值
3 对象实例化的时候就会由编译器自动调用构造函数,又称隐式调用
4 由于可能有多种初始化的方式,所以构造函数允许构成重载
1 编译器生成的构造函数的作用:
我们都知道构造函数是编译器调用来初始化对象的,而对象内部实际只存储成员变量,那当我们初始化对象的时候,编译器调用自己生成的构造函数会对成员变量如何处理呢?首先编译器将类型分为内置类型和自定义类型,那对象内的成员变量类型也就两种——内置类型和自定义类型,对于内置类型c++语法规定不对其进行初始化,不是所有编译器都遵循该规定的,甚至编译器自己的版本都未保持一致,有些编译器的版本会对内置类型进行初始化,有些又不会,所以我们统一认为编译器自己生成的构造函数不对内置类型进行处理,而对于自定义类型成员的初始化,编译器则会去调用自定义类型自己的默认构造函数来初始化自定义类型成员,大家还可以调试看看在进入类的构造函数前会跳转到类的自定义成员的构造函数中去,然后才进入构造函数体内(这个顺序的实质了解了初始化列表即可自行解答)。自己写的构造函数对自定义类型成员的处理是一样的,那对内置类型成员处不处理那不是我们自己决定的嘛。由此得当编译器生成的构造函数无法满足我们初始化内置类型需求时我们就要自己写一个构造函数。可用下面代码演示自己写的stack的构造函数和编译器生成的Date的构造函数对内置类型和自定义类型成员的处理。
#include<iostream>
class Queue
{
public:
int _a;
int _b;
};
class Date
{
public:
int _Day;
int _year;
int _month;
Queue Que; 初始化Date的构造函数也会调用Que的默认构造函数
同样Que没有则编译器给它生成一个
};
class Stack
{
private:
int* _a=NULL;
int _top = 0;;
int _capcity=0;
Date d1; 若未写构造函数,系统也会自己生成一个默认构造函数
public:
Stack(int capcity=4) 对自定义类型Date的初始化是调用它的默认构造函数
{
int* _a = NULL;
int _top = 0;;
int _capcity = 0;
}
};
int main()
{
Stack stack; 此处是隐式调用stack这个类的默认构造函数,
return 0;
}
上述代码其实就是stack嵌套Date,Date嵌套Queue类型,即便stack,Date,Queue都没有显示定义构造函数(注意构造函数包括非默认构函数和默认构造函数,后面提及的拷贝构造函数也是构造函数),编译器都会帮它们生成一个默认构造函数,谁有显示定义的构造函数,编译器就不会帮谁生成一个构造函数。提到这里,应该会有小伙伴问对象如何调用非默认构造函数?还有对象成员没有默认构造函数,但有非默认构造函数会怎么样?首先对象的构造函数肯定是不会帮我们调用该成员的非默认构造的,那我们应该如何传参调用呢?解答如下。
2 构造函数的调用
默认构造函数是指无需传参调用的构造函数,包括我们写的无参构造函数,全缺省构造函数,以及编译器生成的构造函数都称为默认构造函数,这三个默认构造当然只能存活一个,而且只有编译器生成的构造函数才能称为默认成员函数(规定),非默认构造函数是需要传参数才能调用的构造函数。如下代码,我们对象初始化可以显示传参调用非默认构造函数。
#include<iostream>
using namespace std;
class Date
{
public:
//自定义无参拷贝构造函数
Date ()
{
cout << "Date()" << endl;
_year =2023;
_month =4;
_day = 27;
}
//带参数拷贝构造函数
Date(int year,int month,int day)
{
cout << "Date(year)" << endl;
_year = year;
_month = month;
_day = day;
}
int _year;
int _month;
int _day;
};
int main()
{
Date d1; 自动调用默认构造函数
Date d2(2023,4,28); 调用带参数构造函数
//Date d3(); 会被认为是函数声明,所以调用默认构造函数不用该形式
return 0;
}
而调用自定义类型成员的非默认构造函数同样需要我们显式传参调用(我会在下一篇类和对象终章中的初始化列表提及),此时对象的构造函数就不能是编译器生成的默认构造函数了,编译器生成的构造函数只能调用成员的默认构造函数,非默认构造不显示传参,编译器根本不知道你想传什么参数给构造函数,显示调用构造函数是和我们以前调用函数一样,是用函数名调用,由此得上述均是编译隐式调用,而非我们直接指明函数名让编译器去调用它。
最后补充一点:为什么构造函数的调用是在初始化列表呢,我觉得是因为c++要求自定义类型要求定义时初始化,这其实会更严谨些,而初始化列表又是成员定义的地方,所以构造函数要在此处调用。
二 析构函数
析构函数特点:
函数名在类名前加个波浪号~无返回值无参数,所以析构函数不支持重载(这一点使得析构比构造好理解很多)
程序结束时有编译器自动调用
若无显示定义析构函数,编译器还是会自己生成一个析构函数
编译器生成的析构函数函数对内置类型同样不处理,对自定义类型会去调用该自定义类型的析构函数。内置类型不仅指的是成员变量中的内置类型,还有在外部定义的内置类型,如下,注意理解。
#include<iostream>
using namespace std;
class A
{
public:
A(int a = 0, int b = 0)
: _a(a)
{
cout << "A():" << this << endl;
}
~A()
{
cout << "~A():" << this << endl;
}
private:
int _a;
};
int main()
{
// p1现在指向的只不过是与A对象相同大小的一段空间,还不能算是一个对象,
因为构造函数没有执行
A* p1 = (A*)malloc(sizeof(A));
A a1;//外面定义时也分自定义类型和内置类型,自定义类型编译器会帮我们调用构造函数和析构函数
p1->~A(); p1被认为是内置类型,若要调用析构函数清理资源只能显示调用
return 0;
}
下面总结要写析构函数和不写的析构函数的场景:
1 当成员都是内置类型,且无申请资源时,无需写析构函数,它们都在栈上,程序结束就销毁了,没必要对内置类型处理。
2 当成员中的内置类型有申请动态内存的资源时,比如一个指针指向动态开辟的空间,编译器写的析构函数不会对指针(内置类型)处理,此时需要我们自己写析构函数。
2.当成员都是自定义类型时,对象无需显示定义写析构函数,成员要不要写此时要看看成员的成员根据上述情况讨论
三.拷贝构造函数
有时候我们想要复制一个和对象一模一样的实体,那我们再拷贝一个新的对象往往要自己写一个拷贝函数,那Date,stack, Queue这些自定义类型都要写的话太麻烦了,所以c++就把拷贝函数归为默认成员函数。
拷贝构造函数特点:
1 拷贝构造函数其实是构造函数重载形式,两者参数不同构成重载
所以拷贝构造函数函数名和类名相同且无返回值
2 参数必须为引用,不然会触发无限递归,导致栈溢出
拷贝是拿一个已经定义好的对象来初始化一个新的对象,而赋值则是两个已经定义好的对象之间的赋值,这是不一样的。c++规定自定义类型传参赋值给新对象都要调用拷贝构造
Date(int day=13,int month=5,int year=2023)
{
_day = day;
_month= month;
_year = year;
}
Date(const Date&d)
{
_day = d._day;
_month = d._month;
_year = d._year;
}
Date d1;//已经d1初始化
Date d2(d1); 此时是拿已经初始化好的对象d1来初始化d2,
给构造函数传的参数是Date类型,根据参数匹配规则,所以调用拷贝构造函数
c++规定规定自定义类型传参赋值都要调用拷贝构造函数(先认为是个定理,后面大致叙述)
拷贝构造函数参数是一个已经定义好的Date对象,那我们此时是传引用还是传值呢?如果是传值, 我们d1传给拷贝构造函数中的参数d, 按规定传参要调用拷贝构造,调用拷贝时又要传参数给d,传参又要调用拷贝构造,循环往复,就会不停地递归,最终栈溢出。
只要一开始是传引用就可以停止递归了,也可以传指针,但是目前再介绍c++的内容,所以还是用c++的引用吧。
那c++规定自定义类型传参赋值都要调用拷贝构造函数呢?c语言的传参和赋值方式就不行吗?
我们先说说浅拷贝的问题:
我们都知道拷贝分浅拷贝和深拷贝,c++中对内置类型一般用浅拷贝,而自定义类型会去调用拷贝构造函数,如果此时同样未定义拷贝构造函数,编译器同样会帮我们生成一个拷贝构造函数,但是c++编译器默认生成的拷贝构造是浅拷贝,这会有很大的问题。如下。
#include<iostream>
class Stack
{
private:
int* _a=NULL;
int _top = 0;;
int _capcity=0;
public:
Stack(int capcity=4)
{
int* _a = (int*)malloc(sizeof(int)*capcity);
int _top = 0;;
int _capcity = 0;
}
~Stack()
{
free(_a);
int* _a = NULL;
int _top = 0;;
int _capcity = 0;
}
};
int main()
{
Stack st1;
Stack st2=st1;//无显示定义拷贝构造函数
return 0;
}
问题一:
编译器生成的浅拷贝构造函数使得两个对象st1和st2中的成员变量_a指向同一块动态申请空间,那st1修改就会对st2的值造成影响。
问题二
对同一块空间析构两次
#include<iostream>
class Stack
{
private:
int* _a=NULL;
int _top = 0;;
int _capcity=0;
public:
~Stack()
{
free(_a);
int* _a = NULL;
int _top = 0;;
int _capcity = 0;
}
};
Stack& fun()
{
Stack s1;
return s1;
}
int main()
{
Stack st2=fun(); 此时这里还是拷贝,而不是赋值,由于编译器的浅拷贝构造函数,
使得st2中的成员变量_a为空指针,之后使用同样会出问题。
return 0;
}
又因为c语言传参数和赋值用的也都是不智能的浅拷贝,自定义类型浅拷贝可能的问题如上所述,为了不让自定义类型因为浅拷贝出现上述的问题,所以c++直接规定自定义类型都要调用拷贝构造函数,但是Date类型用浅拷贝又可以,也就是Date类型不写拷贝函数,用c语言的传参赋值方式也可以,但总不能让c++规定说Date类型特殊,可以不写拷贝函数,所以c++让编译器生成的默认拷贝函数也是浅拷贝,可以满足Date这类只需浅拷贝的需求,而且编译器也只能默认生成浅拷贝,深拷贝那你让编译器怎么写,五花八门的。
当然c++规定必须调用构造函数无法完全杜绝问题出现,比如要写深拷贝的时候我们没写,用了编译器自己生成的浅拷贝构造函数,还是会出错,但是我认为这可以提醒程序员考虑拷贝构造函数用浅拷贝还是深拷贝的问题,生活中语言很难限制我们不说脏话,计算机中语言也很难限制我们不犯错,终究还是要看使用者如何使用。
下面总结一下什么时候可以用编译器的浅拷贝函数,什么时候要自己写个深拷贝函数?
(1)当我们的自定义类型的成员变量都是内置类型,且无申请资源时,那我们就可以不写拷贝函数,用编译器生成的拷贝构造函数。
(2)当我们成员有申请动态开辟的资源时,我们就要写深拷贝构造函数。
四.赋值运算符重载
1.运算符重载来历
#include<iostream>
using namespace std;
class Date
{
public:
//自定义无参拷贝构造函数
Date ()
{
cout << "Date()" << endl;
_year =2023;
_month =4;
_day = 27;
}
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2;
return 0;
}
按以往思路,现在我们想比较d1和d2的大小,可能就是写个函数,然后起个名字叫Compare1,如果我们以后有越来越多的自定义类型,名字叫Compare2,Compare3...,如此多相似的函数名,代码可读性大大降低,可是如果能写成d1<d2而不是写成Compare1(d1,d2),代码的可读性是大大提高了的。(此处提高了代码的可读性,但每个类型我们都要写个重载函数,也是非常繁琐的,这里的解决方法我放在模板讲解)
但是我们都知道运算符本来只能用于内置类型,因为语言开发者已经在库里实现了不同内置类型(比如int double,float)的运算符重载函数,这些针对不同内置类型的函数构成函数重载,而自定义类型是多样的,所以语言开发者是无法得知我们写的自定义类型该如何比较大小,那是不是说明我们就无法对自定义类型用比较运算符呢,当然不是,只要我们再写一个针对自定义类型的运算符重载函数,自定义类型也就可以使用运算符号了。
运算符重载函数特征
函数名为:关键字operator+需要重载的运算符号
函数原型:返回类型+operator+运算符+(参数列表)
注意:
不能重载不属于运算符的符号,比如@
运算符重载函数中必须有一个是自定义类型(因为c++怕我们修改了内置类型的运算)
不能改变了用于内置类型的操作符原有的含义,比如将加法重载为自定义类型的减法
.*(称之为点星操作符,我也没见过),sizeof, ?(三目), ::(域作用限定符), 访问成员的点(.)操作符都不能用于运算符重载
当我们使用d1<d2时,当运算符重载函数为全局函数时,编译器会将其转化为operator<(d1,d2),这说明如果我们用operator<(d1,d2)也能调用函数,当运算符重载函数为成员函数时,编译器会将其转化为d1.operator<(d2),如下代码,其中Date::是指明类域。
bool Date::operator<(Date& d)//类内声明,类外定义指明所属类域即可
还有该成员函数还有个默认参数this指针,一般是运算符
从左往右的第一个操作数
{
if (this->_year < d._year)
return true;
else if (this->_year == d._year && this->_month < d._month)
return true;
else if (this->_year == d._year && this->_month == d._month && this->_day < d._day)
return true;
else
return false;
}
那好,大家觉得这个重载函数写在哪里?首先肯定不是库里面,这里你别想了,那重载为全局函数呢?那我们看看上面这个比较重载函数的代码,是要访问类的成员变量的,成员变量为了封装性弄为私有,全局函数是访问不了的,所以唯一的选择是弄为类的成员函数(其它解决方法,友元函数或者用个公有的成员函数访问,但都一定程度上破坏了封装性)。
2.当我们了解了运算符重载就可以说赋值运算符重载的实现
赋值运算符重载函数特点
1.参数可用引用减少拷贝
2.防止自己给自己赋值
3.支持连续赋值,同样返回引用
4.赋值运算符重载函数是默认成员函数之一,若未显示定义,编译器会自己生成一个
赋值运算符重载函数对内置类型成员用浅拷贝,对自定义类型成员调用自定义类型的赋值运算符重载函数,这个自定义类型成员的赋值重载函数必须自己写,否则默认生成的赋值运算符函数浅拷贝会出问题。
//赋值运算符重载
Date& operator=(const Date& d)注意,这里的const修饰的是d,d无法修改自己,
但是(*this)是可以修改自己,所以还是要加if判断
自己给自己赋值的情况
{
//检查自己给自己赋值
if (this != &d)
{
_day = d._day;
_month = d._month;
_year = d._year;
}
return (*this);
}
五 取地址以及const取地址操作符重载
这是最后的两个成员函数,知识点非常少,基本上编译器自己生成的也可用,但是后面有特殊场景还是要自己生成,比如不想把地址给别人,而是直接返回地址指向空间的内容。如果有小伙伴能坚持看到这里,类和对象的坎就差不多过去了。下面代码演示上述两个函数。编译器自己生成的也是返回this指针。
//取地址操作符重载
Date* operator&()
{
return this;
}
const取地址操作符重载,两函数构成重载
const Date* operator&()const 这个const修饰的是this指针,this指针本来是Date*const
现在是const Date*const,实在是无其它位置安放修饰this指针的const了,只能放这了
{
return this;
}
(上面每句话都是我疑惑许久才总结出来的,还有些补充内容在初始化列表,没理解的可以私信我,我知道这个部分学习时有很多细节被忽略,我往后学偶尔都会猛然发现类和对象有些被忽略的细节,我刚经历这个阶段,我非常理解大家对这部分的感受)