重生之从0开始学习c++之类与对象(中)

1. 类的默认成员函数

认成员函数就是用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。一个类,我们不写的情况下编译器会默认生成以下6个默认成员函数,其次就是C++11以后还会增加两个默认成员函数,移动构造和移动赋值。默认成员函数很重要,也比较复杂,我们要从两个方面去学习:

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

2. 构造函数

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使用的局部对象是栈帧创建时,空间就开好了),而是对象实例化时初始化对象。构造函数的本质是要替代我们以前Stack和Date类中写的Init函数的功能,构造函数自动调用的特点就完美的替代的了Init。

构造函数的特点:

  1. 函数名与类名相同。
  2. 无返回值。(返回值啥都不需要给,也不需要写void,不要纠结,C++规定如此)
  3. 对象实例化时系统会自动调用对应的构造函数。
  4. 构造函数可以重载。
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
  6. 无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。但是这三个函数有且只有一个存在,不能同时存在。无参构造函数和全缺省构造函数虽然构成函数重载,但是调用时会存在歧义。要注意很多同学会认为默认构造函数是编译器默认生成那个叫默认构造,实际上无参构造函数、全缺省构造函数也是默认构造,总结一下就是不传实参就可以调用的构造就叫默认构造。
  7. 我们不写,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是否初始化是不确定的,看编译器。对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要用初始化列表才能解决,初始化列表,我们下个章节再细细讲解。

说明:C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的原生数据类型,如:int/char/double/指针等,自定义类型就是我们使用class/struct等关键字自己定义的类型。

那么此时可能会有老铁要问,既然我们不写时,编译器会调用默认构造函数,那我们还写它干啥呢?我们接下来看看是为什么。

cpp 复制代码
#include<iostream>
using namespace std;
class Date
{
public:
    //1.无参构造函数
    Date()
    {
        _year = 1;
        _month = 1;
        _day = 1;
    }

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

    //3.全缺省构造函数
    /*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()
{
    // 如果留下三个构造中的第二个带参构造,第一个和第三个注释掉
    // 编译报错:error C2512:"Date":没有合适的默认构造函数可用
    Date d1; // 调用默认构造函数
    Date d2(2025, 1, 1); // 调用带参的构造函数

    // 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则编译器无法
    // 区分这里是函数声明还是实例化对象
    // warning C4930:"Date d3(void)":未调用原型函数(是否是有意用变量定义的?)
    Date d3();

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

    return 0;
}

我们看以上代码,我们之前说过**无参构造函数、全缺省构造函数、我们不写构造时编译器默认生成的构造函数,都叫做默认构造函数。但是这三个函数有且只有一个存在,不能同时存在。**也就是说当我们把这两个都放出来时,编译器就会这样:

编译器搁这里想,你来告诉我这是啥意思?你来调用构造函数,但是还不传参,那你要是不传参的话我用无参的还是全缺省的呢?对不对,这一下给编译器干懵了。

事实上,全缺省的是不是就包括无参和带参的呀,我们用全缺省的,那么你不带参,我们也可以,你带参,我们也可以是不是呀。

还有一点,我们调用无参构造函数时可不敢这么写,会报错的:

cpp 复制代码
int main()
{
	Date d1;
	d1.Print();
	Date d2();
	d2.Print();

	return 0;
}

你说你调用无参的默认构造函数后面还带个括号,那你这是啥意思啊?

你这么看可能看不出来啥,那我换个名字呢?

cpp 复制代码
Date Func();
Func.Print();

这下看懂了嘛,这不就跟函数声明一样了吗而造成歧义,又给编译器干懵了。

那么此时会有老铁要问,既然这个编译器自带的默认构造函数这么鸡肋,那么还有什么作用呢?它的意义在哪里呢?看如下:

cpp 复制代码
#include<iostream>
using namespace std;

typedef int STDataType;
class Stack
{
public:
    Stack(int n = 4)
    {
        _a = (STDataType*)malloc(sizeof(STDataType) * n);
        if (nullptr == _a)
        {
            perror("malloc申请空间失败");
            return;
        }
        _capacity = n;
        _top = 0;
    }
    //...
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

// 两个Stack实现队列
class MyQueue
{
public:
    //编译器默认生成MyQueue的构造函数调用了Stack的构造,完成了两个成员的初始化
private:
    Stack pushst;
    Stack popst;
};

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

当我们用两个Stack实现队列的时候,此时就用到默认构造函数了,它会直接调用Stack的默认构造函数,所以在这里我们就不用自己写了

另外还有一点,我们不写,编译器默认生成的构造,对内置类型成员变量的初始化没有要求,也就是说是否初始化是不确定的,看编译器。对于自定义类型成员变量,要求调用这个成员变量的默认构造函数初始化。

你看,此时我的vs2022就是随机值,那么假如你的编译器不是随机值,给你初始化好了,那么此时我们也不要大意,因为这很有可能时一个大坑,你在这个编译器行,在别的编译器就不行了这不是给自己挖坑呢嘛,所以我们最好还是自己去写构造函数。

3. 析构函数

析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,比如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++规定对象在销毁时会自动调用析构函数,完成对象中资源的清理释放工作。析构函数的功能类比我们之前Stack实现的Destroy功能,而像Date没有Destroy,其实就是没有资源需要释放,所以严格说Date是不需要析构函数的。

析构函数的特点:

  1. 析构函数名是在类名前加上字符~。
  2. 无参数无返回值。(这里跟构造类似,也不需要加void)
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
  4. 对象生命周期结束时,系统会自动调用析构函数。
  5. 跟构造函数类似,我们不写编译器自动生成的析构函数对内置类型成员不做处理,自定义类型成员会调用他的析构函数。
  6. 还需要注意的是我们显示写析构函数,对于自定义类型成员也会调用他的析构,也就是说自定义类型成员无论什么情况都会自动调用析构函数。
  7. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,如Date;如果默认生成的析构就可以用,也就不需要显示写析构,如MyQueue;但是有资源申请时,一定要自己写析构,否则会造成资源泄漏,如Stack。
  8. 一个局部域的多个对象,C++规定后定义的先析构。
cpp 复制代码
#include<iostream>
using namespace std;

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

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

private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

// 两个Stack实现队列
class MyQueue
{
public:
    // 编译器默认生成MyQueue的析构函数调用了Stack的析构,释放的Stack内部的资源
    // 显示写析构,也会自动调用Stack的析构
    /*~MyQueue()
    {}*/
private:
    Stack pushst;
    Stack popst;
};

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

其实对于我们的析构函数来说,我们析构函数只需处理本类直接申请的资源(通常是通过内置类型指针或句柄管理的资源)。内存泄漏是不会报错的,所以我们这里不好演示,不过大家一定要多加注意,这是个大坑。

有趣的是,就如同上面所说:显示写析构,也会自动调用Stack的析构

在这里我们作死来测试一下:

cpp 复制代码
#include<iostream>
using namespace std;

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

    ~Stack()
    {
        cout << "~Stack()" << endl;
        free(_a);
        _a = nullptr;
        _top = _capacity = 0;
    }
    //...
private:
    STDataType* _a;
    size_t _capacity;
    size_t _top;
};

class MyQueue
{
public:
    // 编译器默认生成MyQueue的析构函数调用了Stack的析构,释放的Stack内部的资源
    // 显示写析构,也会自动调用Stack的析构
    ~MyQueue()
    {
        cout << "MyQueue()" << endl;
    }
private:
    Stack pushst;
    Stack popst;
};

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

我们再来看一种情况,假如我们定义两个st,那么销毁时先销毁st1还是st2呢?

我们可以看到,是不是st2先被释放呀,所以在这里因为也是存在栈嘛,所以也满足后进先出的一个原则。

对比一下用C++和C实现的Stack解决之前括号匹配问题isValid,我们发现有了构造函数和析构函数确实方便了很多,不会再忘记调用Init和Destroy函数了,也方便了不少。

cpp 复制代码
#include<iostream>
using namespace std;

// 用最新加了构造和析构的C++版本Stack实现
bool isValid(const char* s) {
    Stack st;

    while (*s)
    {
        if (*s == '[' || *s == '(' || *s == '{')
        {
            st.Push(*s);
        }
        else
        {
            // 右括号比左括号多,数量匹配问题
            if (st.Empty())
            {
                return false;
            }
            // 栈里面取左括号
            char top = st.Top();
            st.Pop();
            //顺序不匹配
            if ((*s == ']' && top != '[')
                || (*s == ')' && top != '(')
                || (*s == '}' && top != '{'))
            {
                return false;
            }
        }
        ++s;
    }
    //栈为空,返回真,说明数量都匹配 左括号多,右括号少匹配问题
    return st.Empty();
}

//用之前c版本stack实现
bool isValid(const char* s) {
    ST st;
    STInit(&st);
    while (*s)
    {
        //左括号入栈
        if (*s == '(' || *s == '[' || *s == '{')
        {
            STPush(&st, *s);
        }
        else //右括号取栈顶左括号尝试匹配
        {
            if (STEmpty(&st))
            {
                STDestroy(&st);
                return false;
            }
            char top = STTop(&st);
            STPop(&st);
            //不匹配
            if ((top == '(' && *s != ')')
                || (top == '[' && *s != ']')
                || (top == '{' && *s != '}'))
            {
                STDestroy(&st);
                return false;
            }
        }
        ++s;
    }
    // 栈不为空,说明左括号比右括号多,数量不匹配
    bool ret = STEmpty(&st);
    STDestroy(&st);
    return ret;
}

int main()
{
    cout << isValid("[()][])" << endl;
    cout << isValid("[()][])" << endl;
    return 0;
}

4. 拷贝构造函数

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

拷贝构造的特点:

  1. 拷贝构造函数是构造函数的一个重载。
  2. 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器直接报错,因为语法逻辑上会引发无穷递归调用。拷贝构造函数也可以多个参数,但是第一个参数必须是类类型对象的引用,后面的参数必须有缺省值
  3. C++规定自定义类型对象进行拷贝行为必须调用拷贝构造,所以这里自定义类型传值传参和传值返回都会调用拷贝构造完成。
  4. 若未显式定义拷贝构造,编译器会生成自动生成拷贝构造函数。自动生成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝 (一个字节一个字节的拷贝),对自定义类型成员变量会调用他的拷贝构造。
  5. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的拷贝构造就可以完成需要的拷贝,所以不需要我们显示实现拷贝构造。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源,编译器自动生成的拷贝构造完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的拷贝构造会调用Stack的拷贝构造,也不需要我们显示实现MyQueue的拷贝构造。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写拷贝构造,否则就不需要。
  6. 传值返回会产生一个临时对象调用拷贝构造,传值引用返回,返回的是返回对象的别名(引用),没有产生拷贝。但是如果返回对象是一个当前函数局部域的局部对象,函数结束就销毁了,那么使用引用返回是有问题的,这时的引用相当于一个野引用,类似一个野指针一样。传引用返回可以减少拷贝,但是一定要确保返回对象,在当前函数结束后还在,才能用引用返回。

那么我们先来看看第二点说的是什么意思呢?什么叫做使用传值传参会引发无穷递归调用呢?这是啥意思,让我们来看看:

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

	  //error C2652: "Date": 非法的复制构造函数: 第一个参数不应是"Date"
	 //Date d2(d1)
	/*Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}*/

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

//void Func1(const Date& d)
void Func1(Date d)
{
	cout << &d << endl;
	d.Print();
}

int main()
{
	Date d1(2026, 4, 7);
	d1.Print();

	// C++的规定,传值传参要调用拷贝构造
	Func1(d1);

	//调用拷贝构造要调用传值传参,而c++规定传值传参要调用拷贝构造,由此形成了无限递归
	//而且需要注意的是最好加const,因为如果你不加const,那么你想要用d1拷贝d2,那么假如你在拷贝函数出现bug的话,d1的值就会被改变
	//本来是想要让d1拷贝d2的,结果d1的值被改变了,那还得了,对不对
	//所以我们如果不想要改变实参的时候,把const死死加上就可以了。

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

	return 0;
}

假如说我们现在直接传值传参:

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

那么我们来想一想,传值传参是不是要调用拷贝构造呀,那么现在问题来了,你调用拷贝构造函数就要传值传参,你想想,是不是形成了一个死循环呀,这就是为什么形成了无限递归,而且如果你真想这么些的时候编译器也不让,会直接报错。

而且在我们写拷贝构造函数时最好加上const,为什么呢?因为你不加const有的时候就会把你的值乱修改,比方我们在拷贝构造函数时逻辑给写反了:

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

那么此时是不是就是大错特错呀,所以为了安全起见,当我们加上const的时候,我们就会直接报错,这样也就知道哪里有错误了:

那么接下来我们看第四点,可能会有老铁要问,既然编译器默认生成的拷贝构造函数能把内置类型拷贝,自定义类型也能拷贝,那我觉得这个是不是就很吊啊,这根本就不需要我们去出手啊,全部交给编译器不就得了吗,那么如果真是这样就好了对吧(哈哈),事实上,我们还不能全部交给编译器,那为什么不行呢??,接下来我们来看看:

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

	// st2(st1)
	//Stack(const Stack& st)
	//{
	//	cout << "Stack(const Stack& st)" << endl;
	//	// 需要对_a指向资源创建同样大的资源再拷贝值
	//	_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
	//	if (nullptr == _a)
	//	{
	//		perror("malloc申请空间失败!!!");
	//		return;
	//	}
	//	memcpy(_a, st._a, sizeof(STDataType) * st._top);
	//	_top = st._top;
	//	_capacity = st._capacity;
	//}

	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity *sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;

	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};

void Func(Stack st)
{

}

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

	Func(st1);

	// Stack不显示实现拷贝构造,用自动生成的拷贝构造完成浅拷贝
	// 会导致st1和st2里面的_a指针指向同一块资源,析构时会析构两次,程序崩溃
	//Stack st2(st1);

	return 0;
}

我们在这里用栈来演示一下,当我们的栈push的时候,是不是要动态申请空间啊,在这里我们就用Func来演示,传值传参是不是要调用拷贝构造函数啊,在这里我们就用编译器默认的拷贝构造函数,看看会怎么样:

那么你看,可能会有老铁要说,这步拷贝的挺好嘛,这数据一摸一样,甚至连地址都一样的(狗头),那么此时相信大家也发现到问题了,这一块空间两个人用,这能行嘛,对吧,是不是要出大问题呀,那么为什么会造成这种原因呢?

因为编译器的默认拷贝构造函数其实都是浅拷贝,跟c语言是一样的,c语言在这里就是个大坑,还好我们c++有机会解决(其实c语言也有),那么言归正传,一块空间两个人用会造成什么问题呢?

最直接的问题就是程序会崩溃,因为当st出了Func的时候,这块空间是不是就会被析构函数给释放了呀,但是在整个程序结束时,st1是不是也终将会被释放啊,那一块空间被析构了两次,当然会报错了

那之前为什么说在c语言也是个坑呢?

因为你想,我们假如在c语言中把这个结构体传给某一个函数,你以为你传的是形参,对自己本身并没有影响,可实际呢,它两是同一块空间,导致你在函数那边的改动也使本身的值发生变化了,这是不是一个大坑呀

所以只有我们自己去实现深拷贝,才能解决这个问题:

cpp 复制代码
Stack(const Stack& st)
{
	cout << "Stack(const Stack& st)" << endl;
	// 需要对_a指向资源创建同样大的资源再拷贝值
	_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
	if (nullptr == _a)
	{
		perror("malloc申请空间失败!!!");
		return;
	}
	memcpy(_a, st._a, sizeof(STDataType) * st._top);
	_top = st._top;
	_capacity = st._capacity;
}

那么此时,是不是就对头了呀。

那我们来看一下第六点说的是什么意思呢?

当我们在调用传值Func2的时候,st是不是局部变量呀,那么在这里返回的其实是对于st的拷贝,再传给我们的ret

那么当我们传引用的时候,就出现问题了,如果这里传引用返回的话,你返回的什么呢?你在func2作用域中是不是被析构掉了呀,所以相当于这里是野引用啊,这样是不是就不行了,如下:

那么解决办法就让它不是局部变量是不是就可以了,我们可以在前面加一个static呀:

这样我们的ret就正常了

我们传值是不是要拷贝呀,传引用可以减少拷贝,我们可以来实验一下:

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

	// st2(st1)
	Stack(const Stack& st)
	{
		cout << "Stack(const Stack& st)" << endl;
		// 需要对_a指向资源创建同样大的资源再拷贝值
		_a = (STDataType*)malloc(sizeof(STDataType) * st._capacity);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败!!!");
			return;
		}
		memcpy(_a, st._a, sizeof(STDataType) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}

	void Push(STDataType x)
	{
		if (_top == _capacity)
		{
			int newcapacity = _capacity * 2;
			STDataType* tmp = (STDataType*)realloc(_a, newcapacity * sizeof(STDataType));
			if (tmp == NULL)
			{
				perror("realloc fail");
				return;
			}
			_a = tmp;
			_capacity = newcapacity;
		}
		_a[_top++] = x;

	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_a = nullptr;
		_top = _capacity = 0;
	}
private:
	STDataType* _a;
	size_t _capacity;
	size_t _top;
};

Stack& func2()
{
	/*st.Push(1);
	st.Push(1);
	st.Push(1);*/

	static Stack st;
	//Stack st;

	return st;
}

int main()
{
	//Stack ret = func2();
	
	func2();

	return 0;
}


那么在这里我们还需要注意一点,由于编译器会进行返回值优化,所以通常在这里当我们Stack st时时看不到编译器又拷贝了个临时对象返回的,所以为了演示效果,我们采用静态区变量来演示,因为存放在静态区,生命周期是整个程序,不能像局部变量那样被"搬到"调用方栈帧上。编译器无法对其进行 RVO,所以必须老老实实地调用拷贝构造,将静态对象的内容复制一份作为返回值。而我们正常使用传引用返回时本身就不用再调用拷贝构造。

5. 赋值运算符重载

5.1 运算符重载

  • 当运算符被用于类类型的对象时,C++语言允许我们通过运算符重载的形式指定新的含义。C++规定类类型对象使用运算符时,必须转换成调用对应运算符重载,若没有对应的运算符重载,则会编译报错。
  • 运算符重载是具有特殊名字的函数,他的名字是由operator和后面要定义的运算符共同构成。和其他函数一样,它也具有其返回类型和参数列表以及函数体。
  • 重载运算符函数的参数个数和该运算符作用的运算对象数量一样多。一元运算符有一个参数,二元运算符有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。
  • 如果一个重载运算符函数是成员函数,则它的第一个运算对象默认传给隐式的this指针,因此运算符重载作为成员函数时,参数比运算对象少一个。
  • 运算符重载以后,其优先级和结合性与对应的内置类型运算符保持一致。
  • 不能通过连接语法中没有的符号来创建新的操作符:比如operator@。
  • .* :: sizeof ?: . 注意以上5个运算符不能重载。
  • 重载操作符至少有一个类类型参数,不能通过运算符重载改变内置类型对象的含义,如:int operator+(int x,int y)
  • 一个类需要重载哪些运算符,是看哪些运算符重载后有意义,比如Date类重载operator-
    就有意义,但是重载operator+就没有意义。
  • 重载++运算符时,有前置++和后置++,运算符重载函数名都是operator++,无法很好的区分。C++规定,后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。
  • 重载<<和>>时,需要重载为全局函数,因为重载为成员函数,this指针默认抢占了第一个形参位置,第一个形参位置是左侧运算对象,调用时就变成了对象<<cout,不符合使用习惯和可读性。重载为全局函数把ostream/istream放到第一个形参位置就可以了,第二个形参位置当类类型对象。
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;
    }

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

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

//重载为全局的面临对象访问私有成员变量的问题
//有几种方法可以解决:
//1、成员放公有
//2、Date提供getxxx函数
//3、友元函数
//4、重载为成员函数
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(2024,7,5);
    Date d2(2024,7,6);

    // 运算符重载函数可以显示调用
    operator==(d1,d2);

    // 编译器会转换成operator==(d1,d2);
    d1 == d2;

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

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

    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(2026, 4, 7);
	Date d2(2026, 4, 8);

    // 运算符重载函数可以显示调用
    d1.operator==(d2);

    // 编译器会转换成 d1.operator==(d2);
    d1 == d2;
    
    return 0;
}

5.2 赋值运算符重载

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

赋值运算符重载的特点:

  1. 赋值运算符重载是一个运算符重载,规定必须重载为成员函数。赋值运算重载的参数建议写成const当前类类型引用,否则会传值传参会有拷贝
  2. 有返回值,且建议写成当前类类型引用,引用返回可以提高效率,有返回值目的是为了支持连续赋值场景。
  3. 没有显式实现时,编译器会自动生成一个默认赋值运算符重载,默认赋值运算符重载行为跟默认拷贝构造函数类似,对内置类型成员变量会完成值拷贝/浅拷贝(一个字节一个字节的拷贝),对自定义类型成员变量会调用他的赋值重载函数。
  4. 像Date这样的类成员变量全是内置类型且没有指向什么资源,编译器自动生成的赋值运算符重载就可以完成需要的拷贝,所以不需要我们显示实现赋值运算符重载。像Stack这样的类,虽然也都是内置类型,但是_a指向了资源 ,编译器自动生成的赋值运算符重载完成的值拷贝/浅拷贝不符合我们的需求,所以需要我们自己实现深拷贝(对指向的资源也进行拷贝)。像MyQueue这样的类型内部主要是自定义类型Stack成员,编译器自动生成的赋值运算符重载会调用Stack的赋值运算符重载,也不需要我们显示实现MyQueue的赋值运算符重载。这里还有一个小技巧,如果一个类显示实现了析构并释放资源,那么他就需要显示写赋值运算符重载,否则就不需要。
cpp 复制代码
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

    Date(const Date& d)
    {
        cout << " Date(const Date& d)" << endl;
        _year = d._year;
        _month = d._month;
        _day = d._day;
    }

    // 传引用返回减少拷贝
    // d1 = d2;
    Date& operator=(const Date& d)
    {
        // 不要检查自己给自己赋值的情况
        if (this != &d)
        {
            _year = d._year;
            _month = d._month;
            _day = d._day;
        }
        // d1 = d2表达式的返回对象应该为d1,也就是*this
        return *this;
    }

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

int main()
{
   Date d1(2026, 4, 7);
	Date d3(2026, 4, 8);
	
    Date d2(d1);
    d1 = d3;

    // 需要注意这里是拷贝构造,不是赋值重载
    // 请牢牢记住赋值重载完成两个已经存在的对象直接的拷贝赋值
    // 而拷贝构造用于一个对象拷贝初始化给另一个要创建的对象
    Date d4 = d1;

    return 0;
}

我们接下来依次看看这些是什么意思:

首先,就像上面开篇说的,赋值运算符重载用于完成两个已经存在的对象直接的拷贝赋值,那么在这里我们需要跟拷贝构造区分一下,因为真的是比较容易混肴的:

cpp 复制代码
int main()
{
	Date d1(2024, 7, 5);
	//拷贝构造
	Date d2(d1);
	Date d1 = d2;
	//赋值运算符
	d1 = d2;

	return 0;
}

那么接下来我们看看第一点说的是什么意思,可能有小伙伴在这里有疑问:

为什么说参数写成const当前类类型引用,否则会传值传参会有拷贝呢?

问什么之前的拷贝构造不能传值传参否则会出现无限递归的情况呢?

因为拷贝构造所引发的情况是因为逻辑出现了死循环,拷贝构造->传值传参->拷贝构造...,而这里的情况则不同,赋值运算->传值传参->拷贝构造->返回->接着赋值,没有构成循环,只是多调用了一次拷贝构造


那我们来看看第二点什么意思:

cpp 复制代码
int main()
{
	
	Date d1(2026, 4, 7);
	Date d2(2026, 4, 8);

	// 赋值重载拷贝
	d1 = d2;

	// 拷贝构造
	Date d3(d2);
	Date d4 = d2;

	d4 = d3 = d1;

	int i, j, k;
	i = j = k = 1;

	return 0;
}

众所周知,当我们这种连续的赋值时是不是从右往左走的呀,1赋值给k,返回值回左操作数,也就是k嘛,然后1依次赋值就可以,那么我们重载连续赋值运算符是不是也一样的呀:

cpp 复制代码
 Date& operator=(const Date& d)
    {
            _year = d._year;
            _month = d._month;
            _day = d._day;
            
        // d1 = d2表达式的返回对象应该为d1,也就是*this
        return *this;
    }

同样,有返回值,且建议写成当前类类型引用,引用返回可以提高效率。


那么对于第三点,我们可以来验证一下:


我们看,尽管我把上面写的注释掉了,依然会成功赋值,所以这也是第四点的意思。

5.3 日期类实现

cpp 复制代码
#pragma once
#include <iostream>
using namespace std;

class Date
{
public:
	Date(int _year = 2026, int _month = 4, int _day = 10);
	void Print();

	int GetMonthDay(int year, int month)
	{
		static int monthDayArray[13] = { -1, 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 monthDayArray[month];
	}

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

	Date operator+(int day);
	Date& operator+=(int day);

	Date operator-(int day);
	Date operator-=(int day);
	Date operator++(int);
	Date& operator++();
	int operator-(const Date& d);

private:
	int _year;
	int _month;
	int _day;

};

// Date.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "Date.h"

Date::Date(int year, int month, int day)
{
	_year = year;
	_month = month;
	_day = day;
}
void Date::Print()
{
	cout << _year << " " << _month << " " << _day << endl;
}

//d1+100
Date Date::operator+(int day)
{
	Date tmp = *this;
	tmp += day;
	return tmp;
}
//d1+=100
Date& Date::operator+=(int day)
{
	_day += day;
	while (_day > GetMonthDay(_year, _month))
	{
		_day -= GetMonthDay(_year, _month);
		++_month;
		if (_month == 13)
		{
			++_year;
			_month = 1;
		}
	}
	return *this;
}
//d1<d2
bool Date::operator<(const Date& d)
{
	if (_year < d._year)
	{
		return true;
	}
	//_year > d._year
	else if (_year == d._year)
	{
		if (_month < d._month)
		{
			return true;
		}
		else if (_month == d._month)
		{
			return _day < d._day;
		}
	}
	return false;
}
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 _year == d._year
		&& _month == d._month
		&& _day == d._day;
}
bool Date::operator!=(const Date& d)
{
	return !(*this == d);
}

Date Date::operator-=(int day)
{
	if (day < 0)
		return *this += (-day);   // 负数减法转为加法
	_day -= day;
	while (_day <= 0)
	{
		--_month;
		if (_month == 0)
		{
			--_year;
			_month = 12;
		}
		_day += GetMonthDay(_year, _month);
	}
	return *this;
}
Date Date::operator-(int day)
{
	Date tmp = *this;
	tmp -= day;
	return tmp;
}

// d1++;
// d1.operator++(0);
Date Date::operator++(int)
{
	Date tmp = *this;
	*this += 1;
	return tmp;
}

// ++d1;
// d1.operator++();
Date& Date::operator++()
{
	*this += 1;
	return *this;
}

//d1-d2
int Date::operator-(const Date& d)
{
	int flag = 1;
	Date max = *this;
	Date min = d;
	if (max<min)
	{
		max = d;
		min =*this;
		flag = -1;
	}
	int n = 0;
	while (min != max)
	{
		min++;
		n++;
	}
	return n * flag;
}

// Test.cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include "Date.h"

int main()
{
	Date d1;
	d1.Print();
	Date d2(2026, 4, 11);
	d2.Print();

	/*d1 = d2;
	d1.Print();
	Date d3 = d2;
	d3.Print();*/
	Date d3 = d1 + 100;
	d3.Print();
	d2 += 1000;
	d2.Print();
	int d4 = d1 - d2;
	cout << d4;
	return 0;
	
}

那么以上是一个很基础的日期类,让我们看看某个部分的一些小逻辑是怎么实现的:

5.3.1构造函数

我们这里的构造函数是在函数体内赋值,事实上,在下一篇文章中我们会说到初始化列表,使用那样的方式更加合理,在这里我们浅浅提一下,后续再深入讲解:

cpp 复制代码
Date::Date(int year, int month, int day)
    : _year(year), _month(month), _day(day)
{
    // 可选的校验代码
}

逻辑拆解:

  1. 参数列表 (int year, int month, int day):用户创建对象时可以传入三个整数。
  2. 初始化列表 : _year(year), _month(month), _day(day):
复制代码
  	这是 C++ 初始化成员变量最高效的方式。
复制代码
   它直接调用成员的构造函数(对于 int 就是直接赋值),比在函数体内赋值更高效。
  1. 函数体 { ... }:这里可以放校验逻辑(比如检查日期是否合法)。
cpp 复制代码
// 方式一:初始化列表(推荐)
Date::Date(int year, int month, int day)
    : _year(year), _month(month), _day(day) { }

// 方式二:函数体内赋值(不推荐)
Date::Date(int year, int month, int day)
{
    _year = year;   // 这里实际上是"赋值",而不是"初始化"
    _month = month;
    _day = day;
}

区别图示:

cpp 复制代码
方式一(初始化列表):
对象内存分配 ──► 直接写入初始值 ──► 完成 

方式二(函数体内赋值):
对象内存分配 ──► 先写入默认值(如果有)──► 再覆盖为新值 ──► 完成 ,多一步
5.3.2 获取月份天数

逻辑拆解:

  1. 静态常量数组 monthDayArray:
  • static:这个数组在整个程序运行期间只有一份,所有 Date 对象共享。每次调用函数不会重新创建,节省内存和时间。

  • const:数组内容不可修改。

  • 下标 [0] 是 -1,只是为了占位,让下标 1~12 对应月份。

  1. 闰年判断:
cpp 复制代码
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)

能被 4 整除但不能被 100 整除,或者能被 400 整除。

如果满足且月份为 2,返回 29 天。

图示:静态局部变量的内存布局

cpp 复制代码
程序数据段(静态存储区):
┌────────────────────────────────────────────┐
│  monthDayArray (唯一一份,程序启动时初始化)  │
│  [ -1, 31, 28, 31, 30, 31, 30, ... ]      │
└────────────────────────────────────────────┘
        ↑         ↑         ↑
        │         │         │
    调用1        调用2      调用3   (所有调用都访问同一份数组)

static 局部变量的生命周期:

  • 普通局部变量:函数结束时销毁。
  • static 局部变量:第一次调用时初始化,然后一直存在直到程序结束,之后调用不再初始化。
5.3.3 比较运算符(<, <=, >, >=, ==, !=)

逻辑拆解(字典序比较)

比较日期就像比较字符串(字典序):先比年,年相同再比月,月相同再比日。

图示:比较 d1 < d2 的决策树

cpp 复制代码
         比较 _year
        /          \
   d1._year < d2._year  → 返回 true
   d1._year > d2._year  → 返回 false
   d1._year == d2._year → 继续比较 _month
                              |
             比较 _month
            /            \
     d1._month < d2._month  → 返回 true
     d1._month > d2._month  → 返回 false
     d1._month == d2._month → 返回 d1._day < d2._day

其他比较运算符的实现技巧:复用

cpp 复制代码
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 _year == d._year && _month == d._month && _day == d._day;
}

bool Date::operator!=(const Date& d) 
    return !(*this == d);   // 不等于就是 等于的反面
}

重点语法解析

参数为什么是 const Date& d?

  • const:表示函数内部不会修改参数 d。
  • &(引用):避免拷贝。如果写成 bool operator<(Date d),调用时会复制一份完整的 Date 对象,浪费内存和时间。
cpp 复制代码
传值 (Date d):              传引用 (const Date& d):
调用 d1 < d2                 调用 d1 < d2
    │                            │
    V                            V
复制一份 d2 到栈上            直接使用原 d2 对象(只读)
(浪费 12 字节)                (0 拷贝开销)
5.3.4 算术运算符(+ 和 +=、- 和 -=)
cpp 复制代码
逻辑拆解(进位算法)
举例:2026-4-10 加上 100 天。

_day = 10 + 100 = 110。

进入 while 循环:

4 月有 30 天,110 > 30,所以:

_day = 110 - 30 = 80

_month 变成 5

5 月有 31 天,80 > 31:

_day = 80 - 31 = 49

_month 变成 6

6 月 30 天,49 > 30:

_day = 49 - 30 = 19

_month 变成 7

7 月 31 天,19 <= 31,循环结束。

最终日期:2026-7-19。

图示:进位过程

cpp 复制代码
初始: [2026] [4] [10]
_day += 100  →  [2026] [4] [110]
                ┌──────────┘
                │
while(110 > 30) │ _day -= 30 → 80
                └─ _month++  → 5
while(80 > 31)  │ _day -= 31 → 49
                └─ _month++  → 6
while(49 > 30)  │ _day -= 30 → 19
                └─ _month++  → 7
while(19 > 31?) 否,退出
结果: [2026] [7] [19]

返回值类型为什么是 Date&?

  • *this 就是当前对象本身。
  • 返回引用允许链式调用:(d1 += 5) += 10;
  • 如果返回值 Date,链式调用会作用在临时拷贝上,导致逻辑错误。

那么对于+来说就是复用就可以了,在这里不再多说。

5.3.5 自增运算符(++ 前置和后置)

前置 ++(++d1)

cpp 复制代码
Date& Date::operator++()
{
    return *this += 1;   // 直接调用 operator+=
}
  1. 语义:先加 1,再返回加之后的对象。
  2. 返回类型:Date&(自身引用),支持链式:++++d1;。
  3. 参数列表:空。

后置 ++(d1++)

cpp 复制代码
Date Date::operator++(int)   // 注意这个 int 参数,只用于区分,没有实际意义
{
    Date tmp = *this;   // 保存旧值
    *this += 1;         // 自身加 1
    return tmp;         // 返回旧值
}
  1. 语义:先返回加之前的旧值,然后自身再加 1。
  2. 返回类型:Date(值),因为返回的是局部变量 tmp,不能用引用。
  3. 参数列表:有一个 int 占位符,这只是一个标记,用来让编译器区分前置和后置,你调用时不需要传递任何参数。

为什么要用 int 占位?

这是 C++ 早期设计的一个"无奈之举"。因为函数重载只能靠参数类型和个数区分,不能靠返回值区分。为了给前置和后置两个同名函数不同的签名,委员会规定后置版本带一个额外的 int 参数。这个 int 在调用时由编译器自动传 0,你永远不需要手动写 d1.operator++(0)(虽然语法上可以)。

5.3.6日期相减(d1 - d2)

逻辑拆解

确定大小和符号:

  1. 假设 *this >= d,则 max = *this, min = d, flag = 1。
  2. 若 *this < d,则交换,并设 flag = -1。

逐日递增计数:

从 min 开始,每次加 1 天(调用 operator++),计数器 n 加 1。

直到 min == max 停止。

返回带符号的天数:n * flag。

示例:2026-4-10 减 2026-4-1 → max = 4-10, min = 4-1,循环 9 次,返回 9。

6. 取地址运算符重载

6.1 const成员函数

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

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

    // void Print(const Date* const this) const
    void Print() const
    {
        cout << _year << "-" << _month << "-" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};

int main()
{
    // 这里非const对象也可以调用const成员函数是一种权限的缩小
    Date d1(2026, 4, 10);
    d1.Print();

    const Date d2(2026, 4, 11);
    d2.Print();

    return 0;
}

6.2 取地址运算符重载

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

cpp 复制代码
class Date
{
public :
    Date* operator&()
    {
        return this;
        // return nullptr;
    }
    const Date* operator&()const
    {
        return this;
        // return nullptr;
    }
private :
    int _year ; // 年
    int _month ; // 月
    int _day ; // 日
};
相关推荐
AI_零食2 小时前
开源鸿蒙跨平台Flutter开发:快递单号批量查询应用
学习·flutter·华为·开源·harmonyos·鸿蒙
四谎真好看2 小时前
Redis学习笔记(高级篇2)
redis·笔记·学习·学习笔记
鱼鳞_2 小时前
Java学习笔记_Day26(不可变集合)
java·笔记·学习
不爱吃炸鸡柳2 小时前
5道经典贪心算法题详解:从入门到进阶
开发语言·数据结构·c++·算法·贪心算法
AI_零食2 小时前
开源鸿蒙跨平台Flutter开发:密码生成器应用
网络·学习·flutter·华为·开源·harmonyos·鸿蒙
智者知已应修善业2 小时前
【51单片机1,左边4个LED灯先闪烁2次后,右边4个LED灯再闪烁2次:2,接着所用灯一起闪烁3次,接着重复步骤1,如此循环。】2023-5-19
c++·经验分享·笔记·算法·51单片机
xiaoye-duck2 小时前
《算法题讲解指南:优选算法-队列+宽搜》--70.N叉树的层序遍历,71.二叉树的锯齿形层序遍历,72.二叉树的最大宽度,73.在每个树行中找最大值
数据结构·c++·算法·队列
代码改善世界2 小时前
【C++初阶】双向循环链表:List底层结构的完整实现剖析
c++·链表·list
fengci.2 小时前
LilCTF2025web(前半部分)
开发语言·网络·学习·php