前言
紧接着上一篇文章,接下来我们来认识下类的六大默认成员函数,如下图。之所以叫他默认成员函数,是因为即使我们不写,编译器会默认帮我们写,但只要我们自己显示的写了,编译器就不会帮我们生成对应的成员函数。 类似于我们脱贫后就不会再有低保一样。
接下来我们一个一个接着看吧!
构造函数
为什么要有构造函数
在讲构造函数之前,我们在介绍下上篇文章的日期类,实现如下。
class Date
{
public:void Init( int year, int month, int day){_day = day;_month = month;_year = year;}void Print(){printf("%d-%d-%d\n", _year, _month, _day);}private:int _year;char t;int _month;int _day;
};
我们要创建一个日期函数并且打印也十分容易,如下代码。
int main()
{Date t;t.Init(2024, 4, 11);t.Print();return 0;
}
但是我们这里是正常的调用初始化函数,假如我们忘记了初始化,程序任然可以正常的运行,但结果是随机值。如下代码结果。(也许有读者观察到VS随机值打印几乎都是-858993460,这是VS编译器做的初始化,甚至有的编译器初始化为0,每个编译器处理不同,我们在这里就同一称之为随机值)
我们在某些时候可能会忘记给相应的类初始化,造成不可预料的后果,初始化又是个令人容易忘记的事情,那么有什么办法可以解决么?
方法很简单,我们身边就有一个永久记忆装置,一但给他制定相应的规则,他会严格执行,丝毫不差的执行安排的任务。------------显而易见这个东西就是计算机,我们可以把繁琐的任务甩给他,专注于开发。
构造函数的定义
构造函数给人一种开辟空间的感觉,但其实它的作用是初始化而不是开辟空间。我们可以把它当作一个特殊的函数,他的语法如下。
1.要求函数名要和类名相同
2.没有返回值
3.对象实例化的时候编译器自动调用
4.可以重载
构造函数的使用
我们的本贾尼博士通过苦思冥想帮我们创造了方便的工具,于是像上面的日期类便可以改写为如下实现。这样我们每次创建对象的时候,对应构造函数就会自动的调用,帮助我们减小记忆的负担。下面我们看个示例。
class Date
{
public:Date( int year, int month, int day){_day = day;_month = month;_year = year;}void Print(){printf("%d-%d-%d\n", _year, _month, _day);}private:int _year;char t;int _month;int _day;
};
int main()
{Date t;t.Print();return 0;
}
但上面的代码是错误的,我们没有显示的写构造函数的时候,编译器会帮我们生成一个默认的构造函数,这个函数没有参数,如下。这个函数对于内置类型(如int double float等)不做任何的处理,对于自定义类型(class,struct)调用其构造函数,一直往下的递归下去,我们明白所谓的自定义类型就是一些内置类型的集合,所以可以认为默认的构造函数调用到可能什么都没干,除非有自定义构造函数。
Date( )
{}
而一但我们自己定义了构造函数,编译器就不会帮我们在创建构造函数了,于是我们上面的Date t;相当于调用了无参的构造函数。但我们只定义了一个Date( int year, int month, int day)函数,函数参数匹配不上,自然就报错了。
对于这个问题我们有两个解决方案,第一个是重载一个无参数的构造函数,第二个就是用缺省参数,我们用的最多的就是缺省参数。改动后如下。
Date( int year=1, int month=1, int day=1)
{_day = day;_month = month;_year = year;
}
因为构造函数可以重载,我们甚至可以写出如下的构造函数。当然这个在日期类函数中没有什么实际的意义,大家可以根据实际需要重构构造函数。
Date(float t)
{cout << "恭喜你发现隐藏" << endl;
}
我们可以写些代码测试上面的代码是否正确。通过下图我们可以发现,程序的运行如我们所料,缺省参数起了作用。
也许有细心的读者疑问,t1的创建不可以写为如下的形式么?符合我们以往对于无参函数的认知,但构造函数是个特殊的函数,创建类的对象是个特殊的过程。
Date t1();
如果我们抛弃上面日期类的认识,单纯的看这一段代码,他会是什么意思。我们分析这几个符号,首先是一个类型,然后是一个标识符,最后是()即函数调用符。这不就是个函数声明语句么?但从我们刚学的构造函数的角度看似乎又是创建变量,并且调用无参构造函数。
这就产生了歧义,在计算机中凡是有歧义的都是错误的程序,计算机程序必须是明确的,这与我们的自然语言不同,程序作为一种形式语言有严厉的规则。于是摆在眼前的问题就是解决Date t1();歧义的问题,如果我们是本贾尼博士,我相信大多人会保留函数声明的做法,改变无参构造的调用。原因无他:成本小,如果改变函数声明的做法,又要改造函数,又要增加记忆,无参不带括号显得十分合理。
析构函数
有创造就一定有毁灭,这个世界充满二异面。析构函数与构造函数是一对函数,析构函数在类的销毁时调用。
为什么要有析构函数
假设我们现在有如下的函数调用。
void test()
{Date t;int c = 0;
}
当这个test函数调用完之后,随着test函数栈帧的释放,Date,c位置的内存被释放,似乎没有什么问题,但有些情况就十分特殊。
假设我们此时给日期类增加一个指针用于存储特定日期的信息,将代码稍微改写一下。
class Date
{
public:Date( int year=1, int month=1, int day=1){_day = day;_month = month;_year = year;t = (int*)malloc(4 * sizeof(int));}void Print(){printf("%d-%d-%d\n", _year, _month, _day);}private:int _year;int _month;int _day;int* t;
};
此时我们在运行test函数就会发现内存泄露了。当然解决方法十分简单,我们只需要在test函数结束前加上free即可,如下代码。
void test()
{Date t;int c = 0;free(t.t);
}
但这个比初始化更容易忘记,于是我们的本贾尼博士为了解决这个烦恼,引入了析构函数的概念,在对象销毁的时候调用。
析构函数的定义
析构函数的定义和构造函数的定义十分的相似。他的语法如下。
1.函数名字必须为 ~类名,相当于在构造函数名字前加了个~(取反)
2.函数没有返回类型,没有形参(其实有个隐藏的this指针,可以看上篇文章)
3.在对象销毁时自动的调用。
4.不能重载
析构函数没有形参自然也就无法重载了。我们使用析构函数就可以完成堆区内存的释放了。
同构造函数一样,如果我们没有自己定义析构函数,编译器会帮我们自动的生成默认析构函数,此时对于内置类型不做处理,对于自定义类型调用他的析构函数。此时我们要明白指针也是内置类型,他不会帮我们释放指针所指的空间。
析构函数的使用
析构函数的使用也十分简单,例如上述改装的日期类函数便可以写如下的析构函数。
~Date()
{free(t);
}
这样我们就可以在对象销毁的时候自动释放堆区的空间,而不需要我们手动的释放了。
由此可见本贾尼博士对于C的优化十分戳中痛点,让编译器帮我们处理许多的问题,简化我们自己的操作。
拷贝构造函数
引入
听名字就可以判断出他是一种特殊的构造函数,重点在于拷贝。对于单个数据我们可能手动的赋值,但对于多个数据就有了拷贝,复制操作。同样在初始化的操作中我们可能想要创造两个完全相同的日期类对象,便可以采用下述代码。
两次采用相同的初始化,但我们可以采用接下来要讲的拷贝构造函数,简化代码。
int main()
{Date t1(2024, 4, 18);Date t2(2024, 4, 18);t1.Print();t2.Print();return 0;
}
拷贝构造函数定义
拷贝构造函数他属于特殊的构造函数,当然要满足构造函数的要求,名字为类名,没有返回值,在创建对象的时候自动调用,他还有个特殊规定,形参必须是类的引用,这就可以与普通的构造函数进行重载,将上面日期类重载拷贝构造函数如下。
Date(Date& d)
{_day = d._day;_month = d._month;_year = d._year;t = (int*)malloc(4 * sizeof(int));memcpy(t, d.t, 4 * sizeof(int));
}
拷贝构造函数的使用
于是上面的代码我们就可以简写为如下代码,调用拷贝构造函数创建两个相同的日期类。
int main()
{Date t1(2024, 4, 18);Date t2(t1);t1.Print();t2.Print();return 0;
}
相信大家看到了上面在处理指针指向的数组时单独开辟了一段空间,我们不可以直接写t=d.t么?答案是显而易见的,不可以。如下图。
我们俗称为浅拷贝,只是将原来的数据按字节的拷贝而不做任何的处理,对于用指针开辟的数组就会造成多个对象指向同一块内存空间,从而造成多次释放同一块内存。如下图
而我们的默认拷贝构造函数就是按字节拷贝,如果对于指针指向数组这种情况不做任何处理,就会报错。所以我们有时需要自己写拷贝构造函数进行特殊处理。
其次我们来谈一个问题,既然是拷贝一个对象,我们可以把形参Date&改为Date么?对于现在的编译器而言,不允许存在形参只为为Date的构造函数,他会报出如下错误。
为什么呢?我们在调用函数的时候会先传递参数,如果参数的类型为Date&,实际上我们会把当前对象的地址传递过去(为什么引用传地址可以看这篇博客,在这里就不过多赘述了从C到C++过渡知识 下(深入理解引用与指针的关系)-CSDN博客)我们传递地址直接对其变量取地址传递就可以了。
但如果形参为Date会怎么样?我们会先开辟一份空间,分配给Date实例化后的对象d,然后调用d的拷贝构造函数,d的拷贝构造函数又会开辟一份空间给实例化对象,然后调用其实例化对象的拷贝构造函数,陷入一个死循环,故我们不可以将形参写为Date。
拷贝构造函数的使用还有一种方式,就是在创建对象的时候用=,可能祖师爷觉得=符合直观的感觉就加上了这条规定,使用如下。
但一定记住,只有在创建变量的使用用=是拷贝构造函数,在创建后用=就是赋值重载函数!也就是我们接下来要讲的函数。
赋值重载函数
引入
回顾我们引入拷贝构造函数的原因,为了简单的让两个对象的相同,其实比起在初始化的时候让两个对象相同,我们更多的情况是在定义完变量后让他与其他对象相同,赋值重载函数就是对操作符=进行重载,使两个类的对象可以像内置类型一样,简单的复制。
这样便可以提高代码的可读性,相较于函数而言,使用=更加符合人的习惯。
赋值重载函数的定义
赋值重载函数是对操作符=进行重载,所以要求和操作符重载的要求一样。但为了高效使用我们通常定义如下模式。
Date& operator=(const Date& d){_day = d._day;_month = d._month;_year = d._year;t = (int*)malloc(4 * sizeof(int));memcpy(t, d.t, 4 * sizeof(int));return *this;}
参数类型写为const Date& d,如果写为Date d又要去调用Date的构造函数,那么这样就会白白的浪费内存,而传引用就可以大大提高效率。其次我们在调用=运算符的时候不会修改等号右边的值,尽管我们可以在函数的内部实现,但不要将运算符偏离原来的意思,写出防御性代码。因此我们就可以将参数写为const Date& d形式,及提高效率,又预防不测。
最后我们为什么要将函数的返回值写为Date&呢?我们看如下代码。
我们可能出现连续赋值的情况这个时候就需要我们返回Date&,为什么不写Date原因如上。
最后我们再来区分以下赋值重载函数和拷贝构造函数。读者可看如下代码。
int main()
{Date t1(2024, 4, 18);Date t2=t1;Date t3(t2);t3 = t2 ;t1.Print();t2.Print();return 0;
}
Date t2=t1; Date t3(t2);这两个调用的使拷贝构造函数,而t3 = t2 ;调用的是赋值重载函数。
如果=两边的对象都是创建好的对象,分配过了空间,那么调用的就是赋值重载函数,如果=左边是刚创建变量,就是拷贝构造函数。
其实在vs中还有种技巧判断,VS中图片如下。
我们可以发现,调用拷贝构造函数的=是黑色,而调用赋值重载函数的=被标为了特殊颜色,这也可以作为判断的一点技巧。
取地址运算符重载
这个成员函数我们自定义的不多,一般用编译器帮我们默认生成的就可以了.他的实现如下
Date* operator&(){return this ;}
const成员函数
但有些时候我们对const成员取地址,但const 成员不可修改,形参就要写为const,最终代码如下。
const Date* operator&()const{return this ;}
第一个const是用来修饰返回值的,第二个const是修饰隐藏的this指针的,上述的两个函数又可以构成重载。一般我们用编译器生成的就可以了。
当我们需要对隐藏的tihis指针修饰常量的时候在最后加上const就可以了。这看起来有些奇怪,但也是无奈之举,为了减小复杂度将参数this隐藏,而又要修饰this只能在创造规则了。
总结
到此我们就认识完了类的六大成员函数,如果文章又错误欢迎在评论区指正。由上述可得类的六大默认成员函数,最重要的是前四个,而前四个中构造函数又是十分重要的。下一篇文章我将与大家共同实现一个日期类,帮我们巩固知识,更好的理解类和对象。、
喜欢的点点关注和赞吧!!!