类与对象—中
目录
一、类的6个默认成员函数
1.默认成员函数概念
2.默认成员函数分类
二、C++提出构造函数、析构函数的背景
1.构造函数的提出背景
2.析构函数的提出背景
3.案例分析
三、构造函数
1..构造函数概念
2..构造函数特性
2.1.特性1:构造函数的函数名与类名相同。
2.2.特性2:构造函数无返回值。
2.3.特性3:对象实例化时编译器自动调用对应的构造函数对对象进行初始化。
2.4.特性4:构造函数可以重载(相当于1个类可以有多个构造函数,也相当于1个类可以有多个初始化方式)。
2.4.1.对象调用无参构造函数初始化
(1)调用格式:
(2)案例:
2.4.2.对象调用带参构造函数初始化(不是全缺省参数)
(1)调用格式:
(2)案例:
2.4.3.对象调用全缺省参数的构造函数初始化
案例:日期类
(1)正确代码
(2)错误代码:
2.5.特性5:如果类中没有自定义实现构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户自定义实现构造函数则编译器将不再默认生成构造函数。
2.5.1.默认构造函数
(1)编译器自动生成的默认构造函数
(2)自定义默认构造函数的类型解析
2.5.2.下面展示了,若我们自己不写构造函数则编译器自己回生成默认构造函数;若我们自己实现构造函数则编译器会不会生成默认构造函数。
(1)案例1:编译器生成默认构造函数
(2)案例2:编译器没有生成默认构造函数
2.6.特性6:编译器生成的默认构造函数对于自定义类型的成员变量只会调用它的默认构造函数(即不用传参的默认构造函数,例如无参、全缺省、编译器生成),但是对于内置类型的成员变量是不做处理的。
2.6.1.编译器生成的默认构造函数出现的问题:不对内置类型的成员变量进行初始化。
2.6.2.对于编译器生成的默认构造函数无法对内置类型的成员变量进行初始化的问题,以下是解决方式。
2.7.特性7:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
2.7.1.结论
3.编译器自动生成的默认函数的引用场景
3.1.案例1:用两个栈实现队列
四、析构函数
1.析构函数概念
2.析构函数特性
2.1.析构函数名是在类名前加上字符 ~。
2.2.无参数无返回值类型。
2.3.一个类只能有一个析构函数。若未自定义析构函数,系统会自动生成默认的析构函数。注意:析构函数不能重载。
2.4.对象生命周期结束时,C++编译系统系统自动调用析构函数。
2.5.编译器生成的默认析构函数,对自定类型成员调用它的析构函数(即编译器生成、自定义实现)。
2.6.如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
3.析构函数使用案例
3.1.案例1:有效的括号
C++提出拷贝构造函数背景
五、拷贝构造函数
1.拷贝构造函数概念
2.拷贝构造函数特性
2.1.特性1:拷贝构造函数是构造函数的一个重载形式。
2.2.特性2:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
1.由于编译器会自动生成默认拷贝构造函数对自定义类型的类对象进行值拷贝(浅拷贝),但为什么还要我们自定义实现拷贝构造函数呢?对于成员变量包含指向动态内存分配的指针的类对象,进行值拷贝(浅拷贝)可能带来哪些危害?
1.1.编译器默认情况下会对自定义类型的类对象进行拷贝操作,但这通常是执行浅拷贝。
1.2.浅拷贝的危害
1.3.案例:类Stack的成员变量包含
1.4.总结
2.为什么程序员在自定义实现拷贝构造函数时,通常不建议使用传值传参方式来实现拷贝构造函数?拷贝构造函数采用传值传参会无穷递归的原因?
3.传引用传参Date(const Date& d)的形参d用const修饰的原因:
4.拷贝构造函数的两种实现方式:传引用传参、传指针传参
5.使用一个已存在的对象初始化一个新对象的两种方法
6.总结
2.3.特性3:若未显式定义(即未自定义拷贝构造函数),编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
1.编译器自动生成的默认拷贝构造函数
1.1.编译器生成的默认拷贝构造函数是会对内置类型数据进行处理
(1)案例1:日期类
(2)案例2:栈类
1.2.编译器生成的默认拷贝构造函数对自定义类型数据的处理方式是:调用它的拷贝构造函数。
(1)案例1:用两个栈实现队列
2.默认拷贝构造函数
3.总结
3.拷贝构造函数典型调用场景
4.拷贝构造使用案例
案例1:实现一个计算给定日期向后推算指定天数的新日期的功能
六、运算符重载函数(运算符重载)
1.C++提出运算符重载的作用
1.1.运算符重载作用
1.2.对作用2进行解析
(1)对于运算符的操作数类型,编译器会自动识别内置类型的操作数,而无法识别自定义类型的操作数?为什么内置类型可以直接使用运算符,而自定义类型不可以直接使用运算符?
2.运算符重载函数介绍
2.1.运算符重载函数概念
2.2.运算符重载函数的格式
2.3.operator==判断相等运算符重载函数的实现
2.4.operator<运算符重载函数实现
2.5.operator>、operator>=、operator<=、operator!=的运算符重载函数实现
3.运算符重载注意事项
3.1.不能通过连接其他符号来创建新的操作符:比如operator@
3.2.重载操作符必须有一个类类型参数
3.3.用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
3.4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
3.5. .* :: sizeof ?: .注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
七、赋值运算符重载函数(赋值重载)
1.日期类Date& operator=(const Date& d)运算符重载函数实现过程
1.1.operator=(const Date d)赋值运算符重载函数可以传值传参
(1)赋值运算符重载函数可以传值传参而不引发无穷递归的原因:
(2)赋值运算符重载函数使用传值传参、传值返回的缺陷
1.2.Date& operator=(const Date& d)赋值运算符重载函数用传引用传参、传引用返回实现过程:
(1)代码1:void operator=(const Date& d)
(2)代码2:Date operator=(const Date& d)
(3)代码3:Date& operator=(const Date& d)
2.赋值运算符重载格式
3.赋值运算符只能重载成类的成员函数不能重载成全局函数
4. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
4.1.编译器自动生成的默认赋值运算符重载函数
4.2.拷贝构造和赋值重载的判断
八、日期类的实现
日期类成员函数的解析
1.日期+=天数:Date& operator+=(int day)
1.1.日期+=天数的思路
1.2.写法1
(1)代码
(2)代码解析
(3)测试
(4)结论
1.3.写法2
1.3.写法3
2.日期+天数:Date operator+(int day)
2.1.写法1
(1)代码
2.2.错误代码
(1)代码
(2)解析即使operator+内部的临时对象被static修饰但是operator+也不能用引用返回的原因
2.3.写法2
(1)代码
3.对实现operator+、operator+=两种方式进行总结
3.1.方式1:先自定义实现operator+,然后operator+=函数内部复用operator+从而实现operator+=函数。
3.2.方式2:先自定义实现operator+=,然后operator+函数内部复用operator+=从而实现operator+函数。
3.3.总结
4.operator++函数的实现
4.1.前置++运算符重载函数
(1)实现思路
(2)代码
4.2.后置++运算符重载函数
(1)实现思路
(2)代码
4.3.前置++效率高,还是后置++效率高?
4.4.编译器调用前置++、后置++的方式
5.operator-=、operator-函数的实现
5.1.日期-=天数 Date& operator-=(int day) 的函数实现
5.1.1.思路1
(1)思路
(2)代码
5.1.2.思路2
(1)思路
(2)代码
(3)测试
(4)代码解析
5.2.日期-天数 Date& operator-(int day)的函数实现
(1)思路
(2)代码
5.3.日期-日期 int operator-(const Date& d)的函数实现
(1)思路
(2)代码
(3)测试
6.operator--函数的实现
6.1.前置--
6.2.后置--
6.3.总结
7.插入数据(右操作数)是自定义类型(例如:对象)的流插入'<<'运算符重载函数
7.1.知识点
7.2.解释 C++ 中 cout << 插入数据(右操作数) 表达式对内置类型和自定义类型的插入数据(右操作数)识别差异的原因
7.3.猜想自定义类型插入数据(右操作数)的流插入运算符重载函数在什么位置定义实现比较好?
7.3.1.猜想1:声明和定义成类的成员函数,即void Date::operator<<(ostream& out)
(1)代码实现
(2)错误调用方式导致发生报错
(3)正确调用方式没有发生报错
(4)方式1的缺陷
7.3.2.猜想2:声明和定义成类的非成员函数(即在全局域中定义实现),ostream& operator<<(ostream& out, const Date& d)
ostream& operator<<(ostream& out, const Date& d)函数实现的过程如下:
7.4.C++支持流插入运算符重载函数的原因
8.插入数据(右操作数)是自定义类型(例如:对象)的流提取'>>'运算符重载函数
8.1.istream& operator>>(istream& in, Date& d)的实现过程
9.把流插入运算符重载函数、流提取运算符重载函数声明和定义成内联函数
9.1.对于流插入运算符重载函数、流提取运算符重载函数的优化
(1)优化方式
(2)优化原因
(3)注意事项
(4)优化后的代码
9.2.详细说明内联函数声明和定义不能分离的原因
(1)类的声明和定义
(2)源文件中函数声明与定义的关系及其对调用和链接过程的影响
(3)对于声明和定义分离的非内联函数来说,符号表的作用是什么?
(4)内联函数的介绍
(5)内联函数为什么不入符号表的原因
(6)内联函数不入符号表导致内联函数声明和定义分离时会发生编译报错的原因(或者说内联函数声明和定义不能分离的原因)
日期类整个工程
1.1.Date.h
1.2.Date.cpp
1.3.Test.cpp
九、const成员函数
1.C++提出const成员函数的背景
1.1.权限放大代码
(1)代码报错的原因
(2)解决方式
1.2.权限缩小代码
2.const成员函数的介绍
2.1.const成员函数定义
2.2.类成员函数是否加const修饰*this的判断方式
2.2.1.判断方法
2.2.2.案例
3.思考下面的几个问题
3.1. const对象可以调用非const成员函数吗?
3.2. 非const对象可以调用const成员函数吗?
3.3. const成员函数内可以调用其它的非const成员函数吗?
3.4. 非const成员函数内可以调用其它的const成员函数吗?
十、取地址运算符重载函数、const取地址运算符重载函数
1.编译器自动生成两种默认取地址运算符重载函数
2.自定义实现两种取地址运算符重载函数
3.两种取地址运算符重载函数的调用
3.1.取地址运算符重载函数A* operator&()的调用
3.2.const取地址运算符重载函数const A* operator&() const的调用
4.两种取地址运算符重载函数A* operator&()、const A* operator&() const 的区别
4.1.*this是否被const修饰
4.2.返回值不同
十一、下标运算符‘[]’的运算符重载函数operator[](int i)的实现
1.int& operator[](int i)的实现
2.const int& operator[](int i)的实现
3.int& operator[](int i)、const int& operator[](int i)可以构成函数重载
一、类的6个默认成员函数
注意:如果一个类中什么成员都没有,简称为空类。空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员函数。
1.默认成员函数概念
(1)概念:在类定义时若程序员没有自定义实现且是由编译器默认自动生成的成员函数称为默认成员函数。
2.默认成员函数分类
(1)编译器自动生成默认成员函数一共有6种,如下图所示:
二、C++提出构造函数、析构函数的背景
1.构造函数的提出背景
在C++出现之前,C语言程序员需要手动初始化对象的数据成员。这个过程容易出错,尤其是在对象较为复杂时,程序员可能会忘记初始化某些成员变量,导致程序运行时出现不可预测的行为。
C++引入了构造函数的概念,其目的是为了确保对象在创建时自动进行初始化。构造函数是一种特殊的成员函数,它在对象实例化时自动被调用,用于执行初始化操作。这样,程序员只需在构造函数中定义初始化逻辑,就可以保证每次创建对象时,其成员变量都会被正确地初始化。
2.析构函数的提出背景
在C++之前,管理动态分配的内存和其他资源是一个容易出错的过程。程序员需要在不再需要这些资源时手动释放它们,以避免内存泄漏和其他资源管理问题。
C++通过引入析构函数来解决这个问题。析构函数是另一个特殊的成员函数,它在对象生命周期结束时自动被调用,用于执行清理操作,如释放动态分配的内存和其他资源。析构函数的引入简化了资源管理,减少了因忘记释放资源而导致的错误。
总计:为了解决我们忘记对对象进行初始化的问题和忘记销毁对象向内存申请的资源(即动态空间),C++才提出构造函数、析构函数。
3.案例分析
(1)案例
①问题1:忘记初始化
在C++中定义了一个Stack
类的对象st
后,如果忘记调用Init
函数来初始化栈,那么直接对栈进行操作(如Push
函数)可能会导致未定义行为,因为成员变量a
可能是一个空指针,对空指针解引用将导致程序崩溃。
②问题2:忘记销毁
在使用完Stack
类的对象后,如果没有调用Destroy
函数来释放动态分配的内存,将导致内存泄漏。特别是在程序结束时,忘记手动调用销毁函数是常见错误。而且还有一个很麻烦的地方是当很多地方要用Destroy销毁(数据结构)时若我们常常会忘记。
(2)问题解决方式:
为了解决上述问题,C++引入了构造函数和析构函数的概念:
-
构造函数:构造函数在对象创建时编译器会自动被调用,用于执行初始化操作。在
Stack
类中,我们可以定义一个构造函数来自动分配内存并设置初始状态。 -
析构函数:析构函数在对象生命周期结束时编译器会自动被调用,用于执行清理操作。在
Stack
类中,析构函数可以用来释放之前分配的内存。
三、构造函数
1..构造函数概念
构造函数是类中用于初始化对象的一个特殊成员函数。它的名称与类名一致,并且在创建类的实例时,编译器会自动调用构造函数来执行对象的初始化操作。构造函数的主要职责是为对象的成员变量赋予初始值,确保对象在诞生之初就处于一个有效状态。值得注意的是,构造函数在整个对象的生命周期内仅被调用一次,即在对象创建的时刻,而在对象即将被销毁之前不会再调用构造函数。这个过程确保了每个成员变量在对象的使用期间都有一个合适的初始值。
注意:
①构造函数不是创建对象而是用来初始化对象的,即构造函数的作用相等于初始化函数Init一样对类的所有成员变量进行初始化。
②构造函数是用来初始化对象的,构造函数的本质工作确实是对对象的所有成员变量进行初始化。当创建一个类的实例时,构造函数会被自动调用,以便为对象的成员变量设置初始值,确保对象在可以使用之前处于一个定义良好的状态。
//自定义实现的构造函数案例
//.cpp
#include<iostream>
using namespace std;
class Date
{
public:
//注意:无论是无参构造函数还是带参构造函数都是我们自定义实现构造函数,所以编译器不会自动
//生成默认构造函数。
//1.无参构造函数
Date()
{}
//2.带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
void TestDate()
{
Date d1;//调用无参构造函数
Date d2(2015, 1, 1);//调用带参的构造函数
//注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
//以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
//warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)
Date d3();//注意:这是一个函数声明。
}
2..构造函数特性
注意:构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象。
2.1.特性1:构造函数的函数名与类名相同。
2.2.特性2:构造函数无返回值。
2.3.特性3:对象实例化时编译器自动调用对应的构造函数对对象进行初始化。
注意:我们平时说的对对象进行初始化指的是对对象中的成员变量进行初始化,不管这个成员变量是自定义类型还是内置类型。
(1)案例:下面展示了对象实例化编译器会自动调用对应的构造函数对对象进行初始化
①对象实例化之前:
①对象实例化之后:编译器自动调用对应的构造函数对对象进行初始化。
2.4.特性4:构造函数可以重载(相当于1个类可以有多个构造函数,也相当于1个类可以有多个初始化方式)。
2.4.1.对象调用无参构造函数初始化
(1)调用格式:
//对象传参调用无参构造函数进行初始化的格式:
Stack st;
//注意:对象调用无参构造函数初始化时一定不要把Stack st写成Stack st()不然会发生报错,原因是编译器区分不清楚Stack st()是函数声明还是在定义对象.
(2)案例:
2.4.2.对象调用带参构造函数初始化(不是全缺省参数)
(1)调用格式:
Stack st(参数列表);//带参构造函数传参调用格式
Stack st;//全缺省构造函数不用传参的调用格式
//注意:参数列表都是代码构造函数各个参数的值,若构造函数是全缺省参数则调用时可以不用传参。
(2)案例:
(3)注意事项:不能把Stack st(4)理解成st.Stack(4)的原因
- 若把Stack st(4)写成成st.Stack(4)去调用带参构造函数对对象进行初始化是不行的,因为st.Stack(4)中的st没有实例化,而且只能把st.Stack(4)写成Stack st;st.Stack(4);,但是写成这样那C++提供的构造函数就没有什么意义。
2.4.3.对象调用全缺省参数的构造函数初始化
案例:日期类
(1)正确代码
结论:在大多数场景下,1个类的内部在写构造函数时一般用全缺省或者半缺省来写构成函数,最好用全缺省参数来写构造函数。
(2)错误代码:
①错误原因:在Date
类的定义中,我们有两个构造函数定义,一个是无参构造函数Date()
,另一个是带有全缺省参数的构造函数Date(int year = 1, int month = 1, int day = 1)
。尽管这两个构造函数在语法上构成了重载,但是由于全缺省参数的构造函数在没有提供任何参数时与无参构造函数的行为相同,这会导致歧义。当编译器看到Date d1;
这样的对象定义时,它不知道应该调用哪个构造函数来初始化d1
。为了避免这种歧义和潜在的错误,通常的做法是只保留一个构造函数,并且使用全缺省参数的方式来提供灵活性。这样,无论是否提供参数,都可以使用同一个构造函数来创建对象。因此,我们可以移除无参构造函数,只保留带有全缺省参数的构造函数,如下所示:
②结论:
-
在C++中,当定义一个类时,应避免同时存在无参构造函数和带有全缺省参数的构造函数,因为它们可能会造成调用时的歧义。
-
应该只保留一个构造函数,并使用全缺省参数,这样这个构造函数既可以作为无参构造函数使用,也可以通过传递参数来初始化对象。
-
使用全缺省参数的构造函数能够提供更大的灵活性和便利性,因为它允许程序员以多种方式初始化对象,而无需定义多个不同的构造函数。
-
当只有一个构造函数时,编译器在实例化对象时会明确知道调用哪个构造函数,从而避免了潜在的编译错误。
2.5.特性5:如果类中没有自定义实现构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户自定义实现构造函数则编译器将不再默认生成构造函数。
2.5.1.默认构造函数
注意:
①默认构造函数有3种:自定义实现的默认构造函数(无参、全缺省)、编译器自动生成默认函数。
②默认构造函数(即无参、全缺省、编译器生成)就是不用传参就可以调用的构造函数。
③C++把类型分成内置类型(基本类型)和自定义类型;内置类型就是语言提供的数据类型,如:int/char/任意类型的指针/...,自定义类型就是我们使用class/struct/union/enum等自己定义的类型。
(1)编译器自动生成的默认构造函数
①对编译器自动生成的默认构造函数的介绍:
- 只有当程序员在定义类时没有自定义实现任何构造函数,编译器才会自动生成一个默认构造函数。如果程序员在定义类时至少自定义实现了一个构造函数(即无参、带参),编译器就不会自动生成默认构造函数。
②特性:
注意:不用传参就可以调用的构造函数包括无参、全缺省参数的自定义默认构造函数、编译器自动生成默认构造函数。
- 对内置类型的成员变量不做处理: 这意味着,如果一个类中包含内置类型(如int, float等)的成员变量,编译器自动生成的默认构造函数不会对这些成员变量进行初始化。这些成员变量的值将是未定义的,当我们打印这些成员变量的值时会发现都是随机值。
- 对自定义类型的成员变量会去调用它的默认构造函数(注:这里的默认构造函数指的是不用传参就可以调用的构成函数,例如:无参、全缺省参数的自定义默认构造函数、编译器自动生成): 如果类中包含自定义类型的成员变量(例如:其他类的对象、结构体、联合体、枚举等自定义类型),编译器自动生成的默认构造函数会调用这些自定义类型成员变量自己的不用传参的默认构造函数来初始化自己。
③在 C++ 中,编译器自动生成的默认构造函数对于自定义类型成员变量的初始化过程如下:
-
调用自定义类型的默认构造函数:当编译器自动生成的默认构造函数被执行时,对于类中的每个自定义类型成员变量,编译器会尝试调用该自定义类型的默认构造函数。默认构造函数是指不需要任何参数就可以调用的构造函数。
-
自定义类型没有定义构造函数:如果自定义类型没有显式定义任何构造函数,编译器会为其生成一个默认构造函数,这个默认构造函数通常是空的,不执行任何操作。
-
自定义类型定义了构造函数:如果自定义类型至少定义了一个构造函数,且这个构造函数是无参的或者所有参数都有默认值(即全缺省参数的构造函数),那么这个构造函数就可以作为默认构造函数被编译器自动生成的默认构造函数调用。
(2)自定义默认构造函数的类型解析
自定义默认构造函数可以分为以下2种类型:
- 无参构造函数:这是一个没有参数的自定义构造函数,因此调用时是不提供任何参数。
- 全缺省构造函数:这种自定义构造函数的所有参数都有默认值,因此调用时可以不提供任何参数,或者是程序员自定义提供参数。
注意:由于2种自定义默认构造函数(即无参构造函数、全缺省构造函数)在调用时可以不提供任何参数,导致为了防止调用时发生歧义进而使得我们在类定义时只能定义这2种自定义默认构造函数中的1种,最后自定义全缺省构造函数。
(3)总结
总结来说,编译器自动生成的默认构造函数是一种特殊的默认构造函数,它只在程序员没有定义任何构造函数时才会被创建,并且它对内置类型成员不做初始化,对自定义类型成员调用自己的不用传参的默认构造函数进行初始化。
2.5.2.下面展示了,若我们自己不写构造函数则编译器自己回生成默认构造函数;若我们自己实现构造函数则编译器会不会生成默认构造函数。
注意:C++规定了,对象实例化之后必须调用构造函数对对象进行初始化,即必须调用构造函数对对象的所有成员变量进行初始化。
(1)案例1:编译器生成默认构造函数
解析:从打印结果可以看出编译器生成的默认构成函数是不对内置类型的成员变量进行初始化的,进而看出编译器生成的默认构成函数对内置类型变量的初始化没什么用。
(2)案例2:编译器没有生成默认构造函数
报错原因:由于对象在实例化之后必须调用构造函数而且类定义时我们自己自定义了构造函数,但是下面在定义对象d1之后没有找到不用传参的默认构造函数进而使得对象实例化之后没有调用构造函数,所以这里才会发生报错。
2.6.特性6:编译器生成的默认构造函数对于自定义类型的成员变量只会调用它的默认构造函数(即不用传参的默认构造函数,例如无参、全缺省、编译器生成),但是对于内置类型的成员变量是不做处理的。
2.6.1.编译器生成的默认构造函数出现的问题:不对内置类型的成员变量进行初始化。
(1)案例:
(2)编译器生成问题:有很多人疑惑的是编译器会自动生成默认构造函数,但是为什么我们还要自定义实现构造函数?从上面日期类的对象d1调用了编译器生成的默认构造函数进行初始化后,但是d1对象的所有内置类型的成员变量_year/_month/_day的值依旧是随机值,也就说在这里编译器生成的默认构造函数对内置类型的成员变量的初始化并没有什么用。所以我们有时还要自定义实现构造函数,但是不是说编译器自动生成的默认构造函数没有什么作用,只是在特殊场景下才会有作用,下面我会提到编译器自动生成的默认构造函数的引用场景的。
2.6.2.对于编译器生成的默认构造函数无法对内置类型的成员变量进行初始化的问题,以下是解决方式。
(1)解决方式:C++11 中针对内置类型成员不初始化的缺陷提出的解决方式是:内置类型成员变量在类中声明时可以提供缺省值。给类中的内置类型成员变量提供缺省值如下图所示:
注意:不可以把在类定义时给内置类型成员变量提供缺省值视为对内置类型成员变量进行初始化的原因是:类定义中的内置类型成员变量是声明而不是定义,而定义是要开辟空间的但声明是不需要开辟空间的,所以我们不能把给内置类型成员变量提供缺省值视为对内置类型成员变量进行初始化。
(2)给类中的内置类型成员变量提供缺省值的作用:
①若默认构造函数(例:无参、全缺省、编译器自动生成)没有完成对内置类型的成员变量进行初始化则这些内置类型的成员变量的值就是随机值。给内置类型的成员变量提供缺省值可以确保即使没有调用构造函数来初始化这些内置类型的成员变量,那它们也会有一个已知的初始状态。
②通过明确指定成员变量的缺省值,可以减少因忘记初始化成员变量而导致的潜在错误。
③在类定义中直接提供缺省值可以让其他开发者更容易理解类的默认状态。
(3)测试案例:
①类Date中无自定义的构造函数、没给内置类型成员变量提供缺省值,打印结果如下:
②类Date中无自定义的构造函数、给内置类型成员变量提供缺省值,打印结果如下:
③类Date中有自定义的构造函数、给内置类型成员变量提供缺省值,打印结果如下:
2.7.特性7:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。
2.7.1.结论
结论1:无参构造函数、全缺省构造函数、编译器默认生成的构造函数都可以认为是默认构造函数。
结论2:默认构造函数指的是不传参数(例如:无参、全缺省、编译器生成)就可以自动调用的构造函数。
结论3:一般建议每个类都提供一个默认构造函数(注:这里的默认构造函数指无参、全缺省、编译器生成的),因为这样就可以解决我们在很多地方不想通过传参调用初始化函数对对象进行初始化而是通过编译器自动调用类中的默认构造函数对对象进行初始化。
(1)错误代码
案例1:
代码解析:案例1证明了在类定义时最好应该提供至少1个默认构造函数,这个默认构造函数可以是无参、全缺省参数的自定义默认构造函数、编译器自动生成的默认函数。否则就很容易发生报错。
(2)正确代码
①案例2:写个无参的自定义构造函数
②案例3:写个全缺省参数的自定义构造函数
结论4:若编译器的报错信息是:没有合适的默认构造函数可用。这句话中的默认构造函数不是单一指编译器自动生成的默认构造函数,这个默认构造函数还包括自定义实现的无参、全缺省的默认构造函数。
3.编译器自动生成的默认函数的引用场景
3.1.案例1:用两个栈实现队列
. - 力扣(LeetCode)
注意:下面的解题方式都是在类Queue中没有自定义任何的构造函数和析构函数,而是让编译器自己在类Queue中自动生成默认构造函数、析构函数。由于类Queue的自定义类型的成员变量是对象Stack _pushST、对象Stack _popST,所以在类Queue中的编译器自己生成默认构造函数再对自定义类型的成员变量对象 _pushST、对象_popST进行初始化时会调用它们自己的不用传参的默认构造函数即调用Stack()或者Stack(int n = 4)对对象栈进行初始化。注意,一定不要把Stack()和Stack(int n = 4)放在同一个类Stack中不然会发生歧义,或者把Stack(int n = 4)写成Stack(int n)就可以和Stack()放在同一个类Stack中。
3.1.1.调用不用传参的全缺省参数Stack(int n = 4)进行初始化
注意:下面图形也证明了编译器生成的默认构造函数对于自定义类型的成员变量只会调用它的不用传参的默认构造函数(例如无参、全缺省参数、编译器生成)。
3.1.2.调用不用传参的无参构造函数Stack()进行初始化
(1)案例1:
(2)案例2:
3.1.3.对比C语言和C++在这题的解法
3.1.4.从该案例1得出的结论:
① 编译器生成的默认构造函数,对自定义类型的成员变量,会调用它的不用传参的默认构造函数(例如无参、全缺省参数、编译器生成)。
- 这句话的意思是,当编译器为类生成一个默认构造函数时,如果这个类包含自定义类型的成员变量(即其他类的对象),编译器会在生成的默认构造函数中包含对这些成员变量的初始化。这种初始化是通过调用这些自定义类型成员的不用传参的默认构造函数来完成的。例如,如果类
A
有一个成员变量B b(对象);
,其中B
是另一个类,那么编译器生成的A
的默认构造函数会调用类B
的不用传参的默认构造函数来初始化对象b
。
② 编译器生成默认析构函数,对自定义类型成员变量,会调用它的析构函数(即编译器生成默认析构函数 或者 自定义实现的析构函数)。
- 这句话的意思是,当编译器为类生成一个默认析构函数时,如果这个类包含自定义类型的成员变量,编译器会在生成的默认析构函数中包含对这些成员变量的清理代码。这种清理是通过调用这些自定义类型成员自己的析构函数来完成的。例如,如果类
A
有一个成员变量B b(对象);
,当类A
的对象超出作用域或被删除时,编译器生成的类A
的默认析构函数会调用B
的自定义的析构函数来清理b
。
总的来说,这两句话说明了编译器如何管理类的生命周期,包括自动生成默认构造函数和自动生成默认析构函数来确保成员变量的正确初始化和清理。这对于管理资源(如动态分配的内存)尤其重要,因为这些资源需要在对象生命周期结束时被正确释放。
四、析构函数
注意:日期类通常不包含指向动态分配内存的指针或其他需要手动释放的资源。在这种情况下,编译器生成的默认析构函数就足够了,因为它会自动调用成员变量的析构函数(如果有的话),并且由于日期类中没有需要清理的资源,默认析构函数不会执行任何特定的清理操作。
1.析构函数概念
析构函数:与构造函数功能相反,析构函数不负责对对象本身的销毁,局部对象销毁工作是由
编译器完成的。在对象生命周期结束时即对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
注意:
①析构函数的职责不在于销毁对象本身,而在于资源的清理。析构函数负责释放对象所拥有的资源,如动态分配的内存。
② 对象可以存放在栈上或堆上。存放在栈上的对象在其作用域结束时会被自动销毁,而存放在堆上的对象需要手动管理其生命周期。
③析构函数主要用于在对象生命周期结束时释放对象在运行时动态分配的内存和其他资源,执行资源清理工作。这确保了不会发生资源泄漏。
//案例:
#include<iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:
//构成函数
Stack(size_t capacity = 3)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
// 其他方法...
//析构函数
~Stack()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
void TestStack()
{
Stack s;
s.Push(1);
s.Push(2);
}
2.析构函数特性
2.1.析构函数名是在类名前加上字符 ~。
解析:在C++中,析构函数的名称是在类名前加上字符~
来表示的,这表明析构函数的功能与构造函数的功能是相反的。构造函数用于初始化对象,而析构函数用于在对象生命周期结束时进行清理工作,释放对象所占用的资源。因此,~
前缀在析构函数的命名中,象征性地表示了它与构造函数功能的对立性。
2.2.无参数无返回值类型。
2.3.一个类只能有一个析构函数。若未自定义析构函数,系统会自动生成默认的析构函数。注意:析构函数不能重载。
2.4.对象生命周期结束时,C++编译系统系统自动调用析构函数。
(1)案例:下面展示了是在对象出作用域之后才调用析构函数来清理对象中的占用的资源。
①对象出作用域之前
②对象调用析构函数清理对象中的占用的资源的过程
③对象出作用域之后
2.5.编译器生成的默认析构函数,对自定类型成员调用它的析构函数(即编译器生成、自定义实现)。
解析:对于自定义类型的成员变量,编译器生成的默认析构函数会调用该成员变量的析构函数,无论这个析构函数是编译器默认生成的还是程序员自定义实现的。如果自定义类型有自定义的析构函数,那么就会调用这个自定义的析构函数;如果没有,就会调用编译器默认生成的析构函数。
//案例:
#include<iostream>
using namespace std;
class Time
{
public:
//析构函数
~Time()
{
cout << "~Time()" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
// 基本类型(内置类型)
int _year = 1970;
int _month = 1;
int _day = 1;
// 自定义类型
Time _t;
};
int main()
{
Date d;
return 0;
}
// 程序运行结束后输出:~Time()
// 在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
// 因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month,_day三个是内置类型成员,销毁时不需要资源清理,
// 最后系统直接将其内存回收即可;而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。
// 但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,
// 则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都
// 可以正确销毁,main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数
// 注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
2.6.如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
3.析构函数使用案例
3.1.案例1:有效的括号
. - 力扣(LeetCode)
C++提出拷贝构造函数背景
1.背景:那在创建对象时,难免会创建一个与已存在对象一模一样的新对象,即用同类型的已存在对象初始化新创建的对象
1.1.案例1:定义的对象d2是d1的拷贝。
五、拷贝构造函数
注意:拷贝构造和赋值重载功能类似。
1.拷贝构造函数概念
(1)拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
(2)在C++中,拷贝构造函数是一种特殊的构造函数,它用于创建一个新对象作为另一个同类对象的副本。拷贝构造函数通常在以下几种情况下被调用:
- 当使用一个已存在的对象初始化一个新对象时。
- 当对象作为函数参数进行传值传参时。
- 当函数使用传值返回,返回一个对象时。
(3)下面是拷贝构造函数的一些关键点:
注意:
①编译器生成默认拷贝构造函数对于内置类型成员变量会进行处理,若成员变量不包含指向动态分配内存的指针则编译器生成默认拷贝构造函数对成员变量指向浅拷贝(即值拷贝);若成员变量包含指向动态分配内存的指针,则程序员必须自定义深拷贝的拷贝构造函数。
②编译器生成默认拷贝构造函数对于自定义类型成员变量会调用它自己的拷贝构造函数(即自定义拷贝构造函数、编译器生成)
-
编译器生成默认拷贝构造函数:如果程序员没有自定义实现拷贝构造函数,编译器会自动生成一个默认拷贝构造函数。默认拷贝构造函数只能完成对成员变量的浅拷贝(即值拷贝)而不支持深拷贝。
-
浅拷贝与深拷贝:默认拷贝构造函数通常执行浅拷贝,这意味着它只会复制成员的值,如果类的成员变量中包含指向动态分配内存的指针,这可能会导致问题,因为两个对象将指向同一内存位置。在这种情况下,需要定义一个执行深拷贝的拷贝构造函数,它会复制指针指向的数据,而不是指针本身。
-
自定义拷贝构造函数:在某些情况下,需要自定义拷贝构造函数来正确地复制对象的状态。例如,当类中有指向动态分配内存的成员时,或者当类需要管理资源时。
2.拷贝构造函数特性
2.1.特性1:拷贝构造函数是构造函数的一个重载形式。
解析:拷贝构造函数也是对象的一种初始化方式。
2.2.特性2:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
//案例:
#include<iostream>
using namespace std;
class Date
{
public:
//构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
//Date(const Date d)//错误写法:传值传参,编译报错,会引发无穷递归。
Date(const Date& d)//正确写法:传引用传参。
{
_year = d._year;
_month = d._month;
_day = d._day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(d1);
return 0;
}
解析:拷贝函数定义时若参数不是传引用传参而是传值传参则会引发无穷递归,以下就是拷贝构造函数的参数使用传值传参引发无穷递归的图形:
在C++中,函数调用时的参数传递方式会影响是否进行拷贝操作:
①当参数通过值传递(即传值传参)时,无论参数是内置类型还是自定义类型,都会发生拷贝操作。
- 对于自定义类型参数(例如:对象),拷贝操作是通过调用拷贝构造函数来完成的形参对实参的临时拷贝。
- 对于内置类型参数(例如
int
,float
等),拷贝操作是由编译器隐式完成的,这个过程不涉及拷贝构造函数的调用,因为内置类型没有拷贝构造函数的概念。编译器会直接复制这些类型的值。
②当参数通过引用传递时,不会发生形参对实参的拷贝操作。引用传递实际上传递的是实参的别名,因此不会创建实参的副本,也不会调用拷贝构造函数。这种方式可以减少不必要的拷贝,提高函数调用的效率,尤其是在处理大型对象或需要避免深拷贝的情况下。
1.由于编译器会自动生成默认拷贝构造函数对自定义类型的类对象进行值拷贝(浅拷贝),但为什么还要我们自定义实现拷贝构造函数呢?对于成员变量包含指向动态内存分配的指针的类对象,进行值拷贝(浅拷贝)可能带来哪些危害?
1.1.编译器默认情况下会对自定义类型的类对象进行拷贝操作,但这通常是执行浅拷贝。
解析:在C++中,如果程序员没有为自定义类型显式定义拷贝构造函数和拷贝赋值运算符,编译器会默认提供一个。这个默认提供的拷贝构造函数和拷贝赋值运算符执行的是浅拷贝操作,即它们会逐位复制对象的所有成员变量。对于内置类型成员,这通常没有问题,但对于成员变量包含指向动态内存分配的指针,浅拷贝可能会导致问题。
1.2.浅拷贝的危害
(1)注意:在C++中,如果一个类包含指向动态分配内存的指针成员,那么当这个类的对象被复制时,默认的拷贝行为是浅拷贝。浅拷贝只是简单地复制指针的值,而不是指针所指向的内存内容。
(2)浅拷贝的危害主要体现在以下几个方面,尤其是当成员变量包含指向动态内存分配的指针时:
-
野指针问题:当两个对象的指针成员指向同一块动态分配的内存,并且其中一个对象被销毁时,其析构函数会释放这块内存。这时,另一个对象中的指针就变成了野指针,因为它指向的内存已经被释放了。尝试通过这个野指针访问内存会导致未定义行为。
-
数据不一致:由于两个对象的指针成员指向同一块内存,任何对这块内存的修改都会影响到这两个对象。这可能会导致数据不一致,使得程序的行为变得不可预测。
-
双重释放:如果两个对象在销毁时都尝试释放同一块内存,那么这块内存会被释放两次。第一次释放是正常的,但第二次释放是错误的,因为它会导致试图释放已经释放的内存,这会导致程序崩溃。
为了避免这些危害,应该为类提供自定义的拷贝构造函数和拷贝赋值运算符,以实现深拷贝。深拷贝会为成员指针分配新的内存,并复制原始指针所指向的数据,从而确保每个对象都有自己的独立副本。
(3)以下是深拷贝的基本步骤:
- 在拷贝构造函数和拷贝赋值运算符中,为新对象分配新的内存。
- 将原始对象中的数据复制到新分配的内存中。
- 更新新对象的指针成员,使其指向新分配的内存。
1.3.案例:类Stack的成员变量包含
总结:
①若自定义实现析构函数释放对象占用的资源(如动态内存),则必须自定义实现深拷贝构造函数;若没有自定义实现析构函数则可以使用编译器自动生成的默认拷贝构造函数实现浅拷贝。
②类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。
1.4.总结
注意:只要有拷贝操作就会涉及是否需要调用拷贝构造函数完成拷贝操作,对于内置类型的拷贝不需要调用拷贝构造函数编译器自动完成浅拷贝,对于自定义类型一定要调用拷贝构造函数完成拷贝操作。
(1)结论:
①当拷贝对象是内置类型时,编译器可以直接进行浅拷贝,不需要调用拷贝构造函数。
原因:这是因为内置类型的数据通常存储在栈上,并且不涉及动态内存分配,所以不需要调用拷贝构造函数。
②当拷贝对象是自定义类型(例如:拷贝对象是类对象)时:
- 如果拷贝对象的成员变量全部是内置类型并且不包含指向动态分配内存的指针,编译器生成的默认拷贝构造函数会对所有成员变量执行浅拷贝。这种情况下,浅拷贝通常是安全的。
- 如果拷贝对象的成员变量包含指向动态内存分配的指针,程序员必须自定义实现深拷贝的拷贝构造函数来执行深拷贝,以确保动态内存的正确复制。原因:这样做是为了确保每个对象都有自己独立的资源副本,避免潜在的内存泄漏或资源冲突问题。
- 总的来说,对于自定义类型拷贝对象进行拷贝操作时,无论拷贝对象的成员变量是浅拷贝还是深拷贝,通常都需要调用拷贝构造函数。
③总结:内置类型的拷贝,编译器可以直接拷贝(浅拷贝)。自定义类型的拷贝,不管是浅拷贝还是深拷贝,都需要调用拷贝构造。
对于自定义类型的拷贝,如果成员变量不需要深拷贝,可以使用编译器生成的默认拷贝构造函数(浅拷贝)。如果需要深拷贝,则程序员必须提供自定义的拷贝构造函数。
(2)注意:对于自定义类型拷贝对象执行的是深拷贝操作还是浅拷贝操作需要程序员自己判断,如果需要深拷贝则调用深拷贝构造函数,如何需要浅拷贝则调用浅拷贝构造函数(即编译器生成默认拷贝构造函数、或者自定义实现的浅拷贝构造函数)。
(3)案例:
//案例:
#include<iostream>
using namespace std;
class Date
{
public:
//构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
//Date(const Date d)//错误写法:编译报错,会引发无穷递归。
//浅拷贝的拷贝构造函数
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;
}*/
private:
int _year;
int _month;
int _day;
};
//传值传参
void Func1(Date d) {}
//传引用传参
void Func2(Date& d) {}
//传指针传参。传的是对象的地址。
void Func3(Date* d) {}
int main()
{
Date d1(2023, 2, 3);
Date d2(d1);//拷贝构造
Date d3 = d1;//拷贝构造
//传指针传参
//Date d4(&d1);
//Date d5 = &d1;
Func1(d1);//传值传参,由于拷贝对象是自定义类型即日期类,而且日期类的成员变量不包含指向动态
//内存分配的指针,所以这里调用的是浅拷贝的拷贝 构造函数(即编译器生成、自定义实现
//浅拷贝构造函数)。
Func2(d1);//传引用传参,可以解决传值传参无穷递归问题。
Func3(&d1);//传指针传参,编译器自动完成拷贝,不用调用拷贝构造函数
return 0;
}
//解析:
//1.Func1与Func2函数调用的区别:Func1函数是传值传参,而Func2函数是传引用传参。
//2.
//2.1.传值传参是个临时拷贝,即形参是实参的临时拷贝,即形参d是实参对象d1的拷贝。
//2.2.传引用传参的形参是实参的别名(即形参指向实参,通过改变形参就可以直接改变实参的值)
//形参d是实参对象d1的别名。
//3.在传值传参时,若传参的参数是内置类型的数据则编译器会直接拷贝;若传参的参数是自定义类型(例如:对象),则编译器需要调用拷贝构造函数进行拷贝。
//3.1.自定义类型的实参在传参时不能直接把实参拷贝给形参。编译器自己无法完成自定义类型(对象)拷贝的原因:
//(1)即使在某些场景下,编译器自己可以完成自定义类型变量的拷贝,但是在有些场景下会出现大问题。
2.为什么程序员在自定义实现拷贝构造函数时,通常不建议使用传值传参方式来实现拷贝构造函数?拷贝构造函数采用传值传参会无穷递归的原因?
注意:在传值传参的过程中,参数是内置类型则编译器会自动完成形参对实参的临时拷贝而不需要调用拷贝构造函数;参数是自定义类型则编译器无法完成参数拷贝,则会调用自定义类型参数自己的拷贝构造函数来完成形参对实参的临时拷贝。
(1)拷贝构造函数使用传值传参而不用传引用传参时,自定义类型参数在传值传参过程中发生无穷递归的原因:
①传值传参具体过程:
-
当我们尝试创建一个新对象
Date d2(d1);
时,编译器需要调用拷贝构造函数来初始化d2
。 -
在调用
Date d2(d1);
时,实际上是在尝试使用d1
来初始化Date
类型的形参date
。由于形参是通过值传递的,编译器需要先创建date
的一个副本。 -
为了创建
date
的副本,编译器会再次调用拷贝构造函数Date(Date date);
,但是这次是为了初始化形参date
本身。 -
这个过程会不断重复,因为每次调用拷贝构造函数时,都会尝试创建一个新的
Date
对象,而创建新对象又需要调用拷贝构造函数,从而形成一个无限循环的递归调用。 -
这种递归不会停止,因为它每次都在尝试做同样的事情:使用传值方式创建一个对象的新副本,而这又触发了另一轮拷贝构造函数的调用。
②总结:
当拷贝构造函数使用传值传递而非引用传递时,会发生无穷递归的情况。这是因为每次调用拷贝构造函数时,都会创建一个新的对象date(即Date date(d1))作为参数传入,而这个新对象的创建又会触发一次拷贝构造函数的调用。这种情况下,每个对象的创建都依赖于前一个对象的创建,导致了一个无限循环的过程。
③解决方式:为了避免传值传参出现的无穷递归问题,拷贝构造函数应该使用引用传递,通常是常量引用,如下所示:
Date(const Date& date); // 使用引用传递的拷贝构造函数
解析:在C++中,当拷贝构造函数的参数是通过引用传递时,形参只是实参的一个别名。这意味着在调用拷贝构造函数的过程中,不会对实参进行任何拷贝操作。由于没有发生实参的拷贝,因此也就不会调用拷贝构造函数来创建形参的临时副本。这种方法避免了在拷贝构造函数中使用传值传参时可能出现的无穷递归问题,因为在传值传参的情况下,形参的创建会涉及到对实参的拷贝,这可能导致拷贝构造函数的递归调用。通过使用引用传参,我们确保了拷贝构造函数只会直接操作原始对象,而不会创建任何额外的副本。
案例:当我们尝试创建一个新对象 Date d2(d1);
时,这是通过拷贝构造函数来初始化 d2
的。由于拷贝构造函数 Date(const Date& date)
使用的是引用传递,形参 date
实际上是实参 d1
的一个别名。在这个过程中,并没有创建 d1
的副本,因此不会调用拷贝构造函数来执行形参对实参的拷贝操作。
由于没有发生额外的拷贝,就不会引发拷贝构造函数的递归调用,从而避免了无穷递归的问题。这样,我们就可以安全地通过引用传递实参,而不会导致程序陷入无限循环的状态。
(2)下面代码证明了传引用传参可以解决传值传参无穷递归的问题。
解析:程序没有发生报错,而且从打印结果看Date d2(d1)和Date d3 = d1都调用传引用传参拷贝构造函数来对对象d2、d3进行初始化,则证明了传引用传参可以解决传值传参无穷递归的问题。
3.传引用传参Date(const Date& d)的形参d用const修饰的原因:
(1)原因:关于拷贝构造函数 Date(const Date& d)
的形参 d
被声明为常量引用(const Date&
)原因:这是为了确保在拷贝构造过程中不能通过形参来修改原始对象的状态。这是因为拷贝构造函数的主要目的是创建一个与现有对象相同的新对象,而不是改变它。因此,形参 d
用 const
修饰是为了防止意外地修改传入的对象。
(2)案例
①案例1:
解析:在拷贝构造函数Date(const Date& d)的内部若不小心把新创对象的所有成员变量的值(注:新创对象的成员还没有初始化)拷贝到已完成初始化对象的对应成员变量中,由于形参d是被const修饰的导致程序会发生报错。形参被const修饰的拷贝构造函数Date(const Date& d)如下图所示:
②案例2:
解析:若我们把Date(const Date& d)写成Date(Date& d)时,当我们在拷贝构造函数内部Date(Date& d)不小心把拷贝对象写反了则编译器不会发生报错,如下图所示:
解析:在创建对象 d2
时,我们期望它是对象 d1
的一个副本。为了实现这一点,通常会调用拷贝构造函数。然而,如果拷贝构造函数 Date(Date& d)
的实现不正确,比如错误地将成员变量的赋值方向颠倒,那么就会发生以下情况:
- 对象
d1
已经被正确地初始化,拥有确定的成员变量值。 - 当尝试创建对象
d2
并使用d1
来初始化它时,拷贝构造函数Date(Date& d)
被调用。 - 在拷贝构造函数内部,如果错误地将
d
(即d1
的引用)的成员变量值赋给正在构造的对象this
(即d2
)的成员变量,但赋值操作的方向反了,那么实际上是将d2
的成员变量值赋给了d1
。 - 结果是,对象
d1
的成员变量值被覆盖,变成了对象d2
的成员变量值。由于d2
在此时尚未完全初始化,其成员变量可能包含随机值或默认构造值。 - 最终,对象
d1
的状态被意外改变,而对象d2
的状态则是不确定的,这可能导致程序的行为变得不可预测。
4.拷贝构造函数的两种实现方式:传引用传参、传指针传参
注意:C++一般不使用传指针传参而是使用传引用传参,因为传引用传参可以减少参数拷贝。
5.使用一个已存在的对象初始化一个新对象的两种方法
6.总结
(1) 拷贝构造函数是一种特殊的构造函数,它的函数名与类名相同,并且有一个参数,该参数是对同类对象的引用。
(2) C++规定,在传值传参的过程中,如果参数是自定义类型,则会调用拷贝构造函数来完成形参对实参的拷贝。如果参数是内置类型,编译器会直接进行值拷贝,不需要调用拷贝构造函数。
(3) 拷贝构造函数的参数不能是传值传参的原因是:
如果拷贝构造函数的参数是传值传参,那么在拷贝构造函数被调用时,会尝试创建参数的副本,这又会调用拷贝构造函数,从而形成一个无限递归调用,导致栈溢出(而不是死递归,因为死循环通常指的是循环不会结束,而这里的递归调用会导致程序崩溃)。
(4)关于C语言和C++的传值传参方式的说明:
- C语言中的传值传参会直接进行值拷贝(即浅拷贝),无论是内置类型还是自定义类型(如结构体)。C语言没有拷贝构造函数的概念,因此不会因为传值传参而调用拷贝构造函数。
- C++中的传值传参对于内置类型也是直接进行值拷贝,而对于自定义类型则会调用拷贝构造函数。
注意:
- 拷贝构造函数使用传引用传参确实可以解决传值传参可能引起的无限递归问题。
- 拷贝构造函数使用传引用传参时,如果参数不是做输出型参数,那么引用参数应该使用const修饰,以确保不会修改传入的对象。const修饰的引用参数可以接收任何类型的实参,只要实参的权限不低于const。
2.3.特性3:若未显式定义(即未自定义拷贝构造函数),编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
注:编译器为类生成的默认拷贝构造函数会按照内存中存储的字节顺序,逐个字节地将一个对象的值复制到另一个对象中。这种拷贝方式被称为浅拷贝,也叫做位拷贝或值拷贝。简单来说,浅拷贝就是直接复制对象的所有数据成员,如果数据成员是指针,那么复制的是指针的值,而不是指针指向的数据。
1.编译器自动生成的默认拷贝构造函数
1.1.编译器生成的默认拷贝构造函数是会对内置类型数据进行处理
(1)案例1:日期类
注意:编译器生成的默认拷贝构造函数是对日期类的成员变量进行浅拷贝,而这个浅拷贝没有发生任何问题。
解析:我们不写自定义拷贝构造函数时,编译器生成的默认拷贝构造会对内置类型成员变量进行处理。由于日期类的成员变量都是内置类型数据,而且编译器生成的默认拷贝构造会对内置类型成员变量进行处理,所以日期类可以不需要自己去写拷贝构造。
(2)案例2:栈类
- 下面展示了即使栈类的所有成员变量都是内置类型,但对于内置类型的指针若是采用值拷贝则会发生以下问题:
解析:由于编译器生成的默认拷贝构造函数是按值拷贝方式处理内置类型的成员变量的,进而导致当内置类型指针成员变量发生值拷贝时会发生原对象和拷贝对象的成员变量指针都指向同一块空间但是这会引发以下问题:插入删除数据会互相影响、析构两次同一块空间会发生程序崩溃。
解决方式:为了解决下面问题,我们应该自己写一个深拷贝的拷贝构造函数来解决,而不是使用编译器自动生成默认拷贝构造函数(即浅拷贝)完成拷贝。
- 注意:
①编译器生成的默认拷贝构造函数是对栈类的成员变量进行浅拷贝,而这个浅拷贝发生了问题。所以我们说浅拷贝在有些场景时没有问题的(例:日期类),但浅拷贝在有些场景时有问题的(例如:栈类)。
②上面代码中栈st2先析构,然后栈st1后析构。
原因:栈是后进先出的特性,而构造、析构顺序也是保持后进先出的特性。先定义的对象先进栈先调用构造函数初始化,而后定义的对象后进栈后调用构成函数初始化,进而使得后定义对象先析构而先定义对象后析构(注释:出栈就意味着对象出作用域)。
③由于对象st1和对象st2的析构函数调用有先后顺序进而使得析构函数会把对象st2的成员变量指针_array设置为空指针nullptr但是并不会影响对象st1的成员变量指针_array的值,因为类中的成员变量指针_array是私有private,而对象st1和对象st2的员变量指针_array指向同一块空间进而使得两次调用析构会释放两次同一块空间进而使得程序发生崩溃。
- 代码优化(深拷贝代码)
①深拷贝:深拷贝的拷贝构造函数主要做的是让各自(成员变量指针)有各自(指向)的独立空间。
深拷贝是指复制对象时,不仅复制对象本身及其所有成员变量的值,还会复制成员变量所指向的动态分配的内存或资源。对于包含指针成员的对象,深拷贝会为指针成员分配新的内存,并将原始内存中的数据复制到新分配的内存中。这样,原始对象和复制对象将拥有独立的内存副本,互不影响。
图形解析:
②代码
//深拷贝的构造函数
Stack(const Stack& st)
{
_array = (DataType*)malloc(sizeof(DataType) * st._capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
exit(-1);
}
//把原对象(st1)存放数据的值拷贝到拷贝对象(st2)中
memcpy(_array, st._array, sizeof(DataType) * st._size);
_size = st._size;
_capacity = st._capacity;
}
③调试:
- 调用深拷贝构造函数前:
- 调用深拷贝构造函数后进入函数体内部进行调试:
- 调用完深拷贝构造函数后:
- 对象st1、对象st2出作用域后调用析构函数情况对象中占用的资源:
- 什么情况下需要写深拷贝构造函数:
只要我们自定义实现析构函数则就必须自定义实现深拷贝的拷贝构造函数。原因:我们一般只有在类对象的成员变量包含指针动态内存分配的指针时才会自定义实现析构函数,所以若想对此时的类对象进行拷贝操作则必须自定义实现深拷贝的拷贝函数。
总的来说,我们自定义实现析构函数来释放对象占用的资源则我们就必须实现深拷贝的拷贝构造函数。
注意:并不是说类的成员变量中有指针就必须实现深拷贝构造函数。只有当成员变量的指针指向了堆区(动态分配的内存)时,才必须实现深拷贝构造函数。如果指针指向的是栈内存或其他不需要动态管理的资源,那么可能就不需要深拷贝构造函数。
1.2.编译器生成的默认拷贝构造函数对自定义类型数据的处理方式是:调用它的拷贝构造函数。
(1)案例1:用两个栈实现队列
- 代码
#include<iostream>
using namespace std;
typedef int DataType;
class Stack
{
public:
//构造函数
Stack(size_t capacity = 10)
{
cout << "Stack(size_t capacity = 10)" << endl;
_array = (DataType*)malloc(capacity * sizeof(DataType));
if (nullptr == _array)
{
perror("malloc申请空间失败");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
//深拷贝的构造函数
Stack(const Stack& st)
{
_array = (DataType*)malloc(sizeof(DataType)*st._capacity);
if (nullptr == _array)
{
perror("malloc申请空间失败");
exit(-1);
}
//把原对象(st1)存放数据的值拷贝到拷贝对象(st2)中
memcpy(_array, st._array, sizeof(DataType)*st._size);
_size = st._size;
_capacity = st._capacity;
}
void Push(const DataType& data)
{
// CheckCapacity();
_array[_size] = data;
_size++;
}
//析构函数
~Stack()
{
cout << "~Stack()" << endl;
if (_array)
{
free(_array);
_array = nullptr;
_capacity = 0;
_size = 0;
}
}
//内置类型的成员变量
private:
DataType *_array;
size_t _size;
size_t _capacity;
};
class MyQueue
{
//成员函数(公有)
public:
//编译器:
//1.默认生成构造函数:
//1.1.对于内置类型成员变量不处理但是可以在成员变量声明的地方提供缺省值来解决;
//1.2.对于自定义类型成员变量会调用它的默认构造函数(即无参、全缺省的自定义默认构造函数、编译器自动生成默认构造函数)
//2.默认生成析构函数:
//2.1.对于内置类型成员变量不处理(因为对象是存放在栈区的,对象出作用域之后对象中的所有内置类型成员变量就会跟着销毁);
//2.2.对于自定义类型成员变量(例如:其他对象)会去调用它的析构函数(即自定义的析构函数)
//3.默认生成拷贝构造函数:
//3.1.对于内置类型成员变量:
//(1)若类中没有自定义析构函数则对于内置类型的成员变量进行值拷贝(或者叫做浅拷贝);
//(2)若类中写了自定义析构函数则必须自定义深拷贝构造函数对内置类型的成员变量进行初始化;
//3.2.对应自定义类型成员变量会去调用它的拷贝构造函数
//成员变量(私有)
private:
Stack _pushST;
Stack _popST;
int _size = 0;//缺省值
};
int main()
{
//创建队列q1
MyQueue q1;
//拷贝构造
MyQueue q2(q1);
return 0;
}
- 调试过程
①调用它的默认构造函数、默认拷贝构造函数
②调用它的析构函数
③代码结果
- 总结:
2.默认拷贝构造函数
(1)注意:
①默认拷贝构造函数叫做值拷贝或者浅拷贝。
②编译器默认生成的拷贝构造函数对内置类型的成员变量会完成值拷贝或者浅拷贝。
③编译器默认生成的拷贝构造函数对自定义类型的成员变量的处理方式:调用它的拷贝构造函数。
3.总结
(1) 拷贝构造函数的使用场景包括:
- 显式调用拷贝构造:当我们直接使用一个已存在的对象来初始化另一个新对象时,例如
Date d2(d1)
或Date d2 = d1
,此时会显式调用拷贝构造函数。 - 传值传参:当我们将自定义类型的对象作为函数参数传递,并且使用值传递的方式时,会调用拷贝构造函数来创建参数的副本。
- 传值返回:当函数返回一个自定义类型的对象,并且使用值返回的方式时,会调用拷贝构造函数来创建返回值的副本,因为函数需要创建一个临时对象来承载返回值。
(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;
}
~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;
}
为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用
尽量使用引用。
4.拷贝构造使用案例
案例1:实现一个计算给定日期向后推算指定天数的新日期的功能
解析:具体而言,就是根据当前的年份、月份和日期,计算出往后推算指定天数后的新日期(包括新的年份、月份和日期)。
(1)解题思路
①思路概述:
- 让当前日期的天数
_day
先加上day
天 - 若当前日期的天数达到该月的天数上限时,需要进行进位操作,类似于加法中的进位。
- 进位的过程首先发生在天数上,如果天数增加到超过当前月份的天数,就需要将超出部分转换为下一个月的天数,并进行月份的进位。
- 当月份增加到超过12时,需要进行年份的进位。
总的来说,天满了就往月进位,月满了就往年进位。(思路类似加法进位)
②注意事项:
- 天数的进位规则是不规则的,因为每个月的天数不同,可能是28天、29天、30天或31天。这种不规则性使得在计算天数进位时需要特别小心。
- 闰年的2月有29天,而平年的2月只有28天。这需要在进行日期计算时考虑闰年的情况。
- 获取每个月的天数是一个复杂的过程,但可以通过使用一个数组来简化这个问题。数组中存储了每个月的天数,这样就可以通过查找数组来快速获取任何月份的天数。
③代码实现:
- 在代码中,通过定义一个数组
monthArray
来存储每个月的天数,从而解决了获取每个月天数的难题。 - 通过
GetMonthDay
函数来获取特定年份和月份的天数,这个函数会检查是否是闰年的2月,并返回正确的天数。 - 在
GetAfterXDay
函数中,通过循环和条件判断实现了天数的进位,以及月份和年份的进位逻辑。
(2)代码
#include<assert.h>
#include<iostream>
using namespace std;
//案例1:实现一个计算给定日期向后推算指定天数的新日期的功能
class Date
{
//成员函数(公有)
public:
// 构造函数,用于初始化日期对象。
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//GetMonthDay的功能:获取某一年某一月的总天数
int GetMonthDay(int year, int month)
{
//判断输入的月份是否合法
assert(month > 0 && month < 13);
//把1年12月份每个月对应的天数存放到数组monthArray,由于我们是用月份month作为下标访问数组找到对应月份的天数,
//所以数组monthArray应开辟13个元素空间。注意:对于2月来说,这个数组只存放平年2月份的天数28天。
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
//判断是否为闰年二月份
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
{
return 29;//闰年二月份有29天
}
else
{
//无论month是那个月份(包括非闰年二月份),则返回month这个月份的总天数monthArray[month]。
return monthArray[month];
}
}
//GetAfterXDay的功能:计算当前日期向后推算指定天数后的新日期
Date GetAfterXDay(int x)
{
//拷贝构造方式1:(若没写拷贝构造函数,则我们只能手动完成临时对象tmp的所有成员变量的初始化)
/*Date tmp;
tmp._year = _year;
tmp._month = _month;
tmp._day = _day;*/
//Date tmp(*this);
//拷贝构造方式2:
//注意:题目没有要求GetAfterXDay(int x)函数修改原对象的年月日,所以这里必须定义临时对象,而且是算临时对象x天后的日期。
//利用拷贝构造函数拷贝this指向的原对象来初始化临时对象。(即先定义临时对象,然后利用拷贝构造把临时对象的所有成员变量的
//值初始化成原对象的对应成员变量的值)
//创建一个新的临时日期对象,初始值为当前日期
Date tmp = *this;//this指针指向原日期对象。tmp是个局部对象。
//Date tmp = Date(*this);//调用拷贝构造函数初始化临时对象的另一种写法
//1.先将临时日期对象的天数tmp._day增加指定的天数x
tmp._day += x;
//2.判断是否进位:如果增加后的天数tmp._day超过了当月tmp._month的天数,则需要调整临时日期对象的月份和年份,即要进位。
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
//进位过程:
//2.1.月进位过程:
//(1)当天数tmp._day超过当前月份tmp._month的最大天数时,通过减去当月天数GetMonthDay(tmp._year, tmp._month),
//将超出部分转换为下一个月的天数,并进行月份的增加。
tmp._day -= GetMonthDay(tmp._year, tmp._month);
//(2)月进位
++tmp._month;
//2.2.年进位过程:
//如果月份tmp._month增加到13,表示需要进入下一年的1月:
if (tmp._month == 13)//若tmp._month = 13,则年进位。
{
tmp._year++;//年进位。
tmp._month = 1;//tmp._month变成来年的1月。(月更新)
}
}
//返回计算出的新日期
return tmp;//注意:这里一定不是返回原日期对象*this,而是返回临时日期对象。
}
void Print()
{
//cout << _year << "/" << _month << "/" << _day << endl;
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
//成员变量(私有)
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 3);
//实现一个计算给定日期向后推算指定天数的新日期:
//案例:获取d1的100天之后的一个日期,这个日期包括年月日。
Date d2 = d1.GetAfterXDay(100);
d1.Print();
d2.Print();
return 0;
}
(3)测试
(4)优化
#include<assert.h>
#include<iostream>
using namespace std;
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 拷贝构造
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//获取某个月的天数
int GetMonthDay(int year, int month)
{
assert(month > 0 && month < 13);
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
{
return 29;
}
else
{
return monthArray[month];
}
}
//Date GetAfterXDay(int x)
//{
// /* Date tmp;
// tmp._year = _year;
// tmp._month = _month;
// tmp._day = _day;*/
// //Date tmp(*this);
// Date tmp = *this;
// tmp._day += x;
// while (tmp._day > GetMonthDay(tmp._year, tmp._month))
// {
// //进位
// tmp._day -= GetMonthDay(tmp._year, tmp._month);
// ++tmp._month;
// if (tmp._month == 13)
// {
// tmp._year++;
// tmp._month = 1;
// }
// }
// return tmp;
//}
//原日期对象不改变自己
//注意:Add(int x)不能用传引用返回,因为对象tmp是个在栈区上的局部对象,即tmp出了Add(int x)
//函数作用域之后局部对象tmp就销毁所以Add(int x)不能用传引用返回。
//传值返回
Date Add(int x)
{
Date tmp = *this;
tmp._day += x;
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
//进位
tmp._day -= GetMonthDay(tmp._year, tmp._month);
++tmp._month;
if (tmp._month == 13)
{
tmp._year++;
tmp._month = 1;
}
}
//由于这是传值返回而且返回值的类型是自定义类型,而且传值返回不是直接返回tmp(因为局部对象出了函数作用域之后就会销毁)而是
//把tmp拷贝给临时变量然后用临时变量(注:这个临时变量也是个对象,也可以叫做临时对象)进行返回。由于传值返回涉及拷贝而且这个
//拷贝是自定义类型的拷贝则编译器无法自己完成对自定义类型的拷贝,所以这里会调用拷贝构造函数来完成把局部对象tmp拷贝到临时变量中。
//注意:
//1.函数是否可以传引用返回和函数的形参无关。函数能不能使用传引用返回一定是取决于return 变量;中的变量出了函数作用域之后变量的空间是否还在,
//若在则函数可以使用传引用返回;若不在则函数只能使用传值返回。
//由于这里要调用拷贝构造函数来完成自定义类型的拷贝,使得这里的传值返回的消耗(注:这个消耗指的是调用的函数栈帧、新开辟的临时变量空间)
//无法避免。
return tmp;
}
//原日期对象改变自己
//传值返回:Date AddEqual(int x)//注意:这种写法在函数返回时也会调用拷贝构造函数,因为返回值在返回时涉及自定义类型的拷贝,所以会调用拷贝构造来完成返回值的拷贝。
//传引用返回:
Date& AddEqual(int x)
{
_day += x;
while (_day > GetMonthDay(_year, _month))
{
// 进位
_day -= GetMonthDay(_year, _month);
++_month;
if (_month == 13)
{
_year++;
_month = 1;
}
}
return *this;//即使AddEqual(int x)函数的隐式形参指针this出了AddEqual函数作用域之后会跟着AddEqual函数销毁,但是
//出了AddEqual(int x)函数作用域之后*this原对象还在(注:原对象还在的原因是原对象是在函数作用域之外的创建的或者说原对象在处于上一个函数栈帧的空间中)
//进而使得AddEqual(int x)函数可以使用传引用返回即Date& AddEqual(int x)。总的来说,是因为AddEqual(int x)的返回值原对象*this出了函数作用域之后还在,
//AddEqual(int x)才能使用传引用返回。
//由于Date& AddEqual(int x)是传引用返回,这样就减少自定义类型的拷贝,进而就不用调用拷贝构造函数来对自定义类型的拷贝,
//进而减少消耗。
}
void Print()
{
//cout << _year << "/" << _month << "/" << _day << endl;
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 3);
Date d2 = d1.Add(100);
Date d3 = d1.Add(150);
d1.Print();
d2.Print();
d3.Print();
//用引用返回
d1.AddEqual(200);
d1.Print();
return 0;
}
测试:
六、运算符重载函数(运算符重载)
注意:赋值重载属于运算符重载,下面会先了解运算符重载后再了解赋值重载。
1.C++提出运算符重载的作用
1.1.运算符重载作用
运算符重载是C++语言中的一项特性,它允许开发者为自定义类型(如类和结构体)定义新的运算符行为。以下是运算符重载的详细作用:
(1)作用1:提高代码可读性和表达力
运算符重载允许自定义类型使用熟悉的运算符,如+
、-
、*
、/
等,这使得代码更加直观和易于理解。例如,如果有一个表示日期的类,通过重载 ‘-’ 运算符,可以直接使用date1 - date2来表示两个日期相差多少天,而不是调用一个名为SubtractDates(date1, date2)的函数。
(2)作用2:让自定义类型可以使用运算符
在C语言中,运算符只能用于内置类型,这限制了运算符的适用范围。在C++中,运算符重载使得自定义类型可以像内置类型(如整数、浮点数等)一样使用运算符。这大大扩展了运算符的使用范围,使得自定义类型在操作上更加灵活和方便。
(3)总结
总之,运算符重载是C++为了提高代码的可读性、表达力以及扩展运算符的适用范围而提出的一项重要特性。它使得自定义类型能够像内置类型一样使用运算符,从而简化了代码的编写和理解。
1.2.对作用2进行解析
(1)对于运算符的操作数类型,编译器会自动识别内置类型的操作数,而无法识别自定义类型的操作数?为什么内置类型可以直接使用运算符,而自定义类型不可以直接使用运算符?
①解析:
- 对于内置类型的操作数,编译器能够自动识别并为其提供默认的运算符行为。这是因为内置类型(如int、float、double等)是语言本身定义的,编译器内置了对这些类型及其运算符操作的理解。例如,当编译器遇到表达式
1 + 2
时,它知道1
和2
都是内置的整数类型,因此可以执行加法运算。 - 对于自定义类型的操作数,情况就不同了。自定义类型(如类或结构体)是由程序员定义的,编译器不知道如何对这些类型的对象执行运算符操作。这是因为自定义类型的内部结构和行为不是编译器预先知道的。例如,如果我们有两个自定义的
Date
类的对象d1
和d2
,直接写d1 == d2
这样的代码,编译器不会知道怎么去比较这两个日期对象,因为它不知道Date
类型的对象比较规则。 - 总的来说,对于内置类型操作数可以直接使用运算符;但是自定义类型必须先定义实现对应运算符重载函数才能使用对应运算符,否则会发生编译错误。
②案例:下面代码说明了自定义类型的操作数在没有自定义实现运算符重载函数之前不能使用该运算符,否则会发生报错。
2.运算符重载函数介绍
2.1.运算符重载函数概念
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
2.2.运算符重载函数的格式
2.2.1.运算符重载函数的函数命名方式:关键字operator后面接需要重载的运算符符号。
(1)例:比较相等运算符重载函数的函数名: operator ==(注释:‘==’就是运算符)。
2.2.2.运算符重载函数的函数原型:返回值类型 operator操作符(参数列表)。
(1)比较相等运算符重载函数的函数模型
bool operator == (const Date& d1, const Date& d2)
(2)函数原型解析
①bool是返回值的类型;
②operator是定义或者声明运算符重载函数的关键字;
③‘==’就是运算符;
④运算符重载函数参数的个数由运算符决定,因为不同运算符有各自对应的不同操作数的个数,而运算符的操作数有多少个则对应的运算符重载函数的参数就有多少个。由于运算符‘==’只能有两个操作数进而使得运算符重载函数operator ==的参数个数只有两个即:const Date& d1,、const Date& d2。
(3)运算符重载函数的参数解析
例如,运算符重载函数bool operator == (const Date& d1, const Date& d2)的参数d1就是运算符‘==’的左操作数、参数d2就是运算符‘==’的右操作数。由于有些运算符操作数的顺序不重要但是有些运算符操作数的顺序很重要,所以C++才会规定运算符重载函数的参数那个是左操作符,那个是右操作符。例如,对‘-’运算符,操作数的顺序很重要,因为它决定了那个操作数是被减数,那个操作数是减数。
总结:运算符重载函数的第一个参数就是运算符的左操作数、第二个参数就是运算符的右操作数。
(4)运算符重载函数的参数个数解析(注意:操作符就是运算符)
运算符有几个操作数其运算符重载函数就一共有几个参数:
-
运算符的操作数确实决定了对应的运算符重载函数的参数个数。单目运算符有一个操作数,其重载函数有一个参数;双目运算符有两个操作数,其重载函数有两个参数。
-
关于三目运算符,C++中确实存在三目运算符,例如条件运算符
?:
。然而,C++不支持对?:
运算符进行重载,这是因为它已经是一个内置的、具有特殊语法的三目运算符。
总结:在C++中,大多数运算符重载函数都是单目或双目运算符的重载。三目运算符在C++中是特殊存在的,且不能被重载。因此,运算符重载函数的参数个数与运算符的操作数数量成正比。
(5)运算符重载函数的返回值类型解析:运算符重载函数的返回值类型是由运算符决定的。例如:下面证明了运算符重载函数的返回值类型由运算符决定:
①两个日期比较大小时运算符重载函数的返回值类型是bool:
d1 == d2;
d1 < d2;
②两个日期相减的时运算符函数重载函数的返回值是一个表示天数的整数类型int:
d1 - d2;
2.2.3.总结:
运算符重载函数的参数个数和返回值类型由运算符决定,但是有些运算符重载函数可以没有返回值。
2.3.operator==判断相等运算符重载函数的实现
注意:由于运算符重载函数的参数个数和运算符的操作数成正比,所以判断相等运算符重载函数bool operator == (const Date& d1, const Date& d2)的参数个数只有2个。由于返回值类型也是由运算符决定,所以判断相等运算符重载函数operator ==的返回值类型是bool。
2.3.1.判断相等运算符重载函数:错误代码、解决方式、正确代码
(1)错误代码
解析:若运算符重载函数是在全局域中定义则这个运算符重载函数就是全局函数,由于类的成员变量一般是私有private的,当全局运算符重载函数内部访问类成员变量就一定会发生报错。
结论:要想在运算符重载函数内部访问类成员变量时则一定要在类中定义实现运算符重载函数。
(2)解决方式:
①方式1:指定类Date的所有成员变量被public访问限定符限定。不过我们一般不采取这种方式,因为不安全,即会让类外面随意修改类成员变量(数据),则我们把成员变量封装在类中就没有意义了。
注意:方式1的代价是日期类Date的成员变量变成公有public,但是公有的成员变量是很不安全的。
②方式2:由于类成员函数是可以随意访问类成员变量,所以可以通过在类Date中定义成员函数来获取私有(private)的所有成员变量,然后由于类成员函数是公有使得可以在类Date的外面随意调用类Date的成员函数,所以我们就可以在类外面定义的全局运算重载函数operator==然后在operator==函数内部通过调用成员函数(注意:这个成员函数是用来获取类Date私有成员变量的)来判断两个日期类对象是否相等。注意:这种方式建议也不要采取,因为定义多个成员函数来获取成员变量,使得太过麻烦,我们主要还是采用下面的方式3进行解决。
#include <iostream>
using namespace std;
class Date
{
public:
//构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//下面的GetYear、GetMonth、GetDay是用于获取私有成员变量的值
int GetYear() const
{
return _year;
}
int GetMonth() const
{
return _month;
}
int GetDay() const
{
return _day;
}
// 其他成员函数...
private:
int _year;
int _month;
int _day;
};
//类外部的operator==运算符重载函数
bool operator==(const Date& d1, const Date& d2)
{
//使用getter函数来比较两个Date对象的私有成员变量
return (d1.GetYear() == d2.GetYear() &&
d1.GetMonth() == d2.GetMonth() &&
d1.GetDay() == d2.GetDay());
}
④方式3:把bool operator==(const Date& d1, const Date& d2)在日期类中声明成友元函数,就可以在全局域的operator==运算符重载函数的内部访问日期类私有private的成员变量。
#include <iostream>
using namespace std;
class Date
{
public:
//友元函数
friend bool operator==(const Date& d1, const Date& d2);
//构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// 其他成员函数...
private:
int _year;
int _month;
int _day;
};
//在类外定义这个友元函数operator==运算符重载函数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year &&
d1._month == d2._month &&
d1._day == d2._day;
}
③方式4:在日期类Date中直接定义operator==运算符重载函数,这样运算符重载函数operator==的内部就可以访问类Date中的所有成员变量。
- 在类中错误定义operator==运算符重载函数的方式:在日期类Date中直接定义bool operator==(const Date& d1, const Date& d2)运算符重载函数但是这样编译器会发生报错,报错原因是bool operator==(const Date& d1, const Date& d2)函数的参数太多了,如下图所示:
解析:定义在类Date中的bool operator==(const Date& d1, const Date& d2)之所以会发生报错是因为operator==(const Date& d1, const Date& d2)有一个隐式参数Date*this,只不过这个隐式参数Date*this是编译器自动添加的而不用我们自己写,所以在编译期间编译器会自动把bool operator==(const Date& d1, const Date& d2)转化为bool operator==(Date* this,const Date& d1, const Date& d2)进而使得参数过多而发生报错。
- 在类中正确定义operator==运算符重载函数的方式:
2.4.operator<运算符重载函数实现
(1)代码:operator<运算符重载函数用于比较两个日期对象d1
和d2
的大小。具体来说,当第一个日期对象小于第二个日期对象时,operator<函数应该返回true
;否则返回false
。
解析:以下是实现<
运算符重载的思路:
-
按顺序比较:比较两个日期对象的大小需要按照年、月、日的顺序进行比较。这是因为日期的每个部分都有不同的权重,年是最重要的,然后是月,最后是日。
-
年份比较:首先比较两个日期对象的年份。如果第一个日期的年份小于第二个日期的年份,那么第一个日期小于第二个日期。
-
月份比较:如果两个日期的年份相同,那么接下来比较月份。如果第一个日期的月份小于第二个日期的月份,那么第一个日期小于第二个日期。
-
日期比较:如果两个日期的年份和月份都相同,那么最后比较日期。如果第一个日期的日期小于第二个日期的日期,那么第一个日期小于第二个日期。
-
返回结果:如果在上面任何比较步骤中发现第一个日期小于第二个日期,则返回
true
。如果所有比较步骤都表明第一个日期不小于第二个日期,则返回false
。
//d1 < d2
bool operator<(const Date& d)//第一个参数是this指针、第二个参数是d
{
//写法1与写法2的思路都是:把所有d1 < d2的情况都找出来,若有一个情况成立则d1 < d2就为真。
//写法1->可读性强
/*if (_year < d._year)//年小就是小
{
return true;
}
else if (_year == d._year && _month < d._month)//年相等,月小就是小
{
return true;
}
else if (_year == d._year && _month == d._month && _day < d._day)//年月相等,天小就是小
{
return true;
}
else
{
return false;
}*/
//写法2
//比较日期大小必须从年->月->天的顺序比较。
return _year < d._year//年比你小
|| (_year == d._year && _month < d._month)//年相等情况下月比你小(注意:只有在年相等的情况下,去比月才有意义)
|| (_year == d._year && _month == d._month && _day < d._day);//年月相等情况下天比你小(注意:只有在年月相等的情况下,去比天才有意义)
}
(2)测试
注意: bool布尔类型true(真)的打印结果为1,false(假)的打印结果为0。
2.5.operator>、operator>=、operator<=、operator!=的运算符重载函数实现
(1)代码
#include<iostream>
using namespace std;
// 运算符重载
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
// d1 == d2 <=> d1.operator==(d2)
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// d1 < d2
bool operator<(const Date& d)
{
/* if (_year < d._year)
{
return true;
}
else if (_year == d._year && _month < d._month)
{
return true;
}
else if (_year == d._year && _month == d._month && _day < d._day)
{
return true;
}
else
{
return false;
}*/
return _year < d._year
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && _day < d._day);
}
//注意:下面的所有运算符重载函数的写法在任何类型的类都适用->思路:由于类中的成员函数可以互相调用,所以只要
//完成operator<与operator= 或者 完成operator>与operator=的运算符重载函数,就可以通过复用把operator<=、operator>=、operator!=都写出来。
// d1 <= d2
bool operator<=(const Date& d)
{
//复用:通过转换成调用其它已经实现的成员函数来完成本成员函数的实现
return *this < d || *this == d;//=>d1 < d2 || d1 == d2
}
// d1 > d2
bool operator>(const Date& d)
{
//大于=>转换成:小于等于的逻辑取反
return !(*this <= d);//=>!(d1 <= d2)
}
bool operator>=(const Date& d)
{
//大于等于=>转换成:小于逻辑取反
return !(*this < d);//=>!(d1 < d2)
}
bool operator!=(const Date& d)
{
//不等与=>转换成:等于逻辑取反
return !(*this == d);//=>!(d1 == d2)
}
private:
int _year;
int _month;
int _day;
};
(1)测试
3.运算符重载注意事项
3.1.不能通过连接其他符号来创建新的操作符:比如operator@
3.2.重载操作符必须有一个类类型参数
(1)对重载操作符必须有一个类类型参数进行解析:
①运算符(操作符)的操作数必须至少有一个是自定义类型(即对象)。例如:d2 - d1(运算符‘-’的两个操作数都是自定义类型)、d1 + 5000(运算符‘+’的一个操作数是自定义类型、另一个操作数是内置类型)、d2 = d1(运算符‘=’的两个操作数都是自定义类型)。
②以双目操作符(即运算符)为例进行说明。由于C++规定运算符重载时运算符的操作数至少有一个是自定义类型(例:对象),进而使得运算符重载函数的形参可以全都是内置类型或者是有一个自定义类型。
例1:bool operator==(const Date& d)->解析:operator==运算符重载函数的左操作数是*this,右操作数是d。operator==运算符重载函数的形参有一个是内置类型 Date*this,有一个是自定义类型const Date& d。
例2:Date& operator+=(int day))->解析: operator+=运算符重载函数的左操作数是*this,右操作数是day。 operator+=运算符重载函数的形参都是内置类型即一个是Date*this,另一个是int day。
总结:不管运算符重载函数是在类中定义还是在全局域中定义,运算符重载函数的操作数必须至少有一个是自定义类型(例如:对象)。运算符重载函数的所有操作数不一定都是自定义类型,但是运算符重载函数的所有参数有可能都是内置类型。
3.3.用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不能改变其含义
解析: 不管运算符重载函数是在类中定义还是在全局域中定义,运算符重载函数的所有操作数都不能全都是内置类型,而是至少有一个自定义类型(例如:对象),否则会发生编译报错,如下图所示。
3.4.作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this
解析:当在类中定义运算符重载函数时由于存在隐式形参this指针导致我们运算符重载函数的形参个数看起来比实参个数少1个。
3.5. .* :: sizeof ?: .注意以上5个运算符不能重载。这个经常在笔试选择题中出现。
解析:不能使用 ‘ .* ’ (点星运算符) 、 ‘ :: ’ (域作用限定符)、sizeof(关键字)、‘ ?: ’ (三目选择运算符)、‘ . ’ (成员访问运算符) 以上5个运算符来实现运算符重载函数。
注意:C语言的所有运算符没有运算符重载的概念,C语言的所有运算符都可以直接通过编译转换成指令的。
七、赋值运算符重载函数(赋值重载)
注意:赋值运算符重载指的是把一个对象赋值给另一个对象(例如:d1 = d2),赋值就是拷贝,而把da2赋值给d1表示的是把d2拷贝给d1,则在这拷贝过程中就要用到赋值运算符‘=’。
1.日期类Date& operator=(const Date& d)运算符重载函数实现过程
1.1.operator=(const Date d)赋值运算符重载函数可以传值传参
(1)赋值运算符重载函数可以传值传参而不引发无穷递归的原因:
当我们operator=赋值运算符重载函数传参调用operator=(const Date d)时会存在对自定义类型参数的拷贝,而自定义类型参数拷贝就要调用拷贝构造函数来完成形参对实参的拷贝,但是由于拷贝构造函数是用传引用传参来实现的才会使得operator=赋值运算符重载函数传参并完成参数拷贝后就立马能调用并执行operator=(const Date d)函数的函数体,最终使得赋值运算符重载函数的传值传参才没有引发无穷递归的问题。总的来说,是因为拷贝构造函数是用传引用传参实现的最终导致赋值运算符重载函数operator=可以用传值传参实现而不引发无穷递归。
(2)赋值运算符重载函数使用传值传参、传值返回的缺陷
传值传参时形参的拷贝要调用拷贝构造、传值返回时要创建临时变量返回也涉及调用拷贝构造,所以传参最好使用传引用传参,而函数返回时若返回值出了函数作用域之后没有被销毁则最好使用传引用返回,不管是传引用传参还是传引用返回都是为了减少拷贝进而使得不用调用拷贝构造函数。
1.2.Date& operator=(const Date& d)赋值运算符重载函数用传引用传参、传引用返回实现过程:
注意:赋值运算符重载函数使用传引用传参目的是减少参数拷贝。
(1)代码1:void operator=(const Date& d)
注意:这个代码仍然有缺陷,因为赋值运算符'='是支持连续赋值,但是由于代码1没有返回值使得代码1不支持连续赋值。
//代码1:
void operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
①测试:
②图形解析:下面证明了由于代码1operator=函数没有返回值使得代码1无法支持连续赋值操作。
注意事项:
①连续赋值的执行顺序是从右往左进行赋值的;
②赋值表达式规定:赋值操作完后左操作数会作为表达式(即左操作数 = 右操作数)的返回值。
解析d3 = d1 = d2连续赋值的过程:
①表达式d1=d2的返回值为左操作数d1,其值为刚刚从右操作数d2那里复制过来的新值。接着,该返回值d1又会作为新的右操作数参与下一个赋值表达式d3=d1的求值,从而实现整个连续赋值过程的串联执行。
②d3 = d1 = d2 => d3 = d1.operator=(d2)。由于d3 = d1.operator=(d2)是个赋值操作,,所以operator=函数必须有返回值,若是没有返回值则就无法利用d1.operator=(d2)对d3进行赋值操作。
结论:若运算符支持连续赋值,则用该运算符实现的运算符函数重载就必须有返回值,而且可以的话最好使用传引用返回,这样就可以减少拷贝。
(2)代码2:Date operator=(const Date& d)
解析:代码2为了解决代码1不支持连续赋值操作从而让operator=函数进行返回。
//代码2:
//赋值运算符重载函数
//传值返回
Date operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;//返回左操作数的引用。
}
//解析operator=函数的返回值是左操作数的过程:
//1.d3 = d1 = d2 => d3 = d1.operator=(d2)。由于d3 = d1.operator=(d2)是个赋值操作,
//所以operator=函数必须有返回值,若是没有返回值则就无法利用d1.operator=(d2)对d3进行赋值操作。
//2.赋值表达式规定:赋值操作完后左操作数会作为表达式(即左操作数 = 右操作数)的返回值。
//3.由于d1 = d2=>d1.operator=(d2)而且赋值表达式d1 = d2中的左操作数d1会作为表达式的返回值,所以
//operator=函数的返回值才会是左操作数,而operator=函数的左操作数就是*this。
//解析operator=使用传值返回的缺陷:operator=函数传值返回时会创建临时变量而这一过程涉及自定义类型
//的拷贝则会调用拷贝构造函数,所以为了减少拷贝这里建议operator=使用传引用返回.
//代码2的优化
//传引用返回
Date& operator=(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;//出了作用域之后*this(对象)还在,所以这里可以使用传引用传参。
}
//注意:operator=函数返回一个返回值的目的是支持连续赋值,保持运算符'='的特性。运算符'='是支持连续赋值的。
代码2优化后的测试:
(3)代码3:Date& operator=(const Date& d)
注意:优化后的代码2仍然有缺陷:虽然operator=函数支持自己拷贝自己,但是自己拷贝自己
没有意义。在浅拷贝中自己拷贝自己没有问题,但是在深拷贝中自己拷贝自己就很有问题。所以为了解决优化后的代码2的问题,提出以下解决方式:
//代码3:
//赋值运算符重载函数
//传引用返回、传引用传参
//返回值是为了支持连续赋值,保持运算符的特性。
Date& operator=(const Date& d)
{
//这种写法在深拷贝时很有用。
//&d是右操作数的地址,this是左操作数的地址。
//当右操作数和左操作数的地址一样则我们就不执行自己拷贝自己。
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;//返回左操作数
}
结论:赋值操作的本质是将一个对象的值复制到另一个已经存在的对象上,这个过程可以是浅拷贝或深拷贝,取决于对象的类型和成员变量的特性。当赋值运算符的操作数是自定义类型(例如:类)时,如果类的成员变量不包含指向动态内存分配的指针,可以使用编译器生成的默认赋值运算符重载函数,或者根据需要自定义实现浅拷贝的赋值运算符重载函数。如果类的成员变量包含指向动态内存分配的指针,则我们必须自定义实现深拷贝的赋值运算符重载函数。
代码3测试:
2.赋值运算符重载格式
- 参数类型:const T&,传递引用可以提高传参效率
- 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
- 检测是否自己给自己赋值
- 返回*this :要复合连续赋值的含义
#include <iostream>
using namespace std;
class Date
{
public:
//构造函数
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//浅拷贝的拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._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;
};
3.赋值运算符只能重载成类的成员函数不能重载成全局函数
4. 用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
4.1.编译器自动生成的默认赋值运算符重载函数
(1)结论:
①当赋值运算符的操作数是自定义类型(例如:类)时,如果类的成员变量不包含指向动态内存分配的指针,可以使用编译器生成的默认赋值运算符重载函数,或者根据需要自定义实现浅拷贝的赋值运算符重载函数(也可以不自定义实现,直接用编译器生成的)。如果类的成员变量包含指向动态内存分配的指针(例如:栈类),则我们必须自定义实现深拷贝的赋值运算符重载函数。
②编译器生成默认赋值运算符重载函数,对于内置类型成员变量(注:无论成员变量是否包含指向动态内存分配的指针)是直接执行浅拷贝(值拷贝),对于自定义类型成员变量是调用它的赋值运算符重载函数(包括:编译器自动生成、自定义实现的浅拷贝或者深拷贝的赋值运算符重载函数)。
③什么时候自定义实现深拷贝的赋值运算符重载函数:
- 如果一个类没有自定义实现析构函数,这通常意味着类的成员变量不需要特殊的资源管理。因此,可以依赖编译器生成的默认赋值运算符重载函数来执行浅拷贝,无需自定义实现。
- 如果一个类自定义实现了析构函数,这通常是为了管理类成员中动态分配的内存或其他需要手动释放的资源。在这种情况下,除了自定义析构函数外,还必须自定义实现深拷贝的赋值运算符重载函数,以确保在对象赋值时能够正确地复制和管理这些资源。
(2)案例
①案例1:日期类
①案例2:栈类
既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝(浅拷贝)了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必
须要实现深拷贝的赋值运算符重载函数。
4.2.拷贝构造和赋值重载的判断
八、日期类的实现
注意:日期类的声明和定义是分离的
日期类成员函数的解析
1.日期+=天数:Date& operator+=(int day)
1.1.日期+=天数的思路
图形解析:
①让原日期对象的天数 _day
先加上 day
天:
- 首先,我们将传入的天数
day
加到原日期对象的天数_day
上。
②当原日期对象的天数 _day
达到该月 _month
的天数上限时,需要进行进位操作:
- 如果
_day
超过了当前月份的最大天数(由GetMonthDay
函数确定),我们需要进行进位处理。
③进位过程:
- 当
_day
超过当前月份的最大天数时,我们从_day
中减去当前月份的天数,并将_day
设置为剩余的天数。 - 然后,我们递增
_month
以指向下一个月份。 - 如果
_month
变成了 13(即超出了 12 月的范围),这意味着我们需要进入下一年的一月,因此我们递增_year
并将_month
重置为 1。
④注意事项:
- 天数的进位规则是不规则的,因为每个月的天数不同,可能是28天、29天、30天或31天。这种不规则性使得在计算天数进位时需要特别小心。
- 闰年的2月有29天,而平年的2月只有28天。这需要在进行日期计算时考虑闰年的情况。
- 获取每个月的天数是一个复杂的过程,但可以通过使用一个数组来简化这个问题。数组中存储了每个月的天数,这样就可以通过查找数组来快速获取任何月份的天数。
1.2.写法1
(1)代码
//GetMonthDay的功能:获取某一年某一月的总天数
int Date::GetMonthDay(int year, int month)
{
//判断输入的月份是否合法
assert(month > 0 && month < 13);
//把1年12月份每个月对应的天数存放到数组monthArray,由于我们是用月份month作为下标访问数组找到对应月份的天数,
//所以数组monthArray应开辟13个元素空间。注意:对于2月来说,这个数组只存放平年2月份的天数28天。
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
//判断是否为闰年二月份
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
{
return 29;//闰年二月份有29天
}
else
{
//无论month是那个月份(包括非闰年二月份),则返回month这个月份的总天数monthArray[month]。
return monthArray[month];
}
}
//日期+=天数
//注:运算符‘+=’会改变左操作数的值而不会改变右操作数的值
Date& Date::operator+=(int day)
{
//1.先将原日期对象的天数_day增加指定的天数day
_day += day;
//2.判断是否进位:如果增加后的天数_day超过了当月_month的天数,则需要调整原日期对象的月份和年份,即要进位。
while (_day > GetMonthDay(_year, _month))
{
//进位过程:
//2.1.月进位过程:
//(1)当天数_day超过当前月份_month的最大天数时,通过减去当月天数GetMonthDay(_year, _month),
//将超出部分转换为下一个月的天数,并进行月份的增加。
_day -= GetMonthDay(_year, _month);
//(2)月进位
_month++;
//2.2.年进位过程:
//如果月份_month增加到13,表示需要进入下一年的1月:
if (_month == 13)
{
++_year;//年进位。
_month = 1;//_month变成来年的1月。(月更新)
}
}
//返回计算出的新日期
return *this;
}
//注意事项:
//1.左操作数 += 右操作数中的 ‘+=’ 运算符特性会改变左操作数的值,所以 operator+= 函数的实现也必
//须是改变左操作数。而 void Date::operator+=(int day) 函数只是支持改变左操作数的值,但是这个
//函数没有返回值,使得 void Date::operator+=(int day) 函数不能满足 ‘+=’ 运算符可以连续赋值的特性。
//2.由于 ‘+=’ 运算符支持连续赋值,所以 operator+= 函数必须有返回值。由于出了 operator+= 函数
//作用域之后 *this(对象)没有销毁, 所以 operator+= 函数可以使用传引用返回即
//Date& Date::operator+=(int day)。
//3.Date& Date::operator+=(int day) 函数的参数 int day 不用传引用传参的原因是:用传引用传参
//的目的是为了解决自定义类型的参数拷贝会陷入无穷递归的问题。但是 operator+= 函数的参数 int day
//是内置类型,而内置类型的参数拷贝编译器可以自动且高效地完成,而且参数 int day 不是作为输出型参
//数,所以 operator+= 的实现没有必要使用传引用传参。
(2)代码解析
①增加天数:
_day += day,
这一步将传入的天数day累加 _day
上。
②判断是否需要进位:
while (_day > GetMonthDay(_year, _month)),
这个循环用于检查累加后的 _day
是否超过了当前月份 _month
的最大天数。如果是,则需要执行进位操作。
③执行月份进位:
_day -= GetMonthDay(_year, _month),
将 _day
减去当前月份的天数,得到超出当前月份的天数,这将是下一个月的起始天数。
_month++,
将 _month
递增,指向下一个月。
④检查年份进位:
if (_month == 13),
如果 _month
达到 13,表示已经超出了 12 月的范围,需要进位到下一年。
_year++,
年份递增,表示进入下一年。
_month = 1,
将月份重置为 1,表示下一年的一月。
⑤循环继续:
如果 _day
仍然大于下一个月的天数,则循环继续,直到 _day
不再需要进位为止。
⑥函数结束
return *this;
:返回对当前对象的引用,以支持连续赋值,例如 d1 += 5 += 10
。
(3)测试
(4)结论
①在C++中,如果运算符支持连续赋值,那么用该运算符实现的运算符重载函数必须有返回值,而且这个返回值是运算符的左操作数,当返回值出作用域之后没有被销毁则最好使用传引用返回,这样可以减少拷贝。
②运算符有什么特性,则用该运算符实现的运算符重载函数就必须满足该运算符的所有特性。案例如下:
- 对于支持连续赋值的运算符,如 ‘=’ 和 ‘+=’,其重载函数应返回左操作数的引用。
- 赋值运算符 ‘=’ 的重载函数在改变左操作数的同时,不应改变右操作数的值,并返回左操作数的引用以支持连续赋值。
- 复合赋值运算符 ‘+=’ 的重载函数应改变左操作数的值,而不改变右操作数的值,并返回左操作数的引用以支持连续赋值。
- 对于不支持连续赋值的运算符,如 ‘+’,其重载函数应返回一个新对象,该对象是操作数相加的结果,而不改变原有操作数(即左右操作数)的值。
1.3.写法2
写法1的问题:当日期+=天数中的天数是个负数时,写法1是不支持的,所以下面的写法2就是为了解决写法1的出现的问题。注意:写法2下面会详细提到,这里先展示一下。
//传引用返回
Date& Date::operator+=(int day)
{
//若day是个负数,则日期+=天数(负数)相当于日期-= -天数(注:-天数是正数),则此时应该调用Date& operator-=(int day)
if (day < 0)
{
//复用Date& operator-=(int day)
*this -= -day;
return *this;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
1.3.写法3
代码思路:先自定义实现operator+函数后,然后在operator+=内部复用operator+函数从而实现operator+=函数。注意:下面会提到operator-运算符重载函数如何实现。
//d1 + 100
Date Date::operator+(int day)
{
//若day < 0,则operator+就会复用operator-。
if (day < 0)
return *this - -day;
Date tmp(*this);
tmp._day += day;
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
tmp._day -= GetMonthDay(tmp._year, tmp._month);
tmp._month++;
if (tmp._month == 13)
{
++tmp._year;
tmp._month = 1;
}
}
return tmp;
}
// d1 += 100
Date& Date::operator+=(int day)
{
*this = *this + day;
return *this;
}
2.日期+天数:Date operator+(int day)
2.1.写法1
(1)代码
Date Date::operator+(int day)
{
Date tmp(*this);
tmp._day += day;
while (tmp._day > GetMonthDay(tmp._year, tmp._month))
{
tmp._day -= GetMonthDay(tmp._year, tmp._month);
tmp._month++;
if (tmp._month == 13)
{
++tmp._year;
tmp._month = 1;
}
}
return tmp;
}
注意事项:
①运算符重载函数的参数类型是什么、有无返回值、返回值类型是什么是根据需求定的。
②运算符重载函数的返回值是void / 传值返回 / 传引用返回是由需求而定的,返回值不是一定都是传引用返回。
代码解析:
①C++规定,双目操作符的运算符重载函数的第一个参数就是左操作数,而第二个操作就是右操作数。所以operator+(int day)运算符重载函数的左操作数是Date
对象(即*this
),右操作数是整形天数day,operator+(int day)返回的是一个Date
对象,这是左右操作数相加的结果。
② 运算符 ‘+’ 的确不会改变其左右操作数的值。因此,在实现operator+(int day)
时,为了保持左操作数*this
不变,我们创建了一个临时对象tmp
,它是*this
的一个拷贝。然后,我们在tmp
上执行增加天数的操作,而不影响原始的Date
对象。
③由于运算符 ‘+’ 支持连续赋值,重载的operator+
函数必须返回一个值。在operator+
函数中,函数返回的是在*this
的基础上加上day
天后得到的tmp
对象(即返回的是左右操作数相加之后的结果)。由于tmp
是一个局部对象,它在函数作用域结束时会被销毁,因此这里使用的是传值返回,而不是使用传引用返回。
(2)测试:
2.2.错误代码
(1)代码
①声明:
②定义:
③测试:
(2)解析即使operator+内部的临时对象被static修饰但是operator+也不能用引用返回的原因
①调试过程如下 :
②原因:从上面调用结果可以看出问题出现在临时对象tmp中,因为Date& operator+(int day)函数的隐式形参this指针指向的对象日期是2023/2/4,但是对象tmp的日期是2023/5/15。由于在函数内部定义的静态变量只会被创建一次和初始化一次,所以当我们多次调用Date& operator+(int day)时由于静态变量static Date tmp(*this)只会在第一次调用Date& operator+(int day)时才会被创建和初始化,从第二次开始调用Date& operator+(int day)函数时静态变量static Date tmp(*this)并不会被创建和初始化而是去访问第一次创建静态变量所在的空间,进而使得第二次调用Date& operator+(int day)函数时临时对象tmp的日期与隐式形参this指针指向的对象日期不同的原因,所以这里不能为了能够使用传引用返回而让static修饰临时对象tmp,因为这会导致operator+函数的结果不是我们想要的。注意:使用静态变量时要慎用不然会很容易给自己招惹麻烦。
2.3.写法2
注意:由于operator+函数和operator+=函数的写法很相似,而相似的代码我们是不要重复写的而是应该调用operator+=函数去实现operator+函数,所以写法2是写法1的优化。
(1)代码
代码思路:先自定义实现operator+=函数后,然后在operator+内部复用operator+=函数从而实现operator+函数。
Date& Date::operator+=(int day)
{
if (day < 0)
{
*this -= -day;
return *this;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
// d1 + 100
Date Date::operator+(int day) const
{
Date tmp(*this);
tmp += day;
return tmp;
}
3.对实现operator+、operator+=两种方式进行总结
3.1.方式1:先自定义实现operator+,然后operator+=函数内部复用operator+从而实现operator+=函数。
注意:不考虑拷贝构造函数,operator+=函数的实现是通过调用两个函数,一个是operator+函数,另一个是编译器生成的默认赋值重载函数。
3.2.方式2:先自定义实现operator+=,然后operator+函数内部复用operator+=从而实现operator+函数。
注意:不考虑拷贝构造函数,operator+函数的实现只调用一个函数即operator+=函数。
3.3.总结
(1)方式1与方式2中这两种方式那种好?解答:方式2好。原因如下
①方式1和方式2中operator+函数的实现都是调用的两次拷贝构造函数和调用一次operator+=函数,则方式1和方式2中的operator+函数实现没有区别;两次拷贝构造函数指的是:第一次是直接调用拷贝构造函数Date tmp(*this)、第二次是传值返回调用拷贝构造函数。
②方式2中的自定义实现的operator+=函数没有调用拷贝构造函数。但是方式1中operator+=函数的实现有调用拷贝构造函数的行为即operator+=函数中的*this = * this + day中的* this + day会调用operator+函数而operator+函数中有调用拷贝构造函数的行为从而使得方式1的operator+=函数有调用拷贝构造函数的行为。还有方式2自定义实现的operator+=函数中没有调用编译器生成的默认赋值重载函数,但是方式1中的operator+=函数内部的*this = * this + day中调用了编译器生成的默认赋值重载函数。
③从上面①②可知使用方式2实现operator+=,operator+函数更加好。
结论:先自定义实现operator+=,然后operator+函数内部复用operator+=从而实现operator+函数,这样就可以减少拷贝。
4.operator++函数的实现
注意:operator++函数有两种函数重载即前置++、后置++,但是我们默认使用前置++的运算符重载函数,因为前置++运算符重载函数效率比后置++运算符重载函数效率高。
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//前置++:返回+1之后的结果
//注意:this指向的对象函数结束后不会销毁,故以引用方式返回提高效率
Date& operator++()
{
_day += 1;
return *this;
}
//后置++:
//前置++和后置++都是一元运算符,为了让前置++与后置++形成能正确重载
//C++规定:后置++重载时多增加一个int类型的参数,但调用函数时该参数不用传递,编译器自动传递
//注意:后置++是先使用后+1,因此需要返回+1之前的旧值,故需在实现时需要先将this保存一份,然后给this + 1
//而temp是临时对象,因此只能以值的方式返回,不能返回引用
Date operator++(int)
{
Date temp(*this);
_day += 1;
return temp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d;
Date d1(2022, 1, 13);
d = d1++; // d: 2022,1,13 d1:2022,1,14
d = ++d1; // d: 2022,1,15 d1:2022,1,15
return 0;
}
4.1.前置++运算符重载函数
(1)实现思路
- 前置++运算符重载函数
operator++()只有一个操作数即隐式指针this指向的
的当前(日期类)对象。 - 该函数的作用是直接在当前日期对象上增加一天,即增加成员变量
_day
的值。 - 由于前置++返回的是递增后的对象本身,因此函数返回类型为
Date&
,即返回当前对象的引用。 - 由于返回的是当前日期对象的引用,且当前日期对象在函数外部仍然有效,因此可以使用传引用返回,避免了不必要的拷贝。
(2)代码
//传引用返回
Date& Date::operator++()
{
*this += 1; //增加天数
return *this; //返回递增后的日期对象
}
//注意:由于前置++运算符重载函数operator++()返回的是*this(日期类对象),而对象*this出了operator++()函数的作用域之后没有被销毁从而使得operator++()函数可以使用传引用返回。
4.2.后置++运算符重载函数
(1)实现思路
- 编译器为了区分前置++和后置++,后置++运算符重载函数
operator++(int)
需要有一个额外的占位参数int
,该参数没有实际作用,仅用于让前置++运算符重载函数和后置++运算符重载函数构成函数重载。 - 后置++运算符重载函数首先需要保存递增前的对象状态,因此创建了一个临时对象
tmp
,它是当前对象的拷贝。 - 然后在当前对象上执行递增操作。
- 由于后置++需要返回递增前的对象状态,因此函数返回的是临时对象
tmp
,而且由于对象tmp出了operator++(int)函数作用域之后就会销毁,则operator++(int)只能使用
传值返回,意味着会调用拷贝构造函数创建tmp的临时变量进行返回。
(2)代码
Date Date::operator++(int)
{
Date tmp(*this); //保存递增前的对象状态
*this += 1; //在当前对象上递增
return tmp; //返回递增前的日期对象
}
4.3.前置++效率高,还是后置++效率高?
(1)在效率方面,前置++运算符通常比后置++运算符更高效,尤其是对于自定义类型。
在考虑前置++和后置++操作符的效率时,对于像int、char这样的内置类型,两者的效率差异不大,因为编译器的优化通常能够消除这种差异,使得两者在大多数情况下执行相同的操作。但对于自定义类型,前置++操作符更为高效。这是因为前置++直接返回递增后的对象引用,不涉及任何拷贝操作。而后置++操作符必须返回递增前的对象副本,这要求创建一个临时对象并调用拷贝构造函数进行拷贝。因此,对于自定义类型的成员变量,推荐使用前置++操作符,以避免不必要的拷贝,从而提高代码的执行效率。
结论:对于自定义类型的成员变量最好使用前置++,因为前置++在返回时没有拷贝。
4.4.编译器调用前置++、后置++的方式
注意:调用后置++时编译器会自动识别后置++函数模型的并做出特殊处理。
(1)从下面调试过程可知,编译器自己回调用对应的前置++、后置++的运算符重载函数,如下所示:
①前置++的调用
②后置++的调用
(2)编译器处理前置++、后置++的方式
①注意:在调用后置++函数时编译器自己会传一个整形值(注:这个值是编译器自己确定的,不一定传的是0,而且这个值在函数内部不会被使用),而编译器传参的目的是为了能够匹配调用到Date operator++(int)函数而不是去调用前置++运算符重载函数Date operator++()。
总的来说,编译器会自动传递一个整数值(通常是0)给 operator++(int)
函数,这样就能匹配到后置++的重载版本。
②在C++中,后置增量运算符 ++
的重载版本接受一个 int
类型的参数,但这个参数的名称在函数定义中通常不给出,因为它不会被使用。这个参数的唯一目的是为了区分前置和后置++运算符的重载版本。以下是详细说明:
后置++函数参数int的作用:
- 区分重载版本: C++语言规范要求后置++运算符的重载版本必须接受一个额外的参数,以区分前置和后置++运算符。由于前置++运算符不接收任何参数,因此通过添加一个
int
类型的参数,编译器可以确定调用的是后置++运算符的重载版本。 - 不使用参数: 在后置++运算符的重载函数体内部,这个
int
参数实际上是不被使用的。因此,通常不会为这个参数指定名称,只写出参数类型int
就足够了。 - 总结:int参数仅仅只是为了起到占位作用进而使得前置++和后置++的运算符重载函数构成函数重载。
5.operator-=、operator-函数的实现
注意:
①上面已经说明先自定义实现operator-=函数,然后再通过operator-内部复用operator-=函数从而实现operator-函数的效率高。所以下面采用的是先自定义实现operator-=函数,然后再去复用operator-=函数实现operator-函数。
②运算符重载又构成函数重载。日期-日期有意义(d1 - 100)且返回值是个日期、日期-天数也有意义(d1 - d2)且返回值是个整形。
5.1.日期-=天数 Date& operator-=(int day) 的函数实现
5.1.1.思路1
(1)思路
①基本思路:当从一个日期中减去天数时,如果减去的天数小于或等于当前日期的天数,可以直接从当前日期的天数中减去。如果减去的天数大于当前日期的天数,则需要向上个月“借位”,即减去当前月的天数,然后从上个月的天数中继续减去剩余的天数。
②处理负数:如果传入的天数是负数,那么实际上应该是日期的增加操作,因此可以直接调用日期增加天数的函数(operator+=
)。
③借位逻辑:
- 当月内减:如果当前日期的天数大于或等于要减去的天数,直接从当前日期的天数中减去即可。
- 跨月减:如果当前日期的天数小于要减去的天数,则需要减去当前月的天数,并将月份减一,然后从上个月的天数中继续减去剩余的天数。
- 跨年减:如果减法操作导致月份变为0,则需要将年份减一,并将月份设置为12(即上一年的12月)。
④循环借位:上述借位过程可能需要重复进行,直到减去的天数小于或等于当前日期的天数。
(2)代码
//传引用返回
Date& Date::operator+=(int day)
{
//若day是个负数,则日期+=天数(负数)相当于日期-=(-天数)(注:-天数是正数),则此时应该调用Date& operator-=(int day)
//处理负数情况,转换为减法操作
if(day < 0)
{
//复用Date& operator-=(int day)
*this -= -day;
return *this;
}
_day += day;
while(_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if(_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
Date& Date::operator-=(int day)
{
// 处理负数情况,转换为加法操作
if(day < 0)
{
*this += -day;
return *this;
}
// 循环,直到减去的天数小于等于当前日期的天数
while(day > _day)
{
// 减去当前月的天数
day -= _day;
// 借位,月份减一
--_month;
// 如果月份变为0,则跨年,年份减一,月份设置为12
if(_month == 0)
{
--_year;
_month = 12;
}
// 将上个月的天数加到当前日期上
_day += GetMonthDay(_year, _month);
}
// 从当前日期的天数中减去剩余的天数
_day -= day;
// 返回修改后的日期对象
return *this;
}
5.1.2.思路2
(1)思路
①处理负数情况:
- 如果传入的天数
day
是负数,那么实际上应该是日期增加的操作。因此,代码中首先检查day
是否小于0。 - 如果
day
小于0,则调用operator+=
函数,传入-day
(即day
的绝对值),然后返回。
②当前日期直接减去天数:
- 如果
day
是正数,首先尝试直接从当前日期的天数(_day
)中减去day
。 - 如果减去后的
_day
仍然是正数,那么不需要借位,直接返回当前日期对象。
③借位逻辑:
- 如果减去后的
_day
小于或等于0,则需要从上一个月借位。 - 进入一个循环,每次循环都会减少一个月,直到
_day
变为正数。
④跨年处理:
- 如果在借位过程中月份变为0,则需要跨年。
- 将年份减一,并将月份设置为12(即上一年12月)。
⑤借位后修正日期:
- 每次借位后,将上个月的天数(通过
GetMonthDay(_year, _month)
获取)加到当前日期上,以修正_day
的值。
⑥完成日期减去天数操作:
- 当
_day
变为正数时,表示已经完成了所有的借位操作,此时退出循环。 - 返回修改后的日期对象。
(2)代码
//传引用返回
Date& Date::operator+=(int day)
{
//若day是个负数,则日期+=天数(负数)相当于日期-=(-天数)(注:-天数是正数),则此时应该调用Date& operator-=(int day)
//处理负数情况,转换为减法操作
if(day < 0)
{
//复用Date& operator-=(int day)
*this -= -day;
return *this;
}
_day += day;
while(_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if(_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
//传引用返回
Date& Date::operator-=(int day)
{
//若day是个负数,则日期-=天数(负数)相当于日期+=(-天数)(注:-天数是正数),则此时应该调用Date& operator+=(int day)
//处理负数情况,转换为加法操作
if(day < 0)
{
//复用Date& operator+=(int day)
*this += -day;
return *this;
}
//当前日期直接减去天数day
_day -= day;
//若_day > 0证明不用借位,则直接返回对象*this。
//若_day <= 0则就要借当前月_month的上一个月的天数
while(_day <= 0)
{
//借位导致当前月_month减1.
--_month;
//注意: 借位后,要判断_month是否等于0. 若_month = 0则说明借的是去年12月份的天数,此时
//必须更正当前月_month所在月份的值(即把_month = 0改成_month = 12),同时也需要更正
//当前年_year的值(即当前年_year减少1)
//总的来说,如果月份变为0,则跨年,年份减一,月份设置为12
if(_month == 0)
{
--_year;
_month = 12;
}
//注: GetMonthDay(_year, _month)是上个月所借的天数。
//将上个月的天数加到当前日期上
_day += GetMonthDay(_year, _month);
}
//只有_day > 0时才算完成日期-=天数的操作。
return *this;//返回对象
}
(3)测试
(4)代码解析
图形解析:下面的调试过程说明在operator+=(int day) 函数内部为什么还有判断day小于0这种情况。因为若day是个负数,则日期+=天数(负数)相当于日期-= -天数(注:-天数是正数),则此时应该调用Date& operator-=(int day)。
5.2.日期-天数 Date& operator-(int day)的函数实现
(1)思路
在operator-(int day)函数内部通过复用operator-=(int day)从而实现operator-(int day)函数。
(2)代码
Date Date::operator-(int day)
{
Date tmp(*this);
tmp -= day;
return tmp;
}
5.3.日期-日期 int operator-(const Date& d)的函数实现
注意:两个日期相减很难用借位来实现。
(1)思路
计算两个日期相减后相差多少天的最简单方式:在不考虑效率的情况下,通过计算小日期要不断++多少次(假设n次)可以等于大日期这种计数方式来求两个日期相减差多少天(即相差n天)。总的来说,就是通过不断递增小日期直到它等于大日期来计算两个日期之间的天数差。
然而,这种方法在两个日期相差很大时效率非常低。
(2)代码
bool Date::operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
bool Date::operator!=(const Date& d)
{
return !(*this == d);
}
// d1 - d2;
int Date::operator-(const Date& d)
{
//注意:由于'-'运算符的特性是不会影响左右操作数的值,所以用'-'运算符实现的运算符重载函数的内部是
//不用改变参数日期(对象)的成员变量的,而下面计算方式会修改日期(对象)中成员变量的值,所以我们只能通
//过创建临时对象max、min来找参数中的大日期、小日期。
Date max = *this;//假设左操作数*this是大的那个日期
Date min = d;//假设右操作数d是小的那个日期
int flag = 1;//标签
//利用运算符'<'的运算符重载函数来比较两个日期的大小
if (*this < d)//判断是否假设错误,若假设错误则要更正使得max表示大日期、min表示小日期
{
max = d;//右操作数d是大日期
min = *this;//左操作数*this是小日期
flag = -1;//标签
}
//计算两个日期相减后相差多少天的最简单方式:在不考虑效率的情况下,通过计算小日期要不断++多少次可以等于
//大日期这种计数方式来求两个日期相减差多少天。
int n = 0;//临时变量n是用来计数的。
//调用operator!=运算符重载函数判断两个日期min、max是否相等
while (min != max)//判断小日期min在通过不断前置++后是否等于大日期,若等于则计数结束。
{
++min;//调用operator++运算符重载函数让小日期不断增大。
++n;
}
//若flag = 1,则说明左操作数 > 右操作数,则两个日期相减就是正数。
//若flat = -1,则说明左操作数 < 右操作数,则两个日期相减就是负数。
return n*flag;
//注意:flag的作用是:当我们不知道两个日期相减时不知那个日期大那个日期小,
//而两个日期谁大谁小决定最终两个日期相减的结果是正数还是负数,所以先假设谁是大日期而谁是小日期,
//然后用标签flag = 1进行标记这种假设,然后去判断假设若假设错误则改变标签flag的值即flag = -1。最后flag的正负决定两个日期相减结果n*flag的正负。
}
(3)测试
6.operator--函数的实现
6.1.前置--
//--d1 ->编译器自动转换为: d1.operator--()
Date& Date::operator--()
{
*this -= 1;
return *this;
}
6.2.后置--
//d1-- ->编译器自动转换为: d1.operator--(1)
Date Date::operator--(int)
{
Date tmp(*this);
*this -= 1;
return tmp;
}
6.3.总结
(1) 前置--运算符重载函数
的效率通常比后置--运算符重载函数
高,这是因为前置版本返回的是修改后的对象的引用,而后置版本需要返回对象原始状态的拷贝,然后再对对象进行递减。因此,后置--
需要创建一个临时对象,这涉及到额外的拷贝构造函数调用,从而降低了效率。
(2) 对于内置类型(如 int
),前置和后置递增(递减)的效率差异通常可以忽略不计,因为编译器会进行优化。但是对于自定义类型,前置 --
确实比后置 --
更高效,因为它不需要创建临时对象。
(3)显示调用前置--和后置--运算符重载函数
(4)后置--运算符重载函数Date operator--(int)中的参数类型只能是整形int而不能是其他类型。若我们手动把参数类型int改写成其他类型则会使得编译报错,所以我们不能手动随机改变后置--运算符重载函数的参数类型。
图形解析:通过下面后置++运算符重载函数Date operator++(double)的编译结果说明后置++的运算符重载函数Date operator++(int)的参数类型必须是整形int而不能是其他类型,否则会发生报错。
7.插入数据(右操作数)是自定义类型(例如:对象)的流插入'<<'运算符重载函数
7.1.知识点
(1) cout
、cerr
和 clog
都是 ostream
类的实例化后的全局对象,其中 cerr
用于错误输出,clog
用于日志输出。cin
是 istream
类实例化后的全局对象。
(2) ostream
和 istream
是 C++ 标准库中预定义的类,它们包含在头文件 <iostream>
中。
(3)在头文件 <iostream>
中,流插入运算符 <<
和流提取运算符 >>
的重载函数通常是在全局域中定义和实现的。
这些运算符被定义为非成员函数,但它们是 std::ostream
和 std::istream
类的友元函数,这意味着它们可以访问这些类的私有和保护成员。重载这些运算符是为了能够将它们用于标准输入输出流对象,如 std::cout
和 std::cin
。
(4) 流插入运算符 <<
有两个操作数,但 <<
运算符重载函数的第一个参数是cout
的引用,第二个参数是要插入的数据类型,且可以连续使用 <<
运算符插入多个数据项。
(5)流提取运算符 >> 有两个操作数,但 >> 运算符重载函数的第一个参数是cin 的引用,第二个参数是要提取的数据项的引用,且可以连续使用 >>
运算符提取多个数据项。
7.2.解释 C++ 中 cout << 插入数据(右操作数)
表达式对内置类型和自定义类型的插入数据(右操作数)识别差异的原因
(1)对于表达式 cout <<
插入数据(右操作数)
,如果插入数据(右操作数)
是内置类型(例如 int
、double
、char
等),C++ 标准库已经提供了相应的流插入运算符 <<
的重载函数,程序员无需自定义实现。这些重载函数能够自动识别内置类型的右操作数,并执行相应的输出操作。
(2)对于表达式 cout << 插入数据(右操作数)
,若插入数据(右操作数)
是自定义类型(例如一个类的对象),则 cout << 插入数据(右操作数)
无法自动识别类型,因为 C++ 标准库没有内置对这些自定义类型的支持。为了能够使用流插入运算符 <<
输出自定义类型的对象,程序员必须为该类型定义一个 <<
运算符的重载函数,该函数通常在类中声明成友元函数,并在类定义之外实现,即在全局域定义实现。
如果在C++中没有为自定义类型(例如一个日期类)定义流插入运算符 <<
的重载函数,而直接尝试使用 cout <<
来输出该类型的对象,那么编译器将会报错。这是因为C++标准库的 iostream
不包含对未知自定义类型的默认流插入操作。如下图所示:
7.3.猜想自定义类型插入数据(右操作数)
的流插入运算符重载函数在什么位置定义实现比较好?
注意:
① 在 C++ 标准库中,流插入运算符 <<
已经为内置类型提供了重载实现,因此可以直接用于这些类型的对象。然而,对于自定义类型,标准库并没有提供流插入运算符的重载,因此程序员需要自己为自定义类型实现这个运算符的重载。
② 为了使自定义类型能够使用流插入运算符 <<
,程序员有两种实现方式:
- 第一种方式是在
ostream
类中添加重载函数。然而,由于ostream
类是标准库的一部分,直接修改它是不推荐的,也是不可行的,因为标准库的实现通常是封闭的。 - 第二种方式是在自定义类型(例如一个类)内部声明一个友元函数(非成员函数),并在全局作用域中定义和实现该友元函数。这个友元函数接受一个
ostream
引用的参数(通常是cout
的引用)和一个自定义类型的常量引用作为参数,并返回ostream
的引用(通常是cout
的引用)。这样,就可以通过友元函数访问类的私有成员,并定义如何将自定义类型的对象插入到输出流中。
7.3.1.猜想1:声明和定义成类的成员函数,即void Date::operator<<(ostream& out)
(1)代码实现
①声明
②定义
(2)错误调用方式导致发生报错
① 报错原因:
原因1:在C++中,流插入运算符 <<
通常是非成员函数,且左操作数应当是对象cout,右操作数是自定义类型对象(插入数据)。如果尝试在自定义类型(如 Date
类)内部重载 operator<<
作为成员函数,那么左操作数将默认为自定义类型对象(插入数据,如Date
类对象)(即 this
指针指向的对象),而右操作数将是对象cout。由于我们习惯用表达式cout << d1传参调用流插入运算符重载函数,而表达式cout << d1传参的第一个参数是对象cout,而第二个参数是日期类对象d1,使得传参的参数类型和流插入运算符重载函数void Date::operator<<(ostream& out)的参数类型不匹配,最终导致发生报错。
正确的做法是将 operator<<
实现为非成员函数并在类中声明成友元函数就可以在类外面随机访问类私有的成员变量而不会发生报错,通过这样就可以接受对象cout作为左操作数,自定义类型对象(插入数据)作为右操作数。
原因2:如果尝试在 Date
类中定义 operator<<
作为成员函数,那么该类成员函数的函数名应该是std::ostream& Date::operator<<(std::ostream& out)以便支持运算符连续链式调用,并且在使用时应该是 d1 << cout
,但是这与我们习惯的流插入操作顺序相反。因此,这种方式是不合适的,并且会导致编译错误。
//声明和定义成类成员函数
#include <iostream>
using namespace std;
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
ostream& operator<<(ostream& out)
{
out << _year << "年" << _month << "月" << _day << "日" << endl;
return out;
}
private:
int _year;
int _month;
int _day;
};
//注意:即使 operator<< 在类中定义实现而且还有返回值,也无法支持连续链式调用。实际上,如果
//operator<< 正确地返回 ostream 的引用,它应该支持链式调用。但由于 operator<< 被错误地定义为成员
//函数,即使它返回cout的引用,也无法实现链式调用,因为它的左操作数应该是Date对象,而不是对象
//cout。
(3)正确调用方式没有发生报错
①代码解析:使用表达式d1 << cout传参调用自定义类型流插入运算符重载函数void Date::operator<<(ostream& out)没有发生报错是因为表达式d1 << cout在传参时参数是匹配的。
②注意:下面是C++库中提供的内置类型流插入运算符重载函数传参调用的方式
(4)方式1的缺陷
缺陷描述:
在 Date
类中定义 operator<<
作为成员函数(例如 void Date::operator<<(ostream& out)
)会导致使用时的不习惯和不一致。在 C++ 中,流插入运算符 <<
的标准用法是左操作数是 std::ostream
对象(通常是 std::cout
),而右操作数是要插入到流中的值。当 operator<<
被定义为 Date
类的成员函数时,这将颠倒操作数的顺序,使得左操作数变为 Date
对象,而右操作数变为 std::ostream
对象。
这意味着,如果我们按照成员函数的方式来定义 operator<<
,我们将会使用以下方式来调用它:
Date d1;
d1 << cout; // 这是错误的用法,与常规的 << 运算符使用方式不符
这与我们通常使用内置类型时的方式相反:
int value = 42;
cout << value; // 这是正确的用法,左操作数是 std::ostream 对象
为了保持一致性和习惯用法,operator<<
应该定义为非成员函数,并且接受 std::ostream
引用作为第一个参数,接受 Date
对象的常量引用作为第二个参数。正确的定义方式如下:猜想2。
7.3.2.猜想2:声明和定义成类的非成员函数(即在全局域中定义实现),ostream& operator<<(ostream& out, const Date& d)
注意:在 C++ 中,运算符重载函数确实不一定非要作为类的成员函数来定义。C++ 语言允许运算符重载函数在全局作用域中定义,这意味着流插入运算符 operator<<
可以是一个全局函数。我们之前将运算符重载函数放在类中定义的原因之一是为了方便函数内部直接访问类的私有成员变量,这是因为类的成员函数可以访问类的所有成员,包括私有成员。然而,如果我们将 operator<<
定义为全局函数,我们仍然可以通过声明该函数为类的友元函数来访问类的私有成员。这样,全局的 operator<<
函数就可以访问类的私有成员,同时保持了运算符重载函数的常规使用方式。
ostream& operator<<(ostream& out, const Date& d)函数实现的过程如下:
在全局域中定义void operator<<(ostream& out, const Date& d)流插入运算符重载函数出现的问题及对应的解决方式:
(1)问题(在全局函数内部无法访问日期类对象的私有成员变量):在全局域中实现operator<<流插入运算符重载函数的真正问题是:在全局域中operator<<函数内部无法直接访问日期类对象的私有(private)成员变量d._year、d._month、d._day。
针对上面出现的问题有以下3种解决方式:
(2)解决方式1:把日期类私有(private)的成员变量改成公有(public)的成员变量。这种解决方式是最不好的。
(3)解决方式2:在日期类中定义每个成员变量对应的获取成员变量的公有成员函数(例如:成员变量_year对应的公有成员函数是GetYear()、成员变量_month对应的公有成员函数是GetMonth()、成员变量_day对应的公有成员函数是GetDay()),然后在全局域流插入运算符重载函数operator<<的内部通过形参日期类对象d来调用日期类Date中对应的获取成员变量的成员函数。但是这种方式太麻烦,因为若类的成员变量很多时则我们就要对应写过程获取成员变量的公有成员函数。
注意:C++是不喜欢使用解决方式2的。
(4)解决方式3
①若想在全局域定义的全局函数内部直接访问类私有的成员变量而不会发生报错的方式是:在定义类时,在类的内部用关键字friend声明全局函数是友元函数,这样就可以在全局域中的全局函数内部直接访问类私有的成员变量而不会发生报错。
②案例解析:由于我们想在全局域中定义的全局流插入运算符重载函数void operator<<(ostream& out, const Date& d)的内部访问日期类Date对象d的所有私有(private)成员变量,所以我们就在定义日期类Date(头文件)时在类Date的内部使用关键字friend声明全局函数流插入运算符重载函数operator<<。声明格式:friend void operator<<(ostream& out, const Date& d);
③全局函数operator<<被声明成友元函数、友源函数operator<<在全局域中定义、友源函数operator<<的调用的3个过程:
- 过程1(全局函数operator<<被声明成友元函数):在日期类Date中利用关键字friend声明全局函数void operator<<(ostream& out, const Date& d)是日期类Date的友元函数:
- 过程2(友元函数operator<<在全局域中定义):在全局域的流插入运算符重载函数operator<<(ostream& out, const Date& d)的内部直接调用日期类对象d(形参)的所有私有(private)成员变量而不会发生报错:
- 过程3(友元函数operator<<的调用):调用在全局域中定义的流插入重载运算符的两种方式:
(5)解决方式3的问题
①问题描述:
由于流插入运算符 <<
支持连续的流操作,因此用流插入运算符 <<
实现的运算符重载函数必须返回一个 std::ostream
类型的引用(即返回cout的引用),以便能够连续使用 <<
运算符。在解决方式3中,如果将 operator<<
声明为全局友元函数并且其返回类型是 void
,如 void operator<<(ostream& out, const Date& d)
,这将导致无法支持连续的流操作,因为在连续使用 <<
运算符时会缺少返回的 std::ostream
类型的引用(即返回对象cout的引用),从而出现编译错误。
②案例解析:
整个表达式 cout << d1 << d2 << endl
进行连续流操作的过程解析如下:
- 连续流操作
cout << d1 << d2 << endl
的操作顺序是从左往右。 - 整个表达式可以看作由三个子表达式组成。
- 首先执行表达式1
cout << d1
,这个表达式执行完后会返回一个std::ostream
对象,通常是cout
本身。 - 然后执行表达式2
cout << d2
(这里的cout
是表达式1的返回值),这个表达式同样执行完后会返回一个std::ostream
对象。 - 最后执行表达式3
cout << endl
(这里的cout
是表达式2的返回值),当这个表达式执行完后,整个连续流操作cout << d1 << d2 << endl
就完成了。
报错原因解析如下:
- 由于全局函数
void operator<<(ostream& out, const Date& d)
没有返回值,因此在连续流操作的过程中,当尝试执行表达式2cout << d2
时(这里的cout
应该是表达式1的返回值),会因为缺少返回的std::ostream
引用而导致编译器报错。正确的做法是让operator<<
返回一个std::ostream
引用,如下所示:
(6)解决方式3的代码优化:在全局域中定义ostream& operator<<(ostream& out, const Date& d)函数
全局函数operator<<被声明成友元函数、友源函数operator<<在全局域中定义、友源函数operator<<的调用的3个过程:
- 过程1:友元函数声明。
- 过程2:友元函数定义
- 过程3:友元函数的连续赋值调用
7.4.C++支持流插入运算符重载函数的原因
(1)C++ 支持流插入运算符重载函数的原因
C 语言中的 printf
函数主要用于格式化输出内置类型的数据。然而,printf
无法直接处理自定义类型(如类对象)的打印,因为它不包含对自定义类型的内置支持。C++ 提供了流插入运算符 <<
的重载机制,允许开发者定义如何将自定义类型的数据插入到输出流中,从而可以打印自定义类型的对象。
(2)以下是详细说明
① 在 C++ 中,内置类型的输入和输出可以使用 scanf
和 printf
,或者使用 C++ 标准库提供的 cin
和 cout
。cin
和 cout
是 C++ 的输入输出流对象,它们通过调用 C++ 库中为内置类型提供的流提取运算符 >>
和流插入运算符 <<
来工作。
② 对于自定义类型,如果想要使用 cin
和 cout
进行输入和输出,必须自己实现流提取运算符重载函数 operator>>
和流插入运算符重载函数 operator<<
。
③ 关于效率的问题,scanf
和 printf
通常会比 C++ 的流操作符 >>
和 <<
更高效,因为它们直接操作底层 I/O,而不涉及 C++ 流的封装和状态管理。流操作符 >>
和 <<
在进行连续流操作时会多次调用重载函数,这可能会引入一些额外的开销。但是,这种开销通常不大,而且 C++ 流提供了更灵活和类型安全的接口,这在很多情况下是更重要的。
总结:虽然 scanf
和 printf
可能更高效,但 C++ 流提供了更高级的功能和类型安全,这在许多应用场景中是更优先考虑的因素。
8.插入数据(右操作数)是自定义类型(例如:对象)的流提取'>>'运算符重载函数
8.1.istream& operator>>(istream& in, Date& d)的实现过程
(1) 注意事项
① operator>> 用于从输入流中提取数据并赋值给对象,而 operator<< 用于将对象的值插入到输出流中以进行打印。
② 在 ostream& operator<<(ostream& out, const Date& d)
函数中,第二个参数是 const Date&
引用,这样做是为了避免不必要的对象拷贝,并且确保函数不会修改传入的对象。
在 istream& operator>>(istream& in, Date& d)
函数中,第二个参数是 Date&
引用,这样做是为了直接在传入的对象上进行赋值操作,避免拷贝,并且由于这是一个输出参数,它需要在函数内部被修改,因此不能使用 const
修饰。
(2) 全局函数operator>>被声明成友元函数、友元函数operator>>在全局域中定义、友元函数operator>>的调用的3个过程:
①过程1(全局函数operator>>被声明成友元函数):在日期类Date中利用关键字friend声明全局函数 istream& operator>>(istream& in, Date& d)是日期类Date的友元函数:
②过程2(友元函数operator>>在全局域中定义):在全局域的流提取运算符重载函数
istream& operator>>(istream& in, Date& d)的内部直接调用日期类对象d(形参)的所有私有(private)成员变量而不会发生报错:
③过程3(友元函数operator>>的调用) 传参调用在全局域中定义的流提取重载运算符的方式:
9.把流插入运算符重载函数、流提取运算符重载函数声明和定义成内联函数
9.1.对于流插入运算符重载函数、流提取运算符重载函数的优化
(1)优化方式
在头文件中直接把流插入运算符重载函数、流提取运算符重载函数定义实现成内联函数。
(2)优化原因
由于这些函数在日常编程中被频繁调用,每次调用都会导致新的函数栈帧被创建,这会导致内存空间的浪费。通过将这些函数定义为内联函数,可以避免这种不必要的开销。
(3)注意事项
①内联函数的声明和定义必须在一起,否则编译器无法识别其为内联函数并导致编译错误。因此,通常在内联函数的定义处(即头文件中)同时完成其声明和定义。
②内联函数的使用场景:对于那些经常被调用的短小精悍的函数,如某些运算符重载函数,可以考虑将其定义为内联函数以提升性能。
③在类声明中的意义:在类的声明中直接定义成员函数不仅可以使其成为内联函数,还能确保其声明与定义的一致性,避免了分散在不同文件中的问题。
(4)优化后的代码
9.2.详细说明内联函数声明和定义不能分离的原因
(1)类的声明和定义
①类的声明: 当我们在一个头文件中声明一个类时,通常是指提供类的接口,包括成员函数的声明和成员变量的声明。在这个阶段,我们不实现成员函数的具体功能,只是告诉编译器这些成员函数的存在和它们的签名。这就是类的声明。
②类的定义:类的定义: 类的定义是指在实际的源文件(.cpp文件)中实现类成员函数的具体功能。在定义成员函数时,我们必须指定这些函数属于哪个类,通常是通过类作用域解析运算符 ::
来实现。这就是类的定义。
非内联函数声明和定义分离时,在源文件调用该函数时是如何在链接过程中找到并进行调用的:
(2)源文件中函数声明与定义的关系及其对调用和链接过程的影响
在编译和链接过程中,函数的声明与定义对函数调用的影响如下:
①情况一:源文件中只有函数声明,没有函数定义
- 函数声明: 在源文件中,函数的声明提供了函数的名称、返回类型和参数列表,但不包含函数的实现。
- 编译:编译器在编译源文件(.cpp)时,会检查函数声明的正确性,并生成目标文件(.obj 或 .o)。在目标文件的符号表中,编译器会记录一个未解析的外部引用或称为未定义的符号(即这个函数的函数名是个未定义的符号)。
- 调用函数: 编译后的代码中,函数调用点会留下一个未解析的外部函数的引用,而不是函数的实际地址。这个引用是一个占位符,指示在链接阶段需要找到对应的函数定义。
- 链接: 链接器在链接过程中会解析所有未定义的符号。它会在其他目标文件或库文件中查找对应的函数定义。如果找到了函数定义,链接器会将该函数的实际地址写入符号表,并将函数调用点更新为这个地址。如果链接器找不到任何匹配的函数定义,链接器将报错,指出存在未定义的符号(即这个函数的函数名),这通常会导致链接失败。总的来说,链接器在链接过程中会查找其他目标文件或库文件中的函数定义,若找到函数定义就将函数的地址解析到当前目标文件中的调用点。这样,程序运行时才能正确调用函数。
②情况二:源文件中有函数的声明和定义
- 函数声明和定义:在源文件中,当函数的声明和定义都存在时,编译器会生成这个函数的机器代码,并在符号表中记录其地址。
- 编译:在编译过程中,编译器读取源文件中的函数定义,生成相应的二进制指令(机器代码),并在目标文件中为这些指令分配一个地址。
- 调用函数:在编译后的代码中,当需要调用函数时,会使用
call
指令,这个指令包含了函数的地址,使得程序能够跳转到函数的机器代码处执行。 - 链接:链接器的作用是将当前目标文件与其他目标文件合并,以创建最终的可执行文件。对于已经在源文件中定义的函数,链接器不需要解析其地址,因为这些地址已经在编译阶段确定并记录在符号表中。链接器主要确保程序的所有部分都能够正确地连接,包括解决不同目标文件之间的符号引用和地址重定位,以维护整个程序地址空间的一致性。
③总结:
- 如果源文件仅包含函数声明,则编译器无法在编译时确定函数地址,因此需要通过链接过程在其他源文件中查找函数的定义并解析函数的实际地址,以便通过
call
指令使用函数地址来调用函数并执行函数的汇编代码。 - 如果源文件包含函数的声明和定义,编译器会在编译时确定函数地址,并且在编译后的代码中直接通过
call
指令使用函数地址来调用函数并执行函数的汇编代码,无需在链接过程中再次查找函数地址。
(3)对于声明和定义分离的非内联函数来说,符号表的作用是什么?
①普通函数(非内联函数)在链接过程中,我们是通过符号表来找到在其他源文件中定义的函数地址的。以下是详细的过程和符号表的关系:
-
函数声明: 在源文件中,当你声明一个函数时,你是在告诉编译器这个函数的存在、它的名称、返回类型以及参数类型。这个声明并不包含函数的实现。
-
编译单元: 每个源文件被编译成一个目标文件,这个目标文件包含编译后的代码和数据,以及一个符号表。
-
符号表: 符号表是目标文件的一部分,它列出了所有的变量、函数和类等符号,以及它们在目标文件中的地址。对于函数,符号表会记录函数的名称和它在该编译单元中的地址(如果函数是在该编译单元中定义的)。
-
链接过程: 当链接器将多个目标文件合并成一个可执行文件时,它会解析这些文件中的符号引用。对于函数调用,链接器会做以下事情:
- 在所有目标文件的符号表中查找被调用函数的名称。
- 确定函数的定义(即函数的实现)所在的编译单元。
- 将调用点处的函数引用更新为函数定义的实际地址。
-
通过声明找函数地址: 当源文件中只有函数声明而没有定义时,链接器会通过这个声明在符号表中查找函数的实际地址。如果找到了对应的定义,链接器就会更新调用点处的指令,使其指向正确的函数地址。这个过程称为符号解析。
-
指令
call
: 在汇编语言中,call
指令用于调用函数。在链接过程完成后,call
指令后面的地址将会被替换为函数的实际地址。这样,当程序运行到这个call
指令时,CPU会跳转到函数代码的起始地址并执行函数。
总结来说,符号表在链接过程中起到了桥梁的作用,它帮助链接器将函数调用与函数定义关联起来,确保程序在运行时能够正确地调用函数。
②案例解析:
//函数声明
//Add.h
int Add(int a, int b); // 声明Add函数,不包含实现
//函数定义
//Add.cpp
#include "Add.h"
int Add(int a, int b)
{
return a + b; //实现Add函数
}
//在main.cpp中包含头文件
// main.cpp
#include "Add.h"
int main()
{
int result = Add(1, 2); //调用Add函数
return 0;
}
Add.h
头文件中声明了 Add
函数,但没有定义它。Add.cpp
源文件中定义并实现了 Add
函数。main.cpp
源文件中包含了 Add.h
头文件,并调用了 Add
函数。
-
编译单元:
main.cpp
和Add.cpp
分别被编译成main.o
和Add.o
目标文件。main.o
的符号表中将包含对Add
函数的未解析引用。Add.o
的符号表中将包含Add
函数的实际地址。
-
链接过程:
- 链接器将
main.o
和Add.o
合并成一个可执行文件。 - 链接器在
Add.o
的符号表中查找Add
函数的定义。 - 一旦找到
Add
函数的定义,链接器将main.o
中对Add
函数的未解析引用更新为Add
函数的实际地址。
- 链接器将
-
符号表的作用:
- 符号表使得链接器能够将
main.cpp
中的Add
函数调用解析为Add.cpp
中的实际函数地址。 - 符号解析确保了
main
函数中的Add
调用能够正确地跳转到Add
函数的实现。
- 符号表使得链接器能够将
-
指令
call
:- 在生成的可执行文件中,
main
函数中对Add
的调用将通过call
指令实现,该指令后面跟随Add
函数的实际内存地址。
- 在生成的可执行文件中,
- 总结:在案例中,由于
Add
函数不是内联函数,它在Add.cpp
中有自己的函数地址。main.cpp
通过Add.h
中的声明知道Add
函数的存在,但在链接过程中,链接器通过符号表解析Add
函数的实际地址,并将这个地址用于main.cpp
中的函数调用。这样,程序在运行时就可以正确地调用Add
函数。
(4)内联函数的介绍
内联函数是C语言中的一个优化特性,它通过在编译时将函数体直接插入到每个调用点来减少函数调用的开销。以下是关于内联函数的两个场景:
场景一:内联函数的声明和定义在同一个头文件中
在这种情况下,当你在源文件中包含这个头文件并调用内联函数时,编译器会做以下处理:
- 函数展开:编译器会在编译时将内联函数的函数体直接展开到调用点,而不是生成一个独立的函数调用。因此,内联函数在编译后的代码中并没有一个独立的地址。
- 汇编指令:由于内联函数的函数体被直接插入到调用点,所以调用点处的汇编代码将直接包含函数体的指令,而不是一个
call
指令。
场景二:内联函数的声明和定义分离
在这种情况下,内联函数的声明在一个头文件中,而定义在其他源文件中:
- 函数声明:当源文件包含头文件时,它只看到了内联函数的声明,并没有函数的定义。
- 符号表:由于内联函数是设计为在编译时展开的,它通常不会出现在符号表中,因此没有独立的地址可以链接。
- 链接过程:在链接过程中,由于内联函数没有进入符号表,链接器无法解析内联函数的地址。因此,如果编译器没有正确处理内联函数的定义(例如,定义没有在头文件中展开),则会导致链接错误。
结论
- 内联函数通常没有独立的函数地址:因为它们是在编译时直接展开到调用点的,而不是通过
call
指令调用的。 - 内联函数的声明和定义应该在同一个头文件中:这样编译器才能在编译时展开函数体。如果内联函数的定义在其他源文件中,则它可能不会被正确地内联,因为编译器在编译时可能看不到函数的定义。
- 链接错误:如果内联函数的定义没有在头文件中,且在其他源文件中定义,那么在链接时可能会出现错误,因为编译器无法找到函数的定义来展开。
因此,内联函数的正确使用方式是将它们的声明和定义放在同一个头文件中,并确保在源文件中包含这个头文件。这样,编译器就可以在每个调用点展开内联函数,而不会产生独立的函数地址。
(5)内联函数为什么不入符号表的原因
内联函数通常不会被放入符号表中,原因如下:
-
内联展开:内联函数的目的是在编译时将函数的代码直接插入到每一个调用点,以减少函数调用的开销。因此,内联函数在编译阶段就已经被展开,不需要在运行时通过符号表进行查找和跳转。
-
空间换时间:由于内联函数的代码在每个调用点都会被复制,因此它不需要单独的函数入口,也不需要在符号表中占用一个条目。这样可以节省查找函数地址的时间,但可能会增加编译后的程序大小。
-
编译时决议:内联函数的调用是在编译时解析的,而不是在链接时。编译器在编译阶段就知道内联函数的定义,因此不需要在符号表中记录任何信息供链接器使用。
-
避免链接问题:由于内联函数的代码在每个调用点都是独立的,因此不会出现链接时需要解决的符号依赖问题。这意味着内联函数的定义不需要在符号表中出现,从而简化了链接过程。
-
优化目的:内联函数通常用于优化性能关键的小函数。由于这些函数的代码被直接嵌入到调用者中,编译器可以对嵌入的代码进行更多的优化。
需要注意的是,内联函数是编译器的一个建议,编译器可以选择忽略这个建议,并不一定会将函数内联展开。在某些情况下,即使函数被声明为内联,它也可能出现在符号表中,比如:
- 函数过大:如果内联函数体过大,编译器可能会选择不进行内联,而是将其作为普通函数处理。
- 递归函数:递归函数通常不能被内联,因为内联展开会导致无限递归。
- 编译器策略:编译器可能会根据优化策略和函数的实际使用情况决定是否内联。
总之,内联函数的设计目的是为了减少函数调用的开销,而不是为了在运行时通过符号表进行函数查找。因此,它们通常不会出现在符号表中。
(6)内联函数不入符号表导致内联函数声明和定义分离时会发生编译报错的原因(或者说内联函数声明和定义不能分离的原因)
-
编译时的内联展开:内联函数的设计意图是在编译时将函数体直接插入到每个调用点,而不是通过标准的函数调用机制。因此,编译器在编译阶段就需要知道内联函数的定义,以便进行代码展开。
-
缺失的定义:当内联函数的声明和定义分离时,如果在编译时编译器没有找到内联函数的定义,它就无法在调用点展开函数体。这意味着编译器无法执行内联函数的主要优化功能。
-
符号表的缺失:由于内联函数通常不会进入符号表,编译器不会在链接阶段通过符号表来解析内联函数的地址。如果内联函数的定义不在编译器可见的范围内,编译器就无法进行内联展开。
-
编译报错:如果编译器期望内联函数的定义来进行代码展开,但在编译时找不到该定义,它可能会报告编译错误。这是因为内联函数的调用点没有有效的函数体可以插入。
日期类整个工程
注意:我们在类中实现运算符重载函数时,不是把所有运算符的重载函数都实现,而是根据我们自己需要什么运算符就去实现对应的运算符重载函数。
1.1.Date.h
#include <iostream>
#include <assert.h>
using namespace std;
// 类里面短小函数,适合做内联的函数,直接是在类里面定义的
class Date
{
// 友元
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
public:
Date(int year = 1900, int month = 1, int day = 1);
void Print() const;
int GetMonthDay(int year, int month) 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;
Date& operator+=(int day);
Date operator+(int day) const;
Date& operator-=(int day);
Date operator-(int day) const;
// d1 - d2;
int operator-(const Date& d) const;
// ++d1
Date& operator++();
// d1++
// int参数 仅仅是为了占位,跟前置重载区分
Date operator++(int);
// --d1 -> d1.operator--()
Date& operator--();
// d1-- -> d1.operator--(1)
Date operator--(int);
//void operator<<(ostream& out);
private:
int _year;
int _month;
int _day;
};
inline ostream& operator<<(ostream& out, const Date& d)
{
out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
return out;
}
inline istream& operator>>(istream& in, Date& d)
{
in >> d._year >> d._month >> d._day;
return in;
}
1.2.Date.cpp
#include"Date.h"
int Date::GetMonthDay(int year, int month) const
{
assert(month > 0 && month < 13);
int monthArray[13] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
{
return 29;
}
else
{
return monthArray[month];
}
}
Date::Date(int year, int month, int day)
{
if (month > 0 && month < 13
&& (day > 0 && day <= GetMonthDay(year, month)))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "日期非法" << endl;
}
}
void Date::Print() const
{
cout << _year << "/" << _month << "/" << _day << endl;
}
bool Date::operator==(const Date& d) const
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
// d1 < d2
bool Date::operator<(const Date& d) const
{
return _year < d._year
|| (_year == d._year && _month < d._month)
|| (_year == d._year && _month == d._month && _day < d._day);
}
// d1 <= d2
bool Date::operator<=(const Date& d) const
{
return *this < d || *this == d;
}
// d1 > d2
bool Date::operator>(const Date& d) const
{
return !(*this <= d);
}
bool Date::operator>=(const Date& d) const
{
return !(*this < d);
}
bool Date::operator!=(const Date& d) const
{
return !(*this == d);
}
Date& Date::operator+=(int day)
{
if (day < 0)
{
*this -= -day;
return *this;
}
_day += day;
while (_day > GetMonthDay(_year, _month))
{
_day -= GetMonthDay(_year, _month);
_month++;
if (_month == 13)
{
++_year;
_month = 1;
}
}
return *this;
}
// d1 + 100
Date Date::operator+(int day) const
{
Date tmp(*this);
tmp += day;
return tmp;
}
//Date Date::operator+(int day)
//{
// Date tmp(*this);
//
// tmp._day += day;
// while (tmp._day > GetMonthDay(tmp._year, tmp._month))
// {
// tmp._day -= GetMonthDay(tmp._year, tmp._month);
// tmp._month++;
// if (tmp._month == 13)
// {
// ++tmp._year;
// tmp._month = 1;
// }
// }
//
// return tmp;
//}
//
d1 += 100
//Date& Date::operator+=(int day)
//{
// *this = *this + day;
//
// return *this;
//}
Date& Date::operator-=(int day)
{
if (day < 0)
{
*this += -day;
return *this;
}
_day -= day;
while (_day <= 0)
{
--_month;
if (_month == 0)
{
--_year;
_month = 12;
}
_day += GetMonthDay(_year, _month);
}
return *this;
}
Date Date::operator-(int day) const
{
Date tmp(*this);
tmp -= day;
return tmp;
}
// ++d1
Date& Date::operator++()
{
*this += 1;
return *this;
}
// d1++
Date Date::operator++(int)
{
Date tmp(*this);
*this += 1;
return tmp;
}
Date& Date::operator--()
{
*this -= 1;
return *this;
}
// d1-- -> d1.operator--(1)
Date Date::operator--(int)
{
Date tmp(*this);
*this -= 1;
return tmp;
}
// d1 - d2;
int Date::operator-(const Date& d) const
{
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d)
{
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max)
{
++min;
++n;
}
return n*flag;
}
1.3.Test.cpp
#include "Date.h"
void TestDate1()
{
Date d1(2023, 2, 4);
d1.Print();
/*Date d2(2023, 2, 29);
d2.Print();*/
Date d2 = d1 + 5000;
d2.Print();
d1.Print();
Date d3 = d1;
//d1 = d3 += 100;
d3 += 100;
d3.Print();
d1.Print();
int i = 0, j = 0, k = 1;
i += j += k;
}
void TestDate2()
{
Date d1(2023, 2, 4);
d1.Print();
Date d2 = d1 + 100;
d2.Print();
Date d3 = d1 + 100;
d3.Print();
}
void TestDate3()
{
Date d1(2023, 2, 4);
d1.Print();
Date ret1 = ++d1; // d1.operator++();
d1.Print();
ret1.Print();
Date ret2 = d1++; // d1.operator++(0);
d1.Print();
ret2.Print();
--d1;
d1--;
/*d1.operator++();
d1.operator++(0);*/
}
void TestDate4()
{
Date d1(2023, 2, 4);
d1.Print();
d1 -= 100;
d1.Print();
Date d2(2023, 2, 7);
d2.Print();
d2 += -100;
d2.Print();
Date d3(2023, 2, 7);
d3.Print();
d3 -= -200;
d3.Print();
}
void TestDate5()
{
Date d1(2023, 2, 7);
d1.Print();
Date d2(1368, 1, 1);
d2.Print();
cout << d2 - d1 << endl;
cout << d1 - d2 << endl;
}
void TestDate6()
{
// 流插入
Date d1(2023, 2, 4);
Date d2(2022, 1, 1);
//cout << d1;
//d1.operator<<(cout);
//d1 << cout;
operator<<(cout, d1);
cout << d1;
cout << d1 << d2 << endl;
d1 -= 100;
//cout << d1;
int i = 1;
double d = 1.11;
// 运算符重载+函数重载
cout << i; // cout.operator<<(i) // int
cout << d; // cout.operator<<(d) // double
}
void TestDate7()
{
Date d1;
cin >> d1;
cout << d1;
}
int main()
{
TestDate1();
return 0;
}
九、const成员函数
1.C++提出const成员函数的背景
1.1.权限放大代码
(1)代码报错的原因
代码报错的原因分析: 在代码中,对象 aa
被声明为 const
类型的对象,这意味着其成员不能被修改。当尝试调用 aa.Printf()
方法时,编译器会将该方法调用转换为传递 aa
的地址,即 aa.Printf(&aa)
。这里的 &aa
是一个指向 const A
的指针,类型为 const A*
。然而,类 A
的成员函数 void Printf()
在编译期间会被编译器转换为 void Printf(A* this)
,这里的 this
指针类型为 A*
,没有 const
修饰。由于实参 const A*
和形参 A*
类型不匹配,且 this
指针指向的对象没有被 const
修饰,这会导致 const A
对象的权限被放大,允许通过 this
指针修改对象,从而引发权限放大问题,导致编译错误。
(2)解决方式
①解决方式的描述: 为了解决权限放大问题,需要确保成员函数 Printf
的隐式形参 this
指针的类型与实际对象类型相匹配,即使用 const A*
而不是 A*
。由于我们无法直接修改隐式形参 this
的类型,我们需要采用一种间接的方法。我们可以在成员函数的声明后面加上 const
关键字来修饰 *this
,这样就可以告诉编译器 this
指针指向的是一个 const
对象,从而防止在函数内部修改对象的成员。具体做法是在成员函数的声明中添加 const
修饰符,如下所示:
class A
{
public:
void Printf() const; //声明成员函数为const,表示不会修改对象
};
通过这种方式,我们确保了 Printf
函数不会修改 const A
对象的状态,从而解决了权限放大问题。
②测试
1.2.权限缩小代码
注意:我们是允许对象的权限进行缩小的。
2.const成员函数的介绍
2.1.const成员函数定义
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数
隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
2.2.类成员函数是否加const修饰*this的判断方式
2.2.1.判断方法
判断方法:要确定一个类成员函数是否应该使用 const
修饰 *this
,需要检查该函数的实现是否修改了类的成员变量。如果成员函数内部不修改成员变量的值,那么该函数可以声明为 const
成员函数,这相当于用 const
修饰了隐式形参指针 this
解引用后的对象 *this
。
总的来说,如果成员函数在执行过程中不修改对象的任何成员变量,那么这个函数可以被声明为 const
成员函数。
2.2.2.案例
(1)案例1:
(2)案例2:
注意:
①用const修饰类成员函数的隐式形参指针this解引用后的对象*this时,若类的声明和定义分离,则若成员函数声明时有const修饰对象*this,则定义时也必须有const修饰对象*this。
②当使用 const
修饰类成员函数的隐式形参指针 this
解引用后的对象 *this
时,必须确保在类的声明和定义中保持一致性。为了确保一致性,当类的成员函数在声明时使用了 const
修饰符来修饰 *this
,那么在函数的定义中也必须使用相同的 const
修饰符。如果不保持这种一致性,将会导致编译错误,因为编译器期望 const
成员函数不会修改对象,而定义中没有 const
修饰符的函数可能会修改对象,这违反了 const
成员函数的约定。
③在C++中,const
关键字用于修饰成员函数,以表明该函数不会修改调用它的对象的状态。对于非成员函数(全局函数),通常通过在形参列表中使用 const
来修饰传递的对象的引用或指针,而不是在函数的末尾添加 const
。
总结来说,const
关键字在成员函数和非成员函数中的使用位置和目的不同,在成员函数的后面加const来修饰隐式的 this
指针,而非成员函数则直接在形参中使用 const
来修饰对象引用或指针。
3.思考下面的几个问题
3.1. const对象可以调用非const成员函数吗?
不可以。const对象只能调用const成员函数,因为const对象保证了其成员不会被修改。而非const成员函数可能会修改对象的成员,因此不允许通过const对象调用。
3.2. 非const对象可以调用const成员函数吗?
可以。非const对象可以调用const成员函数,因为const成员函数承诺不会修改对象的状态。这意味着即使通过非const对象调用,对象的状态也不会被const成员函数改变。
3.3. const成员函数内可以调用其它的非const成员函数吗?
不可以。const成员函数承诺不会修改对象的状态,如果在const成员函数内部调用非const成员函数,那么可能会违反这一承诺,因为非const成员函数可以修改对象的数据成员。因此,编译器通常不允许在const成员函数内部调用非const成员函数。
3.4. 非const成员函数内可以调用其它的const成员函数吗?
可以。非const成员函数可以调用const成员函数,因为const成员函数不会修改对象的状态,所以这种调用不会违反非const成员函数的任何约束。实际上,这是常见的一种做法,因为它允许非const成员函数利用const成员函数的逻辑,而不必担心状态被改变。
十、取地址运算符重载函数、const取地址运算符重载函数
1.编译器自动生成两种默认取地址运算符重载函数
解析:当我们没有自定义实现两种取地址运算符重载函数时,编译器会自动生成两种默认取地址运算符重载函数即A* operator&()、const A* operator&() const,如下图所示:
2.自定义实现两种取地址运算符重载函数
解析:自定义实现两种取地址运算符重载函数A* operator&()、const A* operator&() const,如下图所示:
3.两种取地址运算符重载函数的调用
3.1.取地址运算符重载函数A* operator&()的调用
3.2.const取地址运算符重载函数const A* operator&() const的调用
4.两种取地址运算符重载函数A* operator&()、const A* operator&() const 的区别
4.1.*this是否被const修饰
(1)即使类成员函数A* operator&()内部没有修改类成员变量,但是也不能用const修饰取地址运算符重载函数。
若把A* operator&()写成A* operator&() const则会使得返回值类型和形参this指针的类型不同,从而发生报错。
(2)由于类成员函数const A* operator&() const内部内部没有修改类成员变量,而且const A* operator&() const的返回值类型是const A*进而使得返回值类型和形参this指针的类型必须相同,所以可以用const修饰const取地址运算符重载函数。
由于取地址运算符重载函数和const取地址运算符重载函数的函数名相同,为了能够让它们构成函数重载则必须用const修饰const取地址运算符重载函数。
4.2.返回值不同
(1)A* operator&()返回的是非const修饰的对象地址,则可以通过解引用这个对象地址来改变对象的成员变量。
(2)const A* operator&() const返回的是const修饰的对象地址,则不可以通过解引用这个对象地址来改变对象的成员变量
十一、下标运算符‘[]’的运算符重载函数operator[](int i)的实现
1.int& operator[](int i)的实现
2.const int& operator[](int i)的实现
3.int& operator[](int i)、const int& operator[](int i)可以构成函数重载
解析:由于int& operator[](int i)成员函数和const int& operator[](int i)成员函数的参数类型不同进而使得它们构成函数重载。
3.1.int& operator[](int i)的调用
3.2.const int& operator[](int i) const 的调用
4.测试