LoginSignup
7
7

More than 1 year has passed since last update.

「アウトプットのネタに困ったらこれ!?Ruby初心者向けのプログラミング問題」を自力で解いてリファクタリングした

Last updated at Posted at 2020-04-11

プログラマ歴 == 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

リファクタリングのポイント

正規表現を使う

自分で解いたコードは、特殊なケースごとに個別に対応するような冗長な実装になってしまっていたので、参考記事をほぼ写経しました。
キャプチャ()やメタ文字*をもっと使いこなせねばならないですね。

参考

まとめ

全く歯が立たない問題は1問(「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」問題)だけでしたが、よりスマートな書き方を学ぶ良い機会になりました。
1問目から順に解き進めていましたが、後半に進むにつれ回答するまでのスピードが早くなり、コードは徐々に綺麗になってきているなと感じています。

また、私は正規表現が苦手だということがわかり新たな課題を見つけたので、正規表現を克服したいなと感じています。

よい練習問題をご提供いただいた@jnchitoさん、感謝いたします。

7
7
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
7
7