类和对象
- C++类与对象(中):默认成员函数与日期类实战
-
- 一、六大类默认成员函数
- 二、构造函数------对象出生时的事
-
- [2.1 构造函数的定位](#2.1 构造函数的定位)
- [2.2 构造函数的几个关键特性](#2.2 构造函数的几个关键特性)
- [2.3 什么叫"默认构造函数"](#2.3 什么叫"默认构造函数")
- [2.4 两种默认构造不能同时存在](#2.4 两种默认构造不能同时存在)
- [2.5 创建对象时括号别乱加](#2.5 创建对象时括号别乱加)
- [2.6 编译器自动生成的构造函数做什么](#2.6 编译器自动生成的构造函数做什么)
- [2.7 自定义类型成员的构造特点](#2.7 自定义类型成员的构造特点)
- 三、析构函数------对象销毁时的事
-
- [3.1 析构函数的作用](#3.1 析构函数的作用)
- [3.2 析构函数的特性](#3.2 析构函数的特性)
- [3.3 什么时候必须自己写析构函数](#3.3 什么时候必须自己写析构函数)
- [3.4 什么时候可以不写](#3.4 什么时候可以不写)
- [3.5 自定义类型成员一定会自动析构](#3.5 自定义类型成员一定会自动析构)
- [3.6 局部对象的析构顺序](#3.6 局部对象的析构顺序)
- [3.7 构造/析构的自动化优势](#3.7 构造/析构的自动化优势)
- 四、拷贝构造函数------复制出一个新对象
-
- [4.1 什么是拷贝构造](#4.1 什么是拷贝构造)
- [4.2 拷贝构造的本质](#4.2 拷贝构造的本质)
- [4.3 参数为什么必须是引用](#4.3 参数为什么必须是引用)
- [4.4 三个会触发拷贝构造的场景](#4.4 三个会触发拷贝构造的场景)
- [4.5 传引用返回要小心野引用](#4.5 传引用返回要小心野引用)
- [4.6 默认生成的拷贝构造做什么](#4.6 默认生成的拷贝构造做什么)
- [4.7 两类典型的处理方式](#4.7 两类典型的处理方式)
- [4.8 一个实用的小技巧](#4.8 一个实用的小技巧)
- 五、运算符重载------让对象支持标准运算符
-
- [5.1 为什么需要运算符重载](#5.1 为什么需要运算符重载)
- [5.2 运算符重载的语法](#5.2 运算符重载的语法)
- [5.3 参数个数的规则](#5.3 参数个数的规则)
- [5.4 不能做的事](#5.4 不能做的事)
- [5.5 运算符重载要符合常识](#5.5 运算符重载要符合常识)
- [5.6 前置++和后置++如何区分](#5.6 前置++和后置++如何区分)
- [5.7 为什么流插入运算符通常重载为全局函数](#5.7 为什么流插入运算符通常重载为全局函数)
- 六、赋值运算符重载------对象间的赋值
-
- [6.1 赋值重载和拷贝构造的区别](#6.1 赋值重载和拷贝构造的区别)
- [6.2 赋值运算符的典型实现](#6.2 赋值运算符的典型实现)
- [6.3 为什么参数用const引用](#6.3 为什么参数用const引用)
- [6.4 返回值为什么是引用](#6.4 返回值为什么是引用)
- [6.5 为什么要有自赋值检查](#6.5 为什么要有自赋值检查)
- [6.6 默认生成的赋值运算符做什么](#6.6 默认生成的赋值运算符做什么)
- 七、日期类------综合运用示例
-
- [7.1 日期类常用接口一览](#7.1 日期类常用接口一览)
- [7.2 获取月份天数](#7.2 获取月份天数)
- [7.3 日期合法性检查](#7.3 日期合法性检查)
- [7.4 比较运算符的复用技巧](#7.4 比较运算符的复用技巧)
- [7.5 +=和+的区别](#7.5 +=和+的区别)
- [7.6 支持负数天数的技巧](#7.6 支持负数天数的技巧)
- [7.7 前置++和后置++的区别](#7.7 前置++和后置++的区别)
- [7.8 两个日期相减](#7.8 两个日期相减)
- [7.9 流插入和流提取](#7.9 流插入和流提取)
- [7.10 为什么<<和>>需要友元](#7.10 为什么<<和>>需要友元)
- 八、const成员函数
-
- [8.1 什么是const成员函数](#8.1 什么是const成员函数)
- [8.2 const实际上修饰的是什么](#8.2 const实际上修饰的是什么)
- [8.3 const对象只能调用const成员函数](#8.3 const对象只能调用const成员函数)
- [8.4 什么时候加const](#8.4 什么时候加const)
- 九、取地址运算符重载
- 总结
C++类与对象(中):默认成员函数与日期类实战
很多人学完C++类和对象的基本概念后,觉得内容不难。但真正到了写稍微复杂点的代码时,就会遇到各种问题------对象拷贝时程序崩了、赋值运算符写错导致内存泄漏、析构函数被调用两次等等。
说到底,还是对类的默认成员函数理解不够透彻。这篇文章,我们把这个部分彻底讲清楚。
一、六大类默认成员函数
说到类的默认成员函数,很多教材会直接列出6个然后让你背。但我更建议先理解它们分别解决什么问题。
| 默认成员函数 | 作用 | 重要程度 |
|---|---|---|
| 构造函数 | 对象创建时的初始化工作 | ⭐⭐⭐⭐ |
| 析构函数 | 对象销毁时的清理工作 | ⭐⭐⭐⭐ |
| 拷贝构造函数 | 用已有对象初始化新对象 | ⭐⭐⭐⭐ |
| 赋值运算符重载 | 两个已存在对象间的赋值 | ⭐⭐⭐ |
| 普通取地址运算符 | 获取对象地址 | 了解即可 |
| const取地址运算符 | 获取const对象地址 | 了解即可 |
C++11之后还新增了移动构造和移动赋值,但初学阶段先把前4个理解透彻就够了。
学习默认成员函数的核心思路
在展开讲每个函数之前,先记住一个通用的学习框架。每次遇到一个默认成员函数,问自己两个问题:
第一,不写的时候编译器会生成什么?
第二,生成的这个版本能满足我当前类的需求吗?
举一个常见的坑。假设你写了这样一个类:
cpp
class Stack {
int* _a;
size_t _capacity;
size_t _top;
};
然后你写了这样的代码:
cpp
Stack s1;
Stack s2 = s1; // 这里会发生什么?
如果你不理解编译器默认生成的拷贝构造,这个问题可能等你程序崩了都不知道为什么。
二、构造函数------对象出生时的事
2.1 构造函数的定位
首先要纠正一个常见的误解:构造函数不是用来申请内存创建对象的 。对象需要的内存早在构造函数执行之前就分配好了,构造函数要做的事情只有一个------初始化。
打个比方,就像盖房子之前地基已经打好了,构造函数负责决定这个房子里要怎么装修。
2.2 构造函数的几个关键特性
- 函数名 = 类名,没有返回值声明(连void都不写)
- 自动调用,对象创建时就会执行
- 可以重载,提供多种初始化方式
- 不写则编译器自动生成,写了就不再自动生成
一个简单的例子:
cpp
class Date {
public:
Date() { // 无参构造
_year = 1;
_month = 1;
_day = 1;
}
Date(int year, int month, int day) { // 带参构造
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
2.3 什么叫"默认构造函数"
很多人以为默认构造函数就是编译器自动生成的那个,其实不完全对。不传实参就能调用的构造函数,都叫默认构造函数。
具体分三种情况:
- 编译器自动生成的
- 用户写的无参构造
- 用户写的全缺省构造
cpp
Date(); // 情况1:用户写的无参构造
Date(int year = 1, int month = 1, int day = 1); // 情况2和3:全缺省构造
这三个都可以不传参调用,所以都叫默认构造函数。
2.4 两种默认构造不能同时存在
这是个经典坑点,面试时经常被拿出来考:
cpp
Date(); // 无参版本
Date(int year = 1, int month = 1, int day = 1); // 全缺省版本
这两个都能不传参调用,编译器会认为你调用时存在歧义。所以默认构造函数只能保留一个可用版本。
2.5 创建对象时括号别乱加
cpp
Date d1; // ✅ 正确,这是定义对象
Date d2(); // ❌ 错误,这不是创建对象,而是声明了一个返回Date的函数
这个错误太常见了,面试时经常被拿出来考。
2.6 编译器自动生成的构造函数做什么
分两种情况:
| 成员类型 | 自动生成构造的行为 |
|---|---|
| 内置类型(int、指针等) | 不保证初始化,可能是随机值 |
| 自定义类型(class/struct) | 调用它自己的默认构造 |
所以如果你的类里有指针成员,别指望自动生成的构造函数会帮你初始化。
2.7 自定义类型成员的构造特点
假设有两个类,Stack和MyQueue:
cpp
class Stack {
public:
Stack() { cout << "Stack构造" << endl; }
~Stack() { cout << "Stack析构" << endl; }
};
class MyQueue {
Stack _pushst;
Stack _popst;
};
如果MyQueue没有显式写构造,编译器自动生成的版本会自动调用Stack的构造:
cpp
MyQueue mq;
// 输出:
// Stack构造
// Stack构造
这很合理,因为MyQueue的成员本身就是Stack类型的对象,创建MyQueue对象时当然要初始化它的每一个成员。
这里一个细节,我们不写,编译器默认⽣成的构造,对内置类型成员变量的初始化没有要求,也就是说是是否初始化是不确定的,看编译器。对于⾃定义类型成员变量,要求调⽤这个成员变量的默认构造函数初始化。如果这个成员变量,没有默认构造函数,那么就会报错,我们要初始化这个成员变量,需要⽤初始化列表才能解决,初始化列表,我们下个章节再细细讲解。
这里有个易错点,很多人以为只要写了这个构造函数,就会调用它,这里我就用错误例子给大家看一下。
cpp
class Date
{
Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _day;
int _month;
};
class stack
{
private:
Date A;
};
int main()
{
stack d;
}
这里就会报错,因为他不是默认构造函数,对于⾃定义类型成员变量,要求调⽤这个成员变量的默认构造函数初始化.。
三、析构函数------对象销毁时的事
3.1 析构函数的作用
如果说构造函数负责对象的出生,那析构函数就负责对象的死亡。但这里的"死亡"不是把对象从内存中抹掉,而是释放在对象生命周期中申请的资源。
以Stack为例:
cpp
~Stack() {
free(_a); // 释放堆内存
_a = nullptr;
_top = _capacity = 0;
}
3.2 析构函数的特性
- 函数名 = 类名前面加
~,不接受任何参数 - 无返回值,不能重载,一个类只能有一个
- 对象生命周期结束时自动调用
- 不写则编译器自动生成
3.3 什么时候必须自己写析构函数
判断标准很简单:类内部是否直接管理资源。
需要自己写的情况:
- 用malloc/free或new/delete管理内存
- 打开文件后没有关闭
- 建立网络连接后没有断开
- 获取锁后没有释放
换句话说,只要涉及到"谁申请谁释放"的资源,都需要显式写析构函数。
3.4 什么时候可以不写
像Date这种只包含基本类型成员的类,没有需要释放的资源,就可以不写:
cpp
class Date {
int _year;
int _month;
int _day;
};
但要注意,如果类里有自定义类型成员,即使这个类本身没有直接管理资源,它的析构函数也会自动被调用------用来调用自定义类型成员的析构函数。
3.5 自定义类型成员一定会自动析构
无论是否显式写当前类的析构函数,自定义类型成员都会在当前对象析构时自动调用自己的析构函数。
比如即使MyQueue显式写了析构函数而且函数体为空,Stack的析构函数还是会被调用:
cpp
class MyQueue {
public:
~MyQueue() {
// 这里即使什么都不写,_pushst和_popst也会自动析构
}
private:
Stack _pushst;
Stack _popst;
};
3.6 局部对象的析构顺序
同一作用域内,后创建的对象先析构。这个顺序跟构造顺序正好相反。
cpp
{
Stack st;
MyQueue mq;
} // 先析构mq,再析构st
3.7 构造/析构的自动化优势
用C的方式写Stack,需要手动调用Init和Destroy:
c
STInit(&st);
if (something_wrong) {
STDestroy(&st); // 每个错误分支都要记得调用
return ERROR;
}
STDestroy(&st); // 最后还要调用一次
而C++的方式:
cpp
Stack st; // 构造和析构自动配对
if (something_wrong) {
return ERROR; // 即使中途return,析构也会自动执行
}
这就是RAII思想的核心------通过对象的生命周期自动管理资源。
四、拷贝构造函数------复制出一个新对象
4.1 什么是拷贝构造
用一个已存在的对象来初始化一个新创建的对象,这就是拷贝构造,注意是已存在的来初始化新对象,这里和赋值重载区分。
cpp
Date d1(2024, 8, 9);
Date d2(d1); // 拷贝构造
Date d3 = d1; // 也是拷贝构造(因为d3是新创建的)
4.2 拷贝构造的本质
拷贝构造首先是一个构造函数,只是它的参数比较特殊------第一个参数是自身类类型的引用:
cpp
Date(const Date& d) {
_year = d._year;
_month = d._month;
_day = d._day;
}
它和普通构造函数构成重载关系:
cpp
Date(int year = 1, int month = 1, int day = 1); // 普通构造
Date(const Date& d); // 拷贝构造
4.3 参数为什么必须是引用
这是个高频考点。看看下面的错误写法:
cpp
Date(Date d) { // 错误!
}
如果写成传值,调用时需要用d1拷贝构造形参d。但拷贝形参d又需要调用拷贝构造,这就形成了无限递归。
因为传值他会调用拷贝构造,再赋值给d,再把d给返回函数,如果非要写传值的话,就必须这样写.
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(Date d)
Date(const Date & d1)
{
_year = d1._year;
_month = d1._month;
_day = d1._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(Date d)
{
cout << &d << endl;
d.Print();
}
int main()
{
Date d1(2024, 7, 5);
// 所以这⾥的d1传值传参给d要调⽤拷⻉构造完成拷⻉,传引⽤传参可以较少这⾥的拷⻉
Func1(d1);
}
这里的顺序是Func1到Date(const Date & d1)再到Func1,因为传值会调用拷贝构造也就是Func1的d因为拷贝构造得到的,Date d(d1),然后调用Func1。
正确写法:
```cpp
Date(const Date& d) { // 正确
}
加const是更好的风格,表示只读取不修改被拷贝的对象。
4.4 三个会触发拷贝构造的场景
场景一:用已有对象初始化新对象
cpp
Date d1(2024, 8, 9);
Date d2(d1); // 触发拷贝构造
注意Date d3 = d1;也是拷贝构造,不是赋值。因为d3是新创建的对象。
场景二:传值方式给函数传参
cpp
void Func(Date d) { // d是形参,需要用实参拷贝构造
}
Date d1;
Func(d1); // 触发拷贝构造
场景三:传值方式返回对象
cpp
Date Func() {
Date tmp;
return tmp; // 返回时可能触发拷贝构造(取决于编译器优化)
}
4.5 传引用返回要小心野引用
cpp
Date& Func() {
Date tmp(2024, 8, 9);
return tmp; // 危险!tmp是局部对象,函数结束后被销毁
}
tmp在函数结束时销毁了,但函数返回了它的引用。这个引用指向的内存可能已经被其他数据覆盖了,这就是野引用。
4.6 默认生成的拷贝构造做什么
分两种情况:
| 成员类型 | 默认拷贝构造行为 |
|---|---|
| 内置类型 | 字节对字节的拷贝(浅拷贝) |
| 自定义类型 | 调用该类型的拷贝构造 |
4.7 两类典型的处理方式
Date类------成员都是内置类型,没有指向堆的指针:
cpp
class Date {
int _year;
int _month;
int _day;
};
这种情况用编译器自动生成的拷贝构造就够了,因为内置类型的拷贝就是简单赋值。
Stack类------内部有指针管理堆资源:
cpp
class Stack {
int* _a;
size_t _capacity;
size_t _top;
};
如果用默认的浅拷贝:
cpp
Stack s1;
Stack s2 = s1; // s1._a和s2._a现在指向同一块内存!
这会导致两个问题:
- 修改s2的数据会影响s1
- 析构时两次释放同一块内存,程序崩溃
所以Stack必须自己实现深拷贝:
cpp
Stack(const Stack& st) {
_a = (int*)malloc(sizeof(int) * st._capacity);
memcpy(_a, st._a, sizeof(int) * st._top);
_top = st._top;
_capacity = st._capacity;
}
4.8 一个实用的小技巧
如果一个类需要自己写析构函数来释放资源,那它大概率也需要自己写拷贝构造函数。
这个经验法则能帮你快速判断哪些类需要深拷贝。
五、运算符重载------让对象支持标准运算符
5.1 为什么需要运算符重载
C++的标准运算符是为内置类型设计的。比如==可以比较两个int是否相等,但能不能比较两个Date对象呢?
答案是通过运算符重载,让==也能比较Date对象:
cpp
d1 == d2; // 实际等价于 d1.operator==(d2) 或 operator==(d1, d2)
5.2 运算符重载的语法
函数名格式:operator + 运算符符号
cpp
// 全局函数形式
bool operator==(const Date& d1, const Date& d2);
// 成员函数形式
bool Date::operator==(const Date& d) const;
5.3 参数个数的规则
运算符有几个操作数,重载函数就有几个参数。成员函数形式下,左操作数通过隐含的this传递,所以参数比实际操作数少一个。
5.4 不能做的事
- 不能改变运算符的优先级和结合性------重载不会改变运算符的运算规则
- 不能创造新的运算符------只能重载已有的
- 不能重载的5个运算符 :
.*、::、sizeof、?:、.*
重点记一下这5个不能重载的运算符,面试常考。
5.5 运算符重载要符合常识
不是所有能重载的运算符都适合重载。判断标准是:语义是否合理。
以Date为例:
| 运算 | 是否合理 | 原因 |
|---|---|---|
| Date - Date | ✅ | 两个日期相差多少天,有明确含义 |
| Date + int | ✅ | 日期加上N天,有明确含义 |
| Date - int | ✅ | 日期减去N天,有明确含义 |
| Date + Date | ❌ | 两个日期相加没有自然含义 |
5.6 前置++和后置++如何区分
C++规定前置++和后置++通过参数列表区分:
cpp
Date& operator++(); // 前置++,无额外参数
Date operator++(int); // 后置++,多一个int参数(只是用来区分,没有实际意义)
为什么前置++可以传引用呢,因为他传的参数和原来的参数是一样的,但是后置++传的是没有加一之前的值,使用时,要有临时值接受他之前的值,临时变量会销毁,使用不能传引用。
调用时的对应关系:
cpp
++d; // 调用 d.operator++()
d++; // 调用 d.operator++(0)
5.7 为什么流插入运算符通常重载为全局函数
如果你把<<写在Date类里作为成员函数:
cpp
d << cout; // 这不符合使用习惯
但我们希望这样用:
cpp
cout << d; // 这才是自然的写法
如果<<是Date的成员函数,左操作数就是Date对象,而cout是ostream类型,不会去调用Date的operator<<。
所以必须用全局函数:
cpp
ostream& operator<<(ostream& out, const Date& d) {
out << d._year << "年" << d._month << "月" << d._day << "日";
return out; // 返回out是为了支持连续输出:cout << d1 << d2;
}
流提取运算符>>也是同样道理。
这里我之前一个疑惑为什么要传二个变量呢,this指针怎么不见了,经过我的思考后,我发现,不管是cout,还是cin,他们的类是库里因为写好的,这里拿cout,ostream举例。
cpp
class ostream
{
public:
// ========== 成员重载 operator<< 各种内置整数类型 ==========
ostream& operator<<(short val);
ostream& operator<<(unsigned short val);
ostream& operator<<(int val); // int
ostream& operator<<(unsigned int val); // unsigned int
ostream& operator<<(long val);
ostream& operator<<(unsigned long val);
ostream& operator<<(long long val);
ostream& operator<<(unsigned long long val);
// 还有char、double、指针等等一大堆重载
ostream& operator<<(char c);
ostream& operator<<(double d);
// 关键!返回 *this,支持链式调用 cout << a << b << c;
};
可以看见这个才是cout的类,我们写cout<<3这些,都不需要传this指针,但是为什么我们直接写cout<<d1要传二个参数呢,因为这iostream类是库里面写好的,我们要增加一个新的方式,只有在外面写,但是这时就没有this指针了,必须写在全局变量里面,把cout这个参数也传进去。这就解答了我之前的疑惑,cin同理。
六、赋值运算符重载------对象间的赋值
6.1 赋值重载和拷贝构造的区别
这是个极易混淆的点,记住一个判断标准:
- 拷贝构造:新对象还不存在,用已有对象来初始化它
- 赋值重载:两个对象都已经存在了,用一个去覆盖另一个
cpp
Date d1(2024, 8, 9);
Date d2(d1); // 拷贝构造,d2是新建的
Date d3 = d1; // 拷贝构造,d3是新建的
d1 = d2; // 赋值重载,d1和d2早就存在了
6.2 赋值运算符的典型实现
cpp
Date& operator=(const Date& d) {
if (this != &d) { // 防止自己给自己赋值
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this; // 返回引用是为了支持连续赋值:d1 = d2 = d3
}
6.3 为什么参数用const引用
- 用引用避免传参时的拷贝
- 用const表示只读取不修改右操作数
- const引用可以接收临时对象
cpp
d1 = Date(); // Date()产生临时对象,只有const引用能接收
6.4 返回值为什么是引用
两个原因:
- 引用返回减少拷贝开销
- 支持连续赋值:
d1 = d2 = d3
6.5 为什么要有自赋值检查
看一个危险的例子:
cpp
String& operator=(const String& s) {
delete[] _str; // 先删除自己的
_str = new char[strlen(s._str) + 1]; // 再从s拷贝
strcpy(_str, s._str);
return *this;
}
如果不小心写了str = str(自己给自己赋值),在delete\[\]之后,s._str指向的内存已经被释放了,再去拷贝就什么都拿不到了。
6.6 默认生成的赋值运算符做什么
和拷贝构造类似:
- 内置类型:浅拷贝
- 自定义类型:调用该类型的赋值运算符重载
所以,涉及到资源管理的类(需要自己写析构函数的),通常也需要自己写赋值运算符重载。
七、日期类------综合运用示例
日期类是本部分内容最好的实践项目,涵盖了拷贝构造、赋值重载、运算符重载等多种技能。
7.1 日期类常用接口一览
cpp
class Date {
public:
Date(int year = 1900, int month = 1, int day = 1);
void Print() const;
int GetMonthDay(int year, int month); // 获取某月天数
bool CheckDate(); // 检查日期合法性
// 比较运算符(都加const)
bool operator<(const Date& d) const;
bool operator<=(const Date& d) const;
bool operator>(const Date& d) const;
bool operator>=(const Date& d) const;
bool operator==(const Date& d) const;
bool operator!=(const Date& d) const;
// 日期运算
Date& operator+=(int day);
Date operator+(int day) const;
Date& operator-=(int day);
Date operator-(int day) const;
int operator-(const Date& d) const; // 两个日期相减
// ++和--
Date& operator++(); // 前置++
Date operator++(int); // 后置++
Date& operator--();
Date operator--(int);
private:
int _year;
int _month;
int _day;
};
7.2 获取月份天数
用数组存储每个月的天数(下标从1开始),闰年2月单独处理:
cpp
static int monthDayArray[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int GetMonthDay(int year, int month) {
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) {
return 29;
}
return monthDayArray[month];
}
这里用数组非常方便,比写一堆if-else清爽多了。
7.3 日期合法性检查
cpp
bool Date::CheckDate() {
if (_month < 1 || _month > 12
|| _day < 1 || _day > GetMonthDay(_year, _month)) {
return false;
}
return true;
}
核心判断:月份在1-12,日期在1~当月最大天数之间。
7.4 比较运算符的复用技巧
建议先实现<和==,其他的都通过它们来实现:
cpp
bool Date::operator<(const Date& d) const {
if (_year < d._year) return true;
if (_year == d._year && _month < d._month) return true;
if (_year == d._year && _month == d._month && _day < d._day) return true;
return false;
}
bool Date::operator==(const Date& d) const {
return _year == d._year && _month == d._month && _day == d._day;
}
// 其他比较运算符都可以复用这两个
bool Date::operator>(const Date& d) const { return !(*this <= d); }
bool Date::operator<=(const Date& d) const { return *this < d || *this == d; }
bool Date::operator>=(const Date& d) const { return !(*this < d); }
bool Date::operator!=(const Date& d) const { return !(*this == d); }
学会复用可以大大减少代码量和出错的可能。
7.5 +=和+的区别
| 运算符 | 是否修改当前对象 | 返回值 |
|---|---|---|
| += | 修改 | 返回引用(*this) |
| + | 不修改 | 返回临时对象 |
cpp
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;
}
Date Date::operator+(int day) const {
Date tmp(*this); // 拷贝当前对象
tmp += day; // 修改副本
return tmp; // 返回副本,不影响原对象
}
7.6 支持负数天数的技巧
如果想让+=和-=支持负数天数,可以用这个小技巧统一逻辑:
cpp
Date& Date::operator+=(int day) {
if (day < 0) {
return *this -= -day; // 负数转正数,调用-=
}
// 正数加天数逻辑
}
Date& Date::operator-=(int day) {
if (day < 0) {
return *this += -day; // 负数转正数,调用+=
}
// 正数减天数逻辑
}
这样就不需要重复处理正负数两种情况了。
7.7 前置++和后置++的区别
cpp
Date& Date::operator++() { // 前置++
*this += 1;
return *this;
}
Date Date::operator++(int) { // 后置++
Date tmp(*this); // 先保存当前值
*this += 1; // 再自增
return tmp; // 返回自增前的值
}
前置++返回引用,后置++返回临时对象。
7.8 两个日期相减
思路很简单:从较小的日期不断++,直到等于较大的日期,统计次数:
cpp
int Date::operator-(const Date& d) const {
Date max = *this;
Date min = d;
int flag = 1;
if (*this < d) {
max = d;
min = *this;
flag = -1;
}
int n = 0;
while (min != max) {
++min;
++n;
}
return n * flag;
}
7.9 流插入和流提取
cpp
// 流插入运算符(输出)
ostream& operator<<(ostream& out, const Date& d) {
out << d._year << "年" << d._month << "月" << d._day << "日";
return out;
}
// 流提取运算符(输入)
istream& operator>>(istream& in, Date& d) {
in >> d._year >> d._month >> d._day;
return in;
}
返回流引用是为了支持连续操作:cout << d1 << d2; 或 cin >> d1 >> d2;。
7.10 为什么<<和>>需要友元
如果成员变量是私有的:
cpp
private:
int _year;
int _month;
int _day;
全局函数不能直接访问私有成员。解决方案:
- 成员变量设为public(不推荐)
- 提供Get函数
- 声明为友元函数
cpp
class Date {
friend ostream& operator<<(ostream& out, const Date& d);
friend istream& operator>>(istream& in, Date& d);
private:
int _year;
int _month;
int _day;
};
八、const成员函数
8.1 什么是const成员函数
在成员函数参数列表后面加上const,表示这个函数不会修改对象的状态:
cpp
void Print() const;
8.2 const实际上修饰的是什么
普通成员函数中,this指针相当于Date* const this(指针本身不能变)。
const成员函数中,this指针相当于const Date* const this(指针不能变,指向的对象也不能变)。
8.3 const对象只能调用const成员函数
const对象的权限是只读,如果调用非const成员函数,相当于把只读权限放大成可写,这是不允许的。
而普通对象可以调用const成员函数,因为权限缩小是允许的。
8.4 什么时候加const
原则很简单:不修改成员变量的函数都应该加const。
比如Date类中的这些:
Print() constoperator<(const Date& d) constoperator+(int day) const
而这些会修改对象的就不能加:
operator+=(int day)operator++()
九、取地址运算符重载
这个一般不需要自己写,编译器生成的够用了。只有在特殊场景下才需要自己实现,比如不想让外部获取到对象的真实地址。
cpp
Date* operator&() { return this; }
const Date* operator&() const { return this; }
每个类里面都会默认生成的,不用我们手动写他。
总结
- 构造函数 负责初始化,析构函数负责释放资源,两者配对形成对象的生命周期管理
- 拷贝构造 和赋值重载看起来像,但本质不同:一个是创建新对象,一个是对已有对象赋值
- 涉及资源管理的类(典型特征是需要自己写析构函数),通常也需要自己实现拷贝构造和赋值重载
- 运算符重载要符合语义,不能改变运算符的优先级和结合性
- **前置++**返回引用,**后置++**返回临时对象
- const成员函数本质是const修饰this指针,不修改成员变量的函数都应该加const
- 日期类实现是本部分的综合案例,重点理解每个运算符的设计逻辑而不是死记代码
下期预告:类和对象(下),我们将学习初始化列表、static成员、友元、内部类等知识。