C 日期类实现详解
前言
在本篇博客中,我们将一步一步讲解如何实现一个 C 的日期类(Date
)。通过这一项目,你将巩固类与对象的基础知识、构造函数的使用、运算符重载、日期计算等内容。
在阅读本篇前,需要有一定C 类和对象的基础
请见: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 日期加法示例
代码语言:javascript复制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 日期减法示例
代码语言:javascript复制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 运算符实现
实现部分如下:
代码语言:javascript复制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 示例:流插入与提取运算符的使用
代码语言:javascript复制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
对象的私有数据,而这可能并不是你希望的。
例如:
代码语言:javascript复制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
)
后置自增运算符与前置自增的区别在于:后置自增先返回当前对象,然后再执行自增操作。为了区分前置和后置,后置自增多了一个整型参数。
代码语言:javascript复制Date Date::operator (int) {
Date tmp(*this); // 保存当前对象的副本
*this = 1; // 自增
return tmp; // 返回之前的副本
}
- 我们先创建一个当前对象的副本
tmp
,然后执行自增操作,最后返回原对象的副本。
6.3 前置自减运算符(--d1
)
与前置自增类似,前置自减首先减少日期一天,然后返回结果:
代码语言:javascript复制Date& Date::operator--() {
*this -= 1; // 调用 `operator-=` 来减少一天
return *this;
}
6.4 后置自减运算符(d1--
)
后置自减先返回当前对象的副本,再进行自减操作:
代码语言:javascript复制Date Date::operator--(int) {
Date tmp = *this;
*this -= 1; // 减少一天
return tmp; // 返回当前对象的副本
}
6.5 示例:自增与自减运算符的使用
代码语言:javascript复制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 日期差值的实现
代码语言:javascript复制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 日期差值示例
代码语言:javascript复制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
,我们可以验证日期差值的正负是否正确。
写在最后
在这篇博客中,我们深入探讨了如何通过 C 实现一个功能完善的日期类。在这个过程中,我们不仅学习了类与对象的基础知识,还掌握了构造函数的设计、运算符重载的技巧、以及日期的合法性检查与加减运算。通过这些实践,日期类变得像我们日常使用的工具般灵活高效,它的构造不仅代表了编程技巧的提升,更是对面向对象编程思想的深入理解。 在现代软件开发中,日期与时间的处理是不可或缺的一部分,而通过这一项目,你将不仅仅学会如何在代码中精确地操作日期,更能学会如何通过面向对象编程实现高效的、可维护的解决方案。希望本次实现之旅,能够为你打下坚实的编程基础。
以上就是关于【C 篇】C 类和对象实践篇——从零带你实现日期类超详细指南的内容啦,各位大佬有什么问题欢迎在评论区指正,或者私信我也是可以的啦,您的支持是我创作的最大动力!❤️