はじめに
先日、チーム内で質問会を実施しました。私が担当したのは、「SOLID原則について、それぞれの原則の考え方について解説して欲しい」というものです。ポケモンでsolid原則を考えてみたので、それを紹介したいと思います。
いいコードとは
solid原則について解説したいとありますが、その前になぜsolid原則が必要になってくるのか考える必要があると思います。まず前提として、日々コードを書くうえで下記のようなコードになるように意識する必要があります。
- 保守性が高い
- 無駄な部分がない
- 拡張しやすい
他にも色々あると思いますが、今回は省略します。どれも大切ですが、具体的にどうすれば上記の項目を意識してコードを書けるのか気になりますよね。そこでsolid原則が役に立ちます。
そもそもsolid原則とは
ソフトウェアの拡張性や保守性を高めるためのもので、具体的には5個の原則の頭文字を取ってきたものです。
それぞれの原則を目次に乗っ取ってざっくり解説します。
S(Single Responsibility) 単一責任の原則
O (Open-Closed) オープン・クローズドの原則
L (Liskov Substitution) リスコフの置換原則
I (Interface Segregation) インターフェイス分離の原則
D (Dependency Inversion) 依存性逆転の原則
目次
- それぞれの原則について
- ざっくりどんな原則なのか
- 原則を適用すると何がいいのか
- ポケモンでケースを考えてみる
S(Single Responsibility) 単一責任の原則
どんな原則??
一つのクラスが持つ責任は一つであったほうがいいよねという原則です。
責任って何?
自分の意見ではありますが、責任というのは、クラスの内容が変更される理由のことであると考えています。
言い換えれば、クラスの内容が変更される理由は一つでいてほしいということです。
原則を適用すると何がいいのか
ロジックの仕様変更などがあったときに、変更する箇所を一つのクラスが複数の責任を抱えているときと比較して少なくなります。つまり、変更が容易になるということです。
ポケモンでケースを考えてみる
class Pikachu
# ボルテッカー
def volt_tackle
power 100
end
end
ピカチュウクラスにボルテッカーというメソッドがあります。現時点ではピカチュウ専用技のため、問題なく動作します。しかし、仕様に変更が加わりました。
新たにボルテッカーを使えるピチューの登場と、ボルテッカーの威力が120に変更されました。
class Pikachu
# ボルテッカー
def volt_tackle
power 120
end
end
class Pichu
# ボルテッカー
def volt_tackle
power 120
end
end
現状のクラスではボルテッカーに変更が入るたびにピカチュウクラスとピチュークラスに手を入れる必要が出てしまいます。
この状態では、クラスの仕様を変更する時以外に、ボルテッカーの仕様を変更する時と、クラスを変更する理由が複数生じています。つまり、ピカチュウクラスとピチュークラスが複数の責任を持っていることになります。
そこで、新たにわざクラスを作成し、ボルテッカーメソッドを移動させます。
# わざクラス
class Move
# ボルテッカー
def self.volt_tackle
power 120
end
end
class Pikachu
# わざを繰り出すメソッド
def use_move
# わざクラスからボルテッカーを呼び出す
Move.volt_tackle
end
end
class Pichu
# わざを繰り出すメソッド
def use_move
# わざクラスからボルテッカーを呼び出す
Move.volt_tackle
end
end
これで今後ボルテッカーに変更が加わっても、ピカチュウクラスとピチュウクラスに変更を入れる必要がなくなりました。
O (Open-Closed) オープン・クローズドの原則
どんな原則??
クラスは、拡張にはオープンで、変更にはクローズドであるべきという原則です。つまり、クラスは既存のコードを変更せずに修正または追加できるように設計しなければならないということです。
それはそうといった感じですよね。けど実際どんなものかよくわからない...わざで考えてみましょう。
適用すると何がいいのか
まず処理を追加するときのミスを減らすことができます。既存のロジックに触れる量が減るので、バグが生み出される機会が減ることに加え、コードの拡張性が高まるなどのメリットがあります。
ポケモンでケースを考えてみる
ポケモンの最新作(2024年2月現在)にはテラバーストというテラスタルのタイプによってタイプが変化するわざがあります。まずはcase文で愚直に処理を書いてみましょう。
テラスタルとテラバーストについて
テラスタルは、ポケモンのタイプを任意に変更できるギミックです。これにより、ピカチュウを含むどのポケモンも地面タイプなど、他のタイプに変更することが可能になります。
テラバーストはポケモンがテラスタル化しているタイプに基づいて攻撃タイプが変化する特殊なわざです。
class Move
# ポケモンのテラスタルタイプによって技のタイプが変化する
def tera_blast(terastal_type)
case terastal_type
when 'Grass'
# 草タイプ時の攻撃処理
when 'Fire'
# 炎タイプ時の攻撃処理
when 'Water'
# 水タイプ時の攻撃処理
end
end
end
これでテラバーストのタイプ別の処理を書くことができます。しかし、新しくタイプを追加するたびに、既存のロジックに変更を加える必要があります。ポケモンでタイプが増えることはままあるため、何度も変更する内にミスを招きそうな構造をしています。
それを解決することができる、オープン・クローズドの原則です。
class TeraBlast
# 継承元に未実装エラーを置くことで、子クラスでのメソッド実装忘れを防ぐことができる
def use_tera_blast
raise NotImplementedError
end
end
class GrassTeraBlast < TeraBlast
def use_tera_blast
# 草タイプ時のテラバーストの処理
end
end
class FireTeraBlast < TeraBlast
def use_tera_blast
# 炎タイプ時のテラバーストの処理
end
end
class WaterTeraBlast < TeraBlast
def use_tera_blast
# 水タイプ時のテラバーストの処理
end
end
# 炎テラバーストしたい時
FireTeraBlast.new.use_tera_blast
上記のようにすることで、新たにじめんタイプを追加したり、はがねタイプを追加する際も、新しいタイプのテラバーストを定義することで、既存ロジックに変更を加えることなく、新しい機能を追加することができます。
インターフェースを利用して具体クラスの振る舞いを定める。そうすることでタイプごと異なった実装をしつつも、共通した振る舞いのクラスを作ることができます。
L (Liskov Substitution) リスコフの置換原則
どんな原則??
「親クラスのインスタンスが適用されるコードに対して、子クラスのインスタンスで置き換えても、問題なく動くべき」という原則です。
適用すると何がいいのか
適応すると何がいい、というよりこれを適応していないほうが不自然な感じがします。
例えるなら、親クラスではBoolean(true, falseのこと)で返すメソッドを子クラスではString型で返却する状況がこの原則に反しています。この状況では、メソッド名と実態が乖離する上、想定外の動きをすることになるため避けるべきです。
ポケモンでケースを考えてみる
親クラスに特性クラスがあり、それを継承して各ポケモンの特性を定義しているとします。
class Ability
def ability_name
# 特性の名称を返す処理
end
end
class Pikachu < Ability
def ability_name
# ピカチュウの特性の処理を返す
# 親クラスと返却するものが一致していない!!
end
end
上記の例は極論ですが、親クラスで実装されているability_name
とピカチュウで実装されているもので返却するものが全く異なっています。これはリスコフの置換原則に違反しています。
class Ability
def ability_name
# 特性の名称を返す処理
end
def ability_effect
# 特性の効果を返す処理
end
end
class Pikachu < Ability
def ability_name
"せいでんき"
end
def ability_effect
"電気タイプの攻撃を受けたとき、HPを回復する"
end
end
I (Interface Segregation) インターフェイス分離の原則
どんな原則??
必要なものだけをそのクラスに実装しよう、使わないものはいらないという考え方です。
適用すると何がいいのか
必要なときに必要なものを実装することになるので、コードがスッキリします。
また、必要最低限のロジックだけを持っているので拡張しやすいというメリットがあります。
逆に何でもかんでも実装すると、使わないのにコードを実装する必要がある、なんて状況になってしまうリスクがあります。
ポケモンでケースを考えてみる
ポケモンクラスを実装し、その中に地面技を無効にする処理を実装したとします。この処理は飛行タイプのポケモンの処理を実装する時には便利ですが、全てのポケモンがこの処理を持つ必要はありません。この処理は飛行タイプを実装するときに書くべきです。
そうしないと、うっかり全てのポケモンが地面タイプの技を無効化するなんていうトンチキな状況が発生する可能性があります。
class Pokemon
def use_move
# わざを繰り出す処理
end
def disable_ground_move
# 地面タイプの技を無効化する処理
end
end
class ElectricPokemon < Pokemon
def use_move
# わざを繰り出す処理
end
# 電気タイプのポケモンが地面タイプを無効化してしまった!
def disable_ground_move
# 地面タイプの技を無効化する処理
end
end
module GroundMoveDisable
def disable_ground_move
# 地面タイプの技を無効化する処理
end
end
class Pokemon
def use_move
# わざを繰り出す処理
end
end
class FlyingPokemon < Pokemon
include GroundMoveDisable
def use_move
# わざを繰り出す処理 + disable_ground_move
end
end
class ElectricPokemon < Pokemon
def use_move
# わざを繰り出す処理
end
end
これにより、地面技を無効にしたい特定のクラスのみに地面技を無効化する処理を追加することができました。
D (Dependency Inversion) 依存性逆転の原則
どんな原則??
具体的なクラス同士が依存するのではなく、抽象化した部分に依存するようにしましょうという考え方です。
適用すると何がいいのか
頻繁に変更される具象クラスよりも、滅多に変更が起こらない抽象クラスに依存させていたほうがコードの安定性が高まる利点があります。また、具象クラスにコードの追加や変更が加わっても、他の部分に与える影響が小さくなります。
ポケモンでケースを考えてみる
ポケモンクラスとトレーナークラスがあったとして、サトシはピカチュウに依存し、ピカチュウはサトシに依存している。
ピカチュウはサトシがいないとポケモンバトルをできないし、サトシはピカチュウがいないとポケモンバトルができません。
しかし、将来的にサトシはピカチュウ以外のポケモンを使うかもしれないし、ピカチュウも将来的にサトシ以外のトレーナーとポケモンバトルをすることになるかもしれません。
なので、
class Pikachu
def attack
# ピカチュウは攻撃を繰り出した!
end
end
class Satoshi
def initialize(pikachu)
@pikachu
end
def select_attack
@pikachu.attack
end
end
pikachu = Pikachu.new
satoshi = Satoshi.new(pikachu)
satoshi.select_attack
上記の例の場合、サトシは具体クラスのピカチュウに依存してます。今の所問題なく動作しますが、ピカチュウに変化があった場合、サトシの方も修正を加える必要がある(例:ピカチュウクラスのメソッド名に変更が加わるなど)
加えて、サトシが扱うポケモンにリザードンが追加された場合、リザードンの攻撃メソッドも個別でサトシが依存することになるため、将来の変更を考えるとあまりいいコードだとは思えません。
class Pokemon
def attack
raise NotImplementedError
end
end
class Pikachu < Pokemon
def attack
# ピカチュウは攻撃を繰り出した
end
end
class Trainer
def initialize(pokemon)
@pokemon = pokemon
end
def select_attack
@pokemon.attack
end
end
pikachu = Pikachu.new
trainer = Trainer.new(pikachu)
trainer.select_attack
トレーナーがピカチュウに依存することなく、ポケモンという概念に依存することによって、トレーナーは色々なポケモンを使えるようになりました。
参考記事
イラストで理解するSOLID原則
マリオで学ぶSOLID原則
Rubyでさらっと学ぶSOLID原則⑤「依存性逆転の原則」
Rubyでさらっと学ぶSOLID原則②「オープン・クローズドの原則」
【SOLID】依存関係逆転の原則を完全に理解したい
よくわかるSOLID原則1: S(単一責任の原則)
インターフェースとは?~継承とは役割が違う~|オブジェクト指向プログラミング(OOP)をおさらいしよう(3)
【SOLID原則】依存性逆転の原則 - DIP