5.7 示例:日期运算 (Example: Date Arithmetic)

在某些应用里,能够做日期的加减是很有用的 ── 举例来说,能够算出从 1997 年 12 月 17 日,六十天之后是 1998 年 2 月 15 日。在这个小节里,我们会编写一个实用的工具来做日期运算。我们会将日期转成整数,起始点设置在 2000 年 1 月 1 日。我们会使用内置的 +- 函数来处理这些数字,而当我们转换完毕时,再将结果转回日期。

要将日期转成数字,我们需要从日期的单位中,算出总天数有多少。举例来说,2004 年 11 月 13 日的天数总和,是从起始点至 2004 年有多少天,加上从 2004 年到 2004 年 11 月有多少天,再加上 13 天。

有一个我们会需要的东西是,一张列出非润年每月份有多少天的表格。我们可以使用 Lisp 来推敲出这个表格的内容。我们从列出每月份的长度开始:

  1. > (setf mon '(31 28 31 30 31 30 31 31 30 31 30 31))
  2. (31 28 31 30 31 30 31 31 30 31 30 31)

我们可以通过应用 + 函数至这个列表来测试总长度:

  1. > (apply #'+ mon)
  2. 365

现在如果我们反转这个列表并使用 maplist 来应用 + 函数至每下一个 cdr 上,我们可以获得从每个月份开始所累积的天数:

  1. > (setf nom (reverse mon))
  2. (31 30 31 30 31 31 30 31 30 31 28 31)
  3. > (setf sums (maplist #'(lambda (x)
  4. (apply #'+ x))
  5. nom))
  6. (365 334 304 273 243 212 181 151 120 90 59 31)

这些数字体现了从二月一号开始已经过了 31 天,从三月一号开始已经过了 59 天……等等。

我们刚刚建立的这个列表,可以转换成一个向量,见图 5.1,转换日期至整数的代码。

  1. (defconstant month
  2. #(0 31 59 90 120 151 181 212 243 273 304 334 365))
  3. (defconstant yzero 2000)
  4. (defun leap? (y)
  5. (and (zerop (mod y 4))
  6. (or (zerop (mod y 400))
  7. (not (zerop (mod y 100))))))
  8. (defun date->num (d m y)
  9. (+ (- d 1) (month-num m y) (year-num y)))
  10. (defun month-num (m y)
  11. (+ (svref month (- m 1))
  12. (if (and (> m 2) (leap? y)) 1 0)))
  13. (defun year-num (y)
  14. (let ((d 0))
  15. (if (>= y yzero)
  16. (dotimes (i (- y yzero) d)
  17. (incf d (year-days (+ yzero i))))
  18. (dotimes (i (- yzero y) (- d))
  19. (incf d (year-days (+ y i)))))))
  20. (defun year-days (y) (if (leap? y) 366 365))

图 5.1 日期运算:转换日期至数字

典型 Lisp 程序的生命周期有四个阶段:先写好,然后读入,接着编译,最后执行。有件 Lisp 非常独特的事情之一是,在这四个阶段时, Lisp 一直都在那里。可以在你的程序编译 (参见 10.2 小节)或读入时 (参见 14.3 小节) 来调用 Lisp。我们推导出 month 的过程演示了,如何在撰写一个程序时使用 Lisp。

效率通常只跟第四个阶段有关系,运行期(run-time)。在前三个阶段,你可以随意的使用列表拥有的威力与灵活性,而不需要担心效率。

若你使用图 5.1 的代码来造一个时光机器(time machine),当你抵达时,人们大概会不同意你的日期。即使是相对近的现在,欧洲的日期也曾有过偏移,因为人们会获得更精准的每年有多长的概念。在说英语的国家,最后一次的不连续性出现在 1752 年,日期从 9 月 2 日跳到 9 月 14 日。

每年有几天取决于该年是否是润年。如果该年可以被四整除,我们说该年是润年,除非该年可以被 100 整除,则该年非润年 ── 而要是它可以被 400 整除,则又是润年。所以 1904 年是润年,1900 年不是,而 1600 年是。

要决定某个数是否可以被另个数整除,我们使用函数 mod ,返回相除后的余数:

  1. > (mod 23 5)
  2. 3
  3. > (mod 25 5)
  4. 0

如果第一个实参除以第二个实参的余数为 0,则第一个实参是可以被第二个实参整除的。函数 leap? 使用了这个方法,来决定它的实参是否是一个润年:

  1. > (mapcar #'leap? '(1904 1900 1600))
  2. (T NIL T)

我们用来转换日期至整数的函数是 date->num 。它返回日期中每个单位的天数总和。要找到从某月份开始的天数和,我们调用 month-num ,它在 month 中查询天数,如果是在润年的二月之后,则加一。

要找到从某年开始的天数和, date->num 调用 year-num ,它返回某年一月一日相对于起始点(2000.01.01)所代表的天数。这个函数的工作方式是从传入的实参 y 年开始,朝着起始年(2000)往上或往下数。

  1. (defun num->date (n)
  2. (multiple-value-bind (y left) (num-year n)
  3. (multiple-value-bind (m d) (num-month left y)
  4. (values d m y))))
  5. (defun num-year (n)
  6. (if (< n 0)
  7. (do* ((y (- yzero 1) (- y 1))
  8. (d (- (year-days y)) (- d (year-days y))))
  9. ((<= d n) (values y (- n d))))
  10. (do* ((y yzero (+ y 1))
  11. (prev 0 d)
  12. (d (year-days y) (+ d (year-days y))))
  13. ((> d n) (values y (- n prev))))))
  14. (defun num-month (n y)
  15. (if (leap? y)
  16. (cond ((= n 59) (values 2 29))
  17. ((> n 59) (nmon (- n 1)))
  18. (t (nmon n)))
  19. (nmon n)))
  20. (defun nmon (n)
  21. (let ((m (position n month :test #'<)))
  22. (values m (+ 1 (- n (svref month (- m 1)))))))
  23. (defun date+ (d m y n)
  24. (num->date (+ (date->num d m y) n)))

图 5.2 日期运算:转换数字至日期

图 5.2 展示了代码的下半部份。函数 num->date 将整数转换回日期。它调用了 num-year 函数,以日期的格式返回年,以及剩余的天数。再将剩余的天数传给 num-month ,分解出月与日。

year-num 相同, num-year 从起始年往上或下数,一次数一年。并持续累积天数,直到它获得一个绝对值大于或等于 n 的数。如果它往下数,那么它可以返回当前迭代中的数值。不然它会超过年份,然后必须返回前次迭代的数值。这也是为什么要使用 prevprev 在每次迭代时会存入 days 前次迭代的数值。

函数 num-month 以及它的子程序(subroutine) nmon 的行为像是相反地 month-num 。他们从常数向量 month 的数值到位置,然而 month-num 从位置到数值。

图 5.2 的前两个函数可以合而为一。与其返回数值给另一个函数, num-year 可以直接调用 num-month 。现在分成两部分的代码,比较容易做交互测试,但是现在它可以工作了,下一步或许是把它合而为一。

有了 date->numnum->date ,日期运算是很简单的。我们在 date+ 里使用它们,可以从特定的日期做加减。如果我们想透过 date+ 来知道 1997 年 12 月 17 日六十天之后的日期:

  1. > (multiple-value-list (date+ 17 12 1997 60))
  2. (15 2 1998)

我们得到,1998 年 2 月 15 日。