【C++】类和对象 (中篇)

发布于:2022-10-12 ⋅ 阅读:(363) ⋅ 点赞:(0)

一、默认成员函数

在使用C语言练习初阶数据结构,即线性表、链表、栈、队列、二叉树、排序等内容时,大家可能会经常犯两个错误,特别是第二个错误,可以说是十分普遍:

  1. 在使用数据结构创建变量时忘记对其进行初始化操作而直接进行插入等操作;
  2. 在使用完毕后忘记对动态开辟的空间进行释放而直接返回;

而C++是在C语言的基础上生长起来的 – 修正C语言中的一些不足,并加入面向对象的思想;面对上面C语言存在的问题,C++设计出了默认成员;

默认成员函数:当用户没有显式实现时,编译器会自动生成的成员函数称为默认成员函数;image-20221004231352343

其中,构造函数和析构函数针对的是我们上面提出的两个问题,而其他四个函数则是适用了其他场景与需求;下面,我们来仔细探讨这6个默认成员函数的细节;


二、构造函数

1、基础知识

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的任务并不是创建对象,而是当对象被创建之后完成对象的初始化工作;同时构造函数不能由用户调用,而是在创建类类型对象时由编译器自动调用,并且在对象整个生命周期内只调用一次。

构造函数有如下特性:

  1. 函数名与类名相同;
  2. 无返回值;
  3. 对象实例化时编译器自动调用对应的构造函数;
  4. 构造函数支持重载与缺省参数;
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;
  6. 构造函数对内置类型不处理,对自定义类型调用它自身的默认构造;
  7. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个

我们以Date类为例:

class Date
{
public:
	Date()  //无参构造
	{
		_year = 1970;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month = 1, int day = 1)  //带参构造
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;  //无参
	d1.Print();

	Date d2(2022, 10, 4);  //带参
	d2.Print();
}

image-20221004232930704

可以看到,我们并没有显示的去调用构造函数,而是由编译器自动调用;而构造函数的功能其实就是我们之前Init函数的功能;

这里有两个需要注意的地方:

1、构造函数虽然支持重载和缺省参数,但是无参构造和有参全缺省构造不能同时出现,因为在调用时会产生二义性;image-20221004234347485

同时,当参数有多个时可以构成很多个重载,使得构造函数变得十分冗余,所以一般我们只会显式定义一个全缺省的构造函数,因为这一种就可以构造就可以代表很多种参数情况;image-20221004234038945

2、当我们调用无参构造或者全缺省构造来初始化对象时,不要在对象后面带括号,这样使得编译器分不清这是在实例化对象还是函数声明;image-20221004234712861


2、特性分析 – 自动生成

在构造函数的第5点特性中我们提到:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;下面我们来验证这个特性;

class Date
{
public:
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

可以看到上面的日期类中我们是没有显式的去自己实现构造函数的,所以编译器应该会自己生成一个无参的默认构造函数完成初始化工作:image-20221004235523478

但是我们发现一个问题,默认的构造函数好像并没有完成初始化工作,即d1对象中的_year,_month,_day仍然是随机值;那么是因为这里编译器生成的默认构造函数并没有什么用吗?这个问题我们需要构造函数的第六个特性来回答;

3、特性分析 – 选择处理

内置类型与自定义类型:C++把类型分成内置类型 (基本类型) 和自定义类型;内置类型就是语言本身提供的数据类型,如:int/char/double/指针,自定义类型就是我们使用 class/struct/union 等自己定义的类型,如:Stack/Queue/Date;

而构造函数的第六点特性如下:构造函数对内置类型不处理,对自定义类型调用它自身的默认构造

对于这个特性,我们使用 Date、Stack 和 Myqueue 三个类来对比理解:(注:Myqueue 即 用两个栈实现一个队列)

Date:

class Date
{
public:
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

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;

		cout << "Stack 构造" << endl;
	}

	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

Queue:

class MyQueue
{
public:
	void Push(int x)
	{
		_pushST.Push(x);
	}

	Stack _pushST;
	Stack _popST;
};

可以看到,Stack 的成员变量全部为内置类型,所以当我们不显式定义构造函数时,编译器自动生成一个默认构造函数,但默认生成的构造函数并不会对内置类型进行处理,所以这里我们看到的是随机值;Date 类的情况也是如此:image-20221005093824525

image-20221005094609275

也就是说,编译器自动生成的构造函数不能满足我们的需求,所以我们需要手动定义构造函数:image-20221005094106792

image-20221005094703896

而对于MyQueue来说,它的成员变量全部为自定义类型,所以即使我们不提供构造函数时,编译器自动生成的构造函数也会去调用自定义类型的默认构造,满足需求:image-20221005095129103

总结

那么,到底什么时候需要我们自己提供构造函数,什么时候使用编译器默认生成的构造函数呢?是内置类型全部自己定义,自定义类型全部使用默认生成的吗?答案是:面向需求当编译器默认生成的构造函数就能满足我们的需求时我们就不需要自己提供构造函数,如MyQueue;当编译器提供/的构造函数不能满足我们的需求时就需要我们自己定义,如Date/Stack;

4、特性分析 – 默认构造

构造函数的第七点特性如下:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个

上面这句话的意思就是当我们使用无参的方式实例化一个对象时,编译器会自动去调用该对象的默认构造函数,而默认构造函数有三种:编译器自动提供的无参构造函数、显式定义的无参构造函数、显式定义的全缺省的构造函数;

image-20221005100535418

image-20221005100720684

image-20221005100807435

如果类中没有默认构造函数,那么我们实例化对象时就必须传递参数:image-20221005101052064

5、C++11 补丁 – 缺省值

经过上面的学习我们发现,自动生成的默认构造函数对内置类型不处理,对自定义类型要处理的特性使得构造函数变得很复杂,因为一般的类都有需要初始化的内置类型成员变量,这就使得编译器默认生成的构造函数看起来没什么作用;

C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给缺省值;缺省值的意思就是如果构造函数没有对该变量进行初始化,那么该变量就会使用缺省值:image-20221005101822347

C++11 中的缺省值功能十分强大,它甚至可以缺省一块动态内存:image-20221005102209214

注意:这里对成员变量给定缺省值并不是对其初始化,因为类中的成员变量只是声明,只有当实例化对象之后它才具有物理空间,才能存放数据;而缺省一块动态内存也不难理解,相当于我设计了一份房屋的图纸,我知道某个房间具体要多大,所以我可以在图纸上可以进行标注,当实际建造房屋的时候根据标注给定大小即可;


三、析构函数

1、基础知识

析构函数:和构造函数相反,析构函数完成对象中资源的清理工作,并且在对象销毁时由编译器自动调用;(注:如同构造函数不是创建一个对象一样,析构函数也不是销毁一个对象,对象的销毁工作由编译器完成)

需要注意的是,当变量的生命周期结束时变量被销毁,所以位于函数中的局部对象在函数调用完成时销毁,位于全局的对象在main函数调用完成时销毁;另外,后定义的对象会被先销毁

析构函数的特性如下:

  1. 析构函数名是在类名前加上字符 ~ (表示与构造函数功能相反);;
  2. 无参数无返回值;
  3. 一个类只能有一个析构函数,若未显式定义,系统会自动生成默认的析构函数;(注意:析构函数不能重载)
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数;
  5. 析构函数对内置类型不处理,对自定义类型调用它自身的析构函数;

image-20221005105051313

2、特性分析 – 选择处理

我们还是以 Date、Stack、MyQueue 这三个类来演示:

Date:

class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};

Date 类没有进行资源的申请 (malloc 内存、fopen 文件等操作),所以我们可以不用显式定义析构函数,直接使用编译器自动生成的构造函数即可;(虽然自动生成的构造函数对内置类型不处理,但本来Date类就不需要我们做任何处理)

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;

		cout << "Stack 构造" << endl;
	}

	~Stack()
	{
		free(_a);
		_a = NULL;
		_top = _capacity = 0;
	}

	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

而 Stack 类中的成员变量_a指向了一块动态开辟的空间,如果我们使用自动生成的析构函数,那么析构函数对内置类型 int* _a 不进行处理,就会造成内存泄露;所以我们需要显式定义析构函数;

MyQueue:

class MyQueue
{
public:
	void Push(int x)
	{
		_pushST.Push(x);
	}

	Stack _pushST;
	Stack _popST;
};

MyQueue 的两个成员变量 pushST 与 popST 都是自定义类型,所以编译器会调用它们的析构函数,即 ~Stack,所以MyQueue动态开辟的空间也会得到释放,不需要我们手动定义析构函数,使用系统默认生成的即可。

总结

如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如 Date 类;有资源申请时要写,否则会造成资源泄漏,比如Stack类;但这只是一般情况,不是绝对的,最终还是要看需求 (比如 MyQueue 中我们的成员变量申请了资源,但是也不需要我们手动定义析构函数) 。


四、拷贝构造

1、基础知识

在现实生活中,可能存在一个与你一样的自己,我们称其为双胞胎:image-20221005144029539

那在创建对象时,可否创建一个与已存在对象一模一样的新对象呢?答案是可以的。C++设计了拷贝构造来实现这个功能。

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用 (一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用

拷贝构造也是特殊的成员函数,其特征如下:

  1. 拷贝构造函数是构造函数的一个重载形式,当我们使用拷贝构造实例化对象时,编译器不再调用构造函数;
  2. 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;
  3. 若未显式定义,编译器会生成默认的拷贝构造函数;
  4. 默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数;

Date 类的拷贝构造:

class Date
{
public:
	Date(int year = 1970, 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;
	}

private:
	int _year;
	int _month;
	int _day;
};

image-20221005150710114

2、特性分析 – 引用作参数

拷贝构造的第二点特性如下:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用image-20221005150828401

原因如下:当我们使用d1来拷贝构造创建d2对象时,编译器会自动调用拷贝构造函数,但是我们知道,传值传递时形参是实参的一份临时拷贝;也就是说,拷贝构造函数在执行其函数体中的指令之前,其形参d需要先拷贝一份d1,而d拷贝d1又需要调用拷贝构造函数,如此下去就会引发无穷递归;image-20221005145946375

但是如果拷贝构造函数的参数是引用的话,形参作为实参的别名,不需要拷贝实参,从而使得函数功能顺利实现;

另外,拷贝构造函数的参数通常使用 const 修饰,这是为了避免在函数内部拷贝出错,类似下面这样:image-20221005151515360

3、特性分析 – 深浅拷贝

默认拷贝构造的拷贝规则如下:默认的拷贝构造函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的拷贝构造函数

对于深浅拷贝,我们以栈为例:

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;

		cout << "Stack 构造" << endl;
	}

	~Stack()
	{
		free(_a);
		_a = NULL;
		_top = _capacity = 0;
	}
    
	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

可以看到,我们并没有显式定义 Stack 的拷贝构造函数,那么编译器会自动生成一个拷贝构造,并且会将 Stack 的成员变量 _a、_top、_capacity 按字节拷贝到d2对象中;image-20221005153318021

我们继续往下调试,发现程序异常:image-20221005153539912

对C语言动态内存管理较为敏感的同学可能已经发现了问题:编译器按字节将d1中的内容拷贝到d2中,但成员变量_a指向的是一块动态内存,即_a中存放的是动态空间的起始地址,那么将d1的_a拷贝给d2的_a后,二者指向同一块空间,而main调用完毕时会销毁d1和d2对象,此时编译器会自动调用 Stack 的析构函数,这就造成 _a 指向的同一块空间被析构了两次,从而引发异常;同时,这也造成了我们在 d2中插入3时也改变了d1中的数据;image-20221005154837459

那么正确的拷贝方式应该是:为d2的_a单独开辟一块空间,并将d1中_a指向空间的内容拷贝到该空间中,其余内置成员变量再按字节拷贝:

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;

		cout << "Stack 构造" << endl;
	}

	~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;
	}

	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1;
	st1.Push(1);
	st1.Push(2);

	Stack st2(st1);
	st2.Push(3);
	return 0;
}

image-20221005155459283

image-20221005155654053

在了解了 Stack 的拷贝构造之后,我们再来看 Date 类和 MyQueue 类的拷贝构造;

对于 Date 类来说,其成员变量全是内置类型,且没有资源申请,所以我们可以直接使用编译器默认生成的拷贝构造,直接按字节拷贝:image-20221005160254131

对于 MyQueue 类来说,它的成员变量全部是自定义类型,所以默认成员函数回去调用其自身的拷贝构造,即 Stack 的拷贝构造,而 Stack 的拷贝构造虽然需要深拷贝,但我们已经显式定义,所以也不需要我们提供拷贝构造:image-20221005161159784

总结

如果类中没有资源申请,则不需要手动实现拷贝构造,直接使用编译器自动生成的即可;如果类中有资源申请,就需要自己定义拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况;

其实,拷贝构造和函数析构函数在资源管理方面有很大的相似性,可以理解为需要写析构函数就需要写拷贝构造,不需要写析构函数就不需要写拷贝构造;

拷贝构造的经典使用场景:

  • 使用已存在对象创建新对象;
  • 函数参数类型为类类型对象;
  • 函数返回值类型为类类型对象;

五、运算符重载

1、运算符重载的引入

对于C/C++编译器来说,它知道内置类型的运算规则,比如整形+整形、指针+整形、浮点型+整形;但是它不知道自定义类型的运算规则,比如日期+天数 、日期直接比较大小、日期-日期;我们要进行这些操作就只能去定义对于的函数,比如AddDay、SubDay;但是这些函数的可读性始终是没有 + - > < 这些符号的可读性高的,而且不同程序员给定的函数名称也不一样相同;

所以为了增强代码的可读性,C++为自定义类型引入了运算符重载,运算符重载是具有特殊函数名的函数 – 其函数名为关键字operator+需要重载的运算符符号,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似;换句话说,运算符重载函数只有函数名特殊,其他方面与普通函数一样;我们以日期+天数为例:

Date类:

class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//获取每个月的天数
	int GetMonthDay(int year, int month)
	{
		static int day[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;
		return day[month];
	}

	//打印
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

函数方式实现:

void AddDay(Date& d, int day)
{
	d._day += day;
	while (d._day > GetMonthDay(d._year, d._month))
	{
		d._day -= GetMonthDay(d._year, d._month);
		d._month++;

		if (d._month > 12)
		{
			d._month -= 12;
			d._year++;
		}
	}
}

运算符重载方式实现:

void operator+=(Date& d, int day)
{
	d._day += day;
	while (d._day > GetMonthDay(d._year, d._month))
	{
		d._day -= GetMonthDay(d._year, d._month);
		d._month++;

		if (d._month > 12)
		{
			d._month -= 12;
			d._year++;
		}
	}
}

image-20221009203419368

2、运算符重载函数的位置

如果大家实际上手编写我们上面的 AddDay 和 operator+= 函数就会发现一个问题:类中的成员函数 _year、_month、_day 都是私有的,我们在类外并不能直接修改它们;image-20221009204245298

但是我们又不能直接把成员变量设为共有,这样类的封装线得不到保证;那么如果我们把函数放到类里面呢?比如下面这样:image-20221009204412103

上面这种情况是由我们在 类和对象上篇 中提到的 this 指针引起的 – 类的每个成员函数的第一个参数都是一个隐藏的 this 指针,它指向类的某一个具体对象,且 this 不能显示传递,也不能显示写出,但是可以在函数内部显示使用;

也就是说,本来 += 这个操作符只能有两个操作数,所以使用 operator 重载 += 得到的函数也只能有两个参数;但是由于我们为了使用类的成员变量将函数放在了类内部,所以编译器自动传递了对象的地址,并且在函数中使用一个 this 指针来接收,导致函数参数变成了三个;所以出现了 “operator += 的参数太多” 这个报错;

那么为了解决这个问题,我们在定义 operator+= 函数时,就只显式的传递一个参数 – 右操作数,而左操作数由编译器自动传递;当我们在函数内部需要操作左操作数时,也直接操作 this 指针即可;

还是以日期+天数为例;

//运算符重载+=
void operator+=(int day)  //只传递右操作数,通过this操作左操作数
{
    this->_day += day;  //这里的this->编译器会自动添加
    while (_day > GetMonthDay(_year, _month))
    {
        _day -= GetMonthDay(_year, _month);
        _month++;

        if (_month > 12)
        {
            _month -= 12;
            _year++;
        }
    }
}

image-20221009210114366

注意

1、当我们将函数放在类内部时,不管操作数有几个,this 默认指向第一个操作数;

2、对于在类外部无法访问类的私有成员变量的问题其实也可以使用友元解决,我们后面再学习;

3、运算符重载的特性

运算符重载函数有如下特性:

  • 不能通过连接其他符号来创建新的操作符:比如operator@;
  • 重载操作符必须有一个类类型参数 (因为运算符重载只能对自定义类型使用);
  • 用于内置类型的运算符,其含义不能改变,即不能对内置类型使用运算符重载;
  • 作为类类的成员函数重载时,其形参看起来比操作数数目少1,是因为成员函数的第一个参数为隐藏的 this;
  • 以下5个运算符不能重载:**.* :: sizeof . ?: ** 注意这个经常在笔试选择题中出现,特别是 **.***操作符,希望大家记住;

4、常见的运算符重载

常见的运算符重载有:operator+ (+)、operator- (-)、operator* (*)、operator/(/)、operator+= (+=)、operator-= (-=)、operator== (==)、operator= (=)、operator> (>)、operator< (<)、operator>= (>=)、operator<= (<=)、operator!= (!=)、operator++ (++)、operator-- (–)等;

其中,对于 operator++ 和 operator-- 来说有一些不一样的地方 – 因为 ++ 和 – 分为前置和后置,二者虽然都能让变量自增1,但是它们的返回值不同;但是由于 ++ 和 – 只有一个操作数,且这个操作数还会由编译器自动传递;所以正常的 operator++ 和 operator-- 并不能对二者进行区分;最终,C++规定:后置++/–重载时多增加一个int类型的参数,此参数在调用函数时不传递,由编译器自动传递

其次,上面重载函数中的 operator= 就是默认成员函数之一 – 赋值重载函数

注:由于运算符重载函数很多,情况也比较复杂,所以我们将运算符重载的详细细节 (比如引用做返回值、引用做参数、函数的复用、对特殊情况的处理等知识) 放在 Date 类的实现中去介绍;


六、赋值重载

1、基础知识

赋值重载函数是C++的默认六个成员函数之一,它也是运算符重载的一种,它的作用是两个已存在的对象之间的赋值,其特性如下:

  1. 赋值重载的格式规范;
  2. 赋值运算符只能重载成类的成员函数不能重载成全局函数;
  3. 若未显式定义,编译器会生成默认的赋值重载函数;
  4. 默认的赋值重载函数对内置类型以字节为单位直接进行拷贝 – 浅拷贝,对自定义类型调用其自身的赋值重载函数;

2、特性分析 – 函数格式

赋值重载函数的格式一般有如下要求:

使用引用做参数,并以 const 修饰

我们知道,使用传值传参时函数形参是实参的一份临时拷贝,所以传值传参会调用拷贝构造函数;而使用引用做参数时,形参是实参的别名,从而减少了调用拷贝构造在时间和空间上的消耗;另外,赋值重载只会改变被赋值对象,而不会改变赋值对象,所以我们使用 const 来防止函数内部的误操作;

void operator=(const Date& d);

使用引用做返回值且返回值为*this

我们可以对内置类型进行连续赋值,比如 int i,j; i = j = 0; 那么对于自定义类型来说,我们也可以使用运算符重载来让其支持连续赋值,则重载函数就必须具有返回值;同时,由于我们是在函数外部调用重载函数,所以重载函数调用结束后该对象仍然存在,那么我们就可以使用引用作为函数的返回值,从而减少一次返回值的拷贝,提高程序效率;

另外,我们一般使用左操作数作为函数的返回值,也就是 this 指针指向的对象;

Date& operator=(const Date& d);

检测是否自己给自己赋值

用户在调用成员函数时有可能发生下面这种情况:Date d1; Date& d2 = d1; d1 = d2; 这种情况对于只需要浅拷贝的对象来说并没有什么大碍,但对于有资源申请,需要进行深拷贝的对象来说就会发生不可控的事情,具体案例我们在第四点特性中讲解;

在 《Effective C++》中对赋值重载函数自我赋值的解释是这样的:image-20221010222559294

if(this == &d)  //比较两个对象的地址是否相同
	return *this;

Date 类的赋值重载函数如下:

//赋值重载
Date& operator=(const Date& d)
{
    //自我赋值
    if (this == &d)  
    {
        return *this;
    }

    _year = d._year;
    _month = d._month;
    _day = d._day;

    return *this;
}

image-20221010111021472

3、特性分析 – 重载为成员函数

赋值运算符只能重载成类的成员函数不能重载成全局函数,这是因为赋值重载函数作为六个默认成员函数之一,如果我们不显示实现,编译器会默认生成;此时用户如果再在类外自己实现一个全局的赋值运算符重载,就会和编译器在类中生成的默认赋值运算符重载冲突,从而造成链接错误;

《 C++ prime 》中对此特性的说明如下:image-20221010223323189

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
    
	int _year; 
	int _month;
	int _day;
};

Date& operator=(Date& left, const Date& right)
{
	if (&left != &right)
	{
		left._year = right._year;
		left._month = right._month;
		left._day = right._day;
	}
	return left;
}

image-20221010223752225

4、特性分析 – 深浅拷贝

赋值重载函数的特性和拷贝构造函数非常类似 – 如果我们没有显式定义赋值重载,则编译器会自动生成一个赋值重载,且自动生成的函数对内置类型以字节为单位直接进行拷贝,对自定义类型会去调用其自身的赋值重载函数

所以对于没有资源申请的类来说,我们不用自己去写赋值重载函数,直接使用默认生成的即可,因为这种类只需要进行浅拷贝 (值拷贝),比如 Date 类:image-20221010224541395

注:拷贝构造函数完成的是初始化工作,在创建对象时自动调用;赋值重载完成的是已存在的对象之间的拷贝,需要手动调用;而上图中 Date d2 = d1 是在创建 d2 并对其进行初始化,所以调用的是拷贝构造函数;d3 才是调用赋值重载函数;

而对于有资源申请的类来说,我们必须自己手动实现赋值重载函数,来完成深拷贝工作;比如 Stack 类:image-20221010225340292

image-20221010225812040

如图:这里的情况和 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;
}

image-20221010231202052

对于上面这段程序,可能有的同学会有这样一种疑问:我们可不可以直接对 st1._a 进行扩容呢?那样就不必释放后再出现申请空间了;答案是:直接扩容不是不行,但是不好,因为如果当 st1._capacity 大于 st2._capacity ,我们这时调用 realloc 就是缩容,而缩容需要重新开辟空间并拷贝原数据,效率太低;而如果面对这种情况我们不缩小空间直接拷贝数据的话又会造成空间的浪费;所以先释放原空间再开辟新空间是一种折中的办法;

现在我们为 Stack 类显示定义了赋值重载函数,那么我们再来运行一个新的测试用例:image-20221010234541145

我们发现,当我们使用 st2 自己给自己赋值时,st2._a 中的数据变成了随机值;原因如下:operator= 函数首先会将 st2._a 指向的空间释放,然后再为其申请新空间,但是由于 st2 自己给自己赋值,所以使用 memcpy 拷贝的是新开辟的空间中的数据,即随机值;image-20221011000021134

所以说,在赋值重载函数的函数格式规范中我们强调一定要检查自我赋值;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 类,我们不写编译器调用默认生成的赋值重载函数,而默认生成的对于自定义类型会去调用它们自身的赋值重载函数;image-20221010232909622

总结

自动生成的赋值重载函数对成员变量的处理规则和析构函数一样 – 对内置类型以字节方式按值拷贝,对自定义类型调用其自身的赋值重载函数;我们可以理解为:需要写析构函数的类就需要写赋值重载函数,不需要写析构函数的类就不需要写赋值重载函数


七、取地址及 const 取地址重载

1、const 成员函数

我们将 const 修饰的 “成员函数” 称之为 const 成员函数,const 修饰类成员函数实际上修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对 this 指向的类中的任何成员变量进行修改

我们以Date类为例:image-20221011214108581

我们看到,当我们定义了一个只读的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*;函数的其他参数不受影响;image-20221011224317226

image-20221011224240768

将成员函数的 this 指针类型修饰为 const Date* const 后,不仅 const Date 的对象可以调用相应成员函数;正常的 Date 对象也可以调用,因为权限虽然不能扩大,但能缩小;image-20221011224717093

所以,当我们在实现一个类时,如果我们不需要改变类的成员函数的第一个参数,即不改变 *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;
}

image-20221011231348391

3、const 取地址重载

const 取地址重载也是C++的默认六个成员函数之一,它是取地址重载的重载函数,其作用是返回 const 对象的地址;

const Date* operator&() const
{
    return this;
}

image-20221012171704458

如果我们没有显式定义取地址重载和 const 取地址重载函数,那么编译器会自动生成,因为这两个默认成员函数十分固定,所以大多数情况下我们直接使用编译器默认生成的即可,不必自己定义;

某些极少数的特殊情况下需要我们自己实现取地址重载与 const 取地址重载函数,比如不允许获取对象的地址,那么在函数内部我们直接返回 nullptr 即可:

//取地址重载
Date* operator&()
{
    return nullptr;
}

//const 取地址重载
const Date* operator&() const
{
    return nullptr;
}

image-20221012172334253


八、总结

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 取地址重载是获取一个对象/一个只读对象的地址,需要自己显式调用;它们属于运算符重载,同时它们二者之间还构成函数重载;
  • 大多数情况下我们都不会去显示实现这两个函数,使用编译器默认生成的即可;只有极少数情况需要我们自己定义,比如防止用户获取到一个对象的地址;

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到