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 的每一年執行那條整年計數管線:有注意到什麼奇怪的地方嗎?
Translation missing: zh-TW.number.nth.ordinalized Jan 2024 · 覺得有幫助嗎?