nikkie-ftnextの日記

イベントレポートや読書メモを発信

ソフトウェアを作りたかった私へ:入出力と計算を分ける

はじめに

百合子ちゃん、お誕生日2日目、おめでとうございます!! nikkieです。

週末3/24(日)のOOC 2024 登壇1準備からアウトプットです。

目次

わからん殺し「小さめの関数としてまとめたはずなのに...」

過去の私は、関数に切り出した処理が「関数にまとめたのに再利用しづらい」と感じていました。
(関数を例にしていますが、クラスのメソッドでも同じような状況に陥っています)

def load_data(file_path):
    # ファイルから読み取る処理
    # 読み取ったデータを加工する処理
    return processed_data

読み取ったデータを加工する処理の例を挙げると

  • CSVJSON Linesから、いくつかのカラムだけを抜き出す
  • 2つのカラムのデータから、新しいデータを作ってそれだけを返す

といった処理です

def save_result(data, file_path):
    # 渡されたデータを加工する処理
    # ファイルに保存する処理
    # この関数の返り値はありません

小さめの関数として切り出したはずなのに、なぜ使いづらいんでしょう?

増田さんのスライド「入出力と計算判断を分ける」

増田さんのスライドを見て言語化を得ました2

入出力と計算判断を分ける。これがクラス設計の鉄則。

クラス設計とありますが、上記の関数load_data()save_result()にも該当すると思います。

私がまとめた関数は、なぜ使いづらかったのか

load_data()入力処理と計算処理が一体となっています。
このため、入力処理の結果だけがほしいとき(例:別の計算を適用するため)に使うことができません。
load_data()の亜種を作ることになります。

save_result()計算処理と出力処理が一体となっています。
このため、計算結果だけほしいとき(例:別の計算をさらに適用したり、他の計算結果と合体させた新しい形式のデータを作ったり)に使うことができません。
こちらも亜種が増えていきます。

私は行数だけを見て、「これくらいなら小さいから、関数に切り出せる」と手を動かしていたのですが、注目すべきは行数ではなかったのです。
入出力と計算という異なる種類の処理の片方だけを切り出しているかに注意を払う必要があったのです。

実は使いづらさはテストコードの書きづらさにも現れていました(私は無視し続けてしまったのですが...)。

def load_data(file_path):
    # ファイルから読み取る処理
    # 読み取ったデータを加工する処理
    return processed_data

再掲したload_data()にテストコードを書こうとすると、過去の私は次のようなアプローチを取りました。

  • ファイルから読み取る処理をモックに置き換える
    • モックのreturn_valueとしてファイルにあるデータ形式で数件返す
  • 数件のデータを加工した結果(=期待値)とload_data()の返り値をassert

テストメソッドは読み取ったデータと期待値の2つを持つので縦に長くなります(時に100行を超えたり)。
実装から時間が経って読み返した時に「このテストは何をやっているんだ?」と理解に時間がかかるわけですが、ここへの対処法を持っていませんでした。

なお、読み取る処理をモックにせずに、実ファイルを用意して読み取る方法も考えられます。
この方法と比較して、テストメソッド内にexpected、actualを含むほうが理解しやすいのでは、とモックにする方法を採用しました。

save_result()のような書き込む処理の場合は、書き込み処理に渡される引数を、加工した結果の期待値とassertします

入出力と計算判断を分けた世界

def read(file_path):
    # ファイルから読み取る処理
    return data


def process(data):
    # 読み取ったデータを加工する処理
    return processed_data


def write(data, file_path):
    # ファイルに保存する処理

テストコードは明確に書きやすくなります。
process()関数のテストは、テスト内でデータを作って渡し、返り値と期待値をassertする形になります。
テストの中でデータを作れるというのが大きいです(モックする実装が不要になりました)。

つまり、計算処理にはメモリ上のデータを渡すということです。
そして計算結果はメモリ上のデータとして返します
永続化してあるデータは、計算処理では扱わないというように峻別します

これは、入出力の切り替えが可能になったことを意味します。
例えば入力ファイルの形式だけが変わったとします3
新たに作るread_v2()の返り値はread()と同様とすれば、process()は変更せずに使えますよね。

出力についても同様です。
出力ファイルの形式や出力先が変わっても、process()は影響を受けません。
write()と同様の引数を受け取るwrite_v2()を作って対応できます。

入出力と計算を分けていく(リファクタリング

増田さんの言語化を見て以降は、入出力と計算を分けて実装しています。
しかし、過去に書いたコードはこの分離が全然徹底できていません。
過去に書いたコードは、触るタイミングでリファクタリングしていけばいいと考え4、分けていっています。

具体的なリファクタリングテクニックは、関数の抽出5関数のインライン化6です。

def load_data(file_path):
    # ファイルから読み取る処理
    # 読み取ったデータを加工する処理
    return processed_data

load_data()を例にすると、まずコメントで表した処理(それぞれ複数行あると思ってください)をそれぞれ関数に抽出します。
ゴールは入出力と計算の分離ですから、入力または出力で1つの関数、計算で1つの関数と抽出します。

def load_data(file_path):
    data = read(file_path)
    processed_data = process(data)
    return processed_data

次にload_data()を呼び出しているコードでインライン化します。
呼び出し箇所の実装からload_dataが消えます。

data = read(file_path)
processed_data = process(data)

これで入力と計算処理が分かれた状態になりました!

ここではload_data()の呼び出し箇所が少ないと仮定しています。
多い場合はなにか工夫がいるかもしれません(今後経験するだろう事項)

終わりに

ソフトウェアを作りたかった過去の私に伝えたい、「入出力と計算判断を分ける」(増田さん)でした。

  • 入出力と計算判断を混ぜていると、その関数はもう一度使いづらい
  • 入出力と計算判断を分けると、入力・出力・計算、それぞれの処理は他の箇所でも使いやすい
    • 計算処理はメモリ上のデータが渡され、メモリ上のデータとして返すととらえる。永続化には関与しない
  • 入出力と計算判断が混ざった関数を見つけたら、抽出 & インライン化で分けるリファクタリングをしよう

増田さんスライドを機に理解して入出力と計算を分けてからは、1つ1つの部品は再利用しやすく書けている感覚があります。
「入出力と計算判断を分ける」、オススメです!


  1. 変更しやすいコードを書くコツを話します(時間帯が vs ミノ駆動さん & nrslibさん。ひいいいい)
  2. 記事版をこのたび見つけました。7つの設計原則とオブジェクト指向プログラミング - ソフトウェア設計を考える
  3. 余談ですが、データ読み込みにはこの記事を使ったり、Pydanticを使ったりします。
  4. 費用対効果の話ですね
  5. カタログより Extract Function。普段遣いのVS Codeではこの記事の感じです
  6. カタログより Inline Function。なお、VS Codeのサポートは見つけられておりません(ご存じの方教えてください)