はじめに
コードの中で複数の分岐があるときに、それをある規則やカテゴリなどによって、区分としてモデリングできると見通しがよく、コードを変更しやすくなることがあります。
特に開発を継続的に続けるサービスでは、成長を続けるうちに最初には見つからなかったような分類や区分が見つかったりします。これはそのまま見過ごされ、コードとして表現されないことがあるのですが、そこにさらに変更を加えたりしていく上で変更のしやすさが変わってくるためとても重要です。
この記事では四則演算のできるコマンドラインツールを題材とし、この区分の扱いについての考察していきます。全2回の予定です。
言語はPythonを利用しますので、他の言語でも通じるところはあると思いますが、Pythonに寄った説明になると思いますのでご了承ください。
題材について
インタフェースとして、以下のようなコマンドを実行できるプログラムを用意します。
$ python calc.py add 2 3
5
$ python calc.py sub 2 3
-1
$ python calc.py mul 2 3
6
$ python calc.py div 2 3
0
この振る舞いは変えずに、内部の構造をステップバイステップで変更していきます。
それでは早速やっていきましょう。
最初のプログラム
最初のプログラムは以下になります。
import sys
def main(argv):
# argv[0]は実行されたプログラムのファイル名が入るため、引数はargv[1]から始まります
op = argv[1]
num1 = int(argv[2])
num2 = int(argv[3])
if op == 'add':
print(num1 + num2)
if op == 'sub':
print(num1 - num2)
if op == 'mul':
print(num1 * num2)
if op == 'div':
print(num1 // num2)
main(sys.argv)
単純なif文によって演算の種類を分岐し、各演算に応じた計算をし、その結果を出力します。
このコードは振る舞いとしては問題なく計算ができますが、構造上の問題があります。1つの関数で値の取り出し、処理の分岐、計算、画面への出力、これらすべて行なっているため、コードの構造が好ましくありません(単一責任原則への違反)。1
四則演算という簡単な例なのでこれ以上複雑になったりしませんが、一般的なビジネスロジックでは分岐が増えたり、計算ロジックが複雑になっていくのはよくあることです。
はじめはコードの追加、変更が容易ですが、この構造の上にコードが積み上がっていくといつの間にか容易に変更できない状態になってしまいます。
こういった場合、関心ごとを分離していくことで変更のしやすさを保てるようになります。
先ずは計算ロジックと画面出力を分離してみましょう。
1. 計算ロジックの抽出
main()
から計算ロジックの部分をcalc()
関数として抽出したのが以下のコードです。
import sys
def main(argv):
op = argv[1]
a = int(argv[2])
b = int(argv[3])
result = calc(op, a, b)
print(result)
def calc(op: str, a: int, b: int) -> int:
if op == 'add':
return a + b
if op == 'sub':
return a - b
if op == 'mul':
return a * b
if op == 'div':
return a // b
raise ValueError(f'Invalid operator: {op}')
main(sys.argv)
計算ロジックと画面への出力を分離することでmain()
がすっきりしました。
こうしておくことで、calc()
内で計算ロジックが変わったり、演算が増えたとしてもmain()
を変更する必要はなくなりました。
一方でcalc()
を呼び出す側は、どんな演算が利用できるかはすぐに分かりません。
それを知るにはcalc()
の実装の詳細(関数の中身)を見る必要があります。
こういった中身のロジックを見ないと安心して呼べない関数は危険です。
まず、その関数を呼び出す場合にどんな引数で呼び出せばいいのか把握するのに苦労します。
さらに、この関数自信もどんな引数で呼び出されているかの保証がないため、安心して変更することができなくなります。最悪の場合、呼び出し元をすべて洗い出して、どうやって呼び出しているかをチェックすることになります。2
そこでenum
モジュールを利用して、calc()
でどんな演算が使えるか、という区分を明示してみましょう。
2. enumを利用した区分(演算の種類)の明示
利用できる演算の区分を明示したのが以下のコードになります。
import enum
import sys
class Operator(enum.Enum):
ADD = 'add'
SUB = 'sub'
MUL = 'mul'
DIV = 'div'
def main(argv):
# コンストラクタでvalue値(Operatorでは'add'などの文字列)からOperator型の値へ変換ができる
# 定義されていない値が渡されるとValueErrorがraiseされる
op = Operator(argv[1])
a = int(argv[2])
b = int(argv[3])
result = calc(op, a, b)
print(result)
def calc(op: Operator, a: int, b: int) -> int:
if op == Operator.ADD:
return a + b
if op == Operator.SUB:
return a - b
if op == Operator.MUL:
return a * b
if op == Operator.DIV:
return a // b
main(sys.argv)
calc()
がどんな演算を利用できるか一目で分かるようになりました。
また、有効な値かどうかのチェックする責任が呼び出される側(calc()
)から、呼び出す側(main()
)に移りました。
もし不正な入力があった場合、以前よりエラーになるタイミングが早くなります。
その分、アプリケーションの内側のコードは例外ケースから守られ、処理が簡潔になります。
手続き的なコードから宣言的なコードへ
さて、今度はcalc()
の中でif文で分岐しているコードに着目しましょう。
これまで文字列との比較で分岐をしていましたが、演算の種類によって分岐していることが明確になりました。
有限な状態(=区分)と演算ロジックの対応がはっきりしています。
演算をそれぞれ関数として切り出し、区分からそれぞれの関数へのマッピングを作ってあげることで、これまでの手続き的なロジックによる分岐から、対応づけを明示する宣言的なコードへと変更することができます。
実際に適用したコードが以下になります。
import enum
import sys
class Operator(enum.Enum):
ADD = 'add'
SUB = 'sub'
MUL = 'mul'
DIV = 'div'
def add(a: int, b: int) -> int:
return a + b
def sub(a: int, b: int) -> int:
return a - b
def mul(a: int, b: int) -> int:
return a * b
def div(a: int, b: int) -> int:
return a // b
op_func = {
Operator.ADD: add,
Operator.SUB: sub,
Operator.MUL: mul,
Operator.DIV: div,
}
def main(argv):
op = Operator(argv[1])
a = int(argv[2])
b = int(argv[3])
result = calc(op, a, b)
print(result)
def calc(op: Operator, a: int, b: int) -> int:
f = op_func[op]
return f(a, b)
main(sys.argv)
if文による分岐はすべてなくなりました。
calc()
関数は区分に応じた関数を取り出し、それを適用して値を返すだけです。
この宣言的なマッピングが手続き的なコードに比べて優れているところは、単純な対応づけの集約しかできないところです。単純なことしかできない分、コードを読むのが楽になります。
if文であれば分岐の自由度が高いため、そのロジックを注意深く読む必要があります。
コードのメンテナンス性などのメトリクスを計測できるradonを利用して、循環的複雑度を計測すると、すべての要素で1になっていることが分かります。
$ radon cc -s calc.py
calc.py
F 43:0 add - A (1)
F 47:0 sub - A (1)
F 51:0 mul - A (1)
F 55:0 div - A (1)
F 69:0 main - A (1)
F 80:0 calc - A (1)
C 36:0 Operator - A (1)
ここまでのまとめ
さて、いかがだったでしょうか。
メソッドの抽出やマッピングの利用などいくつかのテクニックも織り交ぜましたが、大切なのは区分を定義してあげることです。
それにより以下のメリットが得られます。
- 名前がつくことで概念として扱うことができるようになり、設計を洗練できるようになる
- 構造としてどうあるべきかを突き詰めたり、それにより周りを取り巻く別のモデルがあぶり出されたりする
- 区分そのものの一覧性が上がる(
enum
の定義に集約される) - 関数の引数に区分を利用することで、呼び出し元にその値を渡す契約(事前条件)を宣言できる
- それにより呼び出される側のコードで例外ケースを扱う必要がなくなり、コードが簡潔になる
- 区分によって分岐する処理をマッピングによって簡潔に表現でき、複雑度を下げることができる
次回は、このコマンドラインツールにヘルプメッセージを表示する機能を追加します。
それにより区分によって分岐する処理がさらに増えることになりますが、そうするとどうなるか、どうしたらよいのかを考察していきたいと思います。
つづき: 区分をモデリングしよう(その2)