Wait the light to fall

Raku 时间戳转换器命令行版

焉知非鱼

Raku 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

使用库 #

Raku 的 DateTimeDate 模块会做实际的转换。 DateTime.new 构造函数有一个接收单个整数作为 UNIX 时间戳的变体:

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

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

#!/usr/bin/env raku
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 raku
sub MAIN(Int $timestamp) {
    say $timestamp.^name;
    say DateTime.new($timestamp)
}

打印出:

IntStr

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

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

#!/usr/bin/env raku
sub MAIN(Int $timestamp) {
    say DateTime.new(+$timestamp)
}

这一次它真的工作了:

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

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

#!/usr/bin/env raku
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 个… 那就是又丑又长。Raku 有一个 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 raku
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 引用子例程作为一个对象,而不调用它。

这是 Raku, formatter => &formatter 有一个简写: &formatter。 作为一般规则,如果要填充一个名称为变量名称并且其值为变量值的命名参数, 可以通过写入 :$variable 创建它。 作为扩展, :thingthing => True 的缩写。

寻找其他途径 #

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

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

sub MAIN($input) {
    if $input ~~ / ^ \d+ $ / {
        # convert from timestamp to date/datetime
    }
    else {
        # convert from date to timestamp

    }
}

但我讨厌无聊, 所以我想看看一个更令人兴奋的(端可扩展)方法。

Raku 支持多重分派。这意味着您可以有多个具有相同名称但不同签名的子例程。 Raku 自动决定要调用哪一个。 您必须通过编写 multi sub 而不是 sub 来显式地启用此功能, 以便 Raku 可以捕获意外的重新声明。

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

#!/usr/bin/env raku

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

系好你的安全带 #

Raku 的隐式变量或主题变量:

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;
    }
}

Raku 还提供了对 $_ 变量的条件语句的快捷方式,可以用作一个通用的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 raku

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 raku

#| 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 时。

原文请参见 Raku By Example: Datetime Conversion for the Command Line