Perl 6 时间戳转换器命令行版

Perl 6 By Example: Datetime Conversion for the Command Line

我偶尔会在数据库中存储 UNIX 时间戳, 即从 1970-01-01 开始的秒数。我在按照日期查询数据库中的数据时, 需要将 UNIX 时间戳转换为人类可读的时间, 所以我写了个很小的工具来帮助我在 UNIX 时间戳和日期/时间之间来回转换:

1
2
3
4
5
6
7
8
$ 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 的 DateTimeDate 模块会做实际的转换。
DateTime.new 构造函数有一个接收单个整数作为 UNIX 时间戳的变体:

1
2
$ perl6 -e "say DateTime.new(1480915200)"
2016-12-05T05:20:00Z

看起来我们已经完成了一个方向的转换,对吗?

1
2
3
4
#!/usr/bin/env perl6
sub MAIN (Int $timestamp) {
say DateTime.new($timestamp)
}

我们来运行它:

1
2
3
4
$ 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。怎么会变成那样呢? 我们添加一些调试输出:

1
2
3
4
5
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
say $timestamp.^name;
say DateTime.new($timestamp)
}

打印出:

1
IntStr

$thing.^name 是 $thing 所属类的名字。 IntStrIntStr 类的子类, 这就是为什么 DateTime 构造函数正常地认为 $timestamp 是一个 Str 的原因。

长话短说, 我们可以在参数前添加一个 + 前缀使参数强制为 “真” 整数, 这也是将字符串转为数值的通用机制:

1
2
3
4
#!/usr/bin/env perl6
sub MAIN(Int $timestamp) {
say DateTime.new(+$timestamp)
}

这一次它真的工作了:

1
2
$ ./autotime-01.p6 1450915200
2015-12-24T00:00:00Z

输出是 ISO 8601 样式的时间戳格式, 对眼睛不太友好。对于小时,分钟和秒数都为 0 的日期, 我们真正想要的只有日期:

1
2
3
4
5
6
7
8
9
10
#!/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;
}
}

这样看起来更好一点:

1
2
$ ./autotime 1450915200
2015-12-24

但是上面那种三个比较都为 0 的写法实在太丑了, 如果是 4 个, 5 个, 6 个… 那就是又丑又长。Perl 6 有一个 all Junction:

1
2
3
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 的整数, 我们甚至可以把上面的例子写为:

1
2
3
if none($dt.hour, $dt.minute, $dt.second) {
say $dt.Date;
}

但是也可能没有必要搞得那么复杂, 如果 $dt 这个 Datetime 对象转换为 Date 然后再转换为 DateTime 而不丢失信息, 那么它肯定是一个 Date:

1
2
3
4
5
6
if $dt.Date.DateTime == $dt {
say $dt.Date;
}
else {
say $dt;
}

DateTime 格式化

如果时间戳没有被解析为整天, 那么当前我们的脚本的输出就会像这样:

1
2015-12-24T00:00:01Z

其中的 “Z” 表示 UTC 或 “Zulu” 时区。

DateTime 类支持自定义格式化, 所以我们来写一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
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;
}
}

现在输出看起来更好看了:

1
2
./autotime 1450915201
2015-12-24 00:00:01

语法 formatter => ... 在参数上下文中表示具名参数。
这样的代码我不喜欢, 因为在 DateTime.new 调用中它是内联的, 这并不清晰。

我们来单独写一个例程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/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 创建它。 作为扩展, :thingthing => True 的缩写。

寻找其他途径

现在, 从时间戳到日期和时间的转换工作的很好, 让我们看另一种途径。
我们的小工具需要解析输入, 并决定输入的是时间戳还是日期和可选的时间。

一种无聊的方式是使用条件:

1
2
3
4
5
6
7
8
9
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 可以捕获意外的重新声明。

让我们看看它在实际中的运用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/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
}

我们看一下效果:

1
2
3
4
5
6
7
$ ./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 候选者接受的字符串的种类。
经典的方法是用一个正则表达式粗略验证传入的参数:

1
2
3
multi sub MAIN(Str $date where /^ \d+ \- \d+ \- \d+ $ /) {
say Date.new($date).DateTime.posix
}

它确实能工作, 但为什么重复 Date.new 已经有用于验证日期字符串的逻辑?
如果你传递一个看起来不像日期的字符串参数,你会得到这样的错误:

1
Invalid Date string 'foobar'; use yyyy-mm-dd instead

我们可以使用这种行为约束 MAIN multi 候选者的字符串参数:

1
2
3
multi sub MAIN(Str $date where { try Date.new($_) }) {
say Date.new($date).DateTime.posix
}

在这里额外的 try 是因为子类型约束后面的 where 不应该抛出异常, 而只是返回一个假值。

现在它的工作得像预期的一样:

1
2
3
4
$ ./autotime 2015-12-24;
1450915200
$ ./autotime 1450915200
2015-12-24

处理时间

剩下要实现的功能是把日期和时间转换为时间戳。换句话说, 我们想这样调用 autotime 2015-12-24 11:23:00:

1
2
3
4
5
6
7
8
9
10
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 构造函数看起来像这样:

1
DateTime.new(date => $d, hour => $h, minute => $m, second => $s);

所以构造函数的命名参数使我选择更多的自解释变量名。

所以, 这个可以工作:

1
2
./autotime 2015-12-24 11:23:00
1450956180

而且我们还可以检测它的原形:

1
2
$ ./autotime 1450956180
2015-12-24 11:23:00

系好你的安全带

Perl 6 的隐式变量或主题变量:

1
2
3
for 1..3 {
.say
}

产生如下输出:

[source]

1
2
3
1
2
3

这个例子中没有显式的迭代变量, 所以 Perl 隐式地把当前循环的值绑定给叫做 $_ 的变量。方法调用 .say$_.say 的缩写。由于我们有一个子例程在同一个变量上调用了 6 个方法, 所以使用 $_ 会有很好的可视效果:

1
2
3
4
5
sub formatter($_) {
sprintf '%04d-%02d-%02d %02d:%02d:%02d',
.year, .month, .day,
.hour, .minute, .second,
}

如果你不想求助于函数定义在词法作用域中设置 $_, 那么你可以使用 given VALUE BLOCK 结构:

1
2
3
4
5
6
7
8
given DateTime.new(+$timestamp, :&formatter) {
if .Date.DateTime == $_ {
say .Date;
}
else {
.say;
}
}

Perl 6 还提供了对 $_ 变量的条件语句的快捷方式,可以用作一个通用的switch语句:

1
2
3
4
given DateTime.new(+$timestamp, :&formatter) {
when .Date.DateTime == $_ { say .Date }
default { .say }
}

如果你有一个只读的变量或参数, 那么你可以不使用 $ 符号, 虽然你可以在声明时使用反斜线:

1
2
3
4
5
6
multi sub MAIN(Int \timestamp) {
...
given DateTime.new(+timestamp, :&formatter) {
...
}
}

所以现在完整的代码看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/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, 例如调用时不提供参数:

1
2
3
4
$ ./autotime
Usage:
./autotime <timestamp>
./autotime <date> [<time>]

我们可以通过在 MAIN subs 之前添加语义注释来为这些用法行添加简短描述:

1
2
3
4
5
6
7
8
9
10
11
#!/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?) {
...
}

现在用法信息变为了:

1
2
3
4
$ ./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

------ Young For Perl 6! ------