[コード設計]条件分岐のアンチパターンと対策
初めに
コード設計について「良いコード/悪いコードで学ぶ設計入門 ―保守しやすい 成長し続けるコードの書き方」を手に取り、学んでいます。本記事はこちらを参考に書いています。
今回は 「条件分岐」 について、アンチパターンとそれに対する対応を挙げていきます。
本題
if文のネスト・else句(アンチパターン)
if文のネストや、else句の活用は、コードの見通しが悪くなり、可読性低下に繋がります。数十行、数百行のコードが分岐ブロックの中に書かれていると、どこからどこまでがどの条件の時に実行されているのか、理解にとても時間がかかるようになります。
(イメージ:Python)
if 条件1:
# 数十行 ~ 数百行
if 条件2:
# 数十行 ~ 数百行
if 条件3:
# 数十行 ~ 数百行
if 条件4:
# 数十行 ~ 数百行
早期Return(対策)
if文のネスト、else句の対策の一つとして早期Returnが挙げられます。
例として「目的地の改札を通過する」をコードに落とし込んでみます。
改札を通るには、以下の条件をクリアする必要があります。
- 目的地の改札か
- 切符は持っているか
- 切符の金額は電車賃よりも多いか
上記の条件を何も考えずに実装すると以下のようなコードになります。
def pass_gate():
if is_arrived_destination:
if has_ticket:
if is_price_enough:
print('改札を通過')
Pythonは改行でコードブロックを表すため、まだ見やすいかもしれませんが、条件が増えるとネストが深くなり、見づらさが増すことはわかると思います。
これに対し、早期Returnを用いると以下のようなコードになります。
def pass_gate():
if not is_arrived_destination: return
if not has_ticket: return
if not is_price_enough: return
print('改札を通る')
上記のようにネストすることなく実装することができました。
早期Returnを用いることで、得られるメリットは以下のようなものが挙げられます。
- ネストが解消されて、ロジックの見通しが良くなる
- else句が不要になる
- 条件と実行ロジックを分離できる
「条件と実行ロジックを分離できる」についてですが、コードからもわかるように、改札を通るための条件が上のブロックにまとめられていることがわかると思います。これにより、もし仕様が変わり他に条件が増えたとしても、上のブロックに追加するだけで済みます。if文のネストやelse句を使用するよりも読みやすく、変更に強いコードであると言えます。
switch文, match文(アンチパターン)
種類ごとに切り替えをしたい場合、switch文を使用されることが多いかと思います。「本書」では、ゲームで使用される魔法を例に取り上げられています。
Pythonでは、3.10からmatch文というswitch文のような機能が追加されました。以下は「魔法の名前」、「消費魔法力」、「攻撃力」をmatchを使用して、切り替えています。
from enum import Enum
class MagicType(Enum):
fire = 1
blizzard = 2
class Magic():
def __init__(self, magic_type: MagicType):
match magic_type:
case MagicType.fire:
self.name = "ファイア"
self.magic_point = 2
self.attack_power = 20
case MagicType.blizzard:
self.name = "ブリザド"
self.magic_point = 3
self.attack_power = 30
magic = Magic(MagicType.fire)
print(magic.name) #=> ファイア
print(magic.magic_point) #=> 20
print(magic.attack_power) #=> 30
一見見やすいかと思いますが、切り替えたい魔法が増えた場合、initメソッドの中のコードが膨れ上がり、巨大化し見づらいコードになります。巨大化したクラスは小さなクラスに分けるのが良いとされています。そこでinterface、Pythonではabc.ABCMetaを用いて、抽象クラスを作成します。
ストラテジパターン(対策)
import abc
class Magic(metaclass=abc.ABCMeta):
# 名前
@abc.abstractmethod
def name(self):
pass
# 消費魔法力
@abc.abstractmethod
def magic_point(self):
pass
# 攻撃力
@abc.abstractmethod
def attack_power(self):
pass
抽象クラスでは、(metaclass=abc.ABCMeta)を継承して定義します。また、子で共通して定義するメソッドの上に@abc.abstractmethodデコレータを定義します。
class Fire(Magic):
def __init__(self):
pass
def name():
return "ファイア"
def magic_point():
return 2
def attack_power():
return 20
class Blizzard(Magic):
def __init__(self):
pass
def name(self):
return "ブリザド"
def magic_point(self):
return 3
def attack_power(self):
return 30
子クラス側で抽象クラスMagicを継承し、@abc.abstractmethodで定義されたメソッドを定義します。
fire = Fire()
blizzard = Blizzard()
magics = {
MagicType.fire: fire,
MagicType.blizzard: blizzard
}
それぞれインスタンス化したものをバリューに、対応するEnumをキーにし、dictで定義します。
def magic_attack(magic_type: MagicType):
using_magic = magics[magic_type]
print(using_magic.name())
print(using_magic.magic_point())
print(using_magic.attack_power())
magic_attack(MagicType.blizzard)
#=> ブリザド
#=> 3
#=> 30
switch文を使用することなく関数にEnumのキーを渡すだけで、処理を切り変えることができるようになりました。このように、interfaceを用いて処理を一斉に切り変える設計をストラテジパターンと呼びます。
ストラテジパターンにより、以下のメリットが得られます。
- switch文による、コードの巨大化の防ぐ
- 未実装のメソッドがある場合、エラーが出る
「未実装のメソッドがある場合、エラーが出る」について、Thunderクラスを以下のように定義し、magic_pointの定義を忘れたとします。
class Thunder(Magic):
def __init__(self):
pass
def name(self):
return "サンダー"
def attack_power(self):
return 50
magic_attack(MagicType.thunder)
#=> Traceback (most recent call last):
File ".../magic.py", line 114, in <module>
thunder = Thunder()
TypeError: Can't instantiate abstract class Thunder with abstract method magic_point
上記のように、コンパイラが叱ってくれるようになります。これにより、新たな機能が追加されたとしても、子クラスによって定義し忘れるといったことは防具ことはでき、変更に強い設計と言えると思います。
Discussion