はじめに
僕と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に置いています。
- 作者: 高橋征義,後藤裕蔵,まつもとゆきひろ
- 出版社/メーカー: ソフトバンククリエイティブ
- 発売日: 2010/03/31
- メディア: 単行本
- 購入: 15人 クリック: 394回
- この商品を含むブログ (79件) を見る
解答例では「万年カレンダーのロジック」を使って解いています。
ふつうのHaskellプログラミング ふつうのプログラマのための関数型言語入門
- 作者: 青木峰郎,山下伸夫
- 出版社/メーカー: ソフトバンククリエイティブ
- 発売日: 2006/06/01
- メディア: 単行本
- 購入: 25人 クリック: 314回
- この商品を含むブログ (320件) を見る
今回久しぶりにしっかり読み直しました。
Excel列名変換問題をRubyとPerlとC#とF#で書いてみた - give IT a try
以前、今回と同じように一つの問題をいろいろな言語で解いてみたことがあります。
このときに使った関数型言語はHaskellではなく、F#でした。