第6节-C++日期类实现详解

前言
在本篇博客中,我们将一步一步讲解如何实现一个 C++ 的日期类(Date)。通过这一项目,你将巩固类与对象的基础知识、构造函数的使用、运算符重载、日期计算等内容。

在阅读本篇前,需要有一定C++类和对象的基础

1. 日期类的基本结构

首先,我们来看一下 Date 类的基本结构。

1.1 Date 类的定义

我们通过 class 关键字来定义 Date 类,类的成员包括私有的日期变量和一些公共方法。

#pragma once
#include<iostream>
using namespace std;

#include<assert.h>

class Date {
    // 友元函数声明
    friend ostream& operator<<(ostream& out, const Date& d);
    friend istream& operator>>(istream& in, Date& d);

public:
    // 默认构造函数
    Date(int year = 1900, int month = 1, int day = 1);
    
    // 打印日期
    void Print() const;
    
    // 获取指定月份的天数
    int GetMonthDay(int year, int month);

private:
    int _year;  // 年份
    int _month; // 月份
    int _day;   // 天数
};

1.2 成员变量

在 Date 类中,我们有三个私有的成员变量,分别代表年、月、日。这些变量用来存储每个日期对象的具体信息。

_year:表示年份
_month:表示月份
_day:表示天数
这些变量被定义为私有,确保它们只能通过类的方法来访问和修改。

1.3 构造函数

构造函数用于初始化 Date 对象,并确保输入的日期合法。我们在构造函数中提供了默认值,以防用户没有传入任何参数时,日期会默认初始化为 1900 年 1 月 1 日。

Date::Date(int year, int month, int day)
{
    _year = year;
    _month = month;
    _day = day;

    if (!CheckDate()) {
        cout << "日期非法" << endl;
    }
}

_year、_month 和 _day 会根据传入的参数进行赋值。
构造函数中调用了 CheckDate() 函数来检查日期是否合法


2. 日期合法性检查与月份天数计算

2.1 日期合法性检查

CheckDate() 函数用于确保日期是有效的,比如:月份在 1 到 12 之间,天数要在 1 到该月的最大天数之间。通过调用此方法,可以确保初始化的日期是合理的。

bool Date::CheckDate() {
    if (_month < 1 || _month > 12 || _day < 1 || _day > GetMonthDay(_year, _month)) {
        return false;
    }
    return true;
}

当月份不在 1 到 12 之间,或者天数不符合该月的最大天数时,函数返回 false,表示日期不合法。
否则,返回 true,表示日期有效。

2.2 获取指定月份的天数

GetMonthDay() 方法根据年份和月份返回该月的天数。尤其对于 2 月份,还需要判断是否是闰年。

int Date::GetMonthDay(int year, int month) {
    assert(month > 0 && month < 13);

    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;
    } else {
        return monthDayArray[month];
    }
}

使用了 assert() 确保月份在有效范围内。
利用一个静态数组 monthDayArray 来存储各个月份的天数。
如果是闰年且月份为 2 月,返回 29 天,否则返回数组中的天数。

2.3 打印日期

为了方便测试和查看日期对象的内容,我们实现了 Print() 方法,该方法会打印出当前日期的年、月、日。

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

这个方法是 const 方法,表明它不会修改类中的任何成员变量。


3. 日期的比较运算符重载

C++ 提供了运算符重载的机制,使得我们可以为类定义一些常见的操作符(如 <、<=、== 等)的行为。在 Date 类中,我们为日期对象之间的比较运算符进行了重载。

3.1 小于运算符 <

为了比较两个日期对象的大小,我们可以通过以下步骤实现小于运算符 < 的重载:

bool Date::operator<(const Date& d) const {
    if (_year < d._year) {
        return true;
    } else if (_year == d._year) {
        if (_month < d._month) {
            return true;
        } else if (_month == d._month) {
            return _day < d._day;
        }
    }
    return false;
}

先比较年份,如果当前对象年份小于目标对象,则返回 true。
如果年份相同,再比较月份。
如果月份也相同,最后比较天数。

3.2 其他比较运算符

我们还可以对其他比较运算符进行类似的重载,包括 <=、>、>=、== 和 !=。它们的实现可以依赖于 < 和 == 运算符:

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

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

通过 operator< 和 operator==,可以简化其他运算符的实现


4. 加法与减法运算

在这一部分,我们将探讨如何实现日期的加法与减法,包括对日期对象加上指定的天数或从日期对象中减去天数。日期类实现中,我们重载了 += 和 -= 运算符,用以处理日期的自增、自减,并通过这些运算符来实现更复杂的日期操作。

4.1 日期加法(operator+= 与 operator+)

日期加法是指给定一个日期对象,将它加上一个整数天数,得到一个新的日期。为了实现这一功能,我们需要重载 += 运算符,并通过该运算符处理日期中的天数、月份和年份的进位逻辑。

4.1.1 重载 += 运算符

+= 运算符用于将一个日期加上指定的天数,并直接修改当前对象的日期。实现的核心在于天数的累加后处理月份和年份的进位。

Date& Date::operator+=(int day) {
    if (day < 0) {
        return *this -= -day;  // 如果天数为负数,调用减法运算符
    }

    _day += day;  // 将天数加到当前对象的天数上
    // 处理跨月、跨年的情况
    while (_day > GetMonthDay(_year, _month)) {
        _day -= GetMonthDay(_year, _month);  // 当前月天数减去
        ++_month;  // 进入下一月
        if (_month == 13) {  // 如果月份超过12月,进入下一年
            ++_year;
            _month = 1;  // 重置为1月
        }
    }

    return *this;
}

如果加上的天数为负数,则调用 -= 运算符将天数转换为减法操作。
每次加上天数后,判断天数是否超过了当前月份的最大天数。如果超过,需要进行进位处理。
将超出的天数减去当前月份的天数,月份加一。
如果月份超过 12,则年份加一并将月份重置为 1 月。

4.1.2 重载 + 运算符

为了方便不修改原始日期对象的情况下进行日期加法,我们可以重载 + 运算符。+ 运算符不会改变原始对象,而是返回一个新的日期对象。我们可以通过调用 += 运算符来实现这一功能。

Date Date::operator+(int day) const {
    Date tmp = *this;  // 创建当前对象的副本
    tmp += day;  // 对副本进行加法操作
    return tmp;  // 返回副本
}

创建一个当前对象的副本 tmp,然后对 tmp 执行 += 操作,将结果返回。
operator+= 负责修改对象的日期,而 operator+ 返回一个修改后的副本。

4.1.3 日期加法示例

void TestDate1() {
    Date d1(2024, 4, 14);
    Date d2 = d1 + 30000;  // 给 d1 加上 30000 天
    d1.Print();  // 输出 d1 的日期
    d2.Print();  // 输出加法运算后的日期 d2
}

测试将一个日期加上大量天数,确保日期对象能够正确进位处理。


4.2. 日期减法(operator-= 与 operator-)

日期减法的逻辑与加法类似,只是需要处理日期的借位问题。如果天数变为负数或零,必须从前一个月借天数,必要时跨年。

4.2.1 重载 -= 运算符

-= 运算符用于将日期对象减去指定的天数,并直接修改当前日期对象。

Date& Date::operator-=(int day) {
    if (day < 0) {
        return *this += -day;  // 如果天数为负,转化为加法
    }

    _day -= day;  // 直接从当前天数中减去指定的天数
    // 处理借位跨月和跨年
    while (_day <= 0) {  // 当天数为 0 或负数时
        --_month;  // 减少一个月
        if (_month == 0) {  // 如果月份减到了 0,表示上一年
            _month = 12;  // 将月份设为12月
            --_year;  // 年份减少
        }
        _day += GetMonthDay(_year, _month);  // 从前一个月借天数
    }

    return *this;
}

如果天数为负数,调用 += 运算符来转换为加法处理。
当天数为零或负数时,说明需要从前一个月借天数:
将月份减一,如果月份变为 0,表示年份需要减少,月份设置为 12 月。
从前一个月的天数中借天数,直到天数大于 0。

4.2.2 重载 - 运算符

与加法类似,- 运算符不会修改原始日期对象,而是返回一个新的日期对象,通过调用 -= 实现。

Date Date::operator-(int day) const {
    Date tmp = *this;  // 创建当前日期的副本
    tmp -= day;  // 对副本执行减法操作
    return tmp;  // 返回修改后的副本
}

创建一个当前对象的副本 tmp,然后对其执行 -= 操作。
返回修改后的临时对象。

4.2.3 日期减法示例

void TestDate1() {
    Date d1(2024, 4, 14);
    Date d3 = d1 - 5000;  // 将 d1 减去 5000 天
    d1.Print();  // 输出 d1 的日期
    d3.Print();  // 输出减法运算后的日期 d3
}

测试将一个日期对象减去大于一个月的天数,确保能够正确处理跨月、跨年的情况。


4.3. 为什么 + 复用 += 而不是 += 复用 +


在上述实现中,我们选择 + 运算符调用 +=,而不是让 += 调用 +。其原因在于代码的设计逻辑和效率:

+= 运算符需要修改对象自身:在 += 操作中,我们直接修改了当前对象的值,这是 += 的主要用途。对于 +=,我们需要处理边界情况(如跨月、跨年)并保证修改后的对象状态是正确的。

+ 运算符返回副本:+ 运算符不应该修改原始对象,而是返回一个副本。因此,调用 += 来处理逻辑是更为合理的设计,因为 += 已经实现了核心的日期加法逻辑。

避免代码重复:如果 += 调用 +,则意味着 += 需要先创建一个副本,调用 + 返回修改后的值,然后将副本赋值给自身。这种做法会导致不必要的对象创建,增加了额外的性能开销。而通过让 + 调用 +=,只需要在 + 中创建一个副本并执行 +=,避免了重复逻辑,提升了代码效率。并且+本身的运算符重载就可能涉及到副本的创建以及传值返回的两次拷贝构造,而+=的运算符重载没有任何副本的创建并且还是传引用返回。

简而言之,+= 是修改当前对象的操作,而 + 是返回一个修改后的副本。因此,在设计上,复用 += 是合理且高效的选择。

对于-和-=也是同理


5. 流插入与提取运算符重载

在 C++ 中,重载 << 和 >> 运算符可以让我们更加方便地进行输入输出操作。通过重载这些运算符,可以直接将 Date 对象与标准输入输出流进行交互,而不用依赖专门的打印函数或者输入函数。

5.1 重载 <<(输出运算符)

<< 运算符通常用于输出。为了实现 Date 类的输出重载,我们可以将其声明为友元函数,使得它能够访问 Date 类的私有成员变量。

思考:

为什么我们推荐使用友元函数来重载流插入与流提取运算符?

5.1.1 友元函数声明

在 Date 类中,我们使用 friend 关键字来声明友元函数:

friend ostream& operator<<(ostream& out, const Date& d);
通过将 operator<< 声明为友元函数,它可以访问 Date 类的私有成员变量,如 _year、_month 和 _day。

5.1.2 运算符实现

接下来是 operator<< 的实现部分:

ostream& operator<<(ostream& out, const Date& d) {
    out << d._year << "年" << d._month << "月" << d._day << "日";
    return out;
}

我们直接将日期的年、月、日格式化输出,格式为“年 月 日”。
使用 out 来处理输出流,并在输出后返回该流,以便支持连续的输出操作(如 cout << d1 << d2)。流输出输入操作是从左往右进行的。

5.2 重载 >>(输入运算符)

与 << 类似,>> 运算符用于从输入流(例如 cin)中获取数据。在 Date 类中,我们也可以重载 >> 运算符,以便直接输入日期。

5.2.1 友元函数声明

同样地,我们将 operator>> 声明为友元函数:

friend istream& operator>>(istream& in, Date& d);

5.2.2 运算符实现

实现部分如下:

istream& operator>>(istream& in, Date& d) {
    cout << "请依次输入年 月 日:>";
    in >> d._year >> d._month >> d._day;

    if (!d.CheckDate()) {
        cout << "日期非法" << endl;
    }
    return in;
}

我们首先提示用户输入年、月、日,然后依次将输入值赋给 Date 对象的 _year、_month 和 _day 成员变量。
输入后调用 CheckDate() 方法,确保用户输入的日期合法。如果不合法,则提示用户“日期非法”。

5.3 示例:流插入与提取运算符的使用

void TestDate4() {
    Date d1(2024, 4, 14);
    Date d2 = d1 + 30000;

    // 流插入操作
    cout << d1;
    cout << d2;

    // 流提取操作
    cin >> d1 >> d2;
    cout << d1 << d2;
}

我们可以直接使用 cout << d1; 来输出日期 d1。
同时,也可以通过 cin >> d1; 来从用户输入中读取日期信息。

5.4 为什么推荐 << 运算符重载为友元函数?

为什么 << 运算符重载时更推荐友元函数呢?接下来,我们逐步分析三种实现方式的差异,并解释友元函数的优势。

5.4.1 三种实现方式的对比

我们可以通过三种方式来重载 << 运算符:

友元函数:它可以访问 Date 类的私有成员,但不属于 Date 类的成员。
成员函数:它属于 Date 类的成员,直接访问私有成员,但调用方式上有所不同。
getter函数:为Date 类提供 getter 函数来获取其私有数据,再在运算符中调用这些 getter 函数进行输出。

5.4.1.1 使用友元函数重载 <<

这是使用友元函数重载 << 运算符的方式:

// Date 类的友元声明
class Date {
    friend std::ostream& operator<<(std::ostream& out, const Date& d);
    // 其他成员变量和函数
};

// 实现 << 运算符
std::ostream& operator<<(std::ostream& out, const Date& d) {
    out << d._year << "-" << d._month << "-" << d._day;
    return out;
}
5.4.1.2 使用成员函数重载 <<

这是将 << 运算符声明为 Date 类成员函数的方式:

// Date 类的成员函数声明
class Date {
public:
    std::ostream& operator<<(std::ostream& out) const;
    // 其他成员变量和函数
};

// 实现成员函数
std::ostream& Date::operator<<(std::ostream& out) const {
    out << _year << "-" << _month << "-" << _day;
    return out;
5.4.1.3 使用getter函数重载<<

这是使用getter函数来实现重载的方式:

class Date {
public:
    int getYear() const { return _year; }
    int getMonth() const { return _month; }
    int getDay() const { return _day; }
    
private:
    int _year;
    int _month;
    int _day;
};

Date date(2024, 5, 15);
std::cout << date.getYear() << "-" << date.getMonth() << "-" << date.getDay();  // 直接使用 getter 函数访问私有数据

5.4.2 为什么不推荐使用成员函数?

5.4.2.1 符合操作数对称性的问题

<< 运算符是一个二元运算符,左操作数是 std::ostream(例如 std::cout),右操作数是 Date 对象。由运算符重载的规则可知,如果将 << 运算符作为 Date 类的成员函数,那么 Date 对象就必须作为左操作数,这会导致以下不自然的用法:

date << std::cout;  // 这与我们习惯的用法相反

而我们通常期望使用 std::cout << date; 这种自然的调用方式。这是因为左操作数应该是 ostream,而非 Date 对象。因此,如果 << 是成员函数,操作数的顺序将显得不对称、不自然。

5.4.3为什么不推荐使用getter 函数的方式来实现 << 运算符重载?


尽管使用 getter 函数也是一种可以实现 << 运算符重载的方式,但它并不理想,原因如下:

5.4.3.1 过度暴露内部实现,破坏封装性

封装是面向对象编程中的一个重要原则,指的是隐藏对象的内部实现,只暴露必要的接口。getter 函数虽然可以安全地访问私有成员,但它们的存在会暴露类的内部实现细节。

当你为 Date 类添加 getYear()、getMonth() 和 getDay() 这样的函数时,你实际上是在公开这些私有成员的访问权限:

class Date {
public:
    int getYear() const { return _year; }
    int getMonth() const { return _month; }
    int getDay() const { return _day; }
    
private:
    int _year;
    int _month;
    int _day;
};


虽然使用 getter 函数可以在 << 运算符中访问私有成员,但这些函数会暴露给类的所有使用者,而不仅仅是 << 运算符。换句话说,任何外部代码都可以通过 getter 函数访问 Date 对象的私有数据,而这可能并不是你希望的。

例如:

Date date(2024, 5, 15);
std::cout << date.getYear() << "-" << date.getMonth() << "-" << date.getDay();  // 直接使用 getter 函数访问私有数据


这违背了封装的原则,因为你可能不希望类的私有数据在其他不必要的情况下被访问。Date 类的设计初衷应该是:私有成员 _year、_month 和 _day 只在内部被管理,外部不应直接访问这些数据,除非通过像 << 这样的专用接口。

5.4.3.2 增加维护成本

当类中包含多个私有成员时,为每个成员都提供 getter 函数不仅增加了代码量,还带来了维护成本。如果你需要经常修改私有成员的结构(例如将 _year、_month、_day 重构为更复杂的对象),就需要修改所有相关的 getter 函数,这会增加代码的复杂性。

5.4.2.3 getter 函数增加类接口的冗余性

在许多情况下,getter 函数会增加类接口的冗余。getter 函数可能只是为了实现 << 运算符才存在的,并不为其他代码所用。这种冗余的接口会让类显得臃肿,增加了不必要的复杂度。

总结:为什么不推荐使用 getter 函数

破坏封装性:getter 函数会暴露类的内部实现,外部代码可以直接访问本应隐藏的私有数据,破坏了封装性。
增加维护成本:当类的私有数据发生变化时,所有的 getter 函数都需要更新,导致代码维护成本增加。
冗余的接口:getter 函数可能仅仅为了 << 运算符而存在,这样会导致类接口的冗余,不利于类的简洁性。

5.4.3 友元函数的优势

将 << 重载为友元函数可以很好地解决上述问题,原因如下:

5.4.3.1 符合运算符的对称性

通过友元函数重载 << 运算符,可以保持其自然的调用方式,即左操作数是 ostream,右操作数是 Date 对象:

std::cout << date;
友元函数不属于 Date 类的成员,因此不受左操作数限制,可以优雅地处理这种操作数对称性的问题。

5.4.3.2 访问私有成员

友元函数可以直接访问 Date 类的私有成员,无需通过 getter 函数。这不仅保持了类的封装性,还简化了代码结构。例如,友元函数可以直接访问 Date 类的 _year、_month 和 _day 成员:

std::ostream& operator<<(std::ostream& out, const Date& d) {
    out << d._year << "-" << d._month << "-" << d._day;
    return out;
}

5.4.3.3 灵活性和通用性

友元函数不仅仅局限于 std::ostream,它还可以适用于其他输出流(如文件流 std::ofstream 或字符串流 std::ostringstream),从而提高代码的灵活性。相比之下,成员函数往往会让运算符绑定在特定类上,缺少通用性。

5.4.4 同理适用于 >> 运算符

与 << 类似,>> 运算符也通常被用作输入运算符,重载方式也更适合声明为友元函数。这是因为:

>> 的左操作数是 std::istream,右操作数是 Date 对象,使用友元函数可以保持其自然对称的调用方式:std::cin >> date。
友元函数可以直接访问 Date 类的私有成员,读取数据并修改对象状态,而不破坏封装性。

5.4.5 总结

为什么选择友元函数?
对称性:友元函数保持 << 和 >> 运算符的自然对称性,使得 std::cout << date 和 std::cin >> date 成为合法的调用方式。
封装性:友元函数可以直接访问 Date 类的私有成员,无需暴露内部实现,保持封装性。
通用性:友元函数更灵活,可以用于多种类型的输入输出流。


6. 日期对象的自增与自减运算符

在 C++ 中,自增(++)和自减(--)运算符经常被用于简单的数值操作。同样地,我们可以为 Date 类重载这些运算符,用来实现日期的加一或者减一天操作。

6.1 前置自增运算符(++d1)
前置自增运算符表示先对对象进行自增操作,然后返回增加后的值。我们可以通过重载 operator++ 实现这一功能。

Date& Date::operator++() {
    *this += 1;  // 直接调用 `operator+=` 来增加一天
    return *this;
}

在这个实现中,我们利用已经实现的 operator+=,使得自增操作实际上等同于 this += 1。

6.2 后置自增运算符(d1++)

后置自增运算符与前置自增的区别在于:后置自增先返回当前对象,然后再执行自增操作。为了区分前置和后置,后置自增多了一个整型参数。

Date Date::operator++(int) {
    Date tmp(*this);  // 保存当前对象的副本
    *this += 1;  // 自增
    return tmp;  // 返回之前的副本
}

我们先创建一个当前对象的副本 tmp,然后执行自增操作,最后返回原对象的副本

6.3 前置自减运算符(--d1)

与前置自增类似,前置自减首先减少日期一天,然后返回结果:

Date& Date::operator--() {
    *this -= 1;  // 调用 `operator-=` 来减少一天
    return *this;
}

6.4 后置自减运算符(d1--)

后置自减先返回当前对象的副本,再进行自减操作:

Date Date::operator--(int) {
    Date tmp = *this;
    *this -= 1;  // 减少一天
    return tmp;  // 返回当前对象的副本
}

6.5 示例:自增与自减运算符的使用

void TestDate2() {
    Date d1(2024, 4, 14);
    Date d2 = ++d1;  // 前置自增
    d1.Print();  // 输出自增后的 d1
    d2.Print();  // 输出自增后的 d2

    Date d3 = d1++;  // 后置自增
    d1.Print();  // 输出自增后的 d1
    d3.Print();  // 输出自增前的 d1 副本
}

这里展示了前置和后置自增的不同效果:前置自增后,d1 和 d2 都会是自增后的日期,而后置自增后,d3 保存的是自增前的日期副本。


7. 日期差计算

除了对日期进行加减操作,我们还需要实现日期之间的差值计算。通过重载减法运算符(operator-),我们可以直接计算两个日期对象之间相差的天数。

7.1 日期差值的实现

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;  // 返回最终的差值,带上正负号
}

我们通过比较两个日期对象,确保 max 是较大的日期,min 是较小的日期。为了计算日期差值,我们使用一个 flag 来记录差值的正负号。
在 while 循环中,我们通过对较小的日期对象进行自增操作,逐步逼近较大的日期对象,同时计数差异的天数。
最后返回差值,并根据日期的大小返回正数或负数。


7.2 日期差值示例

void TestDate3() {
    Date d1(2024, 4, 14);
    Date d2(2034, 4, 14);

    int n = d1 - d2;  // 计算日期差值
    cout << n << endl;  // 输出相差的天数

    n = d2 - d1;  // 反向计算
    cout << n << endl;
}

通过 d1 - d2 计算两个日期之间的差值,并输出相差的天数。
使用相反的操作 d2 - d1,我们可以验证日期差值的正负是否正确。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

绵绵细雨中的乡音

感谢您陌生人对我求学之路的支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值