19 нояб. 2010 г.

Ленивая дата

Календарные функции, представленные в предыдущей заметке, работают, исходя из наивного предположения, что на вход поступают правильные данные. В каких-то случаях ничего плохого не произойдет. К примеру, при вычислении юлианской даты на 29 февраля 1975г (в феврале 1975 было 28 дней), результат будет таким же как для 1 марта:
$ perl julian.pl 1975 2 29
JD: 2442472.500000
JD at 0h: 2442471.500000
Fri

$ perl julian.pl 1975 3 1
JD: 2442472.500000
JD at 0h: 2442471.500000
Fri
Хорошим такое API никак не назовешь. Самое время задуматься над созданием удобного для использования класса.

В одних случаях нужно получать из календарной даты юлианскую, в других -- наоборот. Поэтому объект должен инициализироваться как из первой, так из второй даты. Если на входе год, месяц и число, то они сохраняются, а юлианская дата неопределена (undef) и вычисляется лениво, т.е только при вызове метода djd. Точно так же, если объект создан с аргументом djd, календарная дата неопределена до тех пор, пока ее не запросили.

В Perl-е не существует перегрузки методов, следовательно, нельзя создать два разных конструктора: один -- с календарной датой на входе, другой с юлианской. Можно было бы передавать в конструктор new именованные аргументы, что-то вроде:
Julian->new(date => \%ymd, djd => $scalar)
Но тогда пришлось бы нагромождать проверки: не заданы ли сразу оба аргумента и какой именно задан. А потом еще проверять правильность либо первого либо второго. Проще создать два "фабричных" метода:
  • new_from_date(year => $scalar, month => $scalar, day => $scalar)
  • new_from_djd($djd)

Как правило, я стараюсь не использовать модуль Params::Validate, который создан как раз для проверки входных параметров -- уж больно велики накладные расходы, да и ни к чему превращать динамический язык в подобие Java. Но тут как раз тот случай, когда без него не обойтись.

Начнем с более простого метода new_from_djd.
...
use Params::Validate qw/:all/;
use Readonly;
Readonly our $DJD_TO_JD => 2415020;

...

sub new_from_djd {
my $class = shift;

validate_pos(
@_,
{
type => SCALAR,
regex => qr/^[-+]?0*(\d+|(?:\d*\।\d+))$/,
callbacks => {
'djd range' => sub{ $_[0] >= -$DJD_TO_JD }
}
}
);

bless {
_djd => shift,
_date => undef,
}, $class
}
...
Поскольку параметры здесь не именованные, а позиционные, для валидации данных используется метод validate_pos из пакета Params::Validate. Имя класса предварительно убрано из списка аргументов ( @_ ). Проверка производится по трем критериям:
  • type => SCALAR
    аргумент должен быть скаляром
  • qr/^[-+]?0*(\d+|(?:\d*\.\d+))$/
    аргумент должен быть целым или десятичным числом, с любым количеством ведущих нулей и с необязательным ведущим "плюсом" или "минусом"
  • 'djd range' => sub{ $_[0] >= -$DJD_TO_JD }
    число не должно быть меньше нулевой стандартной юлианской даты (напомню, что видоизмененная юлианская дата, которую мы используем, больше стандартной на 2415020 суток. Функции обратного вызова (callbacks) -- "тяжелая артиллерия" пакет Params::Validate. Внутри них можно осуществлять любые проверки. На входе всегда два параметра: проверяемый аргумент и ссылка на все аргументы (хэш или массив).

В конце мы "благословляем" (bless) новорожденный экземпляр с инициализированным атрибутом djd и пустой (до поры до времени) календарной датой.


Второй метод-фабрика сложнее:

...
use Params::Validate qw/:all/;
use Readonly;

Readonly our $DJD_TO_JD => 2415020;
Readonly our $MIN_YEAR => -4713;
Readonly our $MAX_YEAR => 4713;

...

sub new_from_date {
my $class = shift;

my %args = validate(
@_,
{
year => {
type => SCALAR,
regex => qr/^[\-+]?\d{1,4}$/,
callbacks => {
'year range' => sub{
$MIN_YEAR <= $_[0] && $MAX_YEAR >= $_[0]
},
'non-zero year' => sub{ 0 != $_[0] }
}
},

month => {
type => SCALAR,
regex => qr/^0*([1-9]|(1[0-2]))$/,
},

day => {
type => SCALAR,
regex => qr/^0*([1-9]|([0-2]\d)|(3[0,1]))(\।\d+)?$/,
callbacks => {
'day range' => sub{
my ($d, $arg) = @_;
$d >= 1 &&
$d < _days_per_month($arg->{year}, $arg->{month}) + 1;
}
}
},
}
);


bless {
_djd => undef,
_date => \%args,
}, $class
}
...
Здесь аргументы именованные, поэтому для их валидации используется метод validate.
  • год проверяется по четырем критериям:
    1. тип аргумента -- скаляр
    2. аргумент -- положительное или отрицательное целое число
    3. диапазон: от 4713г. до н.э до 4713г. н.э.
    4. ноль не допускается

  • У месяца тип должен должен быть скаляром, а диапазон (1-12) проверяется регулярным выражением
    qr/^0*([1-9]|(1[0-2]))$/
  • Проверка числа самая сложная.
    1. тип аргумента -- скаляр.
    2. при помощи регулярного выражения проверяем, что это число, возможно, с десятичными долями; при этом целая часть не выходит за рамки диапазона 1-31.
    3. При окончательной проверке диапазона вызывается внешняя функция _days_per_month (см. ниже)
В конце мы "благословляем" (bless) новорожденный экземпляр с сохраненными в хэше годом, номером месяца и числом.

Сколько дней в месяце?

Количество дней во всех месяцах, кроме февраля, в григорианском календаре неизменно.
Чтобы узнать число дней в феврале, надо определить, является ли год високосным. Если да -- то 29, если нет -- то 28. Для определения "високосности" функция _leap_year. Год в григориансмком календаре является високосным, если он кратен 4 и при этом не кратен 100, либо кратен 400 (в отличие от "старого стиля", где високосным считался каждый четвертый год).
# является ли год високосным?
sub _leap_year {
my $y = shift;
return 1 if $y % 400 == 0;
return 0 if $y % 100 == 0;
return 1 if $y % 4 == 0;
return 0;
}

# количество дней в месяце
sub _days_per_month {
my ($y, $m) = @_;
return _leap_year($y) ? 29 : 28 if $m == 2; # февраль
return 30 if grep{ $m == $_ } (4, 6, 9, 11); # апрель, июнь, сентябрь, ноябрь
return 31; # остальные месяцы
}

Ленивые вычисления

Два getter-а: djd и date служат для получения юлианской и календарной даты. Если либо одно либо другое не определено (undef), оно вычисляется и сохраняется как аттрибут объекта.
sub djd {
my $self = shift;
$self->{_djd} = _date2djd(%{$self->{_date}})
unless defined $self->{_djd};
$self->{_djd}
}

sub date {
my $self = shift;
$self->{_date} = _djd2date($self->{_djd})
unless defined $self->{_date};
$self->{_date}
}
Функции _date2djd и _djd2date, которые заняты вычислениями, уже описаны в предыдущей заметке. Здесь я только добавил к их названиям нижнее подчеркивания в качестве рекомендации не использовать их снаружи.

Комментариев нет: