前言
在前面的学习我们知道,“空类”的大小是一个字节,其实空类并不空,任何一个类,它在程序中都会有6个默认成员函数。它们是被隐藏的,是编译器自动生成的。
它们分别是:
- 构造函数,析构函数;
- 拷贝构造函数;
- 赋值运算符重载;
- const成员函数;
- 取地址及const取地址操作符重载
当然,此文仅供初学者参考,C++语法之深入,让许多人中途而废,一知半解。本人不推荐在一开始就学得太深,往往最重要最经常使用的知识不是很难的,更深层次的内容会被更优秀的自己学习。
1. 构造函数
1.1 引例
在之前相对粗略(相较于繁杂的语法而言)的学习中,我们在“封装”这部分知道了:要使得私有成员变量被保护,需要提供对外公开的公有接口。比如Set函数:
class Date { public: void SetDate(int year, int month, int day) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; };
但在使用Get函数初始化成员变量之前,我们很自然地使用了这个语句:
Date date;
很显然,它表明现在正在实例化一个对象。“实例化”表示它是占有实际空间的,也就是系统为这个对象开辟了内存,即建立了栈帧空间。在没有为成员变量赋予初始值时,它们存放的是随机值,编译器为了避免对象在被实例化后,程序员可能会忘记为某个成员变量赋初值,自动地调用一个隐藏的默认构造函数。
1.2 概念
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数称之为构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会指向构造函数。
1.3 特点
- 默认构造函数的函数名和类名相同;
- 无返回值;
- 对象被实例化时,构造函数会被自动调用;
- 只被调用一次;
- 构造函数之间(自定义和默认)构成函数重载。
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
1.4 自定义构造函数
由于默认构造函数的局限性,我们可以自定义构造函数,与默认构造函数构成函数重载。只要显式地自己写了构造函数,编译器就会调用我们自定义的而不调用默认的。
构造函数可以带参与否取决于情况,但是使用构造函数必须遵循语法。
由于构造函数被频繁地调用,所以把它放在类中定义作为内联函数。
class Date { public: //无参构造函数 Date(){} //有参构造函数 Date(int year, int month, int day) { _year = year; _month = month; _day = day; } private: int _year; int _month; int _day; }; int main() { //无参调用 Date date1; //有参调用 Date date2(2002,2,1); //无参调用不要加括号 Date date3(); return 0; }
无参调用不要加括号,否则编译器(VS)会给出这样的警告:未调用原型函数(是否是有意用变量定义的?),有的编译器会直接指出这个函数未被使用。意思是,如果加上括号就相当于函数声明。
自定义构造函数通常也会跟缺省值搭配使用
Date(int year = 2002, int month = 2, int day = 1) { _year = year; _month = month; _day = day; }
这样即使未使用开放的接口给成员赋值,初始值就是缺省值。例如需要初始化容量的数据结构,缺省值起着保险和简化步骤的作用。
虽然无参构造函数和有参构造函数可以与默认构造函数(那个被隐藏的,看不见的)同时存在,因为它们之间构成重载,但是一旦调用就会发生错误,存在二义性。例如将上面代码中的无参构造函数和有参构造函数分别注释掉,只留下另外一个然后编译,会发现留下无参的可以编译,另外一个不行,原因在下面。
1.5 默认构造函数
诸如这样的代码:
Date date;
我们没有为这个对象提供初始值,因此我们知道它执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。
编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值用它来初始化成员。例如C++11的补丁:在类的声明时给在
private
范围中的内置类型成员赋予初始值。 - 否则,默认初始化该成员。
默认构造函数:
- 不写编译器自动生成的;
- 全缺省参数的构造函数(自己写的);
- 无参数的构造函数(自己写的);
- C++11新增的补丁也算是(效果上算是,其实只有上面三种)。
默认构造函数是如何“默认构造”的?
- 内置类型成员变量不作处理;
- 自定义类型成员调用它自己的的默认构造函数。
C++类型分类:
- 内置类型/基本类型:int、double…还有各种类型的指针;
- 自定义类型:通过class、struct关键字定义的类型。
注意:只有当类没有声明然后构造函数式时,编译器才会自动地生成默认构造函数。
1.6 小结
- 如果类的成员不需要显式地赋予值,就不用写构造函数,用默认的就足够满足语法要求了;
- 不能依赖默认构造函数的类需要自己显示地写一个全缺省的构造函数。在特殊情况如某些数据结构需要初始化容量,隐式地写比较好;
- C++11给出的补丁有时也是很有用的:在类的声明时给成员变量给初值。因为类中的成员变量的类型有内置类型和自定义类型,内置类型的成员的数量说不定,要显式地给值,所以给一个缺省值,使得补丁合理。
如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。
2. 析构函数
2.1 引例
析构函数完成什么工作?例如在C语言学习的给栈数据结构释放内存Destroy函数:
void Destory(Stack* st) { free(st); st = NULL; st->capacity = 0; st->size = 0; }
2.2 概念
析构函数执行与构造函数相反的操作:构造函数初始化对象的非static数据成员,还可能做一些其他工作;析构函数释放对象使用的资源,并销毁对象的非static数据成员。
析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,也不接受参数。由于析构函数不接受参数,因此它不能被重载。对一个给定类,只会有唯一一个析构函数。
用析构代替引例中的代码,假设类名是Stack
:
class Stack { public: //...各种方法 ~Stack() { if(data) { free(data); data = NULL; _capacity = 0; _size = 0; }//其实只写free也可以,写上更规范 } private: int _size; int _capacity; int* data; };
如同构造函数有一个初始化部分和一个函数体,析构函数也有一个函数体和一个析构部分。在一个构造函数中,成员的初始化是在函数体执行之前完成的,且按照它们在类中出现的顺序进行初始化。在一个析构函数中,首先执行函数体,然后销毁成员。成员按初始化顺序的逆序销毁。
也就是说,构造和析构,成员的创建和销毁的顺序对应了栈的特性。
注意:按在类中出现的顺序进行初始化,反言之在类外不论成员变量的次序如何,都不会影响成员变量在栈帧上的顺序。
在对象最后一次使用之后,析构函数的函数体可执行类设计者希望执行的任何收尾工作。通常,析构函数释放对象在生存期分配的所有资源。
在一个析构函数中,不存在类似构造函数中初始化列表的东西来控制成员如何销毁,析构部分是隐式的。成员销毁时发生什么完全依赖于成员的类型。销毁类类型的成员需要执行成员自己的析构函数。内置类型没有析构函数,因此销毁内置类型成员什么也不需要做。
2.3 特点
默认生成的析构函数:
- 不处理内置类型,自定义类型会调用它的析构函数。比如内置类型是指针,析构函数不能轻易free它。
调用析构函数的情况:
- 当变量离开其作用域时;
- 当一个对象被销毁时,其成员被销毁;
- 数据结构(容器)被销毁时,其成员被销毁;
需要显式写析构函数的情况:
- 有动态开辟内存,并交给内置类型成员管理的情况。
不需要显式写析构函数的情况:
- 没有资源需要清理的(特指动态开辟),而且析构函数也没做什么时;
- 类的成员只有自定义成员,自己调用析构函数。
值得注意的是,析构函数不能重载。一个类只能有一个析构函数,若未自定义析构函数,则编译器会自动生成默认析构函数。
2.4 自定义析构函数
析构函数的功能和构造函数相反,但它不是“销毁对象”的函数,对象被销毁是在析构函数被调用完毕后系统销毁这个对象的栈帧的时候。自定义析构函数需要根据自定义构造函数写,一般当自定义构造函数中有动态开辟的操作,就需要自定义析构函数。
就像2.2中的~Stack()
析构函数。
2.5 小结
析构函数其实就起着一个功能:确保程序结束时能自动清理它占用的内存资源。其实编译器没有那么智能,无法判断大部分需要清理的情况,单就指针而言,不知道它的用途是不能随便操作它的。
总而言之,析构函数是一个可以被自动调用的Destroy函数。原因是一般有动态内存开辟的构造函数就必须写自定义析构函数去free。
另外,析构函数销毁的并不是栈帧,而是栈帧中存在的地址,这个地址存的是堆区的位置,处理的是堆区的数据,如何再处理栈帧。栈的整体(包括栈帧)是符合“先构造,后析构”的顺序的。
题:
//A是一个类 A a3(3); void fun() { static int i = 0; static A a0(0); A a1(1); A a2(2); static A a4(4); } int main() { fun(); return 0; }
请问0~4的构造和析构顺序:
a3是全局对象,在main函数之前就被创建出来了,而a0和a4都是静态区,相对于全局就是局部,只有在程序结束后才被销毁。a1和a2在main函数的栈帧里面,销毁先后调用a2和a1的析构函数。
构造顺序:30124;析构顺序:21403
改造:
int main() { fun(); fun(); return 0; }
构造顺序:3012412;析构顺序:2121403
生命周期取决于它存储的定位置。
3. 拷贝构造函数
3.1 引例
就内置类型而言,拷贝一个变量就像喝水一样自然,使用=
赋值操作符即可。
int a = 10; int b = a;
但是就对象而言,不论是内置还是自定义对象,用=
来拷贝一个对象显然是有问题的,而拷贝构造函数就能让=
赋值操作符操作对象像操作内置类型一样自然。
3.2 概念
如果一个构造函数的第一个参数是自身类类型的引用,且任何额外参数都有默认值,则此构造函数是拷贝构造函数。
也就是说,拷贝构造函数是构造函数的重载,是特殊的构造函数。
3.3 特点
- 拷贝构造函数的第一个参数必须是一个引用类型,常用
const
修饰; - 必须是构造函数的重载。
3.4 拷贝构造函数
首先要知道拷贝构造函数是构造函数的一种,也就是它的重载。
class Date { //... public: Date(); //默认构造函数 Date(const Date& d); //拷贝构造函数 //... };
稍后解释为何构造函数的参数是自身类类型的引用以及为何要用const修饰它。在上面Date日期类的基础上,下面就是它的拷贝构造函数。
3.4.1 const修饰
Date(Date& d) { _year = d._year; _month = d._month; _day = d._day; }
请注意上面没有用const修饰,对于下面三个赋值操作不加上const是没问题的,但是假如有人这样写了:
Date(Date& d) { d._year = _year; d._month = _month; d._day = _day; }
虽然不太可能,但还是有可能性出现这样的代码,破坏了原始数据,所以一般用const
修饰。
3.4.2 使用本类型的引用
现在假设是第一次写拷贝构造函数,思路就是将原来对象里的每个变量赋值给新对象里的每个变量:
//拷贝构造函数 Date(const Date d) { _year = d._year; _month = d._month; _day = d._day; } //试着构造一个新对象 int main() { Date date1; Date date2(date1); return 0; }
但这样做忽略了最本源的问题:将对象date1作为参数转入拷贝构造函数,但是抛开xx构造函数不谈,就这种传值函数而言,传参过程本身就是一个拷贝的过程,也就是说传值函数会出现无穷递归的情况。当然这与参数是否被const修饰无关。
如果说无穷递归就像一个往下的无底洞,那么传指针函数就能跨过它,通常情况下我们也是这么做的。就像初学C时用指针写交换函数一样。
Date(const Date* d) { _year = (*d)._year; _month = (*d)._month; _day = (*d)._day; }
使用了指针传参,自然必须传地址和解引用。
void test1() { Date d1(2002,2,1); Date d2(&d1); }
用引用代替指针:
Date(const Date& d) { _year = d._year; _month = d._month; _day = d._day; }
使用它:
void test1() { Date d1(2002,2,1); Date d2(d1); }
我们在学习引用时就知道,引用在底层是用指针实现的,它存在的意义就是为了使用接口能方便一些,不用特意传地址,避免了因忘记取地址而造成的错误。而对于拷贝构造函数而言,使用引用也能让“拷贝”的过程更自然。
3.5 小结
拷贝构造函数其实和自定义构造函数没什么区别,只是后者是将值作为参数传给了构造函数,前者则是将已存在的对象的值复制一份给新的对象。
值得注意的是,如果未显式地写拷贝构造函数,编译器会生成默认拷贝构造函数。默认拷贝构造函数是按字节完成的拷贝,类似memcpy,这种拷贝称为浅拷贝,也称值拷贝。
深浅拷贝最大的区别:对于引用数据类型,浅拷贝是拷贝它的地址,深拷贝是在堆内存中开辟内存拷贝引用的对象,新数据与原数据相互独立。对于内置类型,直接拷贝即可。
后续会深入学习深浅拷贝。
上面提到,不显式地写拷贝构造函数编译器会自动生成一个,但是如果对象中有动态内存开辟的操作,必须自己写,否则浅拷贝会造成同一块内存被free两次,造成程序崩溃。因为浅拷贝的引用类型是拷贝类型,新老对象的一部分是共用同一块空间。
而使用引用类型作为拷贝拷贝构造函数的参数,让“拷贝”这一操作更自然,提高效率。
拷贝构造函数的使用情况:
- 用已存在的对象构造新对象;
- 函数参数类型为类类型对象;
- 函数返回值类型为类类型对象。
4. 运算符重载
4.1 引例
对于内置类型,我们使用+
实现加操作,但是对于日期类Date,如何实现日期的加法?
4.2 概念
重载的运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要定义的运算符号共同组成。和其他函数一样,重载的运算符也包含返回类型、参数列表以及函数体。
重载运算符函数的参数数量与该运算符作用的运算对象一样多。对于二元运算符来说,左侧运算对象传递给第一个参数,而右侧运算对象传递给第二个参数。除了重载的函数调用运算符operator()之外,其他重载运算符不能含有默认实参。
4.3 特点
- 如果一个运算符函数是成员函数,则它的(左侧)第一个运算对象是隐式的this指针,所以运算符函数的参数数量(显式)少一个,这和构造函数是一样的;
- 对于非成员的运算符函数
//假设+运算符的重载函数已存在 //普通表达式 a1 + a2; //等价函数 operator+(a1 + a2);
- 对于成员运算符函数
//普通表达式 a1 + a2; //等价函数 a1.operator+(a2);
- 至少有一个类类型参数,原因同拷贝构造函数;
- 不能改变内置类型的运算符,例如
int
型的运算符,它是语言内置的; .* :: sizeof ?: .
这五个运算符不能重载。
4.4 格式
格式:
- 函数名:关键字
operator
+需要重载的运算符号如+=
,合起来就是operator+=
; - 函数原型:
返回值 operator(参数列表)
。
假设+=
已经被重载,且它是成员函数:
a1 += a2; a1.operator+=(a2);
对于是成员函数的运算符函数,函数表达式的括号第一个参数是this指针,因此可以等价地认为第二行的语句是下面这样的,实际上编译器就是这也实现的:将d1的地址传给this指针。
a1.operator+=(&d1, d2);
4.5 =运算符重载
4.5.1 格式
- 返回值类型:引用类型。返回引用可以提高效率,且返回值是为了实现连续赋值;
- 参数类型:const 引用类型;
Date& operator=(const Date& d) { if(this != &d) { _year = d._year; _month = d._month; _day = d._day; } return *this; }
赋值运算符需要判断其操作的对象是否是同一个,所以用this指针和d对象的地址比较。
值得注意的是,赋值运算符重载必须作为成员函数定义。
4.5.2 特点
在这里解释为何要将运算符重载写在类的内部,原因之一是重载的运算符可能会被经常使用。如果要在类的外部实现运算符重载,必须将成员变量的权限设置为公开,否则操作符没有访问类成员的权限。但是这无疑提高了数据被修改的风险,所以将运算符重载写在类中,不必扩大成员变量被访问的权限,利用this指针既能方便地访问成员变量,也能很好地保护它们的安全。运算符重载作为类中的函数,this指针已经占了第一个位置,那么只需要传入一个参数即可。
原因三,这是语法要求的。假如写在全局,不存在this指针,就必须写两个参数:
Date& operator=(Date& l, const Date& r) { if(&l != &r) { l._year = r._year; l._month = r._month; l._day = r._day; } return l; }
会出现编译错误:“operator =”必须是非静态成员
原因是不显式地写运算符重载函数,编译器在类的内部找不到,就会生成一个默认的。但是在全局中的赋值运算符重载和编译器生成的发生冲突。
编译器默认生成的赋值运算符重载,按字节拷贝值。与默认的拷贝构造函数类似,内置类型直接赋值,自定义类型调用对应类的赋值运算符重载赋值。
4.5.3 示例
#include <iostream> #include <assert.h> using namespace std; class Date { public: void DatePrint() { cout << _year << "/" << _month << "/" << _day << endl; } Date() { _year = 111; _month = 222; _day = 1; } Date(int year, int month, int day) { _year = year; _month = month; _day = 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; }; int main() { Date d1(2002,2,1); Date d2; d2 = d1; d1.DatePrint(); d2.DatePrint(); return 0; }
结果:
2002/2/1
2002/2/1
4.6 ==运算符重载
上面提到,赋值运算符必须写在类里面,实际上可以将所有运算符重载函数都写在类中,只不过为了类的代码块的简洁性,将声明和定义分离。如果有很频繁调用的运算符,建议写在类的内部作为内联inline
避免代码重复。
用日期类Date示例(下面的代码都是在类的外部定义的):
bool Date::operator==(const Date& d) { return _year == d._year && _month == d._month && _day == d._day; }
用若干个==
连接多个判断语句,并将它的结果返回,这在刷数据结构初级题目时就已经轻车熟路了。
不同的语句分别占一行,能体现代码的层次。
值得注意的是:虽然三个语句块都是独立且地位相同的,但是它们的顺序会影响效率。例如这里按照年月日的顺序效率最高。
4.7 >运算符重载
用日期Date示例:
bool Date::operator>(const Date& d) { if ((_year > d._year) || (_year == d._year && _month > d._month) || (_year == d._year && _month == d._month && _day > d._day)) { return true; } else { return false; } }
“筛子”结构的判断语句,从年月日依次判断,符合条件则返回真,否则返回假。
在此,我们已经写出了=
、==
、>
三个运算符,对于日期类,还差!=
、>=
、<
、<=
、+
、+=
运算符需要重载,对于任何类,只需写两个运算符:<
或>
、==
,剩下的复用这两个即可。
4.8 其他常用运算符重载
用日期Date示例:
!=
bool Date::operator!=(const Date& d) { return !(*this == d); }
>=、<、<=
bool Date::operator>=(const Date& d) { return (*this > d) || (*this == d); } bool Date::operator<(const Date& d) { return !(*this >= d); } bool Date::operator<=(const Date& d) { return !(*this > d); }
思路十分简单,只要实现了大于和等于,那么小于就是不大于或者等于,其他以此类推。
需要注意的是+
和+=
+、+=
+
和+=
需要先写谁,还是谁都不能复用?(实践体会)假设先写+=
:
a = 1; a += 1; //实际上是这样的: a = 1; a = a + 1;
也就是说+=
是先+
再=
的,所以先写+
?
不,再看这句:
b = a + 1;
前者是将自己加上一个数,然后赋值给自己,后者是将加上后的结果赋值给别的变量。它们都有一个共同的操作(这就是要复用的部分):a+1,然后赋值。可以认为后者的赋值是先赋值给a自己,这样就能复用+=,然后再将其返回值赋值给其他变量,实现+的重载。
Date& Date::operator+=(int day) { _day += day; while (_day > GetMonthDay(_year, _month)) { _day -= GetMonthDay(_year, _month); ++_month; if (_month == 13) { _year++; _month = 1; } } return *this; }
对于日期类Date,对日期实现+=操作,需要先从天数判断,是否会超出月份的天数,超过则月数+1,以此类推。+=和+都必须要有返回值,因为+=和+都必须搭配赋值操作符使用。
Date Date::operator+(int day) { Date ret = *this; ret += day; return ret; }
有点曲线救国的感觉,+的操作是将加起来的值返回,作为在类中定义的函数,this指针接收的是+
左边的操作数,这里的第一句是复用了赋值操作符重载函数,它其实就是Date ret(*this);
(拷贝构造)。如果有写,拷贝构造函数和重载赋值操作符的代码是相同的。
前置++、后置++
它们的区别我们再熟悉不过,无非是返回值是加之前还是加之后的值的问题。
Date& Date::operator++() // 前置 { return *this += 1; } Date Date::operator++(int) // 后置 { Date tmp(*this); *this += 1; return tmp; }
其实先实现+然后复用实现+=也可以,只不过会增加代码拷贝次数,降低效率。
5. const成员函数
被const
修饰的成员函数称为const成员函数。const的位置在小括号后和大括号前:
void func () const {}
const修饰类成员函数,实际上修饰的是该成员函数的this指针,以表明该成员函数不能对this指针指向的对象也即类的任何成员进行修改,进一步提高了数据的安全性。
实际上可以认为编译器眼中的const成员函数是这样的,假设类是Date:
void func (const Date* const this) {}