この記事はRuby Advent Calendar 2016の19日目の記事です。
今回は、リファクタリング系のわりと初歩的な内容かなと思います。
常にこうした方がいいというわけではなく「こういう道もあるんだよ」という選択肢を持っていると、いつか役に立つかも。ぐらいのトピックです。
発想がHash
例えば以下のようなコードがあったとします。
def 料理する(foods)
foods.each do |food|
case food["type"]
when "フルーツ"
皮をむく(food["name"])
切る(food["name"])
when "野菜"
切る(food["name"])
煮る(food["name"])
end
end
end
foods = [
{ type: "フルーツ", name: "みかん" },
{ type: "野菜", name: "キャベツ" },
{ type: "野菜", name: "大根" },
]
料理する(foods)
こういうコード、気をつけていないと書いてしまいがちではないでしょうか。
このコードはいくつか問題があります。
typeの追加問題
このコードに「肉」typeを追加した場合どうなるでしょうか?
arrayに行を追加して、case文にも修正が入ります。
def 料理する(foods)
foods.each do |food|
case food["type"]
when "フルーツ"
皮をむく(food["name"])
切る(food["name"])
when "野菜"
切る(food["name"])
煮る(food["name"])
+ when "肉"
+ 焼く(food["name"])
end
end
end
foods = [
{ type: "フルーツ", name: "みかん" },
{ type: "野菜", name: "キャベツ" },
{ type: "野菜", name: "大根" },
+ { type: "肉", name: "豚バラ" },
]
料理する(foods)
二箇所の修正が必要になりました。では次に「きのこタイプ」「めんタイプ」を追加したら……。
と、タイプを追加するたびに、ロジックの変更が必要になります。
もしcase文の修正を忘れてしまったら……。
もしtype文字列をtypoしてしまったら……。
case文がどんどん巨大になっていく……。
そう、このままではコーディングミスが発生しやすくなってしまいます。
case文を全面否定しているわけではありません。パーサープログラムなどでは、パフォーマンスのために数値や文字列でのcase文は多用されるテクニックだと思います。
しかし、仕様が変わりやすく、パフォーマンスより不具合を恐れるアプリケーションプログラムではどうでしょうか。
case文を消せばいいじゃない
Rubyでこういうcase文が出たら、ダックタイピングを試してみるチャンスです。
# (Rubyって日本語名のメソッドは作れるけどクラスは作れないのね……。)
class Fruit < Struct.new(:name)
def 料理
皮をむく(name)
end
# ...
end
class Vegetable < Struct.new(:name)
def 料理
煮る(name)
end
# ...
end
def 料理する(foods)
foods.each do |food|
food.料理
end
end
foods = [
Fruit.new("みかん"),
Vegetable.new("キャベツ"),
Vegetable.new("大根"),
]
料理する(foods)
あら不思議、case文がなくなりました。
投入されたオブジェクトの料理
メソッドを呼ぶだけで、投入されるオブジェクトのクラスそれぞれが分岐先の処理を知るようになりました。
「肉(Meet)」typeを追加してみましょう。
class Fruit < Struct.new(:name)
def 料理
皮をむく(name)
end
# ...
end
class Vegetable < Struct.new(:name)
def 料理
煮る(name)
end
# ...
end
+ class Meet < Struct.new(:name)
+ def 料理
+ 焼く(name)
+ end
+ # ...
+ end
def 料理する(foods)
foods.each do |food|
food.料理
end
end
foods = [
Fruit.new("みかん"),
Vegetable.new("キャベツ"),
Vegetable.new("大根"),
+ Meet.new("豚バラ"),
]
料理する(foods)
ロジック部分(料理する
メソッド)の修正なしにtypeを追加することができました。
これで、case文の修正を忘れることも、
肥大化を心配することもなくなりました。
さらに、class名をtypoするとuninitialized constant(NameError)
と教えてくれます。
いったいなにがおこったのか
「ロジックを変えずにtypeを追加できる」という不思議な事が起こりました。
一体何が起こったのでしょうか。
今回は説明のため同一スクリプト内での修正でしたが、
もしも料理する
メソッドがgemなどのライブラリーのコードだった場合どうでしょう。
case文を使ったロジックの場合、使いたいtypeを増やしたくなるたびにライブラリーを修正するのでしょうか。
ダックタイピングを使ったロジックなら、ライブラリーの修正はいりません。
データとロジックを分割せよ〜オブジェクト編〜
私はこのダックタイピング化によって「データとロジックが分割された」のだと考えます。
データとロジックを分割する是非については、別の記事で書きました。
今回の場合は、foods
arrayがデータで、料理する
メソッドがロジックというわけです。
ロジックを変えなくても、データは自由に変えることができる。これが分割のメリットです。
ダックとともにあれ
Rubyではダックタイピングを中心とした言語と言っても過言ではなく、例えばInteger
クラスやFloat
クラスなどの数値クラスや、IO
クラスとStringIO
クラスなんかが典型的な例でしょう。
def add(a, b)
a + b
end
# Integerどうしでもいい
add(1, 2) #=> 3
# RationalとFloatでもいい
add(2r, 3.4) #=> 5.4
orig_stdout = $stdout
$stdout = strio = StringIO.new
begin
somemethod() # stdoutに出力するコード
ensure
$stdout = orig_stdout
end
strio.string #=> stdoutの内容をキャプチャーできる
ダックタイピングはメソッド名を組み合わせてロジックを組み、投入するオブジェクトが変わっても同じロジックで動かすことができるというものです。
だからこそ、stdoutのキャプチャーなんてことが簡単にできる様になっています。
Rubyはダックタイピングの言語です。Rubyを使うのなら、ダックタイピングを最大限に活用すべきでしょう。
そして、ダックタイピングのためには、「投入するオブジェクトの部分 == データ」と「メソッドの固まりの部分 == ロジック」という二つの部分があることを意識することが重要、つまりRubyはデータとロジックの分割が得意な言語だと、私はたどり着きました。
これってJSONとかYAMLのデータをあつかうときは適用できなくない?
つ http://magazine.rubyist.net/?0054-typestruct
さらに調子に乗ってRuby3について語ってみる
Ruby3では「型の実装」が謳われていますが、この型は一般的にイメージするintやcharのようなものではなく、「メソッド名の間違いをみつけるもの」とされています(要出典)。まるでC言語などの静的言語のように。
そして、メソッドの引数にクラス名のアノテーションをつけることを明確に否定しています。(要出典)
これは先ほど紹介したダックタイピングによるデータとロジックの分離のことを考えると、納得のいく考えになると思います。
もしも投入するオブジェクトのクラスが決まってしまうと、ダックタイピングができなくなる。ロジックとデータが混在してしまうからです。
つまりどういうことだってばよ
と、こんなことをもんもんと考えていたら「オブジェクト指向設計実戦ガイド」という本の「第5章 ダックタイピングでコストを削減する」でまさに同じようなことを書かれているのを見つけ「自分は間違ってなかった……!」と勇気づけられ、この記事を書くきっかけとなりました。
case文を見つけたらダックタイピングで悦に入るチャンス……!かもしれません。
普段のプログラミングの際には、ぜひ「データ」と「ロジック」の二つがあることを意識してみて下さい。