C++类与对象(中):默认成员函数与日期类实战

类和对象

  • 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 构造函数的几个关键特性

  1. 函数名 = 类名,没有返回值声明(连void都不写)
  2. 自动调用,对象创建时就会执行
  3. 可以重载,提供多种初始化方式
  4. 不写则编译器自动生成,写了就不再自动生成

一个简单的例子:

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 析构函数的特性

  1. 函数名 = 类名前面加~,不接受任何参数
  2. 无返回值,不能重载,一个类只能有一个
  3. 对象生命周期结束时自动调用
  4. 不写则编译器自动生成

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现在指向同一块内存!

这会导致两个问题:

  1. 修改s2的数据会影响s1
  2. 析构时两次释放同一块内存,程序崩溃

所以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 不能做的事

  1. 不能改变运算符的优先级和结合性------重载不会改变运算符的运算规则
  2. 不能创造新的运算符------只能重载已有的
  3. 不能重载的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对象,而coutostream类型,不会去调用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引用

  1. 用引用避免传参时的拷贝
  2. 用const表示只读取不修改右操作数
  3. const引用可以接收临时对象
cpp 复制代码
d1 = Date();  // Date()产生临时对象,只有const引用能接收

6.4 返回值为什么是引用

两个原因:

  1. 引用返回减少拷贝开销
  2. 支持连续赋值: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;

全局函数不能直接访问私有成员。解决方案:

  1. 成员变量设为public(不推荐)
  2. 提供Get函数
  3. 声明为友元函数
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() const
  • operator<(const Date& d) const
  • operator+(int day) const

而这些会修改对象的就不能加:

  • operator+=(int day)
  • operator++()

九、取地址运算符重载

这个一般不需要自己写,编译器生成的够用了。只有在特殊场景下才需要自己实现,比如不想让外部获取到对象的真实地址。

cpp 复制代码
Date* operator&() { return this; }
const Date* operator&() const { return this; }

每个类里面都会默认生成的,不用我们手动写他。


总结

  1. 构造函数 负责初始化,析构函数负责释放资源,两者配对形成对象的生命周期管理
  2. 拷贝构造赋值重载看起来像,但本质不同:一个是创建新对象,一个是对已有对象赋值
  3. 涉及资源管理的类(典型特征是需要自己写析构函数),通常也需要自己实现拷贝构造和赋值重载
  4. 运算符重载要符合语义,不能改变运算符的优先级和结合性
  5. **前置++**返回引用,**后置++**返回临时对象
  6. const成员函数本质是const修饰this指针,不修改成员变量的函数都应该加const
  7. 日期类实现是本部分的综合案例,重点理解每个运算符的设计逻辑而不是死记代码

下期预告:类和对象(下),我们将学习初始化列表、static成员、友元、内部类等知识。

相关推荐
hansang_IR1 小时前
【题解】可持久化区间仿射区间和(Persistent Range Affine Range Sum)
c++·算法·线段树
XWalnut1 小时前
LeetCode刷题 day37
java·数据结构·算法·leetcode
龙仔7251 小时前
人大金仓Kingbase V8 自动全量备份部署完整笔记
数据库·笔记·备份·人大金仓
Smilecoc1 小时前
方向导数与梯度
算法
瑞码空间1 小时前
01背包:动态规划的Hello World
c++·算法·0/1背包问题
鱼子星_2 小时前
【C++】反向迭代器:反向迭代器的底层认识与模拟实现
开发语言·c++·笔记·stl
Wang's Blog2 小时前
AI Agent白手起家63: LangGraph 人机交互——让人类介入 AI 工作流
人工智能·算法·人机交互
郝学胜-神的一滴2 小时前
[简化版 GAMES 104] 现代游戏引擎 05:游戏引擎世界构建核心机制深度解析
c++·程序人生·unity·游戏引擎·计算机图形学·opengl
Darkwanderor2 小时前
C++的流简介和简单使用
开发语言·c++