Date
arithmetic, Part 1
Calculating yesterday's
date
Summary
This will be the first in a three-part series in which Mo will
address the topic of date arithmetic, beginning with a method
for calculating the date one day before any given
"today." This process can be a bit tricky for
certain dates, so watch out for leap years. (2,300 words)
An interesting letter from a Unix
Insider reader posed the problem of how to calculate a date
one day earlier than the present day's date. The problem is that
if today is the first day of the month, or even the first day of
January, finding the date one day earlier is no easy task, and
date arithmetic is sadly neglected in most Unix utilities.
Rather than address this as a one-day-backwards problem, I
decided to look at the general case of adding (or subtracting)
days from a date and calculating the value of the new date.
The scripts in this article rely heavily on the expr command,
which can perform integer arithmetic on shell variables. In the
example below, the variable $a is set to 6 and then divided by 2
using the expr utility. The output of this command is 3, the
result of the division.
$ a=6; expr $a / 2
3
In this second example of expr, the backquotes are used to
assign the result of the expression $a / 2 to the variable $b,
then the variable $b is echoed to the screen.
$ a=6; b=`expr $a / 2`; echo $b
3
This method of assigning a value by using expr in back quotes
is used throughout these scripts. Make sure you understand what
the commands are doing.
The expr expression evaluator uses standard arithmetic
operators and parentheses for grouping parts of an equation
together. The operators are listed below. Note the spaces
between values and the operator characters. In any of the
examples, a number can be replaced with a variable containing a
numeric value.
Operation
output |
Operator |
Example |
Addition
7
|
+
|
expr 5 + 2
|
Subtraction
3
|
-
|
expr 5 - 2
|
Multiplication
10
|
*
|
expr 5 * 2
|
Division
2
|
/
|
expr 5 / 2
|
Remainder division
1
|
%
|
expr 5 % 2
|
Grouping
14
|
()
|
expr ( 5 + 2 ) * 2
|
Less than
1
|
<
|
expr 3 < 5
|
Greater than
0
|
>
|
expr 3 > 5
|
Less than or equal
1
|
<=
|
expr 3 <= 5
|
Greater than or equal
0
|
>=
|
expr 3 >= 5
|
Equal
0
|
=
|
expr 3 = 5
|
There is one hitch to the expr command. Operators interpreted
as special characters by the shell must be preceded by a
backslash if the shell is to leave them alone. For the Korn and
Bourne shells, these operators include parentheses for grouping
("()"), the asterisk for multiplication
("*"), and greater than and less than
(">" and "<"). The table of operators
with the escape characters included becomes:
Operation
output |
Operator |
Example |
Addition
7
|
+
|
expr 5 + 2
|
Subtraction
3
|
-
|
expr 5 - 2
|
Multiplication
10
|
\*
|
expr 5 \* 2
|
Division
2
|
/
|
expr 5 / 2
|
Remainder division
1
|
%
|
expr 5 % 2
|
Grouping
14
|
\(\)
|
expr \( 5 + 2\ ) \* 2
|
Less than
1
|
\<
|
expr 3 \< 5
|
Greater than
0
|
\>
|
expr 3 \> 5
|
Less than or equal
1
|
\<=
|
expr 3 \<= 5
|
Greater than or equal
0
|
\>=
|
expr 3 \>= 5
|
Equal
0
|
=
|
expr 3 = 5
|
Using expr on the command line or in a shell script, you
would include the escape characters wherever needed.
$ a=5; b=2; c=3; expr \( $a + $b \) \* $c
21
When looking at an expr formula, removing the backslashes
will give you a clearer picture of what it's doing.
With that background on expr, we 're ready to look at date
arithmetic.
Date arithmetic is simpler to perform in the so-called Julian
date format. This is not a date in the Julian calendar; rather
it's a date expressed as a year followed by a 3-digit number
representative of the ordinal number of the day in the year.
January 1, 1998, for example, becomes 1998001, and February 1,
1997 becomes 1997032. This format for dates has been incorrectly
called Julian for so long that the name has stuck, but it's more
appropriately called a year-and-ordinal-day format. On the other
hand, you can see why the easier term, Julian, has stuck. I've
also heard it called a military date, as it's a popular format
in military software. Conversely, a date containing a year, a
2-digit month and a 2-digit year is sometimes called Gregorian,
even though both dates are in the Gregorian calendar.
Leap years
The first problem to tackle is how to convert a YYYYMMDD
formatted date into a YYYYDDD formatted date.
This problem would be fairly straightforward if it weren't
for leap years. Since leap years will play a regular part in all
of the following data calculations, I've created a single script
that is passed a 4-digit year on the command line or on standard
input and outputs the number of days in the year. The yeardays
script is shown in the following listing. You may use yeardays
by typing
$ yeardays 1998
365
or by piping a value in to the script as in
$ echo 2000|yeardays
366
The rules for a leap year are as follows:
- A year is a leap year if it is evenly divisible by 4
- ...but not if it's evenly divisible by 100
- ...unless it's also evenly divisible by 400
Testing these rules is simplified by turning them upside down
and testing the year's divisibility by 400 then by 100 then by
4, and taking the result as soon as you have a match.
The yeardays listing has line numbers for this explanation.
At lines 8 through 13, argument $1 is tested by concatenating it
with an X. If it contains nothing ( [ X$1 = X ] ), command read
is used to load the variable $y, otherwise $y is set to $1.
There is no error checking for valid arguments. At lines 19
through 25, $a is set to the remainder of dividing $y by 400. If
this remainder is 0, $y is evenly divisible by 400 and the year
is a leap year. The number 366 is output and the script ends. At
lines 27 through 33, a similar test is made to determine whether
or not $y is evenly divisible by 100. If it is, $y cannot be a
leap year, 365 is output, and the script ends. At lines 35
through 41, the classic evenly-divisible-by-4 test is done on
the year. If it is divisible by 4, 366 is output and the script
ends. Otherwise, 365 is output. This approach, of diminishing
tests that cause the script to exit, makes it possible to do the
leap year tests without a lot of if-else logic.
Please note the spacing used in expr and in the if tests.
1 # yeardays
2 # return the number of days in a year
3 # usage yeardays yyyy
4
5 # if there is no argument on the command line, assume that a
6 # yyyy is being piped in
7
8 if [ X$1 = X ]
9 then
10 read y
11 else
12 y=$1
13 fi
14
15 # a year is a leap year if it is even divisible by 4
16 # but not evenly divisible by 100
17 # unless it is evenly divisible by 400
18
19 # if it is evenly divisible by 400 it must be a leap year
20 a=`expr $y % 400`
21 if [ $a = 0 ]
22 then
23 echo 366
24 exit
25 fi
26
27 #if it is evenly divisible by 100 it must not be a leap year
28 a=`expr $y % 100`
29 if [ $a = 0 ]
30 then
31 echo 365
32 exit
33 fi
34
35 # if it is evenly divisible by 4 it must be a leap year
36 a=`expr $y % 4`
37 if [ $a = 0 ]
38 then
39 echo 366
40 exit
41 fi
42
43 # otherwise it is not a leap year
44 echo 365
45
In the year days listing, a test such as:
a=`expr $y % 100`
if [ $a = 0 ]
can be written more simply as:
if [`expr $y % 100` = 0 ]
Having illustrated expr in use, let's complicate things. We
now have a script (or routine) we can use to determine a leap
year. With this, it should be possible to write a similar
routine that, when given a year and a month, returns the number
of days in the month. The monthdays script shown below
is an example of this.
The monthdays script can be invoked in several ways. A date
in YYYYMMDD format can be used as an argument or can be piped in
from another process. Alternatively, a year in YYYY format and a
month appearing as one or two digits can be given as two
arguments on the command line.
At lines 11 through 19, the arguments are taken care of. If
no argument $1 exists, $ymd is read from standard input.
Otherwise, if no argument $2 exists, the script assumes that one
argument came in on the command line in YYYYMMDD format.
Otherwise, there are two arguments, $1 containing YYYY and $2
containing a month. The expr logic at line 18 combines the year,
the month, and a day of 1 into a date in YYYYMMDD format. This
logic ensures that before the main part of the script begins,
we're always starting with an 8-digit date. Since this routine
only cares about the year and month, the day 1 is added whenever
it's missing. At line 23, $ymd is divided by 10000 to extract
the year into $y. At line 24, remainder division by 10000 and
division by 100 are used to extract the month. You can follow
the logic for extracting the month below, assuming that we're
working with a date of 19981114.
19981114 % 10000 = 1114
1114 / 100 = 11
At lines 26 to 31, the value of the month ($m) is compared to
the list of months containing 30 and 31 days using a case
statement. If the month matches anything in the list, the number
of days is output and the script exits. If there is no match,
we're dealing with the dreaded month 2. At this point yeardays
comes into play. At line 36, the script yeardays is called with
the year that has been extracted. It will return a 365 or a 366
that is assigned to $diy. The value of $diy is tested, and 28 or
29 is output as the number of days for month 2.
1 # monthdays
2 # calculates the number of days in a month
3 # usage monthdays yyyy mm
4 # or monthdays yyyymmdd
5
6 # if there are no command-line arguments, assume that a yyyymmdd is being
7 # piped in and read the value.
8 # if there is only one argument, assume it's a yyyymmdd on the command line;
9 # otherwise it is a yyyy and mm on the command line
10
11 if [ X$1 = X ]
12 then
13 read ymd
14 elif [ X$2 = X ]
15 then
16 ymd=$1
17 else
18 ymd=`expr \( $1 \* 10000 \) + \( $2 \* 100 \) + 1`
19 fi
20
21 # extract the year and the month
22
23 y=`expr $ymd / 10000` ;
24 m=`expr \( $ymd % 10000 \) / 100` ;
25
26 # 30 days hath September etc.
27 case $m in
28 1|3|5|7|8|10|12) echo 31 ; exit ;;
29 4|6|9|11) echo 30 ; exit ;;
30 *) ;;
31 esac
32
33 # except for month 2, which depends on whether the year is a leap year
34 # Use yeardays to get the number of days in the year and return a value
35 # accordingly.
36 diy=`yeardays $y`
37
38 case $diy in
39 365) echo 28 ; exit ;;
40 366) echo 29 ; exit ;;
41 esac
42
With leap years in hand, next month we'll take a look at
adding and subtracting days from a date.
Contact
us for a free consultation. |