5.7 示例:日期运算 (Example: Date Arithmetic)
在某些应用里,能够做日期的加减是很有用的 ── 举例来说,能够算出从 1997 年 12 月 17 日,六十天之后是 1998 年 2 月 15 日。在这个小节里,我们会编写一个实用的工具来做日期运算。我们会将日期转成整数,起始点设置在 2000 年 1 月 1 日。我们会使用内置的 +
与 -
函数来处理这些数字,而当我们转换完毕时,再将结果转回日期。
要将日期转成数字,我们需要从日期的单位中,算出总天数有多少。举例来说,2004 年 11 月 13 日的天数总和,是从起始点至 2004 年有多少天,加上从 2004 年到 2004 年 11 月有多少天,再加上 13 天。
有一个我们会需要的东西是,一张列出非润年每月份有多少天的表格。我们可以使用 Lisp 来推敲出这个表格的内容。我们从列出每月份的长度开始:
> (setf mon '(31 28 31 30 31 30 31 31 30 31 30 31))
(31 28 31 30 31 30 31 31 30 31 30 31)
我们可以通过应用 +
函数至这个列表来测试总长度:
> (apply #'+ mon)
365
现在如果我们反转这个列表并使用 maplist
来应用 +
函数至每下一个 cdr
上,我们可以获得从每个月份开始所累积的天数:
> (setf nom (reverse mon))
(31 30 31 30 31 31 30 31 30 31 28 31)
> (setf sums (maplist #'(lambda (x)
(apply #'+ x))
nom))
(365 334 304 273 243 212 181 151 120 90 59 31)
这些数字体现了从二月一号开始已经过了 31 天,从三月一号开始已经过了 59 天……等等。
我们刚刚建立的这个列表,可以转换成一个向量,见图 5.1,转换日期至整数的代码。
(defconstant month
#(0 31 59 90 120 151 181 212 243 273 304 334 365))
(defconstant yzero 2000)
(defun leap? (y)
(and (zerop (mod y 4))
(or (zerop (mod y 400))
(not (zerop (mod y 100))))))
(defun date->num (d m y)
(+ (- d 1) (month-num m y) (year-num y)))
(defun month-num (m y)
(+ (svref month (- m 1))
(if (and (> m 2) (leap? y)) 1 0)))
(defun year-num (y)
(let ((d 0))
(if (>= y yzero)
(dotimes (i (- y yzero) d)
(incf d (year-days (+ yzero i))))
(dotimes (i (- yzero y) (- d))
(incf d (year-days (+ y i)))))))
(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
,返回相除后的余数:
> (mod 23 5)
3
> (mod 25 5)
0
如果第一个实参除以第二个实参的余数为 0,则第一个实参是可以被第二个实参整除的。函数 leap?
使用了这个方法,来决定它的实参是否是一个润年:
> (mapcar #'leap? '(1904 1900 1600))
(T NIL T)
我们用来转换日期至整数的函数是 date->num
。它返回日期中每个单位的天数总和。要找到从某月份开始的天数和,我们调用 month-num
,它在 month
中查询天数,如果是在润年的二月之后,则加一。
要找到从某年开始的天数和, date->num
调用 year-num
,它返回某年一月一日相对于起始点(2000.01.01)所代表的天数。这个函数的工作方式是从传入的实参 y
年开始,朝着起始年(2000)往上或往下数。
(defun num->date (n)
(multiple-value-bind (y left) (num-year n)
(multiple-value-bind (m d) (num-month left y)
(values d m y))))
(defun num-year (n)
(if (< n 0)
(do* ((y (- yzero 1) (- y 1))
(d (- (year-days y)) (- d (year-days y))))
((<= d n) (values y (- n d))))
(do* ((y yzero (+ y 1))
(prev 0 d)
(d (year-days y) (+ d (year-days y))))
((> d n) (values y (- n prev))))))
(defun num-month (n y)
(if (leap? y)
(cond ((= n 59) (values 2 29))
((> n 59) (nmon (- n 1)))
(t (nmon n)))
(nmon n)))
(defun nmon (n)
(let ((m (position n month :test #'<)))
(values m (+ 1 (- n (svref month (- m 1)))))))
(defun date+ (d m y n)
(num->date (+ (date->num d m y) n)))
图 5.2 日期运算:转换数字至日期
图 5.2 展示了代码的下半部份。函数 num->date
将整数转换回日期。它调用了 num-year
函数,以日期的格式返回年,以及剩余的天数。再将剩余的天数传给 num-month
,分解出月与日。
和 year-num
相同, num-year
从起始年往上或下数,一次数一年。并持续累积天数,直到它获得一个绝对值大于或等于 n
的数。如果它往下数,那么它可以返回当前迭代中的数值。不然它会超过年份,然后必须返回前次迭代的数值。这也是为什么要使用 prev
, prev
在每次迭代时会存入 days
前次迭代的数值。
函数 num-month
以及它的子程序(subroutine) nmon
的行为像是相反地 month-num
。他们从常数向量 month
的数值到位置,然而 month-num
从位置到数值。
图 5.2 的前两个函数可以合而为一。与其返回数值给另一个函数, num-year
可以直接调用 num-month
。现在分成两部分的代码,比较容易做交互测试,但是现在它可以工作了,下一步或许是把它合而为一。
有了 date->num
与 num->date
,日期运算是很简单的。我们在 date+
里使用它们,可以从特定的日期做加减。如果我们想透过 date+
来知道 1997 年 12 月 17 日六十天之后的日期:
> (multiple-value-list (date+ 17 12 1997 60))
(15 2 1998)
我们得到,1998 年 2 月 15 日。