目录
一.序言
类中存在成员变量和成员函数,而在成员函数中,有六大函数是非常特别的存在,也就是我们俗称
的天选之子.
既然是天选之子,肯定就有和其它成员函数不同的地方.
下面,我们会逐一介绍这六大函数,并了解它们的特性.
二.构造函数
1.构造函数的特殊性
在C语言栈和队列那节,我们曾做过一道括号匹配题目,下面是代码的实现(Stack的实现已省略).
具体可以看有关栈实现的一章.写文章-CSDN创作中心
bool isValid(char * s){
ST S;
StackInit(&S);
while(*s)
{
//假如是左括号则入列
if ((*s == '(' )||(*s == '[')||(*s == '{'))
PushStack(&S,*s);
//遇到右括号
else
{
//栈此时是否为空
if (StackEmpty(&S))
return false;
char ch = StackTop(&S);
PopStack(&S);
if (ch == '(' && *s != ')')
return false;
if (ch == '[' && *s != ']')
return false;
if (ch == '{' && *s != '}')
return false;
}
s++;
}
if (StackEmpty(&S))
return true;
else
return false;
DestroyStack(&S);
}
但是,这段代码虽然能够通过,我们可以发现还是有很多漏洞的.
比如,在这过程中,一旦中间返回false,就有可能没有DestroyStack,造成内存泄漏的情况,又或
者在刚开始,很多初学者可能会忘记初始化栈StackInit.
下面是截取《高质量的C++/C编程》的一段话
因为容易被遗忘,而且它被经常使用,很重要,因此构造函数成为了祖师爷挑选的第一位天选之
子.
2.构造函数的特性剖析
我们先来看一段简单的构造函数代码
class Date {
public:
//构造函数
Date()
{}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
对比下面Print成员函数,我们可以发现构造函数独特的两大区别
1. 函数名与类名相同
这个其实很好理解,构造函数的名字不能随便乱取,需要编译器能够识别出来,那最简单的方式就是让构造函数和类同名.
2. 无返回值,与返回值为void的函数不同,是什么都不用写!!!
有关这个特点,《高质量的C++/C编程》也描述得很生动.
构造函数就像生和死,轻飘飘的走,不带走一片云彩.
1.无参构造函数
像上面不带参数的构造函数,我们称之为无参构造函数.
但假如我们把初始化函数省略掉呢?我们可以惊奇的发现,程序依旧能够顺利运行.
class Date {
public:
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
//PS:这里创建对象,后面不需要加括号,否则就和函数声明无法区分开.
Date d1;
d1.Print();
return 0;
}
这是因为C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义(有自己写的构造函
数)编译器将不再生成,不过这个构造函数是空的,什么操作都不会被执行.
从结果上,我们也可以很好的观察到这点,_year ,_month,_day这三个成员变量,最后打印的结果
都是随机值.
那就可能有人有疑惑,d1对象调用了编译器生成的默认构造函数,但是d1对象的
_year/_month/_day,依旧是随机值,那这里编译器生成的默认构造函数有什么用呢?
实际上,确实没有什么用,C++编译器,会把类型划分为两类
一类是内置类型,比如int,double,char...等等
另一类是自定义类型,比如struct,类等等
构造函数对内置类型(char,int等等)成员不做处理,而只会对自定义类型调用其默认的初始值.
像下面的代码,Time是我们其中一个类,在Date类中,我们用它创建了一个对象_t
那用Date类实例化对象d时,_t会自动被初始化.(调用Time类的构造函数)
也就是说默认生成的构造函数只对自定义类型有用.
class Time{
public:
Time()
{
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_seconds = 0;
}
private:
int _hour;
int _minute;
int _seconds;
};
class Date
{
private:
// 基本类型(内置类型)
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
2.带参构造函数
但有时候用户希望对不同的对象赋予不同的初始值,这时就必须用到带参的构造函数,实现不同的
初始化.其对应的实参是在定义对象的时候给定的
比如我们之前提到过的栈的初始化.
struct Stack {
void StackInit(int InitSize = 4)
{
STDataType* newdata = (STDataType*)malloc(sizeof(int) * InitSize);
if (newdata == NULL)
{
perror("malloc fail.\n");
exit(-1);
}
_a = newdata;
_capacity = InitSize;
_top = 0;
}
//...可以放更多有关栈的函数
STDataType* _a;
int _top; //栈顶下标
int _capacity; //栈的容量
};
对它稍微变形,实际上就是Stack的构造函数,此时参数为InitSize,并且是全缺省.
struct Stack {
Stack(int InitSize = 4)
{
STDataType* newdata = (STDataType*)malloc(sizeof(int) * InitSize);
if (newdata == NULL)
{
perror("malloc fail.\n");
exit(-1);
}
_a = newdata;
_capacity = InitSize;
_top = 0;
}
//...可以放更多有关栈的函数
STDataType* _a;
int _top; //栈顶下标
int _capacity; //栈的容量
};
我们可以再举一个例子,比如我们之前的Date类
我们依旧用全缺省来实现它的构造(实际运用中也可以半缺省,但全缺省往往使用上更方便)
#include <iostream>
using namespace std;
class Date {
public:
Date(int year = 2020, int month = 4, int day = 30)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
Date d2(2000, 2, 28);
d2.Print();
return 0;
}
输出结果也符合我们的预期,如果没有传参,则使用默认值,否则,就用我们传进去的参数初始
化.
PS:这里还有一点需要注意的,无参的构造函数,我们没写编译器默认生成的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
这其实也很好理解,比如说下面这段代码,即便初始化成员变量的值都是相同的,但编译器就是无
法识别应该调用哪个默认构造函数.
#include <iostream>
using namespace std;
class Date {
public:
//不能有多个默认构造函数
Date()
{
_year = 2020;
_month = 4;
_day = 30;
}
Date(int year = 2020, int month = 4, int day = 30)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
PS:构造函数支持重载,但每次初始化只会调用其中一个构造函数
注意:全缺省函数才是默认构造函数,也就是说下面的代码,也是可以运行的,创建d1对象,会调
用默认构造函数,而创建d2对象,则会调用我们自己自定义的构造函数.
#include <iostream>
using namespace std;
class Date {
public:
Date()
{}
Date(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;
d1.Print();
Date d2(2022, 12, 31);
d2.Print();
return 0;
}
3.初始化列表
C++还提供了一种初始化数据成员的方法——参数初始化表.
这种方法不再函数体内对数据成员初始化,而是在函数首部实现.
有了不带参初始化,带参初始化,为什么还要专门设计出初始化列表的方式呢?
这是因为有些数据成员比如:引用,const修饰的变量必须在创建的时候初始化,又或者说自定义
类型成员(该类没有默认构造函数)等等,在类中只起到了声明的作用,此时,就只能通过初始化列
表来初始化.
比如说下面这段程序,程序就会发生报错.
#include <iostream>
using namespace std;
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
//声明
int _year;
int _month;
const int _day;
};
即const定义的初始化常量,必须在声明的时候,就赋予相应的值.
上述程序的正确写法,应该是下图所示:
_day作为常量,先在初始化列表被初始化,然后我们再执行构造函数函数体的部分.
#include <iostream>
using namespace std;
class Date {
public:
Date(int year, int month, int day)
:_day(day)
{
_year = year;
_month = month;
}
void Print()
{
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
const int _day;
};
int main()
{
Date d1(2022, 12, 31);
d1.Print();
return 0;
}
同时,它还能提高程序效率,不过这点,我们放在后面讲解.
3.总结
三·拷贝构造函数
1.引言
拷贝构造函数就是用一个已有的对象复制出多个完全相同的对象
就像火影中卡卡西的写轮眼拷贝,可以复制他人的忍术.
只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创
建新对象时由编译器自动调用
一句话概括,拷贝构造函数是构造函数的一个重载形式
按照惯例,我们先来看一段拷贝函数的代码
#include <iostream>
using namespace std;
class Date {
public:
//初始化列表初始化
Date(int year, int month, int day)
:_year(year)
,_month(month)
,_day(day)
{}
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << "address:" << this << endl;
cout << _year << '/' << _month << '/' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 12, 31);
d1.Print();
Date d2(d1);
d2.Print();
return 0;
}
我们用初始化列表的方式创建了d1对象,再利用拷贝构造,拷贝出了d2对象
拷贝构造函数是一个从无到有创建一个新对象的过程.
2.拷贝函数什么时候使用
1.当需要创建一个新的类对象时,我们就可以用另一个同类的对象初始化它
2.函数参数是类对象时,我们也需要调用拷贝构造函数,来临时拷贝出形参作为函数参数
3.函数返回值是类对象时,我们也需要调用拷贝构造函数,临时拷贝一个对象返回.
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year, int minute, int day)
{
cout << "Date(int,int,int):" << this << endl;
}
Date(const Date& d)
{
cout << "Date(const Date& d):" << 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;
}
简单看一下上面这段程序的运行结果:
d1对象,我们调用构造函数进行构造,所以输出了Date(int,int,int)
调用Test函数时,需要生成d的形参,此时就第一次调用拷贝函数.
生成temp对象时,第二次调用拷贝函数.
返回Date类型对象时,又需要调用拷贝函数.
总计调用三次拷贝函数.
3.拷贝函数的特性剖析
1.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
PS:为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用,尽量使用引用
//Error
Date(const Date d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
比如说上面这段构造函数的程序写法就是错误的.
为什么呢?我们知道函数的参数是形参,需要调用拷贝构造函数,但调用拷贝函数,又需要提供形
参,这个形参又需要调用拷贝函数,以此类推,造成程序陷入无限递归,从而程序崩溃.
我们还注意到,在形参部分,我们还加了const进行修饰,主要能带来两大好处:
1.防止写错
const类型一旦初始化后,不能被修改,因此不会出现d._year = _year等错误代码
2.权限可以缩小,不能扩大
假如用户传入const修饰的对象,没有const的话,程序就会报错
而加上const,可以传可读可写的对象,也可以传只读对象.
2.若未显式定义,编译器会生成默认的拷贝构造函数 .
默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝
浅拷贝就是按照字节逐一进行拷贝,类似于memcpy函数的作用.
但是并非所有情况,浅拷贝就足够满足.
比如我们的Stack类
#include <iostream>
using namespace std;
class Stack {
public:
Stack(int default_capacity = 4)
{
_a = (int *)malloc(sizeof(int) * default_capacity);
_capacity = default_capacity;
_top = 0;
}
private:
int* _a;
int _capacity;
int _top;
};
它包含三个成员变量,一个是指针_a,一个是容量_capacity,一个是栈顶_top
假如直接按字节拷贝的话,就会出现_a指针指向同一块空间的情况.
那指向同一块空间,就会带来很多问题
比如插入或者删除数据会相互影响;调用析构函数的时候,会析构两次,造成程序的崩溃;还有对
象空间未释放,导致内存泄漏等等问题.
因此这个时候就需要深拷贝,也就是我们自己实现拷贝函数.
总结:
1.浅拷贝就可以满足需求时,我们不用显式定义,直接用编译器生成默认的拷贝构造函数即可
2.当自己实现的析构函数需要释放空间时(涉及到资源申请),需要深拷贝,则自己编写拷贝函数.
3.和构造函数所有特性一样
在编译器生成的默认拷贝构造函数中:
内置类型是按照字节方式直接拷贝的
自定义类型是调用其拷贝构造函数完成拷贝的
四·析构函数
1.析构函数特性剖析
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由
编译器完成的.而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作
我们依旧先来看一段析构函数的代码
class Time
{
public:
//析构函数
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
Date()
:_year(1970)
,_month(1)
,_day(1)
{}
private:
int _year;
int _month;
int _day;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
对比构造函数,我们可以发现析构函数和它非常相像.
1. 析构函数名是在类名前加上字符 ~
2. 无参数无返回值类型3. 编译器生成的析构函数不会处理内置类型,也只是对自定义类型调用相应的析构函数.
4. 一个类只有一个析构函数,对象生命周期结束时,会自动调用.
5. 析构函数不能重载
注意:因为函数压栈的关系,所以先构造的后析构,后构造的先析构.
如果有全局对象或者静态局部对象,则它们在main函数结束或者调用exit函数时2被析构
规则如下:(针对所有函数都是如此)
1.全局先于局部构造,局部按顺序构造,无论其是否有static修饰
2.析构时,按照堆栈方式析构,static修饰晚于局部普通对象,最后析构全局对象.
可以用下面的程序简单验证一下
class Date
{
public:
Date(int year, int minute, int day)
{
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;
}
输出上面的结果
Test函数中拷贝构造的temp对象,d对象依次被析构
随后返回的临时Date对象也被析构(对于Test函数来说,它是局部对象)
最后是对象d1被析构.
我们可以看看《高质量C++编程》中有关这个问题的探讨
2.总结
五.赋值重载函数
1.赋值重载
#include <iostream>
using namespace std;
int main()
{
int a, b;
a = 1;
//赋值重载
b = a;
cout << "b: " << b << endl;
return 0;
}
上面的代码就是最简单的赋值重载,实际上我们早就已经见过,我们创建a,b两个变量,用a变量的
值去初始化b.
不过这是针对内置类型的,现在C++允许我们使用函数实现自定义类型的赋值重载.
像下面这段代码,我们构造了d1,d2两个对象,并用d1赋值重载了d2.
#include <iostream>
using namespace std;
class Date {
public:
Date(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(2022, 3, 4 );
Date d2(2023, 2, 18);
d2 = d1;
d2.Print();
return 0;
}
PS:
1.要区分赋值运算符重载和拷贝构造函数的功能:
赋值运算符是对一个已经创建的对象赋值.
而拷贝构造函数是创建一个原本不存在的对象.
2.赋值时,同样是浅拷贝,因此如果需要释放空间时(涉及到资源申请),不能直接赋值.
2.赋值运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数
函数原型:返回值类型 operator操作符(参数列表)
比如判断对象d1中的值和对象d2中的值,我们可以编写==的赋值运算符重载的函数.
#include <iostream>
using namespace std;
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
int _year;
int _month;
int _day;
};
bool operator==(const Date&d1,const Date&d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
int main()
{
Date d1(2022, 3, 4);
Date d2(2023, 2, 18);
cout << operator==(d1, d2) << endl;
//运算符优先级的缘故,所以d1 == d2外面要加上括号
cout << (d1 == d2) << endl;
return 0;
}
上面两种方式都可以实现正确结果输出,但显然我们想要的是下面这种d1 == d2直接判断.
还有一点需要注意的,因为成员变量是私有的,函数在外面无法访问,所以这里只是简单把成员函数设置为了公有,实际上并不是这样使用.
更为正确的用法,是将它放置在类的里面.
#include <iostream>
using namespace std;
class Date {
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2022, 3, 4);
Date d2(2023, 2, 18);
//默认调左参数的运算符重载函数
cout << d1.operator==(d2) << endl;
cout << (d1 == d2) << endl;
return 0;
}
由于类中的成员函数的第一个参数为隐藏的this
因此不需要两个参数,而只需要一个参数就可以.
有了上面的基础,我们就可以自己实现赋值重载函数.
//赋值重载函数
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
有几个点需要注意,
第一,参数加上了const和引用,原因和前面拷贝函数参数设定一样.
第二,判断this指针是否和原参数相同,d1 = d1这种,完全不需要赋值
第三,由于函数调用结束后,对象仍然存在,因此可以直接引用返回,不用拷贝构造,大大提高效率.
同时还能实现d1 = d2 = d3这样的操作,保持了运算符的特性
3.一些补充特性
1.赋值运算符只能重载成类的成员函数不能重载成全局函数
原因也很简单,如果我们没有在类里面写,那类就会自动默认生成一个赋值函数,此时就会和我们
写的冲突.
2. .* :: sizeof ?: .注意以上5个运算符不能重载
3.前置++和后置++的实现
人为规定,后置++运算符重载,需要提供一个int的参数
//前置++
Date& operator++()
{
_day += 1;
return *this;
}
//后置++
//注意:后置++需要保存一份临时拷贝返回,否则原来的值被修改,就无法找回
//不能引用返回
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
4.重载操作符必须有一个类类型参数,它是为自定义类型服务的.
并且不能通过连接其他符号来创建新的操作符:比如operator@
六.取地址及const取地址操作符重载
一般不用直接重载,无特殊情况,使用编译器生成的默认取地址的重载即可.
class Date
{
public :
Date* operator&()
{
return this ;
}
const Date* operator&()const
{
return this ;
}
private :
int _year ; // 年
int _month ; // 月
int _day ; // 日
};
PS:因为this指针本身目的就是指向这个对象,因此它是一个常量指针,即我们不允许修改this指
针,或者说this中保存的地址不能被修改
如果想要不能通过this指针改变对象对应的成员变量,使其成为一个指向常量的指针,则可以像上
面的函数一样,在函数后面加一个const.
关于如何判断它是常量指针还是指向常量的指针,一个简单的方法就是从右往左读.
比如说下面这段代码,从右往左,先遇到变量curErr,然后碰到const,const修饰curErr,说明它是
一个常量,不能被修改,剩下的int*就是类型,即curErr是一个常量指针,它始终指向errNumb,并
且errNumb的值是可以修改的.
int errNumb = 0;
int* const curErr = &errNumb;
但下面这段代码就不同,pip遇到遇到*,说明该指针不能被修改,再遇到const double,说明指针指
向的double类型的pi的值不能被修改,它是一个指向常量的指针.
也可以换种角度思考,const修饰的是*pip,即修饰的是指针指向的对象.
const double pi = 3.14;
const double * pip = π
//和double const *pip = &pi等价
所谓指向常量的指针,就是我们不能通过该指针解引用来改变对象的值.
下面给出《C++ Primer》一书,有关指向常量的指针解释.
换句话说,即便上面的代码pi没有用const修饰,程序依旧会报错.
如果采用声明和定义分离的写法,那么声明和定义的函数都需要加上const修饰