【C++】类和对象(中)——默认成员函数详解(万字)

文章目录

  • 上文链接
  • 类的默认成员函数
    • [1. 构造函数](#1. 构造函数)
      • [(1) 什么是构造函数](#(1) 什么是构造函数)
      • [(2) 构造函数的使用](#(2) 构造函数的使用)
    • [2. 析构函数](#2. 析构函数)
      • [(1) 什么是析构函数](#(1) 什么是析构函数)
      • [(2) 析构函数的使用](#(2) 析构函数的使用)
      • [(3) 小练习](#(3) 小练习)
    • [3. 拷贝构造函数](#3. 拷贝构造函数)
      • [(1) 什么是拷贝构造函数](#(1) 什么是拷贝构造函数)
      • [(2) 拷贝构造函数的使用](#(2) 拷贝构造函数的使用)
    • [4. 赋值运算符重载](#4. 赋值运算符重载)
      • [(1) 运算符重载](#(1) 运算符重载)
      • [(2) 运算符重载的简单应用](#(2) 运算符重载的简单应用)
      • [(3) 赋值运算赋重载函数](#(3) 赋值运算赋重载函数)
      • [(4) 赋值运算符重载函数的使用](#(4) 赋值运算符重载函数的使用)
      • [(5) 综合运用(日期类)](#(5) 综合运用(日期类))
    • [5. 取地址运算符重载](#5. 取地址运算符重载)
      • [(1) const 成员函数](#(1) const 成员函数)
      • [(2) 取地址运算符重载](#(2) 取地址运算符重载)

上文链接

类和对象(上)


类的默认成员函数

默认成员函数就是用户没有显式实现,但是编译器会自动生成的成员函数称为默认成员函数。

默认成员函数一共有以下 6 个,其中我们需要重点掌握前 4 个,后面两个了解即可


在 C++11 以后还会增加两个默认成员函数,移动构造和移动赋值。

默认成员函数很重要,也比较复杂,我们要从两个方面去学习:

  • 第一:我们不写时,编译器默认生成的函数行为是什么,是否满足我们的要求。
  • 第二:编译器默认生成的函数不满足我们的需求,我们需要自己实现,那么如何自己实现?

1. 构造函数

(1) 什么是构造函数

构造函数是一种特殊的成员函数。它的功能类似于我们在模拟实现 Stack 类时的 Init() 函数,是用来在实例化对象时初始化对象。虽然它名字里有"构造"二字,但并非是去构造一个对象,我们常使用的局部对象是栈帧创建时,空间就已经开好了的。


(2) 构造函数的使用

上面说构造函数是一种特殊的成员函数,它到底特殊在哪里?

  1. 构造函数的函数名与类名相同
  2. 构造函数无返回值。(没有返回值,也不需要写 void,C++ 就是这么规定的)
  3. *对象实例化时系统会自动调用对应的构造函数
  4. 构造函数可以重载

(还有很多特点,别急!我们先来看看前 4 个特点)

为了方便理解,这里我们简单实现一个 Date 类

cpp 复制代码
#include<iostream>

using namespace std;

class Date
{
public:
	// 构造函数
	Date()  // 没有void, 并且函数名与该类的名称相同
	{
		_year = 2025;  // 初始化的数据
		_month = 1;
		_day = 1;
	}

	void print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}

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

int main()
{
	Date d1;
	d1.print();
    
	return 0;
}

运行结果如下:

可以看到我们并没有调用 Date 这个构造函数,但是打印出来的数据却是我们在构造函数中所初始化的。可见,当我们在对象实例化时系统会自动调用对应的构造函数

除此之外,由于构造函数可以重载,所以我们可以再在 Date 类中定义一个构造函数

cpp 复制代码
class Date
{
public:
	// 构造函数 1
	Date()
	{
		_year = 2025;
		_month = 1;
		_day = 1;
	}
	
    // 构造函数 2
	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;
};

那么我们如何去选择我们要用哪个构造函数去初始化对象呢?我们可以在实例化出对象的后面紧跟构造函数的参数。

cpp 复制代码
int main()
{
	Date d1;  // 调用的是构造函数 1
	d1.print();
    
    Date d2(2025, 4, 18);  // 调用的是构造函数 2
	d2.print();

	return 0;
}

运行结果如下:

这看着比较奇怪,因为在以前的认知里,只有函数后面才会跟括号,括号内是函数的参数。但是没办法, C++ 的创始人就是这样规定的。

那构造函数没有参数的时候可不可以写成 Date d1() 呢?不行,对于没有参数的构造函数不能加一个空括号,因为加了空括号有可能是一个函数声明,因此也就无法和函数声明区分开

cpp 复制代码
Date d1(); // 返回对象为 Date类 的一个函数的声明

int main()
{
	// Date d1(); 
}

  1. 如果类中没有显式定义构造函数,则 C++ 编译器会自动生成一个无参的默认构造函数,一旦显式地定义那么编译器将不再生成

  2. 无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数这三种构造函数都叫做默认构造函数。但是学过前面的知识就知道,这三个函数实际上有且只有一个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是当不传参数调用时会有歧义。

    注意这里引入了一个默认构造函数的概念,不是说编译器默认生成的构造函数叫默认构造函数,实际上无参数、全缺省构造函数也是默认构造,总结一下就是不传参就可以调用的构造就叫默认构造

  3. 我们不写,编译器自动生成的构造函数,对内置类型成员变量的初始化没有要求,也就是说是否初始化是不确定的,具体看编译器。而对于自定义类型的成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量没有默认构造函数,就会报错,我们要初始化这个成员变量,就需要用到初始化列表才能解决,初始化列表我们这里不展开,下一篇讲

注:C++ 把类型分为内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:intchardouble等。自定义类型就是我们使用 classstruct等关键字自己定义的类型。

具体什么意思,我们来看几个例子:

cpp 复制代码
#include<iostream>

using namespace std;

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

上面这个这个 Date 类我们没有写构造函数,根据第 5 点,所以编译器会自动生成一个无参的默认构造函数。而根据第 7 点,由于这个 Date 类中的成员变量全部都是内置类型,因此这个默认构造函数对其初始化没有要求,根据下面的运行结果可以看到 VS 下没有对其进行初始化。

对于自定义类型的初始化,我们来看这样一个例子:

如果你学习了数据结构,那么你应该了解过这样一个经典的例子------用栈来实现队列。

cpp 复制代码
class Stack
{
public:
	// 全缺省 --> 是一个默认构造函数
	Stack(int n = 4)
	{
		_a = (int*)malloc(sizeof(int) * n);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}
    
    // ...

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

class MyQueue
{
	Stack _pushst;
	Stack _popst;
};

int main()
{
	MyQueue q;

	return 0;
}

这里我们的 MyQueue 类中有两个栈,这两个栈都是自定义类型。那么我们在实例化对象 MyQueue q 的时候要求调用这个成员变量的默认构造函数初始化,这个成员变量是谁?是 Stack _pushstStack _popst,它们的默认构造函数是谁?是 Stack(int n = 4)。所以,这里 MyQueue 的两个成员变量(两个栈)的初始化是用 Stack(int n = 4) 来进行初始化的。

通过调试可以看到,这里的两个栈确实都被初始化了。

如果这里我们把 Stack 的构造函数改一下参数,变成这样:

cpp 复制代码
class Stack
{
public:
	Stack(int n)  // 改成需要传参的一个构造函数
	{
		_a = (int*)malloc(sizeof(int) * n);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}
    
    // ...

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

修改成了这样一个需要传参的构造函数,就不是一个默认构造函数了,所以初始化时就会报错

这个时候我们就需要在 MyQueue 类中自己去初始化这两个成员变量,需要用到初始化列表,下一篇会讲到。

所以我们可以总结一点:大多数的类都需要我们自己写构造函数,去确定初始化方式。少数像 MyQueue 这种可以用默认生成构造函数。


2. 析构函数

(1) 什么是析构函数

析构函数的功能和构造函数相反,类似于我们在模拟实现 Stack 类时的 Destroy() 函数,用来完成对象中资源的清理释放工作。注意它不是完成对对象本身的销毁,比如局部对象是存储在栈帧的,函数结束时栈帧销毁,局部对象也就跟着释放了,不需要我们管,和析构函数的功能无关。但是在 C++ 中规定对象在销毁时会自动调用析构函数,以完成资源的清理。如果 Date 类这种没有资源需要释放的,严格来说是不需要析构函数的。


(2) 析构函数的使用

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数无返回值。(和构造函数一样,也不需要 void)
  3. 一个类中只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
  4. 对象生命周期结束时,系统会自动调用析构函数。
  5. 一个局部域有多个对象时,后被定义的会先被析构。
cpp 复制代码
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (int*)malloc(sizeof(int) * n);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}

	~Stack()  // 析构函数
	{
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}

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

int main()
{
	Stack st1;
	Stack st2;

	return 0;
}

当我们正常执行程序,st1 和 st2 会被构造函数初始化。

接下来对象在销毁时后被初始化的对象会先被析构。因为我们的局部变量存储在栈中,它类似数据结构的栈,可以认为先被创建的对象先被压入栈中。因为最后被初始化的对象会先出栈(销毁)。


  1. 和构造函数类似,我们不写,编译器会自动生成析构函数,其对内置类型成员变量不做处理,对自定义类型成员变量会调用它的析构函数。

  2. 还需要注意的是,我们显式写析构函数,对于自定义类型成员而言也会调用它的析构函数,不受我们在该类中写的析构函数的影响。也就是说自定义类型成员无论什么情况都会自动调用析构函数。

还是用栈实现队列的例子

cpp 复制代码
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (int*)malloc(sizeof(int) * n);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}

	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}

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

class MyQueue
{
	Stack _pushst;
	Stack _popst;
};

int main()
{
	MyQueue q;

	return 0;
}

我们在栈的析构函数内部加了一句打印语句,方便我们得知调用了多少次析构函数

从第 6 点可知,MyQueue 的成员变量是两个自定义类型,因此在销毁 MyQueue 所创建的对象时,由于在 MyQueue 中没有写析构函数,所以它会去调用 Stack 类中的析构函数

从运行结果来看,确实是这样的。

而根据第 7 点,即使我们在 MyQueue 中显式地写了析构函数,对于自定义类型 _pushst_popst 来说它们也会去调用 Stack 中的析构函数。

cpp 复制代码
class MyQueue
{
public:
    ~MyQueue()
    {
        free(_ptr);
    }
private:
	Stack _pushst;
	Stack _popst;
    
    int* _ptr;
};

int main()
{
	MyQueue q;

	return 0;
}

  1. 如果类中没有申请资源,析构函数可以不写,直接使用编译器生成的默认析构函数,如 Date 类。

    如果默生成的析构可以用,也就不需要显式地写析构,比如上面的 MyQueue 类。

    但是如果有资源申请,一定要自己写析构,否则会造成内存泄漏,如 Stack 类。


(3) 小练习

设已经有A、B、C、D四个类的定义,程序中A、B、C、D构造函数调用顺序为 ( )

设已经有A、B、C、D四个类的定义,程序中A、B、C、D析构函数调用顺序为 ( )

A. D B A C

B. B A D C

C. C D B A

D. A B D C

E. C A B D

F. C D A B

cpp 复制代码
C c;
int main()
{
	A a;
    B b;
    static D d;
    return 0;
}

由于全局变量在 main 函数之前就要实例化,因此 C 肯定最先被调用构造函数。之后再从 main 函数开始,注意虽然 D 是一个静态变量,它的生命周期是全局的,但是它第一次被创建是在第一次运行到那个位置的时候才发生的,因此在 main 函数中,构造函数被调用的顺序是A B D,因此第一小题选 E。

在出 main 函数时,要销毁栈帧,这里 D 虽然是在 main 函数中定义的,但是它是存储在静态区,不在栈帧中,所以销毁栈帧与它无关。而 B 比 A 后定义,所以 B 先被析构,因此最先调用 B A 两个析构函数。之后 main 函数结束之后,紧接着局部的静态变量才被销毁,所以接下来被调析构函数的是 D,最后才是 C。因此第二小题选 B。

答案:E B


3. 拷贝构造函数

(1) 什么是拷贝构造函数

拷贝构造函数是一个特殊的构造函数。如果一个构造函数的第一个参数是自身类型的引用,且任何额外的参数都有默认值,则此构造函数也叫拷贝构造函数。


(2) 拷贝构造函数的使用

  1. 拷贝构造函数是构造函数的一个重载。

  2. 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以有多个参数,但是第一个参数只能是类类型对象的引用,且后面的参数必须要有缺省值。

    一般第一个参数 (引用) 可以加一个 const 来修饰,这样即可以传普通对象也可以传具有常性的对象。

以日期类为例:

cpp 复制代码
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;
	}

	void print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}

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

int main()
{
	Date d1(2025, 4, 19);
	d1.print();

	Date d2(d1);  // 用 d1 来初始化 d2, 相当于拷贝一份
	d2.print();

	return 0;
}

这一点还是比较好理解的,关键是第 2 点中还有一句使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。这是什么意思?如果我把拷贝构造函数改成下面这样:

cpp 复制代码
Date(const Date d)
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

编译器会报错:

首先我们得先理解一个点,如果不是传引用而是传值传参的话,那么在这个函数参数中的值,它会先去调用拷贝构造函数初始再进入到函数中。

比如我额外写一个 Func() 函数,当我们传入 d1 给形参中的 d 时,这个 d 实际上是怎么被初始化的?是去 Date 类中调用拷贝构造函数把 d1 复制一份初始化好了之后再进入到 Func() 函数中的。

cpp 复制代码
class Date
{
public:
	// ...
    
	Date(const Date& d)  // 拷贝构造函数
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	// ...
};

void Func(Date d)
{

}

int main()
{
	Date d1(2025, 4, 19);
	d1.print();

	Func(d1);

	return 0;
}

理解了这一点之后,那么如果拷贝构造函数是传值传参的话,把参数传给 d 时,为了初始化 d,会先去调用拷贝构造函数,也就是调用它自己,然后又有一个参数 d,那么它又得去调用自己...

所以说要进入拷贝构造函数之前要传值传参,而传值传参是一种拷贝,又形成了新的拷贝构造,进而就形成了无穷递归。

cpp 复制代码
Date(const Date d)  // 引发无穷递归
{
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

而如果现在是引用传参,那么传过去的是 d1,那么 d 就直接作为 d1 的别名,相当于就已经初始化好了,不会形成新的拷贝构造,就可以直接进入到函数中,不会发生无穷递归的问题。


  1. C++ 规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以自定义类型传值传参和传值返回都会调用拷贝构造完成。

    这一点就是我们上面所讲的。

  2. 若未显式定义拷贝构造函数,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝(浅拷贝),即一个字节一个字节地拷贝。而对自定义类型成员变量会调用它的拷贝构造。

    这一点与构造函数以及析构函数类似。

根据第 4 点,这里的 Date 类不写拷贝构造函数也会默认生成一个。

cpp 复制代码
class Date
{
public:
	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(2025, 4, 19);
	d1.print();

	Date d2(d1);
	d2.print();

	return 0;
}

但是如果用默认生成的拷贝构造函数去拷贝 Stack 类就会出问题。

cpp 复制代码
class Stack
{
public:
	Stack(int n = 4)
	{
		_a = (int*)malloc(sizeof(int) * n);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = n;
		_top = 0;
	}

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

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


int main()
{
	Stack st1;
	Stack st2(st1);

	return 0;
}

这段代码运行起来甚至会被电脑误认为是病毒...

所以说为了解决这个问题,我们需要自己写一份拷贝构造函数,来实现一个深拷贝

cpp 复制代码
class Stack
{
public:
	// ...

	Stack(const Stack& st)
	{
        // 拷贝构造函数(深拷贝)
		// 需要对 _a 指向的资源创建同样大小的资源再拷贝值
		_a = (int*)malloc(sizeof(int) * st._capacity);
		if (_a == nullptr)
		{
			perror("malloc申请空间失败!");
				return;
		}
		memcpy(_a, st._a, sizeof(int) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}
	
    // ...
};
  1. 像 Date 这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝函数就可以完成需要的拷贝,所以不需要我们显式地去实现拷贝构造。像 Stack 这样的类,虽然也都是内置类型,但是 _a 指向了资源,编译器自动生成的拷贝构造完成的值拷贝(浅拷贝)不符合我们的要求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。另外像之前实现过的 MyQueue 这样的类型内部主要是自定义类型 Stack 成员,编译器自动生成的拷贝构造会调用 Stack 类中的拷贝构造,也不需要我们自己去写。这一点就是我们上面所提到的。

    这里还有一个小技巧,如果一个类显式地实现了析构并释放资源,那么它就需要显式地写拷贝构造,否则就不需要。

  2. 传值返回会产生一个临时对象调用拷贝构造,传值引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个"野引用"。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回。


4. 赋值运算符重载

(1) 运算符重载

当我们对内置类型使用一些基本的运算符时,编译器都会将其转换成对应的指令,可以直接运算。

cpp 复制代码
#include<iostream>

using namespace std;

int main()
{
	int i = 0;
	++i;
	bool ret = i < 10;

	return 0;
}

但是对于自定义类型,就没有对应的指令了。因为自定义类型是由用户自己定义的,里面的成员变量可以有很多种,在使用一些基本的运算符时编译器就不知道该怎么去执行,这完全有用户自己说了算。比如 1 < 2 这个编译器知道该怎么做,所以它会有对应的指令。但是如果有两个类对象 d1d2,如果用 d1 < d2 这样的语法,编译器就会报错,因为它不知道如何去比较,也没有对应的指令。

那么现在我想让这些自定义类型能够使用这些运算符,我该如何去做呢?

  1. 当运算符被用于类类型的对象时,C++ 语言允许我们通过运算符重载的形式指定新的含义。C++ 规定类类型对象使用运算符时,必须转换成调用对应的运算符重载,若没有对应的运算符重载,编译器就会报错。
  2. 运算符重载是具有特殊名字的函数,它的名字是由 operator 和后面要定义的运算符共同构成。和其他函数一样,它也具有返回类型和参数列表以及函数体。
  3. 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。

如果有两个 Date 类对象 d1d2,普通地使用 d1 == d2 编译器会报错,但是现在我们可以实现一个运算符重载函数,来使这个运算符对这两个类对象合法化。

假设我们定义 d1 == d2 的条件是它们的年、月、日都相等。

cpp 复制代码
#include<iostream>

using namespace std;

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

	int _year;
	int _month;
	int _day;
};

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

int main()
{
	Date d1(2025, 4, 19);
	Date d2(2025, 4, 19);

	if (d1 == d2)
	{
		cout << "相等" << endl;
	}
	else
	{
		cout << "不相等" << endl;
	}

	return 0;
}

在没有运算符重载函数之前,其实我们也可以实现一个函数,通过调用这个函数来判断两个日期类对象是否相等。但是现在有了运算符重载函数,我们就可以把一个函数调用逻辑转换成运算符逻辑,这个意义是非凡的。

当然,我们写了运算赋重载函数你也可以去显式地调用它。

cpp 复制代码
if(d1 == d2) // OK
if(operator==(d1, d2)) // OK

但是明显第一种更好,可读性更强。


上面我们简单地实现了一个日期类的有关运算符重载函数的代码,但是有很多问题。

第一点,我们的运算符重载函数传参的时候传的是对象的值,之前我们就说过,传值传参要调用拷贝构造函数。会有点浪费空间,所以能用引用就尽量用引用,并且常常加上 const。这样的话能避免因为一些疏忽而误修改了引用的值。

cpp 复制代码
bool operator==(const Date& x1, const Date& x2)  // 运算符重载函数
{
	return x1._year == x2._year
		&& x1._month == x2._month
		&& x1._day == x2._day;
}

那我用指针可不可以呢?理论上是可以,但是难道你传参的时候要写一个 &d1 == &d2 吗,好像在比较地址一样。所以说虽然引用和指针有很多地方是相似的,但是这里也体现了 C++ 中引用的一种不可替代性。


第二点,为了能在运算符重载函数中访问到类中的成员变量,我们把成员变量设置公有的,但是这很不安全。因此我们要将成员变量设置成私有,并另外实现几个函数让运算赋重载函数能够访问到类中的成员函数。

cpp 复制代码
class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	int GetYear() { return _year; }
	int GetMonth() { return _month; }
	int GetDay() { return _day; }

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

bool operator==(Date& x1, Date& x2)
{
	return x1.GetYear() == x2.GetYear()
		&& x1.GetMonth() == x2.GetMonth()
		&& x1.GetDay() == x2.GetDay();
}

注意这里虽然我们可以通过这三个函数获取到私有的成员变量,但并不意味着这几个成员变量就变得公开了,我们仅仅是可以读到它们的信息,但是并不能修改,本质还是私有的。

注:这里我并没有在运算符重载函数的参数中加 const,是因为加了的话必须要在 GetYear()GetMonth()GetDay() 函数的定义后面加上 const。这涉及到后面的知识,这里先不展开,到后面再详细讲解。

除了可以实现函数来访问成员变量之外,既然我们要访问成员变量,那么我们还可以将运算符重载函数放在类中作为成员函数。

那我们是不是直接把它放到类里面就完了呢?没那么简单。之一我们之前第 3 点说的运算符重载函数参数个数和该运算符作用的运算对象数量一样多。而放在类中的函数默认隐含了一个传 this 指针的参数 const Date* this

  1. 如果一个运算符重载函数是成员函数,则它的第一个运算对象默认传给隐式的 this 指针,因此运算符重载函数作为成员函数时,参数比运算对象少一个
cpp 复制代码
class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	bool operator==(const Date& x2) // 原来的 x1 默认传给 this 指针,所以少一个参数
	{
		return _year == x2._year  // this->_year == x2._year
			&& _month == x2._month
			&& _day == x2._day;
	}

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

int main()
{
	Date d1(2025, 4, 19);
	Date d2(2025, 4, 19);

	if (d1 == d2)
	{
		cout << "相等" << endl;
	}
	else
	{
		cout << "不相等" << endl;
	}

	return 0;
}

那么现在如果显式地去调用运算符重载函数就只传一个参数。

cpp 复制代码
if (d1 == d2)
// 等价于 if(d1.operator==(d2))

  1. 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。
  2. 不能通过连接语法中没有的符号来创建新的操作符:比如 operator@
  3. .*::sizeof?:. 注意以上 5 个运算符不能重载。(选择题里面经常考)
  4. 重载操作至少要有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如:int operator+(int x, int y)
  5. 一个类需要重载哪些运算符,是看哪些运算符重载后有意义。比如 Date 类针对两个日期之间重载 operator- 就有意义,它可以代表两个日期之间相隔多少天,但是重载 operator+ 就意义不大。

第 7 点的 .* 运算符是语法里存在的,是用于成员函数的指针的调用。

cpp 复制代码
class test
{
public:
	void func() {

	}
};

typedef void(test::* PF)();  // 成员函数指针类型

int main()
{
	PF pf = &(test::func);  // 定义了一个指针 pf 指向 func 函数

	// (*PF)();   // 普通函数可以这样调用,但是成员函数不行
	
	test obj;
	(obj.*pf)();  // 先用.访问到类中, 再解引用*调用 func 函数。这才是正确的调用, 运用到了 ".*" 这个运算符

	return 0;
}

  1. 重载 ++ 运算符时,有前置 ++ 和 后置 ++,运算符重载函数名都是 operator++,无法区分。因此在 C++ 中规定,后置 ++ 重载时,增加一个形参 int,跟前置 ++ 构成函数重载,方便区分。
cpp 复制代码
class Date
{
public:
    // ...
    
	// ++d
	Date operator++();

	// d++
	Date operator++(int i);  // 这个参数 i 仅仅只是为了区分两个函数, 传什么都无所谓
    Date operator++(int);  // 或者可以直接写一个 int, 用来区分
    
    // ...
};

(2) 运算符重载的简单应用

下面我们来简单实现一个顺序表,体现一下运算符重载的魅力。

cpp 复制代码
#include<iostream>

using namespace std;

class SeqList
{
public:
	SeqList(int n = 4)  // 构造函数
	{
		_a = (int*)malloc(sizeof(int) * n);

		// ...检查是否 malloc 成功(略过)

		_size = 0;
		_capacity = n;
	}

	int operator[](int i)  // 运算符重载
	{
		return _a[i];
	}

	void PushBack(int x)  // 尾插元素
	{
		_a[_size++] = x;
	}

	int size()  // 获取顺序表元素个数
	{
		return _size;
	}

	~SeqList()  // 析构函数
	{
		free(_a);
		_size = _capacity = 0;
	}

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

int main()
{
	SeqList s;
	s.PushBack(1);
	s.PushBack(2);
	s.PushBack(3);

	for (int i = 0; i < s.size(); i++)
	{
		cout << s[i] << ' ';
        // 等价于: s.operator[](i)
	}

	return 0;
}

通过上面的例子我们发现,原本 s 是一个类的对象,我们要对这个对象中的数组进行元素的访问,只能先访问到成员变量 _a 然后再使用 [] 来访问元素。但是现在有了运算赋重载函数,我们就可以将赋予 [] 新的定义,让这个类对象 s 也能像数组一样通过 [] 来对它内部的的 _a 的元素进行读取,这样一来更加方便,同时从我们理解层面也更加顺畅和方便。

同时,如果我们再结合我们以前学过的有关引用的知识,我们还可以用这个重载的 [] 直接对数组进行修改。

cpp 复制代码
class SeqList
{
    // ...
    
  	int& operator[](int i)  // 返回一个引用
	{
		return _a[i];
	}  
    
    // ...
};

int main()
{
	SeqList s;
	s.PushBack(1);
	s.PushBack(2);
	s.PushBack(3);

	for (int i = 0; i < s.size(); i++)
	{
		s[i] += 10;
	}

	for (int i = 0; i < s.size(); i++)
	{
		cout << s[i] << ' ';
	}

	return 0;
}

这个地方就非常适合用引用,因为在 main 函数里 s 这个对象是一直存在的,意味着 _a 这个数组一直会存在,不会被销毁,所以传引用返回就可以直接对其进行修改,不存在"野指针"的情况。

所以,运算符重载函数,搭配上传引用返回等各种综合的知识,我们实现了把类对象当作数组一样可以直接进行读和写的功能。


(3) 赋值运算赋重载函数

前面讲的运算赋重载函数不是默认成员函数,这里要讲的赋值运算符重载才是一个默认成员函数。它用于完成两个已经存在的对象直接的拷贝赋值,这里要注意跟拷贝构造区分,拷贝构造用于一个对象拷贝初始化给另一个要创建的对象。


(4) 赋值运算符重载函数的使用

  1. 赋值运算符重载是一个运算符重载,刚刚我们将的运算赋重载函数可以重载为全局也可以重载为成员函数,但是赋值运算符重载函数必须重载为成员函数。赋值运算重载的参数建议写成 const + 当前类类型的引用,否则就变成传值传参,这样会调用拷贝构造。
  2. 有返回值,且建议写成当前类当前类类型的引用,引用返回可以提高效率,有返回值的目的是为了支持连续赋值。

下面针对以上两点来讲解一下。

根据第一点,我们很容易写出下面的赋值运算符重载:

cpp 复制代码
#include<iostream>

using namespace std;

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

	// d1 = d2 -> d1.operator=(d2)
	void operator=(const Date& d)  // 赋值运算符重载函数, 作为成员函数
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	void print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}

	bool operator==(const Date& x2)
	{
		return _year == x2._year
			&& _month == x2._month
			&& _day == x2._day;
	}

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



int main()
{
	Date d1(2025, 4, 20);
	Date d2(2025, 4, 21);

	d1.print();
	d2.print();

	d1 = d2;
    cout << endl;

	d1.print();
	d2.print();

	return 0;
}

上面的赋值运算符重载其实还有一点小小的问题,我们内置类型在赋值的时候支持连续赋值,但是我们写的赋值运算赋重载函数不行。

cpp 复制代码
int i, j;
i = j = 10;

上面的连续赋值的逻辑是从右向左进行赋值,也就是说 10 会先赋值给 j,然后整个结果以 j作为返回值,用这个返回值赋值给 i

而我们实现的赋值运算符重载的返回值是 void,如果有 d1 = d2 = d3 这样的赋值语句,先执行 d2 = d3 之后没有返回值,相当于之后就是 d1 = void,这样就会报错。所以我们为了实现连续赋值,我们要给赋值重载函数一个返回值,返回赋值运算符左侧的值,那我如何拿到这个值呢?用 *this ,因为我们左侧的值作为第一个参数,是传给 this 指针的,所以解引用 this 指针返回即可。

但是还没完,还有一个地方可以稍微优化以下。如果我们函数的返回类型写 Date 的话那么就意味着传值返回,而在前几篇文章中讲过传值返回返回的是返回对象的拷贝,但是我们也不需要拷贝,这里 *this 不是这个函数内部的局部对象,出了函数它还在,所以我们可以返回它的引用,这样就可以不开额外的空间,不用拷贝。

cpp 复制代码
class Date
{
public:
	// ...

	Date& operator=(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;

		return *this;
	}

    // ...
};

所以之前在讲解 this 指针的时候我们提到过有时候我们需要在类中用 this 指针来解决一些问题,那么这里便是使用 this 指针的一处应用

另外,语法上还还可以自己给自己赋值,类似于 d1 = d1 这样的场景。如果我们还完整地走一遍赋值运算符重载函数就比较亏,所以更加完善一点的赋值运算重载是这样写的。

cpp 复制代码
Date operator=(const Date& d)
{
	if (this != &d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	return *this;
}

  1. 没有显式实现时,编译器会自动生成一个默认的赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造类似:对内置类型成员变量会完成值拷贝 / 浅拷贝(一个字节一个字节地拷贝),对自定义类型成员变量会调用它的赋值运算符重载 。

  2. 像 Date 这样的类,成员全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们自己显式地去实现。

    像 Stack 这样地类,虽然也都是内置类型,但是 _a 指向了资源,编译器自动生成的赋值运算符重载就不能满足我们的需求,所以我们需要自己实现深拷贝(对指向的资源也拷贝)。

    像 MyQueue 这样的类,内部主要是自定义类型 Stack 成员,即自定义类型。编译器自动生成的赋值运算符重载会调用 Stack 的赋值运算符重载。

    这里有一个小技巧,如果一个类显式地实现了析构并释放资源,那么它就需要显式地写赋值运算符重载,否则就不需要。

  3. 易混淆:注意像 Date d2 = d1; 这样的语句不是赋值运算符重载!是拷贝构造。因为在赋值运算符重载的定义中明确地说明了是对已经存在的对象进行拷贝赋值,而这里属于初始化阶段,d2 还不属于"已经存在"的一个状态,因此这个语句实际上调用的是拷贝构造,等价于 Date d2(d1);

针对第 5 点,为什么会存在 Date d2 = d1 这样的写法?这是因为像比如我们如果重载了一个运算符 + 用于实现一个日期经过 x 天之后的计算得一个新的日期,那么我们用 Date d2 = d1 + 100 会比 Date d2(d1 + 100) 这样的写法可读性更高一些,但是一定注意这个写法不是赋值而是拷贝构造。


(5) 综合运用(日期类)

说明:

对于日期类,我们主要实现日期的各种运算,比如日期 + 天数,日期++,日期 - 日期。以及日期之间的比较大小等。在这个过程中,可以充分体会到运算符重载以及前几篇所讲的引用等知识的综合应用。
Date.h

cpp 复制代码
#pragma once

#include<iostream>
#include<cassert>

using namespace std;

class Date
{
public:
	// 构造函数
	// 我们会频繁创建对象,因此把它放在类中作为内联函数
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	
    // 根据年月获取当月天数
	// 我们实现加法的时候必然会频繁获取当月天数,所以也放在内部作为内联函数
	int GetMonthDay(int year, int month)
	{
		assert(month > 0 && month < 13);  // 合法的月份区间只能在 [1,12] 内

		// 存储每个月对应的天数,数组下标对应月份,下标为 0 用 -1 占位
		// 加 static 更好, 直接把它放在静态区,这样不用每次调用的时候都创建数组
		static int monthDayArray[] = { -1, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };

		// 判断闰年
		// 这里最好把 month == 2 这个条件放前面,可以节约计算时间
		// 因为放后面每次进入这个 if 的时候都要判断是否为闰年,放前面的话不是 2 月直接就走了
		if (month == 2 && (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) return 29;

		else return monthDayArray[month];
	}
	
    // 日期的基本运算
	Date& operator+=(int day);  // 日期 += 天数
	Date operator+(int day);  // 日期 + 天数
	Date& operator-=(int day); // 日期 -= 天数
	Date operator-(int day);  // 日期 - 天数
	Date& operator++(); // 重载后缀++
	Date operator++(int); // 重载前缀++
	Date& operator--(); // 重载后缀--
	Date operator--(int); // 重载前缀--
	int operator-(Date d); // 日期 - 日期 
	
    // 日期比较大小
	bool operator<(const Date& d);
	bool operator==(const Date& d);
	bool operator<=(const Date& d);
	bool operator>(const Date& d);
	bool operator>=(const Date& d);
	bool operator!=(const Date& d);

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

Date.cpp

cpp 复制代码
#include"Date.h"  

// 实现 日期 += 天数
Date& Date::operator+=(int day)   // 这里 += 可以用引用返回,因为 += 的左操作对象不是这个函数中的局部对象
{
    // 如果 day 小于 0,那么就变成 -= 的逻辑
	if (day < 0)
	{
		return *this -= -day;
	}
    
	_day += day;  // 先把天数全部加上
	
    // 如果天数超过了当月的最大天数,那么就"进位"
	while (_day > GetMonthDay(_year, _month))
	{
        // 天数超了就进月
		_day -= GetMonthDay(_year, _month);
		_month += 1;
		
        // 月数超了就进年
		if (_month > 12)
		{
			_year += 1;
			_month = 1;
		}
	}
	return *this;
}

Date Date::operator+(int day)  // 注意这里不能用引用返回,因为返回对象是函数内部的局部对象,出了函数就销毁了
{
	Date ret(*this);
	return ret += day;  // 用我们已经实现了的 += 来实现我们这里的 +,更简洁
}

Date& Date::operator-=(int day)
{
    if (day < 0)
	{
		return *this += -day;
	}
    
	// 先把天数全部减去
	_day -= day;

	// 如果天数小于等于0,那么就需要向月借位
	while (_day <= 0)
	{
		// 注意我们向月去借位的时候实际上借的是前一个月的天数,所以先让month减一
		--_month;
		// 如果month减到0,那么就要向年借位
		if (_month == 0)
		{
			_month = 12;
			--_year;
		}

		_day += GetMonthDay(_year, _month);
	}

	return *this;
}

Date Date::operator-(int day)
{
	Date ret(*this);
	ret -= day;  // 同样用我们已经实现了的 -= 来实现我们这里的 -

	return ret;
}

Date& Date::operator++() // 后缀++
{
	*this += 1;  // 用我们已经实现过的 += 来实现这里的 ++

	return *this;
}

Date Date::operator++(int) // 前缀++
{
	Date tmp(*this);
	*this += 1;

	return tmp;
}

Date& Date::operator--() // 后缀--
{
	*this -= 1;
    
	return *this;
}

Date Date::operator--(int) // 前缀--
{
	Date tmp(*this);
	*this -= 1;

	return tmp;
}

// 日期 - 日期
// 用小的日期一直++,直到与大的日期相等
int Date:: operator-(Date d)
{
	int flag = 1;
	Date max = *this;
	Date min = d;
	if (*this < d)
	{
		max = d;
		min = *this;
		flag = -1;
	}

	int n = 0;
	while (min != max)
	{
		++min;
		++n;
	}

	return n * flag;
}

bool Date::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;
}

bool Date::operator==(const Date& d)
{
	return _year == d. _year
		&& _month == d._month
		&& _day == d._day;
}

// 实现了上面的 < 和 == 之后剩下的 4 个比较运算符全部都可以复用已经写过的函数
bool Date::operator<=(const Date& d)
{
	return *this < d || *this == d; 
}

bool Date::operator>(const Date& d)
{
	return !(*this <= d);
}

bool Date::operator>=(const Date& d)
{
	return !(*this < d);
}

bool Date::operator!=(const Date& d)
{
	return !(*this == d);
}

void Date::print()
{
	cout << _year << '/' << _month << '/' << _day << endl;
}

test.cpp

cpp 复制代码
#include"Date.h"

void Test1()
{
	cout << "Test1:" << endl;

	Date d1(2025, 4, 21);
	d1.print();

	Date d2 = d1 + 100;
	d2.print();

	d1 += 100;
	d1.print();
}

void Test2()
{
	cout << "Test2:" << endl;

	Date d1(2025, 4, 21);
	d1.print();

	Date d2 = d1 - 100;
	d2.print();

	d1 -= 100;
	d1.print();
}

void Test3()
{
	cout << "Test3:" << endl;

	Date d1(2025, 4, 21);
	Date ret1 = ++d1;
	d1.print();
	ret1.print();

	Date d2(2025, 4, 21);
	Date ret2 = d2++;
	d2.print();
	ret2.print();
}

void Test4()
{
	cout << "Test4:" << endl;
	Date d1(2025, 1, 1);
	Date d2(2100, 1, 1);

	cout << (d2 - d1) << endl;
}

int main()
{
	Test1();
	Test2();
	Test3();
	Test4();
}

对于上面实现的 + 重载和 += 重载,可以用其中一种重载来实现另一种重载。但是有两种方式。

+ 复写 +=

cpp 复制代码
Date Date::operator+(int day)
{
	// 实现方式与上面相同
}

Date& Date::operator+=(int day)
{
	*this = *this + day;  // 运用了赋值运算符重载
	return *this;
}

+= 复写 +

cpp 复制代码
Date Date::operator+=(int day)
{
    // 实现方式与上面相同
}

Date Date::operator+(int day)
{
	Date ret(*this);
	return ret += day;
}

上面的两种写法更推荐第二种。因为我们会发现,上面两种实现方式它们发生的拷贝行为的次数是不同的,拷贝次数多的自然就会造成更多的消耗。


5. 取地址运算符重载

(1) const 成员函数

当我们用上面实现的日期类来定义一个 const 类型的类对象时,如果我们想要打印它的年月日,会报错。

cpp 复制代码
void Test5()
{
	const Date d1;
	d1.print();
}

int main()
{
	Test5();
    
    return 0;
}

因为这涉及到权限放大的问题

所以我们需要想办法把 this 指针这里的隐藏的参数 Date* const this 前面加一个 const 修改成 const Date* const this 才可以。如何做呢?我们可以在成员函数参数列表后面加上 const。

将 const 修饰的成员函数称之为 const 成员函数,const 修饰成员函数放到成员函数参数列表的后面。

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

cpp 复制代码
void Date::print() const
{
	cout << _year << '/' << _month << '/' << _day << endl;
}

因此我们实现的日期类中凡是不改变自己本身的,我们都可以在函数后面加上一个 const。

cpp 复制代码
// 日期的基本运算
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-(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;

// 打印年月日
void print() const;

(2) 取地址运算符重载

取地址运算符重载分为普通取地址运算符重载和 const 取地址运算符重载,一般这两个函数编译器自动生成的就够我们用了,不需要去显式地实现。除非一些很特殊的场景,比如不想让别人取到当前类对象的地址,就可以自己实现一份,返回一个空地址或者胡乱返回一个地址。

cpp 复制代码
Date* operator&()
{
	return this;
	// return nullptr;  // 不想让别人取到你的地址
}

const Date* operator&() const
{
	return this;
	// return nullptr;  // 不想让别人取到你的地址
}
相关推荐
Le_ee15 分钟前
数据结构6 · BinaryTree二叉树模板
数据结构·c++·算法
李匠202425 分钟前
C++负载均衡远程调用学习之TCP连接封装与TCPCLIENT封装
c++·网络协议·学习·tcp/ip
小宋要上岸1 小时前
优雅关闭服务:深入理解 SIGINT / SIGTERM 信号处理机制
c++·信号处理·grpc
李匠20242 小时前
C++学习之shell高级和正则表达式
c++·学习
月落霜满天2 小时前
贪心算法求解边界最大数
开发语言·算法
JQLvopkk2 小时前
C# dataGridView分页
开发语言·c#
unlockjy2 小时前
Linux——进程终止/等待/替换
linux·服务器·c++
敖云岚3 小时前
【安装指南】DevC++的安装和使用(超级详细)
开发语言·c++
zl_dfq3 小时前
C++ 之 【模拟实现 list(节点、迭代器、常见接口)】(将三个模板放在同一个命名空间就实现 list 啦)
数据结构·c++
我命由我123453 小时前
C++ - 数据容器之 list(创建与初始化、元素访问、容量判断、元素遍历、添加元素、删除元素)
c语言·开发语言·c++·后端·visualstudio·c#·visual studio