Rubyでリファクタリングをやってみよう
これは「フィヨルドブートキャンプ Part 1 Advent Calendar 2020」の3日目の記事です。
昨日はべこさんの
日報に書いたイラストたち #fjordbootcamp - 仕事が趣味です
でした!
イラストがとっても可愛いかつ、わかりやすいです!
Part2 もあります。
フィヨルドブートキャンプ Part 2 Advent Calendar 2020 - Adventar
フィヨルドブートキャンプのプラクティスの進捗も終盤に差しかかってきたので、本記事では学習を振り返って一番楽しかったRubyでリファクタリングする話を書きます。
リファクタリングとは
そもそもリファクタリングとは何か?ですが、リファクタリング 既存のコードを安全に改善する(第2版)には以下のように定義されています。
外部から見たときの振る舞いを保ちつつ、理解や修正が簡単になるように、ソフトウェアの内部構造を変化させること
つまり、機能が変わるような変更はリファクタリングとは呼びません。
ユーザーから見てシステムの振る舞いが変わるからです。
じゃあ具体的にリファクタリングって何をすればいいのかというと、それを学ぶ手法は大きく分けて以下の2つになると思っています(あくまで個人の考えなので他にもあるかも)。
- 公式リファレンスなどを読んでこんな書き方できそうと自分で考えてみる
- ブログや技術書などから先人たちが過去に実践しているリファクタリングの手法を知り、それを真似してみる
リファクタリングのやり方を知って既存のコードに適用し、コードの構造が改善されることによって他の人にとっても理解しやすく変更に強いコードになります。
もちろんリファクタリングを行う本人にとっても、カオスなコードをリファクタリングしてスッキリした瞬間はとても快感なのではと思います。
この記事では私がフィヨルドブートキャンプで学習し、活用したリファクタリング手法の一部を紹介します。
これを読んで少しでもリファクタリングやってみたいと思う方が増えたら幸いです。
いざ、リファクタリング
では、早速以下のコードをリファクタリングしていきます。
これは指定した期間の映画チケットの売上を算出するサンプルプログラムです。
Purchaserクラスでチケット購入者の情報、Salesクラスでチケットの売上を管理しています。
Salesクラスのcalcメソッドで指定した期間の売上を算出していますが、このメソッドでリファクタリングできそうな箇所がいくつかあります。
class Purchaser
attr_reader :section, :purchased_at
def initialize(section:, purchased_at:)
@section = section # 購入者の区分(こども、おとな、シニア)
@purchased_at = purchased_at
end
end
class Sales
def initialize(purchasers, start_date, end_date)
@purchasers = purchasers
@start_date = start_date
@end_date = end_date
end
def calc
# start_dateからend_dateの期間内に購入されたデータを集める
target_purchasers = []
@purchasers.each do |purchaser|
target_purchasers << purchaser if purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# 集めたデータの購入金額の合計を算出する
sales = 0
target_purchasers.each do |purchaser|
# 購入者の区分(こども、おとな、シニア)によって購入金額を決定する
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
end
テストコードは以下のとおりです。
リファクタリングしても振る舞いが変わらないことを保証するためには、リファクタリングする度にテストが通ることを確認するのが良さそうです。
人の目で確認できなくはないのですが確認漏れが発生する可能性があるので、テストコードがあったほうが安心してリファクタリングに取り組むことができます。
require "date"
class SalesTest < Minitest::Test
def test_calc_sales
# 11月1日から11月30日までの売上を算出するテスト
purchasers = []
purchasers << Purchaser.new(section: :child, purchased_at: Date.new(2020, 11, 16))
purchasers << Purchaser.new(section: :adult, purchased_at: Date.new(2020, 11, 24))
purchasers << Purchaser.new(section: :senior, purchased_at: Date.new(2020, 11, 30))
purchasers << Purchaser.new(section: :adult, purchased_at: Date.new(2020, 11, 9))
# 以下のデータだけ期間外なので集計されない
purchasers << Purchaser.new(section: :adult, purchased_at: Date.new(2020, 10, 31))
sales = Sales.new(purchasers, Date.new(2020, 11, 1), Date.new(2020, 11, 30)).calc
assert_equal 5700, sales
end
end
Rubyの便利メソッドを活用する
Rubyには配列の操作に関する様々な便利メソッドが揃っており、その使い方を理解することでできるリファクタリング手法があります。
例えばcalcメソッドの以下のコードではtarget_purchasers
という配列を用意してから、ifで指定した条件に一致するデータをこの配列に入れています。
class Sales
# ...
def calc
target_purchasers = []
@purchasers.each do |purchaser|
# 指定された期間内に購入されたデータをtarget_purchasersに保存
target_purchasers << purchaser if purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# ...
end
target_purchasers = []
とわざわざ書かなくてもeachの代わりにmapを使えば以下のように書くことができます。
mapはブロックで評価した値すべてを配列で返すメソッドです。
def calc
target_purchasers = @purchasers.map do |purchaser|
purchaser if purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# ...
end
mapはブロックで評価した値をそのまま返すので、if文の条件に一致しない場合はnilを返します。
今回はif文の条件に一致したデータだけが欲しいので、Ruby2.7で追加されたfilter_mapメソッドを使います。
filter_mapメソッドはブロックで評価した値のうち、真になった値を返すメソッドです。
(2020/12/10追記)filter_mapメソッドを使っていましたが、この場合はブロックの中でpurchaserに対する操作を行わず真になった値をそのまま返すので、selectを使ったほうが良さそうです。
selectの公式リファレンス
filter_mapの公式リファレンス
def calc
target_purchasers = @purchasers.select do |purchaser|
purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# ...
end
さらに、if文の日付範囲内かどうかの確認はcover?メソッドを使うともっと簡潔に書くことができます。
def calc
target_purchasers = @purchasers.select do |purchaser|
(@start_date..@end_date).cover?(purchaser.purchased_at)
end
# ...
end
ちなみにRubyやRailsでリファクタリングに使えそうな便利メソッドについては、フィヨルドブートキャンプのメンターの伊藤さんも以下の記事で解説されています。
[初心者向け] RubyやRailsでリファクタリングに使えそうなイディオムとか便利メソッドとか
eachではなくmapなどのコレクション系メソッドを使うという点に関してはこちらの記事で詳しく解説されています。
Ruby: eachよりもmapなどのコレクションを積極的に使おう(社内勉強会)|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社
変更後のコードは変更前に比べて読みやすくはなっています。しかし、calcメソッドにはまだまだリファクタリングできそうな箇所があります。
変更前
def calc
target_purchasers = []
@purchasers.each do |purchaser|
target_purchasers << purchaser if purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# ...
end
変更後
def calc
target_purchasers = @purchasers.select do |purchaser|
(@start_date..@end_date).cover?(purchaser.purchased_at)
end
# ...
end
1メソッドに与える仕事は1つにする
改めてcalcメソッド全体を眺めてみると行数が20行近くあります。
このメソッドは、「指定した日付範囲のデータを集める」、「集めたデータの売上を算出する」という二つの仕事をしており、仮に「指定した日付範囲のデータを集める」処理だけ別の箇所で使いたいとなっても再利用することができません。
また、メソッドが長ければ長いほど処理の流れを一から読んでいかなければならなくなるので、コードを追うのがつらくなります。
def calc
# データを集める
target_purchasers = @purchasers.select do |purchaser|
purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
# 売上を算出する
sales = 0
target_purchasers.each do |purchaser|
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
そうならないために、データを集める処理をprivateメソッドに切り出します。
def calc
sales = 0
target_purchasers.each do |purchaser|
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
private
def target_purchasers
@purchasers.select do |purchaser|
(@start_date..@end_date).cover?(purchaser.purchased_at)
end
end
それから、target_purchasers
という名前が指すものが曖昧なので、メソッド名をpurchasers_within_date
に変更します。
こうすればpurchasers_within_date
メソッドの中身を一から追わなくても、日付範囲内の購入者データを取得するメソッドであることがメソッド名から推測できるようになります。
def calc
sales = 0
purchasers_within_date.each do |purchaser|
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
private
def purchasers_within_date
@purchasers.select do |purchaser|
(@start_date..@end_date).cover?(purchaser.purchased_at)
end
end
メソッドを切り出したことで変更前のコードに比べてcalcメソッドがスリムになってきました。
ただ、まだ責務を切り分けられる点がありそうです。
データのまとまりをStructに切り出す
このcase文を使っているところが少し冗長な感じがします。いまは3つしか区分が無いので良いのですが、今後区分が増えていくとcase文がどんどん肥大化していきます。
def calc
sales = 0
purchasers_within_date.each do |purchaser|
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
RubyにはStructという構造体のクラスがあり、これを使うことで意味のあるデータのまとまりを定義することができます。
case文のsection(:childや:adult)と金額の組み合わせはデータのまとまりなので、これをStructで書き換えてみます。
# 区分(こども、おとな、シニア)と金額のデータ構造をStructで定義する
Section = Struct.new(:sym, :charge)
SECTION = [
Section.new(:child, 1000),
Section.new(:adult, 1800),
Section.new(:senior, 1100)
].each_with_object({}){ |section, h| h[section.sym] = section }.freeze
class Purchaser
attr_reader :section, :purchased_at
def initialize(section:, purchased_at:)
# 上で定義したStructをPurchaserクラスのインスタンス変数に代入する
# SECTIONの中身は以下のとおり
# => {:child=>#<struct Section sym=:child, charge=1000>, :adult=>#<struct Section sym=:adult, charge=1800>, :senior=>#<struct Section sym=:senior, charge=1100>}
# たとえばinitializeの引数のsectionに:childが指定されると、#<struct Section sym=:child, charge=1000>が@sectionに代入される
@section = SECTION[section]
@purchased_at = purchased_at
end
end
Purchaserクラスのインスタンス変数@section
にStructを代入したので、Salesクラスのcase文を削除し、sales += purchaser.section.charge
に書き換えます。
purchaser.section.charge
と書くことで、購入者の区分ごとの金額を表していることが伝わりやすくなりました。
class Sales
# ...
def calc
sales = 0
purchasers_within_date.each do |purchaser|
sales += purchaser.section.charge
end
sales
end
# ...
最後に、calcメソッドのeachをinjectに書き換えると、最初20行近くあったcalcメソッドを1行まで減らすことができました。
def calc
purchasers_within_date.inject(0) { |sum, purchaser| sum + purchaser.section.charge }
end
これでリファクタリングは完了です。
改めて変更前と変更後のコードを比較してみると、かなり見通しが良くなっていることがわかります。
行数的にはほぼ同じなのですが、変更後のほうがコードの意図が伝わりやすいです。
変更前
class Purchaser
attr_reader :section, :purchased_at
def initialize(section:, purchased_at:)
@section = section
@purchased_at = purchased_at
end
end
class Sales
def initialize(purchasers, start_date, end_date)
@purchasers = purchasers
@start_date = start_date
@end_date = end_date
end
def calc
target_purchasers = []
@purchasers.each do |purchaser|
target_purchasers << purchaser if purchaser.purchased_at >= @start_date && purchaser.purchased_at <= @end_date
end
sales = 0
target_purchasers.each do |purchaser|
sales += case purchaser.section
when :child
1000
when :adult
1800
when :senior
1100
end
end
sales
end
end
変更後
Section = Struct.new(:sym, :charge)
SECTION = [
Section.new(:child, 1000),
Section.new(:adult, 1800),
Section.new(:senior, 1100)
].each_with_object({}){ |section, h| h[section.sym] = section }.freeze
class Purchaser
attr_reader :section, :purchased_at
def initialize(section:, purchased_at:)
@section = SECTION[section]
@purchased_at = purchased_at
end
end
class Sales
def initialize(purchasers, start_date, end_date)
@purchasers = purchasers
@start_date = start_date
@end_date = end_date
end
def calc
purchasers_within_date.inject(0) { |sum, purchaser| sum + purchaser.section.charge }
end
private
def purchasers_within_date
@purchasers.select do |purchaser|
(@start_date..@end_date).cover?(purchaser.purchased_at)
end
end
end
まとめ
上で紹介した方法はあくまでリファクタリングの手法の一つなので、もっと良い方法があるかもしれません。
自分だったらこうリファクタリングする!という方は是非トライしてみてください。
この記事を見て自分が過去に書いたコードを改善できそうというものがあれば是非リファクタリングしてみましょう!
この記事を書く過程でテストコードに何度も助けられたので、リファクタリングする際はテストコードをお供にすると心強いです。
Let's Enjoy リファクタリング!
参考
- リファクタリング 既存のコードを安全に改善する(第2版)
- リファクタリング:Rubyエディション
- [初心者向け] RubyやRailsでリファクタリングに使えそうなイディオムとか便利メソッドとか
- Ruby: eachよりもmapなどのコレクションを積極的に使おう(社内勉強会)|TechRacho(テックラッチョ)〜エンジニアの「?」を「!」に〜|BPS株式会社
- 角谷 信太郎氏による講演 FJORD BOOT CAMP AS A GATE の動画の一般公開が決定 - FJORD,LLC(合同会社フィヨルド)
- Rubyのリファクタリングでイケてないコードを美しいオブジェクト指向設計のコードへ改良するための方法 - Qiita
Discussion
この場合はselectメソッドを使うといいですよ :-)
@yhara
コメントありがとうございます!
おっしゃるとおりですね💦本文に追記してfilter_mapをselectに修正しました!
自分なら、
#calc
は、Purchaser#charge
を追加Array#sum
を使用で、以下のように書きますね。