炒冷饭之日历程序

昨天调试 VSCode 的时候顺手 Copy 了之前文章《[大学程序课] C语言日历程序》里的代码测试,结果发现输出的东西根本就不对,想必我当时也只赶工凑数没有注意细节。哦!我猛地想起来,那个代码作为我的期末作业交上去之后我才发现自己的代码有问题,不过后来就忘了这回事,现在重新调试了一下,给之前的代码修修 Bug ,给自己找点事情,不然爸妈天天在家抱怨我玩游戏不学习。

这里就顺便讲讲我的思路吧,以前那篇文章百科了一下基姆拉尔森计算公式然后就上了一大段代码,估计没有人完全看明白了我在做什么,也不知道我是怎么突然就开始打印年历了,我自己现在看起那篇文章来也有点懵逼。当时和我一起做这个作业的室友问我程序的思路,我说给他听,感觉他像似懂非懂的样子。现在我要将我的思路写下来,让大家尽量的能看懂。


题目主要要求
计算未来某天距离今天的天数和该日期的星期
输入一个年份打印该年的年历,要求一行打印三个月
输入一个月份打印该月的月历
(主函数提供功能菜单供用户选择,用户可以选择调用以下各个功能,也可以选择继续或退出程序)

基姆拉尔森计算公式

这个程序呢,基姆拉尔森计算公式是重点,好在它也不是很难,就是一个计算星期数的公式,年月日三个变量进去返回星期数,百度百科就可以学会,至于你要问我为什么是这个公式那偶母鸡... 公式主要值得注意的地方是其计算结果为某一个数对 7 取余产生的,这就使得返回值从 0 到 6 ,如果单纯的通过自行 +1 来改变输出结果就要特别留意后面代码的写法了。这个公式是这样的,放在这里:

W = (D + 2*M + 3*(M+1)/5 + Y + Y/4 - Y/100 + Y/400 + 1) % 7
// D 日    M 月    Y 年
// Sun 返回 0 , Mon 返回 1 , Tue 返回 2 ,... , Sat 返回 6

计算时间间隔

第一个要求计算两个日期间隔,其中一个是此时此刻,一个是未来某天,这样我们不需要再额外比较两个日期的先后。将现在时间定为 yearNowmonthNowdayNow 日,未来时间 yearmonthday 日。这些变量我都定义为 int 类型。现在时间,可以和未来时间一样让用户自己输入,也可以引入 <time.h> 自动获取,这里引入了指针和结构体的知识(老师加点分?),像这样:

#include <stdio.h>
#include <time.h>
int main(){
    time_t t;
    struct tm *p;
    time(&t);
    p=localtime(&t);
    yearNow = 1900 + p->tm_year;
    monthNow = 1 + p->tm_mon;
    dayNow = p->tm_mday;
}

关于计算两个日期间隔,这里我用的是 sum += days 的大框架,在这个框架中用 for() 语句不断累加。从年份到月份再到日期,从大到小判断,直觉告诉我这样。

年份是否相同作为第一层判断条件,分成两类:两个日期在同一年中;两个日期在不同年份中。

有什么区别呢?我的想法是不同年的话就需要加入中间年份天数的计算,days 要以 365/366 为一次递增。而同年的话只需要加月份天数,days 以 28/29/30/31 为一次递增。所以这里我写了一个 if(year == yearNow) 的结构作为筛选。

经历了上一层年份判断后,这一层要考虑 同一年的月份 还是 不同年的月份 ,也就是上面 if(){}else{} 结构中 {} 里应该写什么。

  • 同一年的月份中,这里需要再加一层判断 month 和 monthNow 的关系。因为同一个月的两天间隔和不同月的两天间隔计算方式也不一样,同月直接相减,不同月需要考虑中间月份(将 前一个时间点到所在月末的间隔后一个时间点到所在月初的间隔 先求出来,再累加中间月份总天数)。前一个时间点到所在月末的间隔 就是该月总天数-日期;后一个时间点到所在月初的间隔 就是该日日期。
  • 不同年的月份中,不管中间隔了 0 年还是 n 年,我们都要先将 前一个时间点到所在年末的间隔后一个时间点到所在年初的间隔 求出并累加。怎么求?和上面求同年不同月的两天间隔思路大同小异。前一个时间点到所在年末的间隔 先累加从 month 月到 12 月之间完整的月份总天数,再加上该日到月末的天数;后一个时间点到所在年初的间隔 先累加从 1 月到 month 月之间完整的月份总天数,再加上该日到所在月初的天数。

至此我的计算方法应该已经很容易懂了,那么如何用代码写出这样的效果呢?

首先高度重复的计算过程(这里是累加)我考虑用 for() 循环来完成,而 for() 本身又带有判断的功能,所以考虑将前面琐碎的简单判断融入 for() 中简化。其他的判断就直接用 if(){}else{} 结构写。

对于反复用到的功能独立写一个函数来实现,比如这里的求年份总天数、求月份总天数这样的。这部分是函数的知识点。我这里写了一个 int dateAPI(int Year, int Month, int Day, int type) 的函数,前三个参数传入需要处理的年月日,type 赋不同值时分别返回不同的值,比如 type=1 返回年份总天数,type=2 返回月份总天数,type=3 返回该月份1号所在星期数。具体思路在后面有讲。

关于第一问我最终写出来的主要代码结构是这样的:

// 最外层年份判断
if (year == yearNow) {
    // 用 for 先累加中间月份总天数,midM 作为计数变量从 monthNow+1 到 month-1 
    // dateAPI(yearNow, midM, 1, 2) 表示 yearNow 年 midM 月的总天数
    for (midM = monthNow + 1; midM < month; midM ++)
        sum += dateAPI(yearNow, midM, 1, 2);
    
    // 加上月初月末天数,稍微看一下这个计算方法应该很容易理解吧
    // dateAPI(yearNow, monthNow, 1, 2) 表示 yearNow 年 monthNow 月的总天数
    if (month == monthNow)
        sum = day - dayNow;
    else
        sum = sum + day - dayNow + dateAPI(yearNow, monthNow, 1, 2);
}
else {
    // 计算当前日期到年末的总天数
    // 先用 for 累加从 monthNow+1 到 12 月之间完整的月份总天数
    // 再加上当前日期到所在月末的天数 
    for (midM = monthNow + 1; midM < 13; midM ++)
        sum += dateAPI(yearNow, midM, 1, 2);
    sum = sum + dateAPI(yearNow, monthNow, 1, 2) - dayNow;
    
    // 计算未来日期到年初的总天数
    // 先用 for 累加从 1 到 month-1 月之间完整的月份总天数
    // 再加上未来日期到所在月初的天数 
    for (midM = 1; midM < month; midM ++)
        sum += dateAPI(year, midM, 1, 2);
    sum = sum + day;
    
    // 累加两个日期之间完整年份的总天数,midY 作为计数变量从 yearNow+1 到 year-1 
    // dateAPI(midY, 1, 1, 1) 表示 midY 年的总天数
    for (midY = yearNow + 1; midY < year; midY ++)
        sum += dateAPI(midY, 1, 1, 1);
}

到这里整个第一问的功能就完成了。

打印年历与月历

下面是打印年历和月历的思路。首先看到这个“每行三个月”类似的字眼,肯定是要用 for() 多层嵌套的。那么计数变量怎么设置怎么循环?这也是当时做作业的时候让我比较头疼的。注意看示例的年历,如果将怎么依次打印这些数字这个问题先放一边,把每一个月当作整体,思路就比较清楚了,剔除首行年份标记后,从上到下每一整行依次是:打印月份标记*3 > 打印星期标记*3 > 输出日期*3 ,这样重复 4 遍将 12 个月全部打印出来。那么我们最外层的 for() 就这样写:

for(line = 0; line < 4; line ++ ){}

然后 {} 里面现在要做的就是如何完成打印三个月的任务。

首先是月份标记,每一次大的循环月份标记分别从 Jan Feb Mar 到 Apr May Jun 再到 July Aug Sept 再到 Oct Nov Dec ,那我们就用 switch(line){} 的不同 case 来实现先判断再输出内容。

然后是星期标记,没什么难度,重复输出三遍就行。值得注意的是每个星期的首天是 Mon 还是 Sun ,不同的选择后面日期输出时代码就会出现差异。这里我以 Sun 为一周第一天,因为感觉这样后面的代码写起来会稍稍简单。

接下来就是如何一次打印日期了。每一行每一行的仔细品就会发现规律。单看一个月,第一行是先空出 n 个位置直到 1 刚好在它所在星期的标记下方,然后打印的数字开始递增到 1+7-该月1日所在星期数 ,我们把这个日期记为 K ,那么这个月的下一行就是从 K+1K+7 ,再下一行是 K+7+1K+7+7 ,直到该月最后一天打印完毕。如果我们试图用 for() 从一个 K 打印到 K+7 并循环 n 遍打印这些数字就需要考虑第一行的特殊性,他总是从 0 打印到 0+1+7-该月1日所在星期数 而不是 0+7 。

思考再三我决定用多维数组来实现这个功能,将每月每行日期的起始值和结束值存入,以便 for() 循环的计数变量调用,具体赋值方法像这样: week[0][0] 第一个月 1 日的星期数,用来首行输出空格占位; week[0][1] 第一个月第 0 行的结束日期(也就是第一行开始的前一天 0 日); week[0][2] 第一个月第 1 行的结束日期,值为 1+7-该月1日所在星期数; week[0][3] 第一个月第 2 行的结束日期,值为 week[0][2]+7 ; week[0][4] 第一个月第 3 行的结束日期,值为 week[0][3]+7 ; week[0][5] 第一个月第 4 行的结束日期,值为 week[0][4]+7 ; week[0][6] 第一个月第 5 行的结束日期,值为 week[0][5]+7 ; week[0][7] 第一个月第 6 行的结束日期,值为 week[0][6]+7 ;数组 week[0][] week[1][] week[2][] 分别保存每一行三个月份的数据。

用 for 循环来赋值这个二维数组 week[3][8]:

for (i = 0; i < 3; i ++) {
    for (j = 0; j < 8; j ++) {
        // 月份的计算方式与 line i 两变量相关
        month = 3*line + i + 1;
        // 计算 month 月 1 日的星期数,用来给 week[][] 赋值
        getweek = dateAPI(year, month, 1, 3);
        switch (j) {
            case 0:
                week[i][j] = getweek;
                break;
            case 1:
                week[i][j] = 0;
                break;
            default:
                 week[i][j] = 7*(j-1) - getweek;
        }
    }
}

打印时类似结构用两个计数变量从数组中取值,整个一行下来取值的顺序像下面这样在三个月份的数据中交替并最终打印为一大行三个月的日历:

                          F I R S T   M O N T H                                                                      SECOND MONTH                                THIRD MONTH               
  Sun                                                     ......  Sat                        Sun                   ......  Sat           Sun                   ......  Sat       
  ......        week[0][1]    1             2             ......  week[0][2]                 ......    week[1][1]  ......  week[1][2]    ......    week[2][1]  ......  week[2][2]
  week[0][2]+1  week[0][2]+2  week[0][2]+3  week[0][2]+4  ......  week[0][2]+7=week[0][3]    week[1][2]+1      ......      week[1][3]    week[2][2]+1      ......      week[2][3]
  week[0][3]+1  week[0][3]+2  week[0][3]+3  week[0][3]+4  ......  week[0][3]+7=week[0][4]    week[1][3]+1      ......      week[1][4]    week[2][3]+1      ......      week[2][4]
  week[0][4]+1  week[0][4]+2  week[0][4]+3  week[0][4]+4  ......  week[0][4]+7=week[0][5]    week[1][4]+1      ......      week[1][5]    week[2][4]+1      ......      week[2][5]
  week[0][5]+1  week[0][5]+2  week[0][5]+3  week[0][5]+4  ......  week[0][5]+7=week[0][6]    week[1][5]+1      ......      week[1][6]    week[2][5]+1      ......      week[2][6]
  week[0][6]+1  week[0][6]+2  week[0][6]+3  week[0][6]+4  ......  week[0][6]+7=week[0][7]    week[1][6]+1      ......      week[1][7]    week[2][6]+1      ......      week[2][7]

输出时再添加一层判断,当日期不超过当月总天数时输出,否则利用制表符输出空位,这样对于第五第六行大部分时候就不输出数字,而是作为空格占位使整个年历表格上下对齐。

以上就是 for(line = 0; line < 4; line ++ ){}{} 里面完成打印三个月任务的全部思路和代码。 C 语言的读写文件操作基本上就用下面这些语句:

FILE * fp;
fp = fopen("CalendarAnnual.txt", "w");
fprintf(fp, "OutPut Contents");
fprintf(fp, "const char *__restrict__ _Format", variable);
fclose(fp);

打印月历的部分思路和上面一样,更加简单,只要考虑一个月份就可以了。

特别的函数

现在剩下的就是前面频繁用到的函数 dateAPI() ,考虑到整个项目中有些功能使用频繁,这里单独提取出来作为其他函数引用的接口。主要是三个功能:

  1. 计算年份总天数
    就是简单的闰年判断,一条 if 语句判断即可,只有两种结果:365/366 。
  2. 计算月份总天数
    使用了 switch case 实现 12 个月份的判断,大体是三种情况 31 30 或 28/29 。其中 2 月天数的判断可以递归调用功能 1 实现。
  3. 计算日期星期数
    基于基姆拉尔森计算公式,注意一点就是 把 1 月和 2 月看成是上一年的 13 月和 14 月 ,所以使用公式前要添加判断,并在得出结果之后 恢复原值

函数需要传入的参数定为日期和调用类型,函数的类型根据返回值定为 int 型,代码如下:

int dateAPI (int togetY, int togetM, int togetD, int type) {
    int week;
    switch (type) {
        case 1:
            return (togetY % 400 == 0 || (togetY % 4 == 0 && togetY % 100 != 0))?366:365;
        case 2:
            switch (togetM) {
                // 对相同结果的月份进行合并简化
                case 1: case 3: case 5: case 7: case 8: case 10: case 12:
                    return 31;
                case 4: case 6: case 9: case 11:
                    return 30;
                case 2:
                    return (dateAPI(togetY, togetM, 1, 1) == 366)?29:28;
            }
        case 3:
            // 从周日开始至周六依次返回 0 ~ 6 ...
            if (togetM < 3) {
                togetM += 12;
                togetY -= 1;
            }
            week = (togetD + 2*togetM + 3*(togetM + 1)/5 + togetY + togetY/4 - togetY/100 + togetY/400 + 1) % 7;
            if (togetM > 12) {
                togetM -= 12;
                togetY += 1;
            }
            return week;
    }
}

细节

此外还有一些细节就是函数的定义、变量的声明、显示效果优化等。整个项目除了 main() 函数外我定义了 4 个函数,相对比较独立,int mian() 作为整个程序的入口,int dateAPI() 作为常用功能接口,void calDate() 用于实现日期计算功能,void printY() 实现打印年历功能,void printM() 实现打印月历功能。变量除去当前日期用到的 yearNow monthNow dayNow、待处理日期用到的 year month day、优化显示用的字符数组 weekday[7] monthname[12] 作为全局变量之外,变量均在对应函数体内声明。

程序结构
程序结构

程序的入口处作业要求是“主函数提供功能菜单供用户选择,用户可以选择调用以下各个功能,也可以选择继续或退出程序”,我这里使用了 while() 的结构,当 scanf("%c",&act) == 1 时保持运行,实现触发退出程序前均可重复操作。

int main () {
    char act;
    
    // ...
    
    while (scanf("%c",&act) == 1) {
        // switch 语句选择服务以进入下一个功能,执行后可以继续选择
        switch (act) {
            case '1':
                calDate();
                printf("\n    * Function 1 is completed! Please proceed to the next step:\n\n");
                break;
            case '2':
                printY();
                printf("\n    * Function 2 is completed! Please proceed to the next step:\n\n");
                break;
            case '3':
                printM();
                printf("\n    * Function 3 is completed! Please proceed to the next step:\n\n");
                break;
            case 'q':
                // 退出程序
                return 0;
        }
    }
}

显示的优化这里是指当要输出日期时将格式统一为 January 1,2020 的形式,星期几也显示为 Monday 的形式,这里不使用中文避免编译后出现乱码问题,逼格更高兼容性更好。

const char *weekday[7]={"Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"};
const char *monthname[12]={"January","February","March","April","May","June","July","August","September","October","November","December"};

利用这两个全局字符数组,输出时调用 weekday[week] monthname[month-1] 即可。

存一张编译后运行的效果图:

ccalendar.exe
ccalendar.exe
Annual Calendar
Annual Calendar
Monthly Calendar
Monthly Calendar

呐呐,写了一天,完成的时候就很开心,虽然也没有什么大的重构,但完整无 Bug 的整出来就很舒服。对于常写程序的人来说我这个程序的算法啊什么的应该都是比较简陋的吧,不过我发现这种不断地写的方式挺有意思的,能发现很多小毛病,以后了解得更多的话可能会继续拿这个小日历开刀实验,那就这样,祝各位宅家愉快!

这个项目的全部文件已经放到 GitHub 了,假装 commit 233,欢迎查看:
https://github.com/monsterxcn/HEU-C-Programs

添加新评论

本站现启用评论投票,被踩的太多就会自动折叠 QwQ ,快来试试吧 ~
评论提交失败请点Artalk备用评论


已有 5 条评论

好像看见过很多日历文章了

哈哈哈哈,毕竟是摸鱼选手,没好玩的就翻翻旧的 🍉

赞!看到这篇文章我才想起来我信竞退役之后就基本没打过C/C++了QAQ

(好像评论不能用Emoji唉……)

可能是我昨天改了评论拦截插件规则导致的