4、特性分析 – 深浅拷贝
赋值重载函数的特性和拷贝构造函数非常类似 – 如果我们没有显式定义赋值重载,则编译器会自动生成一个赋值重载,且自动生成的函数对内置类型以字节为单位直接进行拷贝,对自定义类型会去调用其自身的赋值重载函数;
所以对于没有资源申请的类来说,我们不用自己去写赋值重载函数,直接使用默认生成的即可,因为这种类只需要进行浅拷贝 (值拷贝),比如 Date 类:
注:拷贝构造函数完成的是初始化工作,在创建对象时自动调用;赋值重载完成的是已存在的对象之间的拷贝,需要手动调用;而上图中 Date d2 = d1 是在创建 d2 并对其进行初始化,所以调用的是拷贝构造函数;d3 才是调用赋值重载函数;
而对于有资源申请的类来说,我们必须自己手动实现赋值重载函数,来完成深拷贝工作;比如 Stack 类:
如图:这里的情况和 Stack 默认析构函数的情况很类似,但是比它要严重一些 – 自动生成的赋值重载函数进行浅拷贝,使得 st1._a 和 st2._a 指向同一块空间,而 st1 和 st2 对象销毁时编译器会自动调用析构函数,导致 st2._a 指向的空间被析构两次;同时,st1._a 原本指向的空间并没有被释放,所以还发生了内存泄漏;
所以,对于有资源申请的类我们都需要显式定义赋值重载函数;Stack 类的赋值重载函数如下:
//赋值重载 Stack& operator=(const Stack& st) { free(_a); _a = (int*)malloc(sizeof(int) * st._capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } memcpy(_a, st._a, sizeof(int) * st._capacity); _top = st._top; _capacity = st._capacity; return *this; }
对于上面这段程序,可能有的同学会有这样一种疑问:我们可不可以直接对 st1._a 进行扩容呢?那样就不必释放后再出现申请空间了;答案是:直接扩容不是不行,但是不好,因为如果当 st1._capacity 大于 st2._capacity ,我们这时调用 realloc 就是缩容,而缩容需要重新开辟空间并拷贝原数据,效率太低;而如果面对这种情况我们不缩小空间直接拷贝数据的话又会造成空间的浪费;所以先释放原空间再开辟新空间是一种折中的办法;
现在我们为 Stack 类显示定义了赋值重载函数,那么我们再来运行一个新的测试用例:
我们发现,当我们使用 st2 自己给自己赋值时,st2._a 中的数据变成了随机值;原因如下:operator= 函数首先会将 st2._a 指向的空间释放,然后再为其申请新空间,但是由于 st2 自己给自己赋值,所以使用 memcpy 拷贝的是新开辟的空间中的数据,即随机值;
所以说,在赋值重载函数的函数格式规范中我们强调一定要检查自我赋值;Stack 类如下:
class Stack { public: Stack(int capacity = 4) //构造 { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } _top = 0; _capacity = capacity; } ~Stack() //析构 { free(_a); _a = NULL; _top = _capacity = 0; } Stack(const Stack& st) //拷贝构造 { _a = (int*)malloc(sizeof(int) * st._capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } memcpy(_a, st._a, sizeof(int) * st._capacity); _top = st._top; _capacity = st._capacity; } Stack& operator=(const Stack& st) //赋值重载 { //自我赋值 if (this == &st) { return *this; } free(_a); _a = (int*)malloc(sizeof(int) * st._capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } memcpy(_a, st._a, sizeof(int) * st._capacity); _top = st._top; _capacity = st._capacity; return *this; } void Push(int x) { _a[_top++] = x; } private: int* _a; int _top; int _capacity; };
另外,和拷贝构造一样,并不是说只要有资源申请我们就必须写赋值重载函数,比如 MyQueue 类,我们不写编译器调用默认生成的赋值重载函数,而默认生成的对于自定义类型会去调用它们自身的赋值重载函数;
总结
自动生成的赋值重载函数对成员变量的处理规则和析构函数一样 – 对内置类型以字节方式按值拷贝,对自定义类型调用其自身的赋值重载函数;我们可以理解为:需要写析构函数的类就需要写赋值重载函数,不需要写析构函数的类就不需要写赋值重载函数;
七、取地址及 const 取地址重载
1、const 成员函数
我们将 const 修饰的 “成员函数” 称之为 const 成员函数,const 修饰类成员函数实际上修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对 this 指向的类中的任何成员变量进行修改;
我们以Date类为例:
我们看到,当我们定义了一个只读的Date对象 d2 时,我们再去调用 d2 的成员函数 Print 和 operator+ 时编译器会报错;原因在于类成员函数的第一个参数默认是 this 指针,而 this 指针的类型是 Date const*,而我们的第一个参数即 d2 的类型是 const Date*;将一个只读变量赋值给一个可读可写的变量时权限扩大,导致编译器报错;
注:成员函数默认第一个参数为 Date* const this,这里的 const 别放在 * 号后面,修饰的是 this 本身,表示 this 不能被修改,而 this 指向的内容即 d2 可以被修改;
另外,上面这个问题除了在定义对象时出现之外,在成员函数中也会出现,且十分频繁,特别是运算符重载 – 当运算符重载的两个参数都是类的对象时,如果我们不会改变类的内容,比如只比较大小,我们通常会将函数形参定义为 const Date& 类型,这时候问题就出现了:
我们不能在该成员函数中调用第二个对象的其他成员函数,因为在当前函数中该对象的类型为 const Date,当其调用其他成员函数时自身会作为第一个参数传递给成员函数的 this 指针,而 this 的类型为 Date* const,这时候又会发生权限扩大;
为了解决上面这个问题,C++ 允许我们定义 const 成员函数,即在函数最后面使用 const 修饰,该 const 只修饰函数的第一个参数,即使得 this 指针的类型变为 const Date const*;函数的其他参数不受影响;
将成员函数的 this 指针类型修饰为 const Date* const 后,不仅 const Date 的对象可以调用相应成员函数;正常的 Date 对象也可以调用,因为权限虽然不能扩大,但能缩小;
所以,当我们在实现一个类时,如果我们不需要改变类的成员函数的第一个参数,即不改变 *this,那么我们就应该使用 const 来修饰 this 指针,以便类的 const 对象在其他 (成员) 函数中也可以调用本函数*;
以Date为例:
class Date { public: //构造 Date(); //获取每一个月的天数 int GetMonthDay(int year, int month) const; //获取日期对应天数 int GetDateDay() const; //打印 void Print() const; //运算符重载 //+= Date& operator+=(int day); //+ Date operator+(int day) const; //-= Date& operator-=(int day); //- Date operator-(int day) const; //前置++ Date& operator++(); //后置++ Date operator++(int); //前置-- Date& operator--(); //后置-- Date operator--(int); //日期-日期 int operator-(const Date& d) const; //> bool operator>(const Date& d) const; //== bool operator==(const Date& d) const; //>= bool operator>=(const Date& d) const; //< bool operator<(const Date& d) const; //<= bool operator<=(const Date& d) const; //!= bool operator!=(const Date& d) const; private: int _year; int _month; int _day; };
如上,不需要改变 *this 内容的 (即不改变指向对象的成员变量) 成员函数全部使用 const 修饰;
最后,我们来做几个思考题:
const对象可以调用非const成员函数吗?-- 不可以,权限扩大;
非const对象可以调用const成员函数吗?-- 可以,权限缩小;
const成员函数内可以调用其它的非const成员函数吗?-- 不可以,权限扩大;
非const成员函数内可以调用其它的const成员函数吗?-- 可以,权限缩小;
2、取地址重载
取地址重载函数是C++的默认六个成员函数之一,同时它也是运算符重载的一种,它的作用是返回对象的地址;
Date* operator&() { return this; }
3、const 取地址重载
const 取地址重载也是C++的默认六个成员函数之一,它是取地址重载的重载函数,其作用是返回 const 对象的地址;
const Date* operator&() const { return this; }
如果我们没有显式定义取地址重载和 const 取地址重载函数,那么编译器会自动生成,因为这两个默认成员函数十分固定,所以大多数情况下我们直接使用编译器默认生成的即可,不必自己定义;
在某些极少数的特殊情况下需要我们自己实现取地址重载与 const 取地址重载函数,比如不允许获取对象的地址,那么在函数内部我们直接返回 nullptr 即可:
//取地址重载 Date* operator&() { return nullptr; } //const 取地址重载 const Date* operator&() const { return nullptr; }
八、总结
C++的类里面存在六个默认成员函数 – 构造、析构、拷贝构造、赋值重载、取地址重载、const 取地址重载,其中前面四个函数非常重要,也非常复杂,需要我们根据具体情况判断是否需要显式定义,而最后两个函数通常不需要显示定义,使用编译器默认生成的即可;
1、构造函数
构造函数完成对象的初始化工作,由编译器在实例化对象时自动调用;默认构造函数是指不需要传递参数的构造函数,一共有三种 – 编译器自动生成的、显式定义且无参数的、显式定义且全缺省的;
如果用户显式定义了构造函数,那么编译器会根据构造函数的内容进行初始化,如果用户没有显式定义,那么编译器会调用默生成的构造函数;
默认生成的构造函数对内置类型不处理,对自定义类型会去调用自定义类型的默认构造;
为了弥补构造函数对内置类型不处理的缺陷,C++11打了一个补丁 – 允许在成员变量声明的地方给缺省值;如果构造函数没有对该变量进行初始化,则该变量会被初始化为缺省值;
构造函数还存在一个初始化列表,初始化列表的存在有着非常大的意义,具体内容我们在 [类和对象下篇] 讲解;
2、析构函数
析构函数完成对象中资源的清理工作,由编译器在销毁对象时自动调用;
如果用户显式定义了析构函数,编译器会根据析构函数的内容进行析构;如果用户没有显示定义,编译器会调用默认生成的析构函数;
默认生成的析构函数对内置类型不处理,对自定义类型会去调用自定义类型的析构函数;
如果类中有资源的申请,比如动态开辟空间、打开文件,那么需要我们显式定义析构函数;
3、拷贝构造
拷贝构造函数是用一个已存在的对象去初始化另一个正在实例化的对象,由编译器在实例化对象时自动调用;拷贝构造的参数必须为引用类型,否则编译器报错 – 值传递会引发拷贝构造函数的无穷递归;
如果用户显式定义了拷贝构造函数,编译器会根据拷贝构造函数的内容进行拷贝;如果用户没有显示定义,编译器会调用默认生成的拷贝构造函数;
默认生成的拷贝构造函数对于内置类型完成值拷贝 (浅拷贝),对于自定义类型会去调用自定义类型的拷贝构造函数;
当类里面有空间的动态开辟时,直接进行值拷贝会让两个指针指向同一块动态内存,从而使得对象销毁时对同一块空间析构两次;所以这种情况下我们需要自己显式定义拷贝构造函数完成深拷贝;
4、运算符重载
运算符重载是C++为了增强代码的可读性而引入的语法,它只能对自定义类型使用,其函数名为 operator 关键字加相关运算符;
由于运算符重载函数通常都要访问类的成员变量,所以我们一般将其定义为类的成员函数;同时,因为类的成员函数的一个参数为隐藏的 this 指针,所以其看起来会少一个参数;
同一运算符的重载函数之间也可以构成函数重载,比如 operator++ 与 operator++(int);
5、赋值重载
赋值重载函数是将一个已存在对象中的数据赋值给另一个已存在的对象,注意不是初始化,需要自己显示调用;它属于运算符重载的一种;
如果用户显式定义了赋值重载函数,编译器会根据赋值重载函数的内容进行赋值;如果用户没有显示定义,编译器会调用默认生成的赋值重载函数;
默认生成的赋值重载函数对于内置类型完成值拷贝 (浅拷贝),对于自定义类型会去调用自定义类型的赋值重载函数;
赋值重载函数和拷贝构造函数一样,也存在着深浅拷贝的问题,且其与拷贝构造函数不同的地方在于它还很有可能造成内存泄漏;所以当类中有空间的动态开辟时我们需要自己显式定义赋值重载函数来释放原空间以及完成深拷贝;
为了提高函数效率与保护对象,通常使用引用作参数,并加以 const 修饰;同时为了满足连续赋值,通常使用引用作返回值,且一般返回左操作数,即 *this;
赋值重载函数必须定义为类的成员函数,否则编译器默认生成的赋值重载会与类外自定义的赋值重载冲突;
6、const 成员函数
由于指针和引用传递参数时存在权限的扩大、缩小与平移的问题,所以 const 类型的对象不能调用成员函数,因为成员函数的 this 指针默认是非 const 的,二者之间传参存在权限扩大的问题;
同时我们为了提高函数效率以及保护对象,一般都会将成员函数的第二个参数使用 const 修饰,这就导致了该对象在成员函数内也不能调用其他成员函数;
为了解决这个问题,C++设计出了 const 成员函数 – 在函数最后面添加 const 修饰,该 const 只修饰 this 指针,不修饰函数的其他参数;
所以如果我们在设计类时,只要成员函数不改变第一个对象,我们建议最后都使用 const 修饰;
7、取地址重载与 const 取地址重载
- 取地址重载与 const 取地址重载是获取一个对象/一个只读对象的地址,需要自己显式调用;它们属于运算符重载,同时它们二者之间还构成函数重载;
- 大多数情况下我们都不会去显示实现这两个函数,使用编译器默认生成的即可;只有极少数情况需要我们自己定义,比如防止用户获取到一个对象的地址;