0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【初心者向け】Ruby のまずいコード 25 本Advent Calendar 2021

Day 19

【Ruby のまずいコード】2022 年の日曜日はいくつ?

Last updated at Posted at 2021-12-18

お題

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

となります。

0
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?