Uploaded avatar of sdball

用 cal 找出闰年

@sdball
2年多前

本文最初发表于 Stephen 的网站,经授权在此重新发布(略有改写)

认识 cal

你知道 macOS 和 Linux 的命令行里有一个日历吗?

$ cal

     July 2021
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31

快速查一下tldr,就能看出这个工具有多好用:

$ tldr cal

cal

Prints calendar information.

- Display a calendar for the current month:
    cal

- Display previous, current and next month:
    cal -3

- Display a calendar for a specific month (1-12 or name):
    cal -m month

- Display a calendar for the current year:
    cal -y

- Display a calendar for a specific year (4 digits):
    cal year

- Display a calendar for a specific month and year:
    cal month year

- Display date of Easter (Western Christian churches) in a given year:
    ncal -e year

不过它缺了一样东西:闰年!

当然,cal _知道_闰年这回事,否则就称不上日历了。但它没有办法把闰年列出来。

用取巧的办法识别闰年

我们自己来做吧!当然,我们可以用一门真正的编程语言配上日历库,但把命令行组件拼装起来本身就很有趣,何况我们也不是要把这个计算用到生产环境里。

首先,我们统一一下:二月就是判断闰年的标志。

$ cal 02 2021

   February 2021
Su Mo Tu We Th Fr Sa
    1  2  3  4  5  6
 7  8  9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28

$ cal 02 2020

   February 2020
Su Mo Tu We Th Fr Sa
                   1
 2  3  4  5  6  7  8
 9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29

能不能搜索“29”,靠它来识别闰年?也许可以!

$ cal 02 2020 | grep -q 29 && echo "LEAP" || echo "normal"

LEAP

$ cal 02 2021 | grep -q 29 && echo "LEAP" || echo "normal"

normal

目前一切顺利?不过我敢打赌你已经看出问题了。

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 LEAP
2030 year

2029 年并不是闰年。它之所以匹配上,只是因为 2029 里有个 29。

$ cal 02 2029

   February 2029
Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

我们可以把带“February”的那一整行排除掉,从而去掉这个麻烦的结果。

$ cal 02 2029 | grep -v Feb

Su Mo Tu We Th Fr Sa
             1  2  3
 4  5  6  7  8  9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28

现在我认为,如果在结果里找到“29”,那它才真的是闰年。

$ for year in {2020..2030}; do
  printf "$year ";
  cal 02 "$year" | grep -v Feb | grep -q 29 && echo "LEAP" || echo "year";
done

2020 LEAP
2021 year
2022 year
2023 year
2024 LEAP
2025 year
2026 year
2027 year
2028 LEAP
2029 year

这和实际的闰年对得上吗?

Wolfram Research 的闰年页面列出了 21 世纪上半叶的闰年,如下所示。

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

看看我们做得怎么样:

$ for year in {2000..2051}; do cal 02 "$year" | grep -v Feb | grep -q 29 && echo $year; done
2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

搞定了!

不过这样用 grep 匹配 29 还是太简单了。我们来点更古怪的!

用更取巧的办法识别闰年

闰年和普通年的区别,就在于多出来的那个 29 日。我们能不能通过检查cal输出的字符数来识别闰年?

可以。不过得先绕过一个坑。我来演示一下我的意思。

我们知道 2020 年是闰年,2021 年不是。我们来对比一下这两年的二月输出字符数有什么差别。用顺手的wc工具就能做到,具体说就是用wc -c来统计字节数。

$ printf "hello" | wc -c

5

“hello”有 5 个字符(这里用printf而不是echo,否则还会多出一个换行符)。很好!

$ cal 02 2020 | wc -c

184
$ cal 02 2021 | wc -c

184

拦路石就在这里。每个月的输出都不一样(2020 年有那个 29 日),可两者的字符数却相同,因为 cal 会补空格来保证格式一致。空格和数字一样,都是可以计数的字符。

那怎么办?

我们可以把所有空格都删掉!这样剩下的就只有我们想统计的字符了。

删字符的一件好工具是tr命令。tr命令可以转换字符串数据。比如,它能把所有的a字符变成A字符。

$ echo "aardvark" | tr a A

AArdvArk

它还能删除所有指定的字符,这正是我们想要的。

在我们这里,我们想删掉所有的空白字符,不管是哪种(空格、制表符、换行符等等)。有一个很方便的分组叫字符类,正好能做这件事。字符类[:space:]会匹配所有这些不同类型的空白字符。

$ cal 02 2020 | tr -d '[:space:]'

February2020SuMoTuWeThFrSa1234567891011121314151617181920212223242526272829
$ cal 02 2021 | tr -d '[:space:]'

February2021SuMoTuWeThFrSa12345678910111213141516171819202122232425262728

太好了!现在能不能靠统计字符数来找出闰年?当然能!

$ cal 02 2020 | tr -d '[:space:]' | wc -c

75
$ cal 02 2021 | tr -d '[:space:]' | wc -c

73

现在来输出一些闰年吧!

$ for year in {2000..2051}; do
  cal 02 "$year" |
    tr -d '[:space:]' |
    wc -c |
    grep -q 75 && echo "$year";
done

2000
2004
2008
2012
2016
2020
2024
2028
2032
2036
2040
2044
2048

一层套一层的取巧,居然真能跑通!

你觉得哪个更快?说实话,我也不知道。“去掉 February”那条路也许更快?不过要是两者差距很大,我倒会很意外。

与其等着意外,不如去弄清楚!

有一个很棒的工具叫hyperfine,可以评测多条命令行调用的性能。

哪个更快?让 Hyperfine 告诉我们

下面是我传给 hyperfine 的参数,以及那两条命令。

--style basic
    Plain output styling
--export-markdown hyperfine.md
    Export results as markdown
--warmup 5
     Do five runs before benchmarking
--ignore-failure
     Ignore non-zero exits

我们只关心对单个年份做判断的基准测试,而不关心生成那串年份循环所需的时间。所以我只对每种方法判断某个具体年份是否为闰年做基准测试。

$ hyperfine --style basic --export-markdown hyperfine.md --warmup 5 --ignore-failure "cal 02 2050 | grep -v Feb | grep -q 29" "cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75"

Benchmark #1: cal 02 2050 | grep -v Feb | grep -q 29
  Time (mean ± σ):       1.8 ms ±   0.3 ms    [User: 0.7 ms, System: 1.9 ms]
  Range (min … max):     1.0 ms …   3.6 ms    493 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Benchmark #2: cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75
  Time (mean ± σ):       1.9 ms ±   0.4 ms    [User: 0.9 ms, System: 2.7 ms]
  Range (min … max):     0.7 ms …   5.9 ms    811 runs

  Warning: Command took less than 5 ms to complete. Results might be inaccurate.
  Warning: Ignoring non-zero exit code.

Summary
  'cal 02 2050 | grep -v Feb | grep -q 29' ran
    1.07 ± 0.28 times faster than 'cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75'
命令 平均值 [ms] 最小值 [ms] 最大值 [ms] 相对值
cal 02 2050 | grep -v Feb | grep -q 29 1.8 ± 0.3 1.0 3.6 1.00
cal 02 2050 | tr -d '[:space:]' | wc -c | grep -q 75 1.9 ± 0.4 0.7 5.9 1.07 ± 0.28

正如预料,就实际运行时间而言,两者几乎没有差别,不过“去掉 February”那条路可能_稍微_快一点。

这次拼装命令行工具的小旅程到此结束。

小结

  • 把现有工具组装成数据管道,就能做出新的命令行功能
  • 一点点创意,就能用简单的零件得到出人意料的复杂结果
  • Hyperfine 是给命令行命令做基准测试的好工具

下一步

  • 探索一下cal命令的用法。
  • 试着搭一条管道,统计一整年的所有非空格字符:cal 2020
  • 把这条统计整年字符的管道依次用在 1750 到 1759 的每一年上:发现什么奇怪的地方了吗?
2024年01月30日 · 觉得有用吗?