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

プログラマ歴 == Ruby歴 == 6ヶ月 の若輩者です。

業務でのRubyコードの実装に周囲よりも多くの時間がかかっていることにもどかしさを感じています。そこで、演習を通じて実装スピードを上げたいと思い、前々から気になっていた[アウトプットのネタに困ったらこれ！？Ruby初心者向けのプログラミング問題を集めてみた（全10問）](https://blog.jnito.com/entry/2019/05/03/121235)の記事に掲載されている問題をを解いてリファクタリングしました。


結果的に、RubyのAPIへの習熟と簡易的ななアルゴリズムの勉強ができとても有益でしたので、学習のメモをまとめます。

はじめに自力で解いたコードと次にリファクタリング後のコードを載せ、リファクタリングのポイントを解説します。
(参考にできるコードが見当たらなかった場合は、リファクタリングせず自分のコードだけを載せています。)

「ここはもっとこうしたほうが良い」などのご指摘お待ちしております｡

## 環境

```bash
$ 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.カレンダー作成問題

自分で解いたコード

```ruby
# 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
```

リファクタリング後のコード

```ruby
# 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パターンを勉強してきたのか](https://qiita.com/jnchito/items/e2b1c8904cd7b8e3ce50)の記事に

> ロジック本体と画面出力をきちんと分離すべし

とあるように、ロジック本体と画面の出力を分離しました。
また、再利用性を考えてクラスとメソッドを定義しました。

### 参考
- [カレンダー整形問題を色々なパターンで解いてみた](https://blog.jnito.com/entry/2013/05/01/082049)
- [class Date](https://docs.ruby-lang.org/ja/latest/class/Date.html)
- [String#center](https://docs.ruby-lang.org/ja/latest/method/String/i/center.html)
- [Enumerable#each_with_object](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_with_object.html)


## 2.カラオケマシン問題
自分で解いたコード

```ruby
# 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
```

リファクタリング後のコード

```ruby
# 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](https://docs.ruby-lang.org/ja/2.7.0/method/String/i/gsub.html)ならば、文字列をそのまま置換できます。
わざわざ[Regexp#match?](https://docs.ruby-lang.org/ja/2.7.0/method/Regexp/i/match=3f.html)を使って配列にしたあとにjoinして文字列に整形し直すというのは、冗長でしたね。

#### 剰余を%を使って求めるようにした

好みの問題かもしれませんが、剰余はNumeric#moduloよりも[Numeric#%](https://docs.ruby-lang.org/ja/latest/method/Numeric/i/=25.html)のほうが直感的にわかりやすいと思ったので修正しました。

### 参考
- [2014年7月8日にCodeIQに出題した「カラオケマシン問題」の優秀賞を発表します！ #sgstudy](https://blog.jnito.com/entry/2014/07/02/065550)
- [String#gsub](https://docs.ruby-lang.org/ja/2.7.0/method/String/i/gsub.html)


## 3.ビンゴカード作成問題
自分で解いたコード

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

リファクタリング後のコード

```ruby
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](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_slice.html)を使って実装しました。

#### 二次元配列の値の書き換えをtapを使うようにした

[Object#tap](https://docs.ruby-lang.org/ja/latest/method/Object/i/tap.html)でオブジェクトの値を書き換えるのってエレガントですよね。業務のコードでも見かけるのですが、私は書いたことがなかったですので、良い機会だと思いtapを使いました。

### 参考
- [Ruby -「ビンゴカード作成問題」の優秀作品ベスト3を発表します！ #codeiq](https://blog.jnito.com/entry/2015/03/06/085433)
- [Enumerable#each_slice](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/each_slice.html)
- [Object#tap](https://docs.ruby-lang.org/ja/latest/method/Object/i/tap.html)

## 4.ボーナスドリンク問題

自分で解いたコード

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

### ポイント

- 再帰関数を使った

単語だけ聞いた頃あるレベルの`再帰関数`を使って解答できそうだとの直感を得たので、再帰関数について調べて実装してみました。シンプルなコードをかけたのでなかなか嬉しいです。

また、数学的な考えを使って解かれているている方がもいらっしゃるようでした。

 ([ボーナスドリンク問題の解答](http://yucatio.hatenablog.com/entry/2019/05/07/225412))

### 参考
- [rubyで再帰関数](https://qiita.com/pokotyan/items/53e92a21805f651173aa)

## 5.電話帳作成問題

自分で解いたコード

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

リファクタリング後のコード

```ruby
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](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/detect.html)を使って一致するキーを探して`index`を取得できるようにしました。

### 参考
- [電話帳作成問題の解答](http://yucatio.hatenablog.com/entry/2019/05/19/225005)
- [Enumerable#detect](https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/detect.html)

## 6.国民の祝日.csv パースプログラム

自分で解いたコード

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

リファクタリング後のコード

```ruby
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ファイルの年度がかわると動かなくなってしまうプログラムです。

まだ改良の余地がありますね。


### 参考
- [【短命に終わった】国民の祝日.csvをパースして変換するRubyプログラムとコード解説動画](https://qiita.com/jnchito/items/b8a2ed3544c1dc36fb9d)
- [[実装編]マジック・ナンバーを使ってはいけない](https://xtech.nikkei.com/it/article/COLUMN/20070820/279836/)


## 7.「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」問題
自分で解いたコード

自分では歯が立たず写経しました。
単語を抜き出すだけなら出来そうでしたが、熟語を抜き出すというのが難しかったです。


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


### 参考
- [「Rubyで英語記事に含まれてる英単語を数えて出現数順にソートする」をカッコよく書いてみた](https://qiita.com/jnchito/items/20cadad6ccb4f06574ea)

## 8.行単位、列単位で合計値を求

自分で解いたコード

```ruby
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](https://docs.ruby-lang.org/ja/latest/method/Array/i/transpose.html)を使って行列を入れ換えてうまく計算できました。

### 参考
- [「Ruby力向上のための基礎トレーニング」をテストコード付きで解いてみた](https://qiita.com/jnchito/items/2fedbfa9f887635e6c6a)
- [Array#transpose](https://docs.ruby-lang.org/ja/latest/method/Array/i/transpose.html)

## 9.ガラケー文字入力問題

自分で解いたコード

```ruby
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](https://docs.ruby-lang.org/ja/latest/method/Array/i/rotate.html)を使ってシンプルに実装できました。

### 参考
- [Array#rotate](https://docs.ruby-lang.org/ja/latest/method/Array/i/rotate.html)

## 10.値札分割問題
自分で解いたコード

```ruby
class Price
  def initialize(price)
    @price = price
  end

  def split_price
    return ['', ''] unless @price
    number = @price.scan(/[0-9０-９+|.|,| |\-|価格未定|]/).join
    [number, @price.delete(number).delete('-')]
  end
end

```

リファクタリング後のコード

```ruby
class Price
  def initialize(price)
    @price = price
  end

  def split_price
    @price.match(/([^万円]*)(.*)/)[1..2]
  end
end
```

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

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

### 参考
- [【Rubyプログラミング問題】値札分割メソッド(split_price)を作成してください](https://qiita.com/jnchito/items/2db66f9a5c7d55aed925)
- [【Rubyプログラミング問題】値札分割メソッド(split_price)を作成してください：解答編](https://qiita.com/jnchito/items/c460bff5aec851ed36f9)
- [初心者歓迎！手と目で覚える正規表現入門・その２「微妙な違いを許容しつつ置換しよう」](https://qiita.com/jnchito/items/64c3fdc53766ac6f2008)

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

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

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