お題
2022 年は日曜日がいくつあるか数えてください。
コード
コード 1
require "date"
count = 0
(1..12).each do |month|
(1..31).each do |day|
begin
date = Date.new(2022, month, day)
rescue Date::Error
next
end
count += 1 if date.wday == 0
end
end
puts count
コード 2
require "date"
count = 0
(Date.new(2022, 1, 1)..Date.new(2022, 12, 31)).each do |date|
count += 1 if date.sunday?
end
puts count
講評
どちらのコードも,2022 年一年間のすべての日付を Date オブジェクトとして生成し,それが日曜日である場合にカウンターを 1 増やしています。
違いは,すべての日付を生成するやり方と,日曜日かどうかを判定するやり方ですね。
コード 1
コード 1 では,月(1〜12)と日(1〜31)の二重ループを使って全ての日付を得ようとしています。
しかし,月によって末日は異なるので,その扱いが問題になります。
Date.new は,ありえない年・月・日の組み合わせを与えると Date::Error を発生させるのでした:
require "date"
Date.new(2022, 2, 31)
# => Date::Error
そこで,コード 1 では begin/rescue/end を使ってその例外を捕捉するようにしています。
また,日曜日かどうかの判定に Date#wday を使っています。
「wday」というメソッド名は「曜日」の英語「day of week」に由来するのでしょう。
wday
が「日曜日のとき 0 を返す」という知識が必要です。date.wday == 0
は分かりにくい(≒バグを生みやすい)コードと言えるでしょう。
「日曜始まり」としっかり覚えていればいいですが,これがもし木曜日だったら指折り数えて確認するのではないでしょうか。
コード 2
コード 2 は上記二点について改善されています。
まず全ての日付を生成するのに,「Date オブジェクトを始端・終端とする範囲オブジェクト」および Range#each メソッドを使いました。
範囲オブジェクトは,1..7
のように,数値を始端・終端とするものをよく使いますが,数値以外も可能です。
大雑把に言って,<=>
で比較が可能なオブジェクトを二つ用意すれば,それを始端・終端とする範囲オブジェクトが作れます。
そして Range#each
が使えるための要件は,始端が succ
メソッドを持ち,その返り値もまた succ
メソッドを持ち……となっていて,終端と比較できる,ということです。
succ
は「次の値(successor)」を与えるメソッドとして用いられます。
Date#succ は「次の日付」を返すメソッドですね。
次のように,(当然ながら)閏年も正しく処理して「次の日付」を与えてくれます:
require "date"
puts Date.new(2020, 2, 28).succ # => 2020-02-29
puts Date.new(2021, 2, 28).succ # => 2022-03-01
だから,2022 年のすべての日付についてのイテレーションが
require "date"
(Date.new(2022, 1, 1)..Date.new(2022, 12, 31)).each do |date|
# なんとかかんとか
end
と書けるわけです。
次に,日曜日かどうかの判定ですが,そのものズバリの Date#sunday? というメソッドが存在しています。もちろん,すべての曜日についてこのようなメソッドが用意されています。
これを使えば分かりやすいコードになりますね。
しかし,もっと簡素に書けます。
改善
コード 1 もコード 2 も,「カウンター変数を初期化して,条件によりカウントアップする」ということをあらわに書いているのがイケてないですね。
何かを数えるのに活躍するのが Enumerable#count です。
これを使うと以下のように書けます。
require "date"
date_range = Date.new(2022, 1, 1)..Date.new(2022, 12, 31)
puts date_range.count{ |date| date.sunday? }
Range クラスには each
メソッドが実装されており,かつ Enumerable モジュールが include されています。
Enumerable
モジュールは,each
メソッドを持つものに対して,each
を用いたさまざまな便利なメソッドを提供するためのものです。
Enumerable#count
は,each
を使って要素を一つずつ取り出してブロックを評価し,その評価値(いわゆる「ブロックの返り値」)が真であるものの数を数えるメソッドです。
これで,動作が理解できたことになりますが,さらに短く書くこともできます。
もし,
p [1, 4, 9].map(&:to_s) # => ["1", "4", "9"]
のようなコードに馴染みがあるなら,
require "date"
date_range = Date.new(2022, 1, 1)..Date.new(2022, 12, 31)
puts date_range.count(&:sunday?)
と書けることに気づくでしょう。
この書き換えは,単に字数が減ったというよりは簡潔になったのです。好みはあるかもしれませんが,思考の経済の面でこちらのほうが望ましいと私は思います。
ただし,なぜこのような書き方ができるかを理解するためには,少しく Ruby の学習が必要です。
一言で表現するなら,「メソッド呼び出しにおいてブロックを与える代わりにシンボルオブジェクトを &
付きで与えると,Symbol#to_proc によって Proc オブジェクトが作られ,それがブロックとしてメソッドに与えられる」となります。
余録(2021/12/31 追記)
いちいち全日付の Date オブジェクトを生成しないで計算で求めたらどうでしょうか?
まず,2022 年の日数ですが,これは,2023 年の元旦と 2022 年の元旦の差演算(つまり Date#-)で得られます:
require "date"
first_day = Date.new(2022, 1, 1)
days = first_day.next_year - first_day
この値(days
)はどういうわけか Integer オブジェクトではなく,分母が 1 の Rational オブジェクトです。
次に,この日数の中に日曜日がいくつ入っているかを考えます。
もしも一年が日曜始まりだったら話は簡単で,日数を 7 で割って切り上げればいいですね。
つまり
days.quo(7).ceil
となります。
Numeric#quo というメソッドはあまり馴染みがないかもしれませんが,除数・被除数のクラスに関わらず(整商ではない)商を返すメソッドです。
/
が除数・被除数のクラスによって商だったり整商だったりするのとは違うわけですね。
もっとも,先ほど述べたように days
は Integer ではなく Rational なので,
(days / 7).ceil
と書いても結果は変わりません。しかし,quo
を使ったほうが(整商ではなく)商であることが明確になっていいと思います。
次に,一年が日曜始まりとは限らない一般の場合を考えます。
このときは,一年の日数から「最初の日曜が出てくるより前の日数」を差し引いてやればいいだけですね。
差し引く日数は以下のようになります。あとのために,Date オブジェクトの曜日を表すメソッド wday
の値も併記しました。
元旦の曜日 | 差し引く日数 | wday |
---|---|---|
日曜 | 0 | 0 |
月曜 | 6 | 1 |
火曜 | 5 | 2 |
水曜 | 4 | 3 |
木曜 | 3 | 4 |
金曜 | 2 | 5 |
土曜 | 1 | 6 |
となります。
元旦の日付を first_day
とすると,
(7 - first_day.wday) % 7
を差し引けばよいと分かります。
Ruby の,負数に対する %
の振る舞いをきちんと理解していれば,これは
-first_day.wday % 7
と書いても構わないことが分かりますが,まあ前者のほうが分かりやすいかと思います(他言語から来た方を惑わせませんし)。
以上をまとめると
require "date"
first_day = Date.new(2022, 1, 1)
days = first_day.next_year - first_day
puts (days - (7 - first_day.wday) % 7).quo(7).ceil
となります。