Perl 6 By Example: Datetime Conversion for the Command Line
我偶尔会在数据库中存储 UNIX 时间戳, 即从 1970-01-01 开始的秒数。我在按照日期查询数据库中的数据时, 需要将 UNIX 时间戳转换为人类可读的时间, 所以我写了个很小的工具来帮助我在 UNIX 时间戳和日期/时间之间来回转换:
$ autotime 2015-12-24
1450915200
$ autotime 2015-12-24 11:23:00
1450956180
$ autotime 1450915200
2015-12-24
$ autotime 1450956180
2015-12-24 11:23:00
使用库
Perl 6 的 DateTime 和 Date 模块会做实际的转换。
DateTime.new
构造函数有一个接收单个整数作为 UNIX 时间戳的变体:
$ perl6 -e "say DateTime.new(1480915200)"
2016-12-05T05:20:00Z
看起来我们已经完成了一个方向的转换,对吗?
#!/usr/bin/env perl6
sub MAIN (Int $timestamp) {
say DateTime.new($timestamp)
}
我们来运行它:
$ autotime 1450915200
Invalid DateTime string '1450915200'; use an ISO 8601 timestamp (yyyy-mm-ddThh:mm:ssZ or yyyy-mm-ddThh:mm:ss+01:00) instead
in sub MAIN at autotime line 2
in block <unit> at autotime line 2
发生了什么?看起来 DateTime
构造函数把参数当作了字符串, 尽管 sub MAIN
的参数被声明为 Int
。怎么会变成那样呢? 我们添加一些调试输出:
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
say $timestamp.^name;
say DateTime.new($timestamp)
}
打印出:
IntStr
$thing.^name
是 $thing 所属类的名字。 IntStr 是 Int
和 Str
类的子类, 这就是为什么 DateTime
构造函数正常地认为 $timestamp 是一个 Str
的原因。
长话短说, 我们可以在参数前添加一个 +
前缀使参数强制为 "真" 整数, 这也是将字符串转为数值的通用机制:
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
say DateTime.new(+$timestamp)
}
这一次它真的工作了:
$ ./autotime-01.p6 1450915200
2015-12-24T00:00:00Z
输出是 ISO 8601 样式的时间戳格式, 对眼睛不太友好。对于小时,分钟和秒数都为 0 的日期, 我们真正想要的只有日期:
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
my $dt = DateTime.new(+$timestamp);
if $dt.hour == 0 && $dt.minute == 0 && $dt.second == 0 {
say $dt.Date;
}
else {
say $dt;
}
}
这样看起来更好一点:
$ ./autotime 1450915200
2015-12-24
但是上面那种三个比较都为 0 的写法实在太丑了, 如果是 4 个, 5 个, 6 个... 那就是又丑又长。Perl 6 有一个 all
Junction:
if all($dt.hour, $dt.minute, $dt.second) == 0 {
say $dt.Date;
}
all(...)
创建了一个 Junction, 它是几个其他值的组合值, 它也存储了一个逻辑模式。当你比较一个 junction 和其他值的时候, 那个比较会自动地应用到该 junction 中的所有值上。if
语句在布尔上下文中对该 junction 进行求值, 在这个例子中, 当所有的比较为 True
时, if 也返回 True
。
其他类型的 junction 还有 any
, all
, none
。考虑到在布尔上下文中, 0 是唯一一个求值为 false 的整数, 我们甚至可以把上面的例子写为:
if none($dt.hour, $dt.minute, $dt.second) {
say $dt.Date;
}
但是也可能没有必要搞得那么复杂, 如果 $dt
这个 Datetime 对象转换为 Date
然后再转换为 DateTime 而不丢失信息, 那么它肯定是一个 Date:
if $dt.Date.DateTime == $dt {
say $dt.Date;
}
else {
say $dt;
}
DateTime 格式化
如果时间戳没有被解析为整天, 那么当前我们的脚本的输出就会像这样:
2015-12-24T00:00:01Z
其中的 "Z" 表示 UTC 或 "Zulu" 时区。
DateTime
类支持自定义格式化, 所以我们来写一个:
sub MAIN(Int $timestamp) {
my $dt = DateTime.new(+$timestamp, formatter => sub ($o) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
$o.year, $o.month, $o.day,
$o.hour, $o.minute, $o.second,
});
if $dt.Date.DateTime == $dt {
say $dt.Date;
}
else {
say $dt.Str;
}
}
现在输出看起来更好看了:
./autotime 1450915201
2015-12-24 00:00:01
语法 formatter => ...
在参数上下文中表示具名参数。
这样的代码我不喜欢, 因为在 DateTime.new
调用中它是内联的, 这并不清晰。
我们来单独写一个例程:
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
sub formatter($o) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
$o.year, $o.month, $o.day,
$o.hour, $o.minute, $o.second,
}
my $dt = DateTime.new(+$timestamp, formatter => &formatter);
if $dt.Date.DateTime == $dt {
say $dt.Date;
}
else {
say $dt.Str;
}
}
是的, 你可以把一个子例程声明放在另一个子例程声明的正文中; 子例程只是一个普通的词法符号,就像一个用 my
声明的变量。
在行 my $dt = DateTime.new(+$timestamp,formatter => &formatter);
中, 语法 &formatter
引用子例程作为一个对象,而不调用它。
这是 Perl 6, formatter => &formatter
有一个简写: &formatter
。
作为一般规则,如果要填充一个名称为变量名称并且其值为变量值的命名参数, 可以通过写入 :$variable
创建它。 作为扩展, :thing
是 thing => True
的缩写。
寻找其他途径
现在, 从时间戳到日期和时间的转换工作的很好, 让我们看另一种途径。
我们的小工具需要解析输入, 并决定输入的是时间戳还是日期和可选的时间。
一种无聊的方式是使用条件:
sub MAIN($input) {
if $input ~~ / ^ \d+ $ / {
# convert from timestamp to date/datetime
}
else {
# convert from date to timestamp
}
}
但我讨厌无聊, 所以我想看看一个更令人兴奋的(端可扩展)方法。
Perl 6 支持多重分派。这意味着您可以有多个具有相同名称但不同签名的子例程。
Perl 6 自动决定要调用哪一个。 您必须通过编写 multi sub
而不是 sub
来显式地启用此功能, 以便 Perl 6 可以捕获意外的重新声明。
让我们看看它在实际中的运用:
#!/usr/bin/env perl6
multi sub MAIN(Int $timestamp) {
sub formatter($o) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
$o.year, $o.month, $o.day,
$o.hour, $o.minute, $o.second,
}
my $dt = DateTime.new(+$timestamp, :&formatter);
if $dt.Date.DateTime == $dt {
say $dt.Date;
}
else {
say $dt.Str;
}
}
multi sub MAIN(Str $date) {
say Date.new($date).DateTime.posix
}
我们看一下效果:
$ ./autotime 2015-12-24
1450915200
$ ./autotime 1450915200
Ambiguous call to 'MAIN'; these signatures all match:
:(Int $timestamp)
:(Str $date)
in block <unit> at ./autotime line 17
不是我所想象的。问题又是整数参数自动被转换为了 IntStr
, Int 和 Str multi
(或候选)都接受它作为参数。
避免这种错误的最简单的方法是缩小 Str 候选者接受的字符串的种类。
经典的方法是用一个正则表达式粗略验证传入的参数:
multi sub MAIN(Str $date where /^ \d+ \- \d+ \- \d+ $ /) {
say Date.new($date).DateTime.posix
}
它确实能工作, 但为什么重复 Date.new 已经有用于验证日期字符串的逻辑?
如果你传递一个看起来不像日期的字符串参数,你会得到这样的错误:
Invalid Date string 'foobar'; use yyyy-mm-dd instead
我们可以使用这种行为约束 MAIN multi
候选者的字符串参数:
multi sub MAIN(Str $date where { try Date.new($_) }) {
say Date.new($date).DateTime.posix
}
在这里额外的 try
是因为子类型约束后面的 where
不应该抛出异常, 而只是返回一个假值。
现在它的工作得像预期的一样:
$ ./autotime 2015-12-24;
1450915200
$ ./autotime 1450915200
2015-12-24
处理时间
剩下要实现的功能是把日期和时间转换为时间戳。换句话说, 我们想这样调用 autotime 2015-12-24 11:23:00
:
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
my $d = Date.new($date);
if $time {
my ( $hour, $minute, $second ) = $time.split(':');
say DateTime.new(date => $d, :$hour, :$minute, :$second).posix;
}
else {
say $d.DateTime.posix;
}
}
凭借尾部的?, 新的第二个参数是可选的 。 如果存在第二个参数, 我们用冒号将时间字符串分割成小时,分钟和秒。 我写的第一个本能是使用较短的变量名称, my($h, $m, $s) = $time.split(':')
, 但然后调用 DateTime
构造函数看起来像这样:
DateTime.new(date => $d, hour => $h, minute => $m, second => $s);
所以构造函数的命名参数使我选择更多的自解释变量名。
所以, 这个可以工作:
./autotime 2015-12-24 11:23:00
1450956180
而且我们还可以检测它的原形:
$ ./autotime 1450956180
2015-12-24 11:23:00
系好你的安全带
Perl 6 的隐式变量或主题变量:
for 1..3 {
.say
}
产生如下输出:
[source]
1
2
3
这个例子中没有显式的迭代变量, 所以 Perl 隐式地把当前循环的值绑定给叫做 $_
的变量。方法调用 .say
是 $_.say
的缩写。由于我们有一个子例程在同一个变量上调用了 6 个方法, 所以使用 $_
会有很好的可视效果:
sub formatter($_) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
.year, .month, .day,
.hour, .minute, .second,
}
如果你不想求助于函数定义在词法作用域中设置 $_
, 那么你可以使用 given VALUE BLOCK
结构:
given DateTime.new(+$timestamp, :&formatter) {
if .Date.DateTime == $_ {
say .Date;
}
else {
.say;
}
}
Perl 6 还提供了对 $_
变量的条件语句的快捷方式,可以用作一个通用的switch语句:
given DateTime.new(+$timestamp, :&formatter) {
when .Date.DateTime == $_ { say .Date }
default { .say }
}
如果你有一个只读的变量或参数, 那么你可以不使用 $
符号, 虽然你可以在声明时使用反斜线:
multi sub MAIN(Int \timestamp) {
...
given DateTime.new(+timestamp, :&formatter) {
...
}
}
所以现在完整的代码看起来像这样:
#!/usr/bin/env perl6
multi sub MAIN(Int \timestamp) {
sub formatter($_) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
.year, .month, .day,
.hour, .minute, .second,
}
given DateTime.new(+timestamp, :&formatter) {
when .Date.DateTime == $_ { say .Date }
default { .say }
}
}
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
my $d = Date.new($date);
if $time {
my ( $hour, $minute, $second ) = $time.split(':');
say DateTime.new(date => $d, :$hour, :$minute, :$second).posix;
}
else {
say $d.DateTime.posix;
}
}
MAIN 魔法
为我们调用 sub MAIN
的魔法还为我们提供了一个自动化的用法消息, 如果我们用不匹配任何 multi
的参数调用 MAIN, 例如调用时不提供参数:
$ ./autotime
Usage:
./autotime <timestamp>
./autotime <date> [<time>]
我们可以通过在 MAIN subs 之前添加语义注释来为这些用法行添加简短描述:
#!/usr/bin/env perl6
#| Convert timestamp to ISO date
multi sub MAIN(Int \timestamp) {
...
}
#| Convert ISO date to timestamp
multi sub MAIN(Str $date where { try Date.new($_) }, Str $time?) {
...
}
现在用法信息变为了:
$ ./autotime
Usage:
./autotime <timestamp> -- Convert timestamp to ISO date
./autotime <date> [<time>] -- Convert ISO date to timestamp
总结
我们已经看到了一些 Date 和 DateTime 算法, 但令人兴奋的部分是 multi dispatch, 命名参数,带有 where 从句的子类型约束, given/ when 和 隐式 $_ 变量, 以及一些魔法, 当涉及到 MAIN subs 时。
原文请参见 Perl 6 By Example: Datetime Conversion for the Command Line