プログラマ歴 == Ruby歴 == 6ヶ月 の若輩者です。
業務でのRubyコードの実装に周囲よりも多くの時間がかかっていることにもどかしさを感じています。そこで、演習を通じて実装スピードを上げたいと思い、前々から気になっていたアウトプットのネタに困ったらこれ!?Ruby初心者向けのプログラミング問題を集めてみた(全10問)の記事に掲載されている問題をを解いてリファクタリングしました。
結果的に、RubyのAPIへの習熟と簡易的ななアルゴリズムの勉強ができとても有益でしたので、学習のメモをまとめます。
はじめに自力で解いたコードと次にリファクタリング後のコードを載せ、リファクタリングのポイントを解説します。
(参考にできるコードが見当たらなかった場合は、リファクタリングせず自分のコードだけを載せています。)
「ここはもっとこうしたほうが良い」などのご指摘お待ちしております。
環境
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.15.4
BuildVersion: 19E266
$ ruby -v
ruby 2.7.0p0 (2019-12-25 revision 647ee6f091) [x86_64-darwin19]
1.カレンダー作成問題
自分で解いたコード
# frozen_string_literal: true
require 'date'
today = Date.today
year = today.year
month = today.month
next_month = today.month + 1
start_date = Date.new(year, month, 1)
end_date = Date.new(qyear, next_month, 1) - 1
puts today.strftime('%B %Y').center(20)
puts 'Su Mo Tu We Th Fr Sa'
(start_date..end_date).each_with_object(Array.new(7, ' ')) do |date, result|
result[date.wday] = date.day.to_s.rjust(2, ' ')
if date.day == end_date.day
puts result[0..date.wday].join(' ')
elsif date.wday == 6
puts result.join(' ')
end
end
リファクタリング後のコード
# frozen_string_literal: true
require 'date'
class CalendarData
def initialize(date)
@date = date
end
def output
header + "\n" + body
end
private
def header
sun_to_sat = 'Su Mo Tu We Th Fr Sa'
year_and_month = @date.strftime('%B %Y').center(sun_to_sat.size)
sun_to_sat + "\n" + year_and_month
end
def body
weeks_in_month.map { |week| week.join(' ') }.join("\n")
end
def weeks_in_month
week = 0
dates_in_month.each_with_object([]) do |date, result|
if result.empty?
result << Array.new(7, ' ')
elsif date.sunday?
result << []
week += 1
end
result[week][date.wday] = date.day.to_s.rjust(2, ' ')
end
end
def dates_in_month
(start_date..end_date)
end
def start_date
Date.new(year, month, 1)
end
def end_date
start_date.next_month.prev_day
end
def year
@date.year
end
def month
@date.month
end
end
リファクタリングのポイント
Dateクラスのメソッドを使うようにした
DateDate#next_monthやDate#prev_day, Date#sunday?など多くのメソッドを知り、便利だったので導入しました。
クラスとメソッドを定義した
きみたちは今まで何のためにRailsでMVCパターンを勉強してきたのかの記事に
ロジック本体と画面出力をきちんと分離すべし
とあるように、ロジック本体と画面の出力を分離しました。
また、再利用性を考えてクラスとメソッドを定義しました。
参考
2.カラオケマシン問題
自分で解いたコード
# frozen_string_literal: true
class KaraokeMachine
def initialize(melody)
@melody = melody
end
MELODIES = %w[C C# D D# E F F# G G# A A# B].freeze
def transpose(number)
@melody.split('').map { |code|
code.match?(/\w/) ? adjust_melody_line(code, number) : code
}.join('')
end
def adjust_melody_line(code, number)
MELODIES[new_index(code, number)]
end
def new_index(code, number)
original_index = MELODIES.index(code)
(original_index + number).modulo(MELODIES.size)
end
end
リファクタリング後のコード
# frozen_string_literal: true
class KaraokeMachine
def initialize(melody)
@melody = melody
end
MELODIES = %w[C C# D D# E F F# G G# A A# B].freeze
def transpose(number)
@melody.gsub(/[A-G]#?/){ |code| MELODIES[new_index(code, number)] }
end
def new_index(code, number)
(MELODIES.index(code) + number) % MELODIES.size
end
end
リファクタリングのポイント
文字列の置換をgsubメソッドを使うようにした
String#gsubならば、文字列をそのまま置換できます。
わざわざRegexp#match?を使って配列にしたあとにjoinして文字列に整形し直すというのは、冗長でしたね。
剰余を%を使って求めるようにした
好みの問題かもしれませんが、剰余はNumeric#moduloよりもNumeric#%のほうが直感的にわかりやすいと思ったので修正しました。
参考
3.ビンゴカード作成問題
自分で解いたコード
class Bingo
def output
header + "\n" + body
end
def header
' B | I | N | G | O'
end
def body
lines.map{ |line|
line.join(' | ')
}.join("\n")
end
def lines
[(1..15), (16..30), (31..45), (46..60), (61..75)].map{ |numbers|
column = numbers.map{ |number| number.to_s.rjust(2, ' ') }.sample(5)
column[2] = ' 'if numbers == (31..45)
column
}.transpose
end
end
リファクタリング後のコード
class Bingo
BING_NUMBERS = (1..75)
def output
header + "\n" + body
end
def header
'BINGO'.chars.join(' | ')
end
def body
lines.map { |line|
line.map{ |number| number.to_s.rjust(2) }.join(' | ')
}.join("\n")
end
def lines
BING_NUMBERS.each_slice(15)
.map { |numbers| numbers.sample(5) }
.tap { |table| table[2][2] = '' }
.transpose
end
end
リファクタリングのポイント
15ずつの値の取得をeach_sliceを使うようにした
1から75までの15区切りのオブジェクトをEnumerable#each_sliceを使って実装しました。
二次元配列の値の書き換えをtapを使うようにした
Object#tapでオブジェクトの値を書き換えるのってエレガントですよね。業務のコードでも見かけるのですが、私は書いたことがなかったですので、良い機会だと思いtapを使いました。
参考
4.ボーナスドリンク問題
自分で解いたコード
class BonusDrink
def output(number)
number + bonus(number)
end
def bonus(number)
div, mod = number.divmod(3)
return 0 if div.zero?
div + bonus(div + mod)
end
end
# puts BonusDrink.new.output(100)
# -> 149
ポイント
- 再帰関数を使った
単語だけ聞いた頃あるレベルの再帰関数
を使って解答できそうだとの直感を得たので、再帰関数について調べて実装してみました。シンプルなコードをかけたのでなかなか嬉しいです。
また、数学的な考えを使って解かれているている方がもいらっしゃるようでした。
参考
5.電話帳作成問題
自分で解いたコード
class NameIndex
def initialize(names)
@names = names
end
INDEXES = {
'ア' => ['ア', 'イ', 'ウ', 'エ', 'オ'],
'カ' => ['カ', 'キ', 'ク', 'ケ', 'コ'],
'ハ' => ['ハ', 'ヒ', 'フ', 'ヘ', 'ホ', 'バ', 'ビ', 'ブ', 'ベ', 'ボ'],
'ワ' => ['ワ', 'オ', 'ン'],
}
def output
return [] if @names.empty?
@names.each_with_object({}){ |name, result|
INDEXES.each do |index, list|
if list.include?(name[0])
result[index] = [] unless result.key?(index)
result[index] << name
result[index].sort!
break
end
end
}.to_a.map.sort
end
end
リファクタリング後のコード
class NameIndex
def initialize(names)
@names = names
end
INDEXES = {
'ア' => ['ア', 'イ', 'ウ', 'エ', 'オ'],
'カ' => ['カ', 'キ', 'ク', 'ケ', 'コ'],
'ハ' => ['ハ', 'ヒ', 'フ', 'ヘ', 'ホ', 'バ', 'ビ', 'ブ', 'ベ', 'ボ'],
'ワ' => ['ワ', 'オ', 'ン'],
}
def output
return [] if @names.empty?
@names.sort.each_with_object({}){ |name, result|
index, _value = INDEXES.find { |index, value| value.include?(name[0]) }
result[index] = [] unless result.key?(index)
result[index] << name
}.to_a
end
end
リファクタリングのポイント
ソートの処理をひとまとめにした
自分で解いたときは、each_with_objectのブロック内とブロック外で1回ずつソートの処理をしてしましたが、each_with_objectの前にソート処理を挿入することでソート処理を1回で済むように修正しました。
インデックスの取得
ハッシュのキーに一致するバリューを取得するのにINDEXES
の繰り返しを処理をしていましたがこれでは無駄な処理が発生してしまっています。
Enumerable#detectを使って一致するキーを探してindex
を取得できるようにしました。
参考
6.国民の祝日.csv パースプログラム
自分で解いたコード
require 'csv'
class HolidayParser
def self.parse
csv = CSV.read('holiday.csv')
csv.each_with_object({ 2016 => {}, 2017 => {}, 2018 => {} }).with_index(1) { |(row, result), index|
data = row.uniq
next unless (3..18).cover?(index)
result[2016][data[1]] = data[0]
result[2017][data[2]] = data[0]
result[2018][data[3]] = data[0]
}
end
end
リファクタリング後のコード
require 'csv'
class HolidayParser
CSV_PATH = File.expand_path('../holiday.csv', __FILE__)
HOLIDAY_INDEX = 0
YEARS_INDES = { '2016' => 1, '2017' => 2, '2018' => 3 }
HOLIDAY_ROW_RANGE = (3..18)
def self.parse(csv_path = CSV_PATH)
self.new.parse(csv_path)
end
def parse(csv_path)
generate_csv(csv_path).each_with_object({ 2016 => {}, 2017 => {}, 2018 => {} }).with_index(1) { |(row, result), index|
data = row.uniq
next unless HOLIDAY_ROW_RANGE.cover?(index)
result[2016][data[YEARS_INDES['2016']]] = data[HOLIDAY_INDEX]
result[2017][data[YEARS_INDES['2017']]] = data[HOLIDAY_INDEX]
result[2018][data[YEARS_INDES['2018']]] = data[HOLIDAY_INDEX]
}
end
private
def generate_csv(csv_path)
CSV.read(csv_path)
end
end
リファクタリングのポイント
マジックナンバーを定数化した
csvファイルのパスをCSV_PATH
として、祝日が入っている列数をHOLIDAY_INDEX
として、マジックナンバーとして利用されていた値を定数に置き換えました。
マジックナンバーはコードをスムーズに読むことを阻害する要因になるので使わないようにしたほうが良いですよね。
とはいえ、2016~2018
の数値リテラルが使用されているため、csvファイルの年度がかわると動かなくなってしまうプログラムです。
まだ改良の余地がありますね。
参考
7.「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」問題
自分で解いたコード
自分では歯が立たず写経しました。
単語を抜き出すだけなら出来そうでしたが、熟語を抜き出すというのが難しかったです。
class WordExtracter
TEXT_PATH = File.expand_path('../input.txt', __FILE__)
def output
text = File.read(TEXT_PATH)
words = count_words(text)
compound_words, single_words = words.partition{ |word, _| word.include?(' ') }
output_result(single_words, compound_words)
end
private
def count_words(text)
word_char = '[\w’\/-]'
compound_words = /[A-Z]#{word_char}*(?: of| [A-Z]#{word_char}*)+/
words = /#{word_char}+/
regex = Regexp.union(compound_words, words)
text.scan(regex).each_with_object(Hash.new(0)) { |word, count_table|
count_table[word] += 1
}
end
def output_result(single_words, compound_words)
word_count = single_words.inject(0) { |sum, (_, count)| sum + count }
puts "単語数(熟語以外):#{word_count}"
output_words(compound_words, '英熟語?')
output_words(single_words, '英単語')
end
def extract
input_text.join
end
def output_words(count_table, header)
puts "#{header}------------------------------------------------------------------"
sorted_table = count_table.sort_by { |word, count| [-count, word.downcase] }
sorted_table.each do |word, count|
puts '%d %s' %[count, word]
end
end
end
参考
8.行単位、列単位で合計値を求
自分で解いたコード
class QueueCalculator
INPUT = [
[9, 85, 92, 20],
[68, 25, 80, 55],
[43, 96, 71, 73],
[43, 19, 20, 87],
[95, 66, 73, 62]
]
def self.output
self.new.calculate
end
def calculate
format(calculation)
end
private
def format(matrix)
matrix.map { |row| row.join('| ') }.join("\n")
end
def calculation
INPUT.map { |numbers|
numbers << numbers.sum
}.transpose.map { |numbers|
numbers << numbers.sum
}.transpose
end
end
ポイント
行列の入れ換え
Array#transposeを使って行列を入れ換えてうまく計算できました。
参考
9.ガラケー文字入力問題
自分で解いたコード
class KeitaiMessage
CHARACTERS = {
'1' => ['.', ',', '!', '?', ' ' ],
'2' => ['a', 'b', 'c'],
'3' => ['d', 'e', 'f'],
'4' => ['g', 'h', 'i'],
'5' => ['j', 'k', 'l'],
'6' => ['m', 'n', 'o'],
'7' => ['p', 'q', 'r', 's'],
'8' => ['t', 'u', 'v'],
'9' => ['w', 'x', 'y', 'z'],
}
def initialize(number)
@number = number
end
def output
@number.scan(/[1-9]+/).map { |numbers| CHARACTERS[numbers[0]].rotate(numbers.size - 1).first }.join
end
end
ポイント
正規表現とArray#rotateを使ってシンプルに実装できました。
参考
10.値札分割問題
自分で解いたコード
class Price
def initialize(price)
@price = price
end
def split_price
return ['', ''] unless @price
number = @price.scan(/[0-90-9+|.|,| |\-|価格未定|]/).join
[number, @price.delete(number).delete('-')]
end
end
リファクタリング後のコード
class Price
def initialize(price)
@price = price
end
def split_price
@price.match(/([^万円]*)(.*)/)[1..2]
end
end
リファクタリングのポイント
正規表現を使う
自分で解いたコードは、特殊なケースごとに個別に対応するような冗長な実装になってしまっていたので、参考記事をほぼ写経しました。
キャプチャ()
やメタ文字*をもっと使いこなせねばならないですね。
参考
- 【Rubyプログラミング問題】値札分割メソッド(split_price)を作成してください
- 【Rubyプログラミング問題】値札分割メソッド(split_price)を作成してください:解答編
- 初心者歓迎!手と目で覚える正規表現入門・その2「微妙な違いを許容しつつ置換しよう」
まとめ
全く歯が立たない問題は1問(「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」問題)だけでしたが、よりスマートな書き方を学ぶ良い機会になりました。
1問目から順に解き進めていましたが、後半に進むにつれ回答するまでのスピードが早くなり、コードは徐々に綺麗になってきているなと感じています。
また、私は正規表現が苦手だということがわかり新たな課題を見つけたので、正規表現を克服したいなと感じています。
よい練習問題をご提供いただいた@jnchitoさん、感謝いたします。