Вычисления с датами порой бывают очень запутанными, например, когда нужно выяснить, високосный ли указанный год, сколько дней осталось до Нового года или сколько дней вы прожили. В этой области между Unix-системами, такими как OS X, и системами Linux, с их инструментами GNU, лежит глубокая пропасть. Дэвид Маккензи (David MacKenzie), взявший на себя труд переписать утилиту date для GNU-версии Linux, значительно расширил ее возможности.
Если вы пользуетесь OS X или другой системой, где команда date --version выводит сообщение об ошибке, загрузите комплект основных утилит, в состав которого входит расширенная утилита GNU date (иногда устанавливается как gdate). В OS X это можно выполнить с помощью диспетчера пакетов (не установлен по умолчанию, но установить его легко):
$ brew install coreutils
После установки GNU-версии утилиты date определить, високосный ли указанный год, можно с помощью самой программы, без необходимости путаться в сложных правилах о годах, кратных 4, но не 100, и других:
if [ $( date 12/31/$year +%j ) -eq 366 ]
Иными словами, если последним днем года является 366-й, этот год — високосный.
Еще одна замечательная особенность GNU date — возможность вернуться назад во времени. Стандартная команда date в системе Unix основана на понятии «нулевого времени» или дате эпохи: 00:00:00 по Гринвичу 1 января 1970 года. Хотите узнать что-нибудь о происходившем в 1965? Тогда вам не повезло. К счастью, с тремя остроумными сценариями в этой главе вы сможете в полной мере использовать преимущества GNU date.
Вкратце: в какой день недели вы родились? В какой день недели Нил Армстронг и Базз Олдрин ступили на Луну? Сценарий в листинге 15.1 поможет вам быстро найти ответы на эти типичные вопросы и наглядно продемонстрирует мощь GNU date.
Листинг 15.1. Сценарий dayinpast
#!/bin/bash
# dayinpast -- получая дату, сообщает соответствующий ей день недели.
if [ $# -ne 3 ] ; then
echo "Usage: $(basename $0) mon day year" >&2
echo " with just numerical values (ex: 7 7 1776)" >&2
exit 1
fi
date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.
baddate="$?" # И сохранить только возвращаемый код.
if [ ! $baddate ] ; then
date -d $1/$2/$3 +"That was a %A."
else
if [ $2 -lt 10 ] ; then
pattern=" $2[^0-9]"
else
pattern="$2[^0-9]"
fi
dayofweek="$(ncal $1 $3 | grep "$pattern" | cut -c1-2)"
case $dayofweek in
Su ) echo "That was a Sunday."; ;;
Mo ) echo "That was a Monday."; ;;
Tu ) echo "That was a Tuesday."; ;;
We ) echo "That was a Wednesday."; ;;
Th ) echo "That was a Thursday."; ;;
Fr ) echo "That was a Friday."; ;;
Sa ) echo "That was a Saturday."; ;;
esac
fi
exit 0
Мы так нахваливали GNU date, и знаете почему? А потому, что весь сценарий сводится к к единственной команде в .
До смешного просто.
Если требуемая версия date недоступна, сценарий использует ncal , разновидность простой программы cal, которая представляет указанный месяц в любопытном, он очень полезном (!) формате:
$ ncal 8 1990
August 1990
Mo 6 13 20 27
Tu 7 14 21 28
We 1 8 15 22 29
Th 2 9 16 23 30
Fr 3 10 17 24 31
Sa 4 11 18 25
Su 5 12 19 26
Когда есть эта информация, для определения дня недели достаточно найти строку с соответствующим днем месяца и преобразовать двухбуквенное сокращение в полное название.
Нил Армстронг и Базз Олдрин прилунились на Базе Спокойствия 20 июля 1969 года, и, как сообщают результаты в листинге 15.2, это было воскресенье.
Листинг 15.2. Запуск сценария dayinpast с датой посадки Армстронга и Олдрина на Луну
$ dayinpast 7 20 1969
That was a Sunday.
День «Д» высадки союзнических войск в Нормандии, 6 июня 1944 года:
$ dayinpast 6 6 1944
That was a Tuesday.
А вот еще пример, день принятия Декларации о независимости США, 4 июля 1776 года:
$ dayinpast 7 4 1776
That was a Thursday.
Все сценарии в этой главе принимают входные данные в формате месяц день год, но было бы хорошо дать пользователям возможность указывать данные в более привычном им виде, например: месяц/день/год. К счастью, это совсем несложно, и отличной отправной точкой вам послужит сценарий № 3 в главе 1.
Сколько дней вы живете? Сколько дней прошло с того момента, как ваши родители встретились? Подобные вопросы, связанные с определением разности между датами, возникают часто, но вычислить ответ на них обычно бывает непросто. И снова на помощь нам приходит утилита GNU date.
Идея обоих сценариев, № 100 и № 101, заключается в том, что суммируются дни от первой даты до конца года, дни от начала года до второй даты и число дней во всех промежуточных годах. Один и тот же подход можно использовать для вычисления количества дней, прошедших с некоторой даты в прошлом (этот сценарий), и количества дней, оставшихся до некоторой даты в будущем (сценарий № 101).
Листинг 15.3 довольно длинный. Готовы?
Листинг 15.3. Сценарий daysago
#!/bin/bash
# daysago -- получая дату в формате месяц/день/год, вычисляет количество
# дней, прошедших от нее до текущего дня, учитывая високосные годы, и пр.
# Если вы используете Linux, замените "$(which gdate)" на "$(which date)".
# Если вы используете OS X, установите пакет coreutils с помощью brew или
# из исходного кода, чтобы получить команду gdate.
date="$(which gdate)"
function daysInMonth
{
case $1 in
1|3|5|7|8|10|12 ) dim=31 ;; # Постоянное значение
4|6|9|11 ) dim=30 ;;
2 ) dim=29 ;; # Зависит от года: високосный/невисокосный
* ) dim=-1 ;; # Неизвестный месяц
esac
}
function isleap
{
# Возвращает ненулевое значение в $leapyear, если $1 -- високосный год.
leapyear=$($date -d 12/31/$1 +%j | grep 366)
}
#######################
#### ОСНОВНОЙ БЛОК
#######################
if [ $# -ne 3 ] ; then
echo "Usage: $(basename $0) mon day year"
echo " with just numerical values (ex: 7 7 1776)"
exit 1
fi
$date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.
if [ $? -ne 0 ] ; then
echo "Sorry, but $(basename $0) can't run without GNU date." >&2
exit 1
fi
eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")
startmon=$1; startday=$2; startyear=$3
daysInMonth $startmon # Инициализирует глобальную переменную dim.
if [ $startday -lt 0 -o $startday -gt $dim ] ; then
echo "Invalid: Month #$startmon only has $dim days." >&2
exit 1
fi
if [ $startmon -eq 2 -a $startday -eq 29 ] ; then
isleap $startyear
if [ -z "$leapyear" ] ; then
echo "Invalid: $startyear wasn't a leap year; February had 28 days." >&2
exit 1
fi
fi
#######################
#### ВЫЧИСЛЕНИЕ КОЛИЧЕСТВА ДНЕЙ
#######################
#### ДНЕЙ В НАЧАЛЬНОМ ГОДУ
# Собрать строку формата с начальной датой.
startdatefmt="$startmon/$startday/$startyear"
calculate="$((10#$($date -d "12/31/$startyear" +%j))) \
-$((10#$($date -d $startdatefmt +%j)))"
daysleftinyear=$(( $calculate ))
#### ДНЕЙ В ПРОМЕЖУТОЧНЫХ ГОДАХ
daysbetweenyears=0
tempyear=$(( $startyear + 1 ))
while [ $tempyear -lt $thisyear ] ; do
daysbetweenyears=$(($daysbetweenyears + \
$((10#$($date -d "12/31/$tempyear" +%j)))))
tempyear=$(( $tempyear + 1 ))
done
#### ДНЕЙ В ТЕКУЩЕМ ГОДУ
dayofyear=$($date +%j) # Это просто!
#### ТЕПЕРЬ СЛОЖИТЬ ВСЕ ВМЕСТЕ
totaldays=$(( $((10#$daysleftinyear)) + \
$((10#$daysbetweenyears)) + \
$((10#$dayofyear)) ))
/bin/echo -n "$totaldays days have elapsed between "
/bin/echo -n "$startmon/$startday/$startyear "
echo "and today, day $dayofyear of $thisyear."
exit 0
Сценарий получился довольно длинным, но не слишком сложным. Функция определения високосного года достаточно простая — она всего лишь проверяет, равно ли количество дней в году 366.
В строке выполняется интересная проверка наличия GNU-версии date в системе перед продолжением работы.
Оператор перенаправления отбрасывает любой вывод и сообщения об ошибках, а возвращаемый код проверяется на неравенство нулю, которое свидетельствует об ошибке при попытке разобрать параметр --version. В OS X, например, устанавливается версия date с минимальными возможностями — она не поддерживает параметра --version и многих других.
Далее остается только выполнить простейшие арифметические операции. Спецификатор %j возвращает номер дня в году, поэтому вычисление дней, оставшихся до конца года от начальной даты выполняется просто: . Суммарное количество дней в промежуточных годах вычисляется в цикле while, в котором очередной год хранится в переменной tempyear.
Наконец, определение числа дней от начала текущего года до текущей даты реализуется проще простого: .
dayofyear=$($date +%j)
Затем остается только сложить полученные дни и вывести результат!
Вернемся вновь в листинге 15.4 к историческим датам, рассматривавшимся выше.
Листинг 15.4. Запуск сценария daysago для разных дат
$ daysago 7 20 1969
17106 days have elapsed between 7/20/1969 and today, day 141 of 2016.
$ daysago 6 6 1944
26281 days have elapsed between 6/6/1944 and today, day 141 of 2016.
$ daysago 1 1 2010
2331 days have elapsed between 1/1/2010 and today, day 141 of 2016.
Эти команды были выполнены... Пусть date скажет за нас:
$ date
Fri May 20 13:30:49 UTC 2016
Имеется одно ошибочное условие, которое не проверяется сценарием: когда прошлая дата находится лишь в нескольких днях от текущей или даже в будущем. Что получится в таких ситуациях и как исправить эту ошибку? (Совет: загляните в сценарий № 101, где вы найдете дополнительные проверки, которые можно применить в этом сценарии.)
Логичным дополнением сценария № 100, daysago, является другой сценарий, daysuntil. Он, по сути, выполняет те же вычисления, но сначала подсчитывает количество дней, оставшихся до конца текущего года, затем количество дней в промежуточных годах и количество дней до указанной даты в целевом году, как показано в листинге 15.5.
Листинг 15.5. Сценарий daysuntil
#!/bin/bash
# daysuntil -- фактически выполняет те же вычисления, что и daysago,
# но в обратном порядке, когда текущая дата становится "датой в прошлом",
# а дата в будущем -- "текущей".
# Так же, как в предыдущем сценарии, используйте 'which gdate’ в OS X
# и 'which date' в Linux.
date="$(which gdate)"
function daysInMonth
{
case $1 in
1|3|5|7|8|10|12 ) dim=31 ;; # Постоянное значение
4|6|9|11 ) dim=30 ;;
2 ) dim=29 ;; # Зависит от года: високосный/невисокосный
* ) dim=-1 ;; # Неизвестный месяц
esac
}
function isleap
{
# Возвращает ненулевое значение в $leapyear, если $1 -- високосный год.
leapyear=$($date -d 12/31/$1 +%j | grep 366)
}
#######################
#### ОСНОВНОЙ БЛОК
#######################
if [ $# -ne 3 ] ; then
echo "Usage: $(basename $0) mon day year"
echo " with just numerical values (ex: 1 1 2020)"
exit 1
fi
$date --version > /dev/null 2>&1 # Отбросить сообщение об ошибке, если появится.
if [ $? -ne 0 ] ; then
echo "Sorry, but $(basename $0) can't run without GNU date." >&2
exit 1
fi
eval $($date "+thismon=%m;thisday=%d;thisyear=%Y;dayofyear=%j")
endmon=$1; endday=$2; endyear=$3
# Необходимые проверки параметров...
daysInMonth $endmon # Инициализирует переменную $dim
if [ $endday -lt 0 -o $endday -gt $dim ] ; then
echo "Invalid: Month #$endmon only has $dim days." >&2
exit 1
fi
if [ $endmon -eq 2 -a $endday -eq 29 ] ; then
isleap $endyear
if [ -z "$leapyear" ] ; then
echo "Invalid: $endyear wasn't a leapyear; February had 28 days." >&2
exit 1
fi
fi
if [ $endyear -lt $thisyear ] ; then
echo "Invalid: $endmon/$endday/$endyear is prior to the current year." >&2
exit 1
fi
if [ $endyear -eq $thisyear -a $endmon -lt $thismon ] ; then
echo "Invalid: $endmon/$endday/$endyear is prior to the current month." >&2
exit 1
fi
if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -lt $thisday ]
then
echo "Invalid: $endmon/$endday/$endyear is prior to the current date." >&2
exit 1
fi
if [ $endyear -eq $thisyear -a $endmon -eq $thismon -a $endday -eq $thisday ]
then
echo "There are zero days between $endmon/$endday/$endyear and today." >&2
exit 0
fi
#### Если целевая дата находится в этом же году,
#### вычисления должны выполняться немного иначе.
if [ $endyear -eq $thisyear ] ; then
totaldays=$(( $($date -d "$endmon/$endday/$endyear" +%j) - $($date +%j) ))
else
#### Вычислить количество дней по фрагментам,
#### начиная с количества дней до конца текущего года.
#### ДНЕЙ ОСТАЛОСЬ В НАЧАЛЬНОМ ГОДУ
# Собрать строку формата с начальной датой.
thisdatefmt="$thismon/$thisday/$thisyear"
calculate="$($date -d "12/31/$thisyear" +%j) - $($date -d $thisdatefmt +%j)"
daysleftinyear=$(( $calculate ))
#### ДНЕЙ В ПРОМЕЖУТОЧНЫХ ГОДАХ
daysbetweenyears=0
tempyear=$(( $thisyear + 1 ))
while [ $tempyear -lt $endyear ] ; do
daysbetweenyears=$(( $daysbetweenyears + \
$($date -d "12/31/$tempyear" +%j) ))
tempyear=$(( $tempyear + 1 ))
done
#### ДНЕЙ В КОНЕЧНОМ ГОДУ
dayofyear=$($date --date $endmon/$endday/$endyear +%j) # Это просто!
#### ТЕПЕРЬ СЛОЖИТЬ ВСЕ ВМЕСТЕ
totaldays=$(( $daysleftinyear + $daysbetweenyears + $dayofyear ))
fi
echo "There are $totaldays days until the date $endmon/$endday/$endyear."
exit 0
Как уже говорилось, этот сценарий во многом повторяет сценарий daysago. Возможно, их стоило бы объединить в один и проверять дату, указанную пользователем, — в прошлом она находится или в будущем. Большая часть вычислений в этом сценарии — просто инверсная версия вычислений в сценарии daysago, в том смысле, что они позволяют заглянуть вперед, в будущее, а не назад, в прошлое.
Однако этот сценарий чуть надежнее, потому что перед фактическими вычислениями проверяет намного больше ошибочных ситуаций. Возьмите, например, нашу любимую проверку в строке .
Если кто-то попытается обхитрить сценарий, указав сегодняшнюю дату, эта проверка определит такую попытку и выведет ответ «zero days» (ноль дней).
Сколько дней осталось до 1 января 2020? Листинг 15.6 отвечает на этот вопрос.
Листинг 15.6. Запуск сценария daysuntil для первого дня 2020 года
$ daysuntil 1 1 2020
There are 1321 days until the date 1/1/2020.
Сколько дней осталось до Рождества 2025?
$ daysuntil 12 25 2025
There are 3506 days until the date 12/25/2025.
Готовитесь отметить трехсотлетие Соединенных Штатов? Посмотрите, сколько дней осталось до этой даты:
$ daysuntil 7 4 2076
There are 21960 days until the date 7/4/2076.
Наконец, учитывая следующий результат, есть все шансы, что нас не будет в третьем тысячелетии:
$ daysuntil 1 1 3000
There are 359259 days until the date 1/1/3000.
В сценарии № 99 в начале главы мы научились определять, на какой день недели выпадает указанная дата. Было бы очень полезно объединить эту возможность с возможностями сценариев daysago и daysuntil, чтобы сразу получить всю информацию о выбранной дате.