give IT a try

プログラミング、リモートワーク、田舎暮らし、音楽、etc.

カレンダー整形問題を色々なパターンで解いてみた

はじめに

僕とAkiさん(@spring_aki)で主催している西脇.rb & 東灘.rbでは、youRoom内でいろいろとRubyに関する雑談や情報交換をしています。


そんな中で参加メンバーの一人であるShimodaさん(@yuji_shimoda)がこんな問題を出題してきました。


たのしい Ruby 第2版 第17章 Time クラスと Date クラス 練習問題(3) - るびー めも

# 問題: 次のような形式でカレンダーを表示せよ。
      April 2013
 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

 

「みなさんならどう解きますか?」と聞かれたので、僕も解答例をちょっと考えてみました。


この手の問題はプログラミング初心者時代によく見た気がしますが、最近はご無沙汰していました。
「解けなかったらめっちゃカッコ悪いやん!!」と思いつつトライしてみました。


今回のエントリでは僕が書いたRubyのコード2種類と、試しにHaskellで書いてみたコードの合計3種類を紹介してみます。


あ、ちなみにこのエントリにはコードがたくさん出てくるので、スマホで見るのはちょっとしんどいと思います。
なので、できるだけPCの大きな画面で見ることをオススメします〜。


Ruby その1: 初期バージョン

最初に「とりあえず動けばOK」なコードとテストコード(RSpec)を書いて、その後どんどんリファクタリングしました。
そのコードがこちらになります。

require 'date'

class CalendarRenderer
  def initialize(year, month)
    @first_date = Date.new(year, month, 1)
  end

  def to_s
    calendar_rows.join("\n")
  end

  private

  def calendar_rows
    header_rows + body_rows
  end

  def header_rows
    sun_to_sat = "Su Mo Tu We Th Fr Sa"
    month_year = @first_date.strftime("%B %Y").center(sun_to_sat.size).rstrip
    [month_year, sun_to_sat]
  end

  def body_rows
    format_row = -> week {
      # if Rails => week.map {|date| date.try(:strftime, "%e") || "  " }.join(" ")
      week.map {|date| date.nil? ? "  " : date.strftime("%e") }.join(" ")
    }
    weeks_in_month.map {|week| format_row.call(week) }
  end

  def weeks_in_month
    dates_in_month.inject([]) {|weeks, date|
      weeks << [] if weeks.empty? or date.sunday?
      weeks.tap {|weeks| weeks.last[date.wday] = date }
    }
  end

  def dates_in_month
    # if Rails => (@first_date..@first_date.end_of_month).to_a
    last_date = @first_date.next_month.prev_day
    (@first_date..last_date).to_a
  end
end

 

ロジックの説明

2013年4月を例に挙げると、まず最初に4月1日から4月30日までのDateオブジェクトを配列に詰めます。(dates_in_monthメソッドの戻り値)

# dates_in_month =>
[Date<4/1>, Date<4/2>, ... Date<4/30>]

 

次にこんな感じの2次元配列を作ります。(weeks_in_monthメソッドの戻り値)

# weeks_in_month =>
[
  [nil, Date, Date, Date, Date, Date, Date],
  [Date, Date, Date, Date, Date, Date, Date],
  [Date, Date, Date, Date, Date, Date, Date],
  [Date, Date, Date, Date, Date, Date, Date],
  [Date, Date, Date]
]

 

その後、各行のデータを整形します。(body_rowsメソッドの戻り値)

# body_rows =>
[
  "    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"
]

 

さらに、ヘッダーとボディの配列をくっつけます。(calendar_rowsメソッドの戻り値)

# calendar_rows =>
[
  "     April 2013",
  "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"
]

 

で、最後に配列を改行文字でjoinすればできあがり、というロジックです。(to_sメソッドの戻り値)

# to_s =>
"     April 2013
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"

 

いきなり文字列を切ったり貼ったりするのもいいんですが、いったんModelを作ってから(weeks_in_monthメソッド)、それから見た目(View)を整形する(body_rows→calendar_rows→to_s)というのが、オブジェクト指向っぽいかな〜と思ってこんな実装にしてみました。


Ruby その2: 文字列ぶった切りバージョン

次にShimodaさんが「新しいロジックで書き直してみました」と言うのでチェックしたところ、それはそれでなかなか興味深いロジックでした。


そこで、そのロジックをベースにこんな感じで書き直してみました。

require 'date'

class CalendarRenderer
  DAY_LENGTH = 3
  WEEK_LENGTH = DAY_LENGTH * 7

  def initialize(year, month)
    @first_date = Date.new(year, month, 1)
  end

  def to_s
    (header_rows + body_rows).join("\n")
  end

  private

  def body_rows
    split_pattern = /.{#{DAY_LENGTH},#{WEEK_LENGTH}}/
    body_text.scan(split_pattern)
  end

  def body_text
    first_week_offset + calendar_text
  end

  def first_week_offset
    ' ' * @first_date.wday * DAY_LENGTH
  end

  def calendar_text
    last_day = @first_date.next_month.prev_day.day
    rjust_all 1..last_day
  end

  def header_rows
    [month_year, sun_to_sat]
  end

  def month_year
    indent_length = 1
    @first_date.strftime("%B %Y").center(WEEK_LENGTH + indent_length).rstrip
  end

  def sun_to_sat
    rjust_all %w(Su Mo Tu We Th Fr Sa)
  end

  def rjust_all(enum)
    enum.to_a.map{|e| e.to_s.rjust(DAY_LENGTH) }.join
  end
end

 

ロジックの説明

2013年4月であれば、まず次のように1から30までの一行の文字列を作ります。なお、各値は3桁右詰めになっています。(calendar_textメソッドの戻り値)

# calendar_text =>
"  1  2  3 ... 29 30"

 

次に最初の週の開始位置をずらすための空白文字列を用意します。2013年4月であれば月曜日始まりなので、3文字 × 1日ぶん = 3文字の空白を作ります。(first_week_offsetメソッドの戻り値)

# first_week_offset =>
"   "

 

上の二つの文字列をつなげて、一行の文字列にします。(body_textメソッドの戻り値)

# body_text =>
"     1  2  3 ... 29 30"

 

で、その文字列を21文字(3文字 × 7日)ずつちょん切っていきます。(body_rowsメソッドの戻り値)

# body_rows =>
["    1  2  3  4  5  6", " 7  8 ... 26 27", " 28 29 30"]

 

ここまで来ればあとは最初のロジックと同じで、ヘッダーとボディをつなげてjoinすればおしまいです。


ただ、このロジックでちょっと気になるのは、カレンダーの左側に空白文字が一つ入ってしまうことです。
個人的には無用なインデントが入らない方が好みです。

# 最初のロジック
     April 2013
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

# 2つ目のロジック
      April 2013
 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

 

Haskellバージョン

ところで、Rubyで書いた2つ目のロジックはなんとなく関数型言語向きな気がする、と思ってHaskellで書いてみることにしました。


とりあえず、僕はHaskellは全くの初心者です。
ふつうのHaskellプログラミング」を読んだことはありますが、自分でコードを書いたことはありませんでした。


そんな僕が必死こいて書いてみた最初のバージョンがこちら。
ロジックは基本的に「Ruby その2: 文字列ぶった切りバージョン」と同じです。

module CalendarRenderer (renderCalendar) where

import qualified Data.List.Split as L (chunksOf)
import Data.Time (formatTime, fromGregorian, gregorianMonthLength)
import Data.Time.Calendar.WeekDate (toWeekDate)
import qualified Data.Text as T (pack, unpack, center)
import System.Locale (defaultTimeLocale)
 
rjust :: Int -> String -> String
rjust width s = replicate (width - length s) ' ' ++ s

renderCalendar :: Integer -> Int -> String
renderCalendar y m = do
  let dayLength = 3
  let weekLength = dayLength * 7
  let monthYear = T.unpack $ T.center weekLength ' ' $ T.pack $ formatTime defaultTimeLocale "%B %Y" $ fromGregorian y m 1
  let header = [monthYear, " Su Mo Tu We Th Fr Sa"]
  let (_,_,wday) = toWeekDate $ fromGregorian y m 1
  let offset = replicate wday "   "
  let calendar = map (rjust dayLength) $ map show $ take (gregorianMonthLength y m) [1..]
  let body = map concat $ L.chunksOf 7 $ concat [offset, calendar]
  unlines $ concat [header, body]

 

うーむ、よくわからないけど、なんとなく「イケてない感」がぷんぷんと漂ってきます。


というわけで、本やネットを調べてリファクタリングしたのがこちらです。

module CalendarRenderer (renderCalendar) where

import qualified Data.List.Split as L (chunksOf)
import qualified Data.Text as T (pack, unpack, center, justifyRight)
import Data.Time (formatTime, fromGregorian, gregorianMonthLength, toGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
import System.Locale (defaultTimeLocale)

dayLength = 3
weekLength = dayLength * 7

renderCalendar year month = do
  let day = fromGregorian year month 1
  unlines $ header day ++ body day
 
header day = do
  let monthYear = center $ formatTime defaultTimeLocale "%B %Y" day
  let sun_to_sat = concat $ map rjust ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]
  [monthYear, sun_to_sat]

body day = map concat $ L.chunksOf 7 $ firstWeekOffset day ++ calendar day

calendar day = do
  let (year, month, _) = toGregorian day
  map (rjust . show) [1..gregorianMonthLength year month]

firstWeekOffset day = do
  let (_, _, wday) = toWeekDate day
  let offsetLength = if wday == 7 then 0 else wday
  replicate offsetLength $ replicate dayLength ' '

center = applyTextFunction $ T.center weekLength ' '

rjust = applyTextFunction $ T.justifyRight dayLength ' '

applyTextFunction f = T.unpack . f . T.pack

 

関数が分割されて、多少は見やすくなったような気がします。
が、まだまだ「手続き型脳」を脱しきれていないんじゃない?と思ったりもします。


「関数型脳プログラマならこう書くんだ!!」というアドバイスがもしあれば、どうぞご教示くださいませ〜。


ちなみにHaskellでコードを書く時もHSpecというRSpecライクなテストツールを使いました。
やっぱりリファクタリングをガンガンやっていくためにはテストツールは必須ですね。


まとめ: カレンダー整形問題は練習問題にちょうどいい!

このカレンダー整形問題、プログラミング言語の勉強をするにはいろんな意味でちょうどいい問題かも、と思いました。


まず、簡単すぎず、難しすぎずなので、僕がHaskellにチャレンジしたように、新しい言語を習得しようとするときに書いてみると良い勉強になると思います。
文字列や日付、リスト(配列)等の処理、関数の分割、テストコードの書き方等々、基本的なことをいろいろとカバーしています。


また、初心者は初心者なりの、上級者は上級者なりの書き方ができるので、FizzBuzz問題にみたいに「誰が書いてもほとんど同じ」という結果にはならないはずです。
ロジック自体、いろいろなアプローチがあると思うので、他人のコードを見ると「なるほど、そういう手もあるのか」と勉強になると思います。


問題も複雑すぎないので、コードがあまり長くなりません。
なので他の人が書いたコードを読む時も、比較的短時間で読むことができます。


そんなわけで、みなさんも是非いろんな言語でカレンダー整形問題を解いてみてください。
一人で解くよりも、会社の同僚や勉強会コミュニティのメンバーと一緒に解いてみた方が、より面白みが増すはずです!


あわせて読みたい

西脇.rb & 東灘.rb 第3回 合同もくもく会の案内とコミュニティで使っているSNSツールを紹介します #nshgrb - give IT a try
西脇.rb & 東灘.rbでは月一回ペースでもくもく会を開催しています。
西脇〜神戸近辺でRubyに興味がある方は参加してみてくださーい。


GitHub - Ruby その1: 初期バージョン
GitHub - Ruby その2: 文字列ぶった切りバージョン
GitHub - Haskellバージョン
今回書いたコードはGitHubに置いています。


たのしいRuby 第3版

たのしいRuby 第3版

  • 作者: 高橋征義,後藤裕蔵,まつもとゆきひろ
  • 出版社/メーカー: ソフトバンククリエイティブ
  • 発売日: 2010/03/31
  • メディア: 単行本
  • 購入: 15人 クリック: 394回
  • この商品を含むブログ (79件) を見る
今回の問題はこの本の練習問題でした。
解答例では「万年カレンダーのロジック」を使って解いています。


ふつうのHaskellプログラミング ふつうのプログラマのための関数型言語入門

ふつうのHaskellプログラミング ふつうのプログラマのための関数型言語入門

僕が数年前に読んだ本です。
今回久しぶりにしっかり読み直しました。


Excel列名変換問題をRubyとPerlとC#とF#で書いてみた - give IT a try
以前、今回と同じように一つの問題をいろいろな言語で解いてみたことがあります。
このときに使った関数型言語はHaskellではなく、F#でした。