C++深入浅出(三)—— 类和对象(中)

导读:本篇文章讲解 C++深入浅出(三)—— 类和对象(中),希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com


前言

本篇文章是「类和对象」的第二篇,主要介绍 类的6个默认成员函数,这篇文章非常的重点!

1. 类的 6 个默认成员函数

如果一个类中什么成员都没有,简称为空类(如下👇)。

class Date
{

};

空类中什么都没有吗?

并不是的,任何一个类在我们不写的情况下,都会自动生成下面 6 个默认成员函数。
在这里插入图片描述

注意:

其实严格上有 8 个默认成员函数,其余 2 个是 C++11 的,后面会涉及到,其次 C++98 这里有 2 个是不太重要的,提一下即可。

另外,这里的 “默认”“缺省” 的意思差不多,也就是你不写这 6 个函数,编译器会自动生成,你若是写了,则编译器就不生成了。

2. 构造函数

🍑 概念

对于下面 Date 类,可以通过 Init 公有的方法给对象设置内容,但是如果每次创建对象都调用该方法设置信息,未免有点麻烦,那能否在对象创建时,就将信息设置进去呢?

📝 代码示例

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_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.Init(2022, 5, 1);
	d1.Print();

	Date d2;
	d2.Init(2022, 7, 1);
	d2.Print();
	return 0;
}

这时候就引出了我们的 构造函数!

🍑 特性

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次。

需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务 并不是开空间创建对象,而是初始化对象。

🍅 特性一

(1)构造函数的函数名与类名相同

在这里插入图片描述

🍅 特性二

(2)构造函数无返回值

这里是指,函数的返回值不用写,并不是 void

在这里插入图片描述

🍅 特性三

(3)对象实例化时编译器自动调用对应的构造函数

当你用类创建一个对象时,编译器会自动调用该类的构造函数对新创建的变量进行初始化。

🍅 特性四

(4)构造函数可以重载

这意味着你可以有多种初始化对象的方式,编译器会根据你所传递的参数去调用对应的构造函数。

📝 代码示例

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

	//带参的构造函数
	Date(int year, int month, int day) {
		_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(2000, 10, 1); //调用带参的构造函数
	d2.Print();
	return 0;
}

调用结果

在这里插入图片描述

注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明

比如:我定义了 Date d3(),这句代码的意思是,声明了 d3 函数,该函数无参,返回一个日期类型的对象,编译的时候会有警告,这种是不对的!

在这里插入图片描述

🍅 特性五

(5)无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。

注意:无参构造函数全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,也就是说,不用传参就可以调用的。

所以,一般情况下,最好把构造函数写成全缺省的:

class Date
{
public:
	//全缺省的构造函数
	Date(int year = 1, 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(2000, 10, 1);
	d2.Print();
	return 0;
}

调用过程如下:

在这里插入图片描述

🍅 特性六

(6)如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。

📝 代码示例

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

int main()
{
	Date d1; // 没有定义构造函数,对象也可以创建成功,因为此处调用的是编译器生成的默认构造函数
	d1.Print();

	return 0;
}

可以看到,编译器将调用 自动生成 的默认构造函数对 d1 进行初始化。

在这里插入图片描述

看到这里,我相信大家会有很多疑惑,在我们不实现构造函数的情况下,编译器会生成默认的构造函数。

d1 对象调用了编译器生成的默认构造函数,但是 d1 对象的 _year、_month、_day,依旧是随机值。

也就说在这里编译器生成的默认构造函数并没有什么用???

这是因为编译器有一套自动生成的构造函数机制:

  • 编译器自动生成的构造函数对内置类型不做处理。
  • 对于自定义类型,编译器会再去调用它们自己的默认构造函数。

🍅 特性七

(7)如果一个类中的成员全是自定义类型,我们就可以用默认生成的构造函数;如果有内置类型的成员,或者需要显示传参初始化,那么都要自己实现构造函数。

C++ 把类型分成 内置类型(基本类型)和 自定义类型

  • 内置类型(基本类型):intchardouble指针 等等…
  • 自定义类型:class 或者 struct 定义类型的对象。

看看下面的程序,就会发现编译器生成默认的构造函数,会对自定类型成员 _aa 调用的它的默认成员函数

class A
{
public:
	// 无参构造函数
	A()
	{
		cout << " A()" << endl;
		_a = 0;
	}
private:
	int _a;
};

class Date
{
public:
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日

	A _aa;
};

int main()
{
	Date d1;
	d1.Print();

	return 0;
}

可以看到:默认生成构造函数 对于内置类型成员变量不做处理,对于自定义类型成员变量才会处理

在这里插入图片描述

我知道大家会很晕,因为你们可能认为,我们不写,编译器默认生成的构造函数就叫做构造函数,这个是不对的!

它还有无参的构造函数和全缺省的构造函数都是默认构造函数。

我们再来看一个例子,就以 栈实现队列 来举例:

class Stack
{
public:
    //构造函数
    Stack(int capacity = 10) {
        _a = (int*)malloc(sizeof(int) * capacity);
        assert(_a);

        _top = 0;
        _capacity = capacity;
    }
private:
    int* _a;
    int _top;
    int _capacity;
};

//队列
class MyQueue {
public:
    void push(int x) {
        //操作 ...
    }

    int pop() {
        //操作 ...
    }
private:
    Stack _st1;
    Stack _st2;
    int _size;
};

我们定义了一个栈,并且在栈里面声明了 3 个 内置类型 的变量;然后我们又定义了一个队列,是用栈的类型来声明了 2 个 自定义类型 的变量 和 1 个 内置类型 的变量。

那么此时在队列里面我们并没有给构造函数,那么编译器就会在类里面查找,发现 _st1_st2 是自定义类型的,所以它们就会去栈里面调用写好的构造函数;但是 _size 是内置类型的,所以编译器不做处理,默认给个随机值:

在这里插入图片描述

🍑 总结

默认构造函数3 种:

  1. 我们不用手动去写,编译器自动生成 的。
  2. 我们自己写的 无参 的构造函数。
  3. 我们自己写的 全缺省 的构造函数。

虽然我们在不写的情况下,编译器会自动生成构造函数,但是编译器自动生成的构造函数可能达不到我们想要的效果,所以大多数情况下都需要我们自己写构造函数,并且最好是写全缺省的构造函数。

3. 析构函数

🍑 概念

前面通过构造函数的学习,我们知道了一个对象是怎么来的,那么一个对象又是怎么没呢的?

析构函数: 与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。

📝 代码示例

class Date
{
public:
	// 全缺省的构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

	// 析构函数
	~Date()
	{
		cout << "~Date()" << endl; // 证明我来过
	}
private:
	int _year;
	int _month;
	int _day;
};


int main()
{
	Date d1(2022, 9, 1);
	d1.Print();

	return 0;
}

我这里给了 全缺省 的构造函数,然后给了一个析构函数,并且在里面加了一个打印,因为编译器是会默认调用这个析构函数。

在这里插入图片描述

注意:像 Date 这样的类是不需要析构函数的,因为它内部没有什么资源需要清理。

🍑 特性

析构函数是特殊的成员函数。

我们知道,当一个类对象销毁时,其中的局部变量也会随着该对象的销毁而销毁。

例如,我们用日期类创建了一个对象 d1,当 d1 被销毁时,对象 d1 当中的局部变量 _year、_month、_day 也会被编译器销毁。

但是这并不意味着析构函数没有什么意义。

像栈(Stack)这样的类对象,当该对象被销毁时,其中动态开辟的栈并不会随之被销毁,需要我们对其进行空间释放,这时析构函数的意义就体现了。

📝 代码示例

class Stack
{
public:
	// 构造函数
	Stack(int capacity = 2)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a == nullptr)
		{
			cout << "malloc fail" << endl;
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	// 析构函数
	~Stack()
	{
		free(_a);
		_a = nullptr;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st;
	return 0;
}

在数据结构中,我们实现栈时都需要写一个 Destroy 在程序结束前销毁动态开辟的内存,如果使用完动态开辟的内存没有及时销毁,那么就会导致内存泄漏的操作。

而析构函数的出现就是为了解决这种场景的,对象实例化后,同构造函数一样,它不需要我们主动调用,它是在对象生命周期结束后自动调用,需要注意的是,析构函数没有参数所以不能重载。

构造函数是为了替代 Init 进行初始化,析构函数是为了替代 Destroy 进行销毁。

🍅 特性一

(1)析构函数名是在类名前加上字符 ~

class Date
{
public:
	// 构造函数
	Date()
	{}

	// 析构函数
	~Date()
	{}
private:
	int _year;
	int _month;
	int _day;
}

🍅 特性二

(2)析构函数无参数,无返回值

这一点和构造函数一样。

🍅 特性三

(3)对象生命周期结束时,C++ 编译器会自动调用析构函数

这就大大降低了 C 语言中栈空间忘记释放问题的发生,因为当栈对象生命周期结束时,C++ 编译器会自动调用析构函数对其栈空间进行释放。

🍅 特性四

(4)一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数

  • 编译器自动生成的析构函数对内置类型不做处理。
  • 对于自定义类型,编译器会再去调用它们自己的默认析构函数。

为什么内置类型不处理呢?

因为它不好处理,如果这个指针是一个文件指针,那你也要去 free 吗 ?

那对于什么样的类可以不写析构呢或者它的价值是什么呢?

我们还是拿栈实现队列来举例:

//栈
class Stack
{
public:
    //构造函数
    Stack(int capacity = 10) {
        _a = (int*)malloc(sizeof(int) * capacity);
        if (_a == NULL) {
            exit(-1);
        }

        _top = 0;
        _capacity = capacity;
    }
    //析构函数
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
private:
    int* _a;
    int _top;
    int _capacity;
};

//队列
class MyQueue {
public:
    void push(int x) {
        //操作 ...
    }

    int pop() {
        //操作 ...
    }
private:
    Stack _st1;
    Stack _st2;
};

int main()
{
	MyQueue q;
	return 0;
}

现在对于 MyQueue,我们可以不写构造函数和析构函数,让编译器自动生成构造函数和析构函数也可以 初始化销毁,具体如下:

首先,调用自定义类型的构造函数:

在这里插入图片描述

然后,再调用自定义类型的析构函数:

在这里插入图片描述

🍅 特性五

(5)先构造的后析构,后构造的先析构

因为对象是定义在函数中的,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合先进后出的原则。

在这里插入图片描述

4. 拷贝构造函数

🍑 概念

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

在这里插入图片描述

那在创建对象时,可否创建一个与对象一模一样的新对象呢?

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

📝 代码示例

class Date
{
public:
	//构造函数
	Date(int year = 1, 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;
};

int main()
{
	Date d1(2021, 5, 31);
	Date d2(d1); // 用已存在的对象d1创建对象d2

	return 0;
}

我们可以调试可以看到 d2 就是 d1 的拷贝构造

在这里插入图片描述

🍑 特性

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

🍅 特性一

(1)拷贝构造函数是构造函数的一个重载形式

因为拷贝构造函数的函数名也与类名相同。

🍅 特性二

(2)拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用

这个是重点,需要好好讲一下

📝 代码示例

class Date
{
public:
	Date(int year = 0, int month = 1, int day = 1)// 构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(Date d)// 拷贝构造函数
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022, 9, 1);
	Date d2(d1); // 用已存在的对象d1创建对象d2

	return 0;
}

思考一下,上面这段代码能不能正常运行呢?答案是:肯定不行的!

在这里插入图片描述

这里会引发一个无穷递归的现象,只是语法进行了强制检查,所以它由运行时错误转向了编译时错误。

在这里插入图片描述

为了便于理解,可以看下面:
在这里插入图片描述

要调用 拷贝构造函数 就需要先 传参,若传参使用传值传参,那么在传参过程中又需要进行对象的拷贝构造,如此循环往复,最终引发无穷递归调用。

但是对于这种问题,有什么解决办法呢?

这里用一个 引用 来解决了问题,当使用了 引用 以后,形参就是实参的别名,即 dd1 的别名。

但是,如果不希望 d 被改变的话,最好在实参部分加上 const

在这里插入图片描述

🍅 特性三

(3)若未显示定义,系统生成默认的拷贝构造函数

默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝我们叫做 浅拷贝,或者 值拷贝

📝 代码示例

class Date
{
public:
	Date(int year = 0, 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(2021, 5, 30);
	Date d2(d1); // 用已存在的对象d1创建对象d2

	d1.Print();
	d2.Print();
	return 0;
}

运行结果

在这里插入图片描述

代码中,我们自己并没有定义拷贝构造函数,但编译器自动生成的拷贝构造函数最终还是完成了对象的拷贝构造。

编译器自动生成的拷贝构造函数机制:

  • 编译器自动生成的拷贝构造函数对内置类型会完成浅拷贝(值拷贝)。
  • 对于自定义类型,编译器会再去调用它们自己的默认拷贝构造函数。

🍅 特性四

(4)编译器自动生成的拷贝构造函数不能实现深拷贝

编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?

当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

📝 代码示例

class Stack
{
public:
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a == nullptr)
		{
			cout << "malloc fail" << endl;
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}
	~Stack()
	{
		free(_a);
		_a = nullptr;
	}
private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1;
	Stack st2(st1);
	return 0;
}

从调试结果可以看到,已经完成了拷贝:

在这里插入图片描述

但是程序崩溃了!

在这里插入图片描述

那么为什么会崩溃呢?

st1 栈和 st2 栈空间的地址相同,这就意味着,就算在创建完 st2 栈后,我们对 st1 栈做的任何操作都会直接影响到 st2 栈。

在这里插入图片描述
为什么呢?

因为 st2 栈 是 st1 栈的浅拷贝(值拷贝),所以 st2._ast1._a 两个指针存储的是同一个地址,也就是说这两个指针指向同一块儿空间。

首先我们自己定义的析构函数是正确的情况下,当程序运行结束,st2 栈将被先析构,也就是说 st2st1 指向同一块儿空间已经被释放了,那么当 st1 栈再去调用析构函数的时候,会再次对那一块空间进行释放。

这种情况下,出现了对同一块空间释放多次的问题,程序肯定会崩溃的!

显然这不是我们希望看到的结果,我们希望在创建时,st2 栈和 st1 栈中的数据是相同的,并且在创建完 st2 栈后,我们对 st1 栈和 st2 栈之间的任何操作能够互不影响。

可以看到,这种情况下编译器自动生成的拷贝构造函数就不能满足我们的要求了。

🍑 总结

我们不写,编译器会默认生成一个拷贝构造:

(1)内置类型的成员会完成值拷贝,也就是浅拷贝。

Date 这样的类,需要的就是浅拷贝,那么编译器自动生成的拷贝构造函数就够用了,我们不需要自己写。

(2)自定义类型的成员,去调用这个成员的拷贝构造

Stack 这样的类,它是自己直接管理资源,那么需要自己实现深拷贝,浅拷贝的话会导致析构两次、程序崩溃等问题。

5. 赋值运算符重载

🍑 运算符重载

假设我们创建了一个 Date 类,定义了一个对象 d1d2,现在需要判断 d1d2 是否相等。

📝 代码示例

class Date
{
public:
	//构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022, 9, 1);
	Date d2(2022, 9, 1);

	d1 == d2;

	return 0;
}

当我们编译时,会报错👇

在这里插入图片描述

为什么会报错呢?那是因为 运算符默认都是给内置类型变量用的。自定义类型的变量想用这些运算符,得自己实现运算符的重载。

运算符重载指的是需要自己写一个函数实现这里运算符的行为。

C++ 为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。

  • 函数名:关键字 operator 后面接需要重载的运算符符号。
  • 参数:运算符操作数
  • 返回值:运算符运算后的结果

函数原型: 返回值类型 operator操作符(参数列表)

注意:

  1. 不能通过连接其他符号来创建新的操作符:比如 operator@
  2. 重载操作符必须有一个类类型或者枚举类型的操作数。
  3. 用于内置类型的操作符,其含义不能改变,例如:内置的整型 +,不能改变其含义
  4. 作为类成员的重载函数时,其形参看起来比操作数数目少1成员函数的操作符有一个默认的形参this,限定为第一个形参。
  5. .*::sizeof?:. 这 5 个运算符不能重载。

这里以重载 == 运算符作为例子:我们可以将该运算符重载函数作为类的一个成员函数,此时该函数的第一个形参默认传的是 this 指针。

📝 代码示例

class Date
{
public:
	Date(int year = 0, int month = 1, int day = 1) // 构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
	// 等价于 bool operator==(Date* this, const Date& d2)
	// 这里需要注意的是,左操作数是this指向的调用函数的对象
	bool operator==(const Date& d) // ==运算符重载
	{
		return _year == d._year
			&& _month == d._month
			&& _day == d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022, 9, 1);
	Date d2(2022, 9, 1);

	d1.operator==(d2);
	d1 == d2;//同上,编译器会自动识别转换为 d1.operator==(d2) ---> d1.operator(&d1, d2);
	
	return 0;
}

我们可以打印看下结果(bool 返回的 1 就表示 true

在这里插入图片描述

我们也可以将该运算符重载函数放在类外面,但此时外部无法访问类中的成员变量,这时我们可以将类中的成员变量设置为共有(public),这样外部就可以访问该类的成员变量了(也可以用 友元 函数解决该问题)。

并且在类外没有 this 指针,所以此时函数的形参我们必须显示的设置两个。

📝 代码示例

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1) // 构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}
//private:
public://这里破坏封装使得可以在类外访问成员
	int _year;
	int _month;
	int _day;
};

bool operator==(const Date& d1, const Date& d2)// ==运算符重载函数
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1._day == d2._day;
}

int main()
{
	Date d1(2022, 9, 1);
	Date d2(2022, 9, 1);

	operator==(d1, d2); // 可以这样调用,但是这样可读性很差,还不如写一个函数
	
	d1 == d2; //同上,如果没有重载会报错,如果重载了它会转换为 operator==(d1, d2);

	cout << (d1 == d2) << endl;

	return 0;
}

可以看到,这样也是可以滴!

在这里插入图片描述

不推荐这种写法,因为破坏了封装!

以上两种写法编译器会自动识别转换,如果是全局函数那么它会转换成 operator==(d1, d2);

如果是成员函数那么它会转换成 d1.operator(d2);

不管是全局还是成员一般我们都是直接写 d1 == d2

🍑 赋值运算符重载

上面我们重载了 == 这个符号,这里我们要重载的是 = 符号。

📝 代码示例

class Date
{
public:
	// 默认生成的析构函数,内置类型成员不做处理,自定义类型成员会去调用它的析构函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

	// d2 = d1; --> d2.operator=(&d2, d1)
	// d1 = d1
	Date& operator=(const Date& d)
	{
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}

		return *this;
	}
private:
	int _year;
	int _month;
	int _day;
};


int main()
{
	Date d1(2022, 9, 1);
	Date d2(2022, 9, 2);

	Date d3(d1); // 拷贝构造  -- 一个存在的对象去初始化另一个要创建的对象

	d3 = d2 = d1;  // 赋值重载/复制拷贝 --> 两个已经存在对象之间赋值
	
	(d3 = d2) = d1; // 赋值重载/复制拷贝 --> 两个已经存在对象之间赋值

	return 0;
}

我们可以调用 Print 函数,看一下结果👇

在这里插入图片描述

有一个特殊情况需要思考一下,比如下图中,你是不是以为我们把 d1 赋值给了 d3 呢?

当然不是的,这里不是 赋值,而是 拷贝构造,因为这是在 实例化对象,它等同于 Date d3(d1);

在这里插入图片描述

所以我们这里不能使用 拷贝构造 替代 赋值运算符重载 ,因为它们的使用场景是不一样滴:

  • 拷贝构造是用于一个对象准备定义时,用另一个对象来初始化它;
  • 赋值运算符重载是用于两个已经定义出来的对象间的拷贝复制。

🍑 特性

赋值运算符主要有以下 5 点。

🍅 特性一

(1)参数类型设置为引用,并用 const 进行修饰

赋值运算符重载函数的第一个形参默认是 this 指针,第二个形参是我们赋值运算符的右操作数。

由于是自定义类型传参,我们如果使用 传值 传参,会额外调用一次拷贝构造函数,所以函数的第二个参数最好使用 引用传参(第一个参数是默认的 this 指针,我们不用管)。

其次,第二个参数,即赋值运算符的右操作数,我们在函数体内不会对其进行修改,所以最好加上 const 进行修饰。

在这里插入图片描述

🍅 特性二

(2)函数的返回值使用 引用 返回

实际上,我们若是只以 d2 = d1 这种方式使用赋值运算符,赋值运算符重载函数就没必要有返回值,因为在函数体内已经通过 this 指针对 d2 进行了修改。

但是为了支持连续赋值,即 d3 = d2 = d1,我们就需要为函数设置一个返回值了,而且很明显,返回值应该是赋值运算符的左操作数,即 this 指针指向的对象。

和使用 引用 传参的道理一样,为了避免不必要的拷贝,我们最好还是使用 引用 返回,因为此时出了函数作用域 this 指针指向的对象并没有被销毁,所以可以使用 引用 返回。

在这里插入图片描述

🍅 特性三

(3)赋值前检查是否是给自己赋值

若是出现 d1 = d1,我们不必进行赋值操作,因为自己赋值给自己是没有必要进行的。

所以在进行赋值操作前可以先判断是否是给自己赋值,避免不必要的赋值操作。

在这里插入图片描述

🍅 特性四

(4)引用返回的是 *this

赋值操作进行完毕时,我们应该返回赋值运算符的左操作数,而在函数体内我们只能通过 this 指针访问到左操作数,所以要返回左操作数就只能返回 *this

在这里插入图片描述

🍅 特性五

(5) 一个类如果没有显式定义赋值运算符重载,编译器也会生成一个,完成对象按字节序的值拷贝。

没错,赋值运算符重载编译器也可以自动生成,并且也是支持连续赋值的。

📝 代码示例

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;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2(2022, 9, 1);

	// 这里d1调用的编译器生成 operator= 完成拷贝,d2和d1的值也是一样的。
	d1 = d2;

	d1.Print();
	d2.Print();

	return 0;
}

编译器自动生成的赋值运算符重载完成的是对象按字节序的值拷贝。

例如 d1 = d2,编译器会将 d2 所占内存空间的值完完全全地拷贝到 d1 的内存空间中去,类似于 memcpy

在这里插入图片描述

对于日期类,编译器自动生成的赋值运算符重载函数就可以满足我们的需求,我们可以不用自己写。

但是这也不意味着所有的类都不用我们自己写赋值运算符重载函数,当遇到一些特殊的类,我们还是得自己动手写赋值运算符函数的。

6. const 成员

🍑 const 修饰类的成员函数

我们把 const 修饰的类成员函数称之为 const 成员函数。

const 修饰类成员函数,实际修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对类的任何成员进行修改。

先看下面一段代码:

class Date
{
public:
	// 构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	// 打印函数
	void Print1()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	const Date d1(2022, 9, 1);
	d1.Print1();
	
	return 0;
}

我们运行程序可以看到在 d1 对象调用 Print1() 函数的时候报错了

在这里插入图片描述

那么为什么 const 修饰的对象 d1 不能调用 Print1 呢?

其实很简单,因为在 d1 对象去调用 Print1 函数的时候,实参会把 d1 的地址传过去,但是 d1 是被 const 修饰的,也就是传过去的是 const Date*

那么在 Print1 函数这边,形参部分会有一个隐含的 this 指针,也是 Date* const this(当 const* 号的右边,表示该指针本身不能被修改,但是可以进行第一次初始化赋值),也就是说把 const Date* 传给了 Date* const this,在这里属于权限的放大,所以编译会不通过。

在这里插入图片描述

所以就引出了 const 修饰类的成员函数:把 const 放在成员函数之后,实际就修饰类 this 指针:

class Date
{
public:
	// 构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	// const成员函数
	void Print2() const
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

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

int main()
{

	const Date d1(2022, 9, 1);
	d1.Print2();

	return 0;
}

这个时候我们再来运行看一下:

在这里插入图片描述

那么在参数传递部分,实参还是和上面一样,形参部分因为 const 修饰的成员函数,所以就变成了 const Date* const this,那么此时就是权限相等了。

在这里插入图片描述

额外补充: 建议成员函数中不修改成员变量的成员函数都可以加上 const,这样普通函数和 const 对象都可以调用。

在这里插入图片描述

🍑 思考

🍅 问题一

const 对象可以调用非 const 成员函数吗?

不可以,非 const 成员函数,即成员函数的 this 指针没有被 const 所修饰,我们传入一个被 const 修饰的对象,使用没有被 const 修饰的 this 指针进行接收,属于权限的放大,函数调用失败。

🍅 问题二

非 const 对象可以调用 const 成员函数吗?

可以,const 成员函数,即成员函数的 this 指针被 const 所修饰,我们传入一个没有被 const 修饰的对象,使用被 const 修饰的 this 指针进行接收,属于权限的缩小,函数调用成功。

🍅 问题三

const 成员函数内可以调用其它的非 const 成员函数吗?

不可以,在一个被 const 所修饰的成员函数中调用其他没有被 const 所修饰的成员函数,也就是将一个被 const 修饰的 this 指针的值赋值给一个没有被 const 修饰的 this 指针,属于权限的放大,函数调用失败。

🍅 问题四

非 const 成员函数内可以调用其它的 const 成员函数吗?

可以,在一个没有被 const 所修饰的成员函数中调用其他被 const 所修饰的成员函数,也就是将一个没有被 const 修饰的 this 指针的值赋值给一个被 const 修饰的 this 指针,属于权限的缩小,函数调用成功。

7. 取地址及 const 取地址操作符重载

取地址操作符重载和 const 取地址操作符重载,这两个默认成员函数一般不用自己重新定义,使用编译器自动生成的就行了。

📝 代码示例

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

	//普通对象 取地址操作符重载
	Date* operator&()
	{
		return this;
	}

	//const对象 取地址操作符重载
	const Date* operator&() const
	{
		return this;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2021, 10, 13);
	const Date d2(2021, 10, 14);

	cout << &d1 << endl;
	cout << &d2 << endl;
	return 0;
}

我们可以打印看一下结果

在这里插入图片描述

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容,就可以自己实现。

如果不想让别人获取对象的地址,也可以自己实现,直接返回 nullptr(把 return this 改为 return nullptr)。

总结

这篇文章的干货细节还是蛮多的,需要细细阅读!

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/80858.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!