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

でも、1つだけできないことがあります。うるう年の表示です!

もちろんcalはうるう年のことを_知っています_。そうでなければカレンダーとして役に立ちませんからね。ただ、うるう年を一覧表示する方法がないのです。

力技でうるう年を見分ける

自分で作ってみましょう! カレンダーライブラリのある本格的なプログラミング言語を使うこともできますが、コマンドラインの部品を組み合わせて何かを作るのは楽しいですし、この計算を本番環境で使うわけでもありません。

まず、2月がうるう年の目印になることを確認しておきましょう。

$ 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

完成です!

でも、29をgrepで探すだけでは簡単すぎます。もっと変なやり方をしてみましょう!

さらに力技でうるう年を見分ける

うるう年と平年の違いは、29日という余分な1日があることです。calの出力の文字数を数えて、うるう年を見分けることはできないでしょうか?

できます。ただし、その前に1つつまずく点があります。どういうことか見てみましょう。

2020年がうるう年で、2021年がそうでないことはわかっています。それぞれの年の2月の出力の文字数の違いを調べてみましょう。ここでは便利なwcユーティリティ、とくにバイト数を数えるwc -cを使います。

$ printf "hello" | wc -c

5

"hello"は5文字です(改行文字が加わらないように、echoではなくprintfを使っています)。いいですね!

$ 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

荒技に次ぐ荒技ですが、ちゃんと動きます!

どちらが速いと思いますか? 正直、私にもまったくわかりません。「2月を取り除く」方法のほうが速いのでしょうか? でも、大きな差があるとは思えません。

驚くのはやめましょう。実際に確かめてみましょう!

複数のコマンドライン呼び出しのパフォーマンスを評価できる、hyperfineという優れたユーティリティがあります。

どちらが速いのか? hyperfineが教えてくれます

2つのコマンドとあわせて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

ここでベンチマークしたいのは1年分の判定にかかる時間だけで、年を並べるループの生成にかかる時間は対象外です。そこで、特定の年について、それぞれの方法でうるう年かどうかを判定する部分だけを測定します。

$ 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

予想どおり、実際の経過時間にはほとんど違いがありません。ただし、「2月を取り除く」方法のほうが_ほんの少し_速いかもしれません。

これで、コマンドラインツールを組み立てる旅はおしまいです。

まとめ

  • 既存のツールを組み合わせてデータパイプラインを作れば、新しいコマンドライン機能を自分で作ることができます
  • 少しの工夫で、単純な部品から驚くほど複雑な結果が得られます
  • hyperfineはコマンドラインコマンドのベンチマークに便利なツールです

次のステップ

  • calコマンドの使い方をいろいろ試してみましょう。
  • 1年分の空白以外の文字を数えるパイプラインを作ってみましょう:cal 2020
  • 1750年から1759年までの各年について、この1年分を数えるパイプラインを実行してみましょう。何かおかしなことに気づきませんか?
Translation missing: ja.number.nth.ordinalized Jan 2024 · 役に立ちましたか?