ソフトウェアを作りたかった私へ
変更しやすいコードを書くコツが見えてきた今伝えられること
- Event:
Object-Oriented Conference 2024
- Presented:
2024/03/24 nikkie
聞きに来ていただき、ありがとうございます
変更しやすいコードを書きたいですか〜?📣
コール&レスポンス スライド
お前、誰よ(自己紹介)
nikkie / 毎日 ブログ 執筆、490日突破
ソフトウェア開発 2016年〜(歴8年)
2019年〜 データサイエンティスト。 Python ・機械学習・ソフトウェアエンジニアリング(We're hiring!)
Pythonでこんな開発しています
ファイルを入力し、別のファイルに出力する処理(を組合せた、機械学習パイプライン)
CLI(小さいライブラリを個人開発)
テストコード 書いてます(リファクタリング ゴリゴリ)
経験が薄いもの:がっつりWebアプリ開発、DDD
変更しやすいコードを書きたい!🔥
変更しやすいコードというのはずっと憧れでした。(fortee)
(そのコードがユーザに価値があるという前提で)
ソフトウェア(soft + ware)
ware:製品
soft: 振る舞いを変更 できる
ref: 『Clean Architecture』第2章
ソフトウェアの 2つ の価値
振る舞い:ユーザが認知
構造:開発者が認知
振る舞いと構造
ソフトさ(振る舞いを変更できるか)は、 構造による
構造がソフトウェアをソフトにする
ref: 『Clean Architecture』第15章
振る舞いを変更できるコードを書きたい! のに
過去の自分が最善を尽くしたコードの構造が、それを 妨げる 😢
振る舞いの変更がめちゃくちゃ大変。ハードウェアじゃん
わからん殺し されている
セルフわからん殺し(ミノ駆動さん)
自分の外側に知識を求めた
Python使いの観点から、アウトプット中心の 読書会 を共同主催(2022年〜)
シーズン1 ミノ駆動本
シーズン2 ちょうぜつ本(次回最終回 予定)
本トーク「ソフトウェアを作りたかった私へ」では
過去の私向け(振る舞いを変更しやすいコードを書きたいのに、作った構造に阻まれてしまう方)
変更しやすいコードの 構造 について、セルフわからん殺しを共有・言語化
👉 過去の私 にとっての、ソフトウェアを作る 知の高速道路
道程(お品書き)
指針を得る(小さい)
小さな部品の作り方の気づき(3点)
難所(インターフェース、継承)
訪れた書籍(本トークにおける 呼称まとめ)
指針を得るパート
シンプル
クラスの使い所
1️⃣シンプル
変更しやすいコードの構造についての考え方
⚠️世間一般に言う「シンプル」と違う意味で使ってます
シンプルは 簡単 という意味でよく使われるように思われる
https://www.oxfordlearnersdictionaries.com/definition/english/simple
not complicated; easy to understand or dosynonym easy
このトークで言うシンプルは、 LEGOブロック のイメージ
LEGOブロック
ブロックの1つ1つは単純(2x2, 2x4)
ブロックを組合せることで どんな大きなものでも作れる (例:城)
小さな部品を組み合わせる
変更しやすい構造のコードを書くために採用した戦略
1つ1つの部品はLEGOブロックのようにして、組合せて機能を実現する
影響:Rich Hickeyの「Simple Made Easy」
訳すなら「 誰でもわかる Simple」
Clojureの作者のプログラミング観
参考:『Clean Craftsmanship』第6章
シンプルは「簡単」という意味ではない。シンプルとは「もつれていない、絡み合っていない」という意味である。(Kindle版 p.262)
※直前の脚注で「Simple Made Easy」を案内。また、Uncle BobはClojureを書きます
2️⃣クラスをどう使うかを知る
小さい部品? 関数は任せて!
クラス? うっ...🙈
ずっと分からなかった、クラスの使い所
nikkieは、(必ずクラスを書く)Javaの経験がありません
関数でも書ける言語を使ってきた(PHP、Python)
「文法は完全に理解したが、 クラスはどんな時に使うの?」
クラスの文法を説明する例:犬
class Dog:
kind = 'canine' # クラス変数
def __init__(self, name):
self.name = name # インスタンス変数
よい構造のコードとしてのクラスの例(ミノ駆動本 2章)
強く関係し合うデータとロジックを一箇所にギュッと集めておく (Kindle版 p.51)
例:ヒットポイント
Pythonでの実装例
class HitPoint:
MIN: Final[int] = 0
MAX: Final[int] = 999
def __init__(self, value: int) -> None:
if value < self.MIN:
raise ValueError(f"{self.MIN}以上を指定してください")
if value > self.MAX:
raise ValueError(f"{self.MAX}以下を指定してください")
self.value = value
def damage(self, damage_amount: int) -> HitPoint:
"""ダメージを受ける"""
def recover(self, recovery_amount: int) -> HitPoint:
"""回復する"""
クラス変数
ヒットポイントの最大、最小
class HitPoint:
MIN: Final[int] = 0
MAX: Final[int] = 999
def __init__(self, value: int) -> None:
if value < self.MIN:
raise ValueError(f"{self.MIN}以上を指定してください")
if value > self.MAX:
raise ValueError(f"{self.MAX}以下を指定してください")
self.value = value
def damage(self, damage_amount: int) -> HitPoint:
"""ダメージを受ける"""
def recover(self, recovery_amount: int) -> HitPoint:
"""回復する"""
インスタンス変数
値を検証している!!
class HitPoint:
MIN: Final[int] = 0
MAX: Final[int] = 999
def __init__(self, value: int) -> None:
if value < self.MIN:
raise ValueError(f"{self.MIN}以上を指定してください")
if value > self.MAX:
raise ValueError(f"{self.MAX}以下を指定してください")
self.value = value
def damage(self, damage_amount: int) -> HitPoint:
"""ダメージを受ける"""
def recover(self, recovery_amount: int) -> HitPoint:
"""回復する"""
ヒットポイントに関わるメソッド
class HitPoint:
MIN: Final[int] = 0
MAX: Final[int] = 999
def __init__(self, value: int) -> None:
if value < self.MIN:
raise ValueError(f"{self.MIN}以上を指定してください")
if value > self.MAX:
raise ValueError(f"{self.MAX}以下を指定してください")
self.value = value
def damage(self, damage_amount: int) -> HitPoint:
"""ダメージを受ける"""
def recover(self, recovery_amount: int) -> HitPoint:
"""回復する"""
クラスは データとロジックをまとめる もの
文法を学べば、処理系が動作するクラスは書ける
コードの構造としてよいクラスも悪いクラスも、同様に動作してしまう
良し悪しの 判断基準 「まとめているか」(過去の私はこれが持てていなかった)
データだけのクラスに注意⚠️
ロジックを持たない
クラスを使うコードがデータを取り出して加工してしまっている
私はよくやってました
データクラス
クラスを使う側のコードよ、 楽 してこうぜ
クラスにデータとロジックをまとめる効能
参考:「コーヒーちょうだい」
参考:増田本 第1章
使う側のプログラムの記述がかんたんになるように、使われる側のクラスに便利なメソッドを用意する (Kindle版 p.54)
まとめ🌯 得た指針
小さい部品を組合せる (シンプル。LEGOのように)
クラスでデータとメソッドをまとめる
👉関数やクラスを小さい部品として使っていく
道程
指針を得る(小さい)
小さな部品の作り方の気づき (3点)
難所(インターフェース、継承)
小さな部品の作り方
小さい責務とは
入出力と計算判断
作ると使う
小さいは、正義!
1️⃣小さい責務とは
SOLIDのSについて
※ここでは責任と責務を同じものと扱っていきます
単一責任原則
モジュールを変更する理由はたったひとつだけであるべき
『Clean Architecture』第7章
単一責任の私の 誤解
数学の証明のように、この状況であれば 単一責任は絶対こう決まると示せる と私は思っていた
クラスの責務は絶対の解があるという思い込み
単一責任は 恣意的
クラスの責務は、何かの法則で機械的に決まるものではなく、将来の保守開発への想像から恣意的に生み出すもの (Kindle 版 p.152)
ちょうぜつ本 5.2
恣意的(スーパー大辞林)
その時々の思いつきで物事を判断するさま。
例:このデータベースドライバは単一責任?
データベースを読み取る
データベースに書き込む
読み書き(と 2つのこと を)やっているけれど、単一責任?
単一責任!(ちょうぜつ本の解答)
「ドライバのreadとwriteを分けるよりも、ドライバ ver1とver2を分けるのを優先」
別の文脈では、readとwriteを分けることがありうるという気づき
コード例
class DatabaseDriverInterface(metaclass=ABCMeta):
@abstractmethod
def write(self, key: str, data) -> None:
...
@abstractmethod
def read(self, key: str):
...
class DatabaseDriverVer1(DatabaseDriverInterface):
def write(self, key: str, data) -> None:
...
def read(self, key: str):
...
class DatabaseDriverVer2(DatabaseDriverInterface):
def write(self, key: str, data) -> None:
...
def read(self, key: str):
...
単一責任
将来のコードの変更を想像 して決める(保守、拡張)
単一責任が現実と合わなくなったら修正すればよいのか!
2️⃣入出力と計算を 分ける
分けて小さく、再利用しやすく
計算結果を書き込む処理(悪しき例)
def save_result(data, file_path):
result = []
for obj in data:
# 実際は、summaryの末尾に句点「。」がなければ補うなども入ってきます
result.append({"text": obj["summary"] + obj["detail"]})
with jsonlines.open(file_path, "w") as writer:
writer.write_all(result)
参考例:クラスの場合
class DataCollection:
def __init__(self, data):
self.data = data
def save(self, file_path):
result = []
for obj in self.data:
result.append({"text": obj["summary"] + obj["detail"]})
with jsonlines.open(file_path, "w") as writer:
writer.write_all(result)
少し後になって
「今から実装する処理、前に作った
save_result()
と 同じ計算 が出てくる。その計算結果をさらに計算するのか」「
save_result()
使えば最初の計算はできるな。あれ、これなんでファイルに保存までするの? 余計〜」
実はテストも書きづらかった
@patch("(Pythonの詳細なので略).jsonlines")
def test_save_result(jsonlines): # 書き込まないようモックにする
writer = jsonlines.open.return_value.__enter__.return_value
data = [
# 実際はsummaryやdetail以外の項目も含みます
{"summary": "すごい要約。", "detail": "かくかくしかじか、ほげほげふがふが"},
{"summary": "今北産業", "detail": "..."},
]
save_result(data, file_path)
writer.write_all.assert_called_once_with(
[
{"text": "すごい要約。かくかくしかじか、ほげほげふがふが"},
{"text": "今北産業。..."},
]
)
計算して保存する関数を書いていた私
計算部分だけ再利用できない (保存が余計)
テストコードはデータとその計算結果を含むので、縦に長くなる
🧭入出力と計算判断を分ける
計算と出力を分けて書く
def calculate(data): # データを受け取ってデータを返す
result = []
for obj in data:
result.append({"text": obj["summary"] + obj["detail"]})
return result
def write(file_path, data): # ファイルに保存する処理
with jsonlines.open(file_path, "w") as writer:
writer.write_all(data)
# calculateしてwriteするとして、先のsave_resultを実現
入出力と計算判断を分けた実装
計算 では メモリ上のデータ を扱う。再利用しやすい
永続化は出力処理で担う。計算は気にしなくてよい(出力形式の変更も対応できる)
小さく分かれたので、テストコードも書きやすい
ふりかえると、注目すべき箇所が見えていなかった
コードの行数に注目していた(N行くらいだから関数にしよう)
コードの 目的 (入出力、計算判断)に盲目でした
混ぜていたので、計算だけ使えない
入出力と計算判断を分けていく
過去の自分のコードをリファクタリングしていく
目的ごとに関数に 抽出
def save_result(data, file_path):
result = calculate(data)
write(result, file_path)
def calculate(data):
result = []
for obj in data:
result.append({"text": obj["summary"] + obj["detail"]})
return result
def write(file_path, data):
with jsonlines.open(file_path, "w") as writer:
writer.write_all(data)
元の関数を インライン化
# save_resultを呼び出していた箇所(呼び出しがなくなる)
result = calculate(data)
write(result, file_path)
参考: 基本 のリファクタリングテクニックでできちゃいます
『リファクタリング(第2版)』6章「リファクタリングはじめの一歩」
関数の抽出
関数のインライン化
関数宣言の変更(後述)
3️⃣作ると使うを 分ける
分けて小さく、拡張しやすく
例:外部のWeb APIを使ったOCR(光学文字認識)
画像中の文字を認識(例:標識)
ドキュメントの画像から文字を認識(例:帳票)
手元にクライアントを実装
画像中の文字を認識(例:標識):
ImageOcrClient
ドキュメントの画像から文字を認識(例:帳票):
DocumentOcrClient
recognize()
メソッドで使う
作ると使うが一体となった処理(悪しき例)
「ImageOcrClient
使うぞ!」
def execute_ocr(image) -> str:
client = ImageOcrClient()
result = client.recognize(image) # 読み込まれた画像を渡す
return result
参考例:クラスの場合
class OcrExecutor:
def __init__(self):
self.client = ImageOcrClient()
def execute(self, image):
result = client.recognize(image)
return result
少し後になって
「ドキュメントの画像からの文字認識も試したい」
DocumentOcrClient().recognize()
に画像を渡す「
execute_ocr()
あるけど、別に関数作ったほうがいいかー」
爆誕する似た処理
def execute_document_ocr(image) -> str:
client = DocumentOcrClient()
result = client.recognize(image)
return result
作ると使うが一体の関数を書いていた私へ
3つ目のクライアントを追加したら、
execute_ocr
の亜種をまた増やしますか?クライアントの文字認識結果を返すだけ。なのに
execute_ocr()
の拡張で済まないのはどうして?
🧭生成と使用の分離
『ちょうぜつ本』第7章
作ると使うを 分けよう
生成と使用
- 生成(作る):
処理が依存するモノ(例:
client
)を作ること- 使用(使う):
処理が依存するモノを使うこと
使う だけ
使う時に作るように実装しない
依存するモノは外で作られて 与えられる (依存性注入💉)
操作するだけ
作ると使うを分けて書く
def execute_ocr(client, image) -> str:
result = client.recognize(image)
return result
# ImageOcrClientを作ってexecute_ocr関数を呼び出す
作ると使うを分けた実装
与えられた
client
を使うだけ!(使い方は共通だった!)ImageOcrClient
を渡してもDocumentOcrClient
を渡してもよい画像を受け付ける
recognize()
メソッドを持ったモノならいいぞ(👉インターフェースの話へ)
作ると使うを分けていく
過去の自分のコードをリファクタリング
関数宣言の変更 (抽出 & インライン化)
一時的な関数に 抽出
def execute_ocr(image) -> str:
client = ImageOcrClient()
return xx_execute_ocr(client, image)
def xx_execute_ocr(client, image) -> str:
result = client.recognize(image)
return result
元の関数を インライン化
def xx_execute_ocr(client, image) -> str:
result = client.recognize(image)
return result
client = ImageOcrClient()
xx_execute_ocr(client, image)
関数名をrename
def execute_ocr(client, image) -> str:
result = client.recognize(image)
return result
client = ImageOcrClient()
execute_ocr(client, image)
まとめ🌯 小さな部品の作り方
小さい責務は 恣意的 に決める
小さく切り出す際は、コードの行数だけでなく 目的 にも目を向ける(入出力、計算)
依存するモノを引数で渡す(依存性注入)
道程
指針を得る(小さい)
小さな部品の作り方の気づき(3点)
難所(インターフェース、継承)
難所
インターフェースとまつわる原則
クラスの継承(失敗談)
1️⃣インターフェースの2原則
SOLIDから2つ
⚠️Pythonにインターフェースはありません
「インターフェースを実装」は 文法にありません
抽象基底クラス(の 継承)や、Protocolで実現します
言語にないために、私は理解するのに非常に苦労しました
1️⃣インターフェース分離原則(ISP)
SOLIDのI
インターフェースとは、 利用 時の関心
利用時、つまり使うとき
1つのインターフェースに、利用時の関心 複数を結合させない (小さな部品に分ける)
セルフわからん殺し:利用時の関心が複数ある大きなインターフェース
class DatabaseDriverInterface(metaclass=ABCMeta):
@abstractmethod
def write(self, key: str, data) -> None:
...
@abstractmethod
def read(self, key: str):
...
解決策:小さなインターフェースを組合せる
write()メソッドがあることを伝える
DataInputInterface
read()メソッドがあることを伝える
DataOutputInterface
2つを組合せた
DatabaseDriverInterface
ちょうぜつ本 5.5
組合せる例
class DataInputInterface(metaclass=ABCMeta):
@abstractmethod
def write(self, key: str, data) -> None:
...
class DataOutputInterface(metaclass=ABCMeta):
@abstractmethod
def read(self, key: str):
...
class DatabaseDriverInterface(DataInputInterface, DataOutputInterface):
# DatabaseDriverInterfaceを継承したクラスはreadとwriteを実装する必要がある
...
なぜインターフェースを小さくする?
使うときは概念の一部しか見えない から
read
メソッドがあるという使い方だけ伝えればよいread
と一緒にwrite
メソッドもあるということは、read
を使う上では不要
2️⃣依存性逆転原則(DIP)
SOLIDのD
(最小の)インターフェースに依存させる
使い方 (fooメソッドがある。 抽象)に依存
fooメソッドが具体的にどう実装されているかは一切気にしない
作ると使うを分けた先に
def execute_ocr(client: ImageOcrClient | DocumentOcrClient, image) -> str:
result = client.recognize(...)
return result
client = ImageOcrClient()
execute_ocr(client, image)
インターフェースを導入
def execute_ocr(client: OcrClientInterface, image) -> str:
result = client.recognize(...)
return result
画像を受け取れる recognize()
メソッドを持つ
差し替え可能になっている
recognize
メソッドがある 別のクラス (具象)を 注入 することもできる作ると使うが一体になり具象に依存していた状態からは、雲泥の差
「作ると使うを分ける」から 依存性逆転 へ
最初は引数に切り出しただけ
引数の共通の性質として、インターフェースを導入
結果、インターフェースに依存させられた!
2️⃣クラスの継承
ここで野球をしてはいけません
『達人プログラマー(第2版)』より
第5章に「インヘリタンス(相続)税」
継承が答えになることは滅多にない (Kindle版 p.380)
継承の代わりに
型情報の共有には、 インターフェース (やプロトコル)を
『達人プログラマー(第2版)』より
セルフわからん殺し:ほしかったのはインターフェース
(インターフェースとしての)抽象基底クラスで始めた
同時にTemplate Methodパターンも導入(あれ、インターフェース?)
class DoNotUseThisExample(metaclass=abc.ABCMeta):
def method(self, ...): # インターフェースのはずが
# 省略
retval = self.process(...)
# 省略
@abc.abstractmethod
def process(self, ...): # 穴空き問題にする(Template Method)
raise NotImplementedError
わからん殺しされて言える「気軽に継承してはいけません」
穴埋め問題(
process
の実装)に落とし込めないケースが後から登場🤯インターフェースが別で定義されていない のが痛い。穴埋め問題で吸収できないと全体を修正せざるを得ない
継承の代わりに(もう一つ)
機能の追加には、 委譲 を
『達人プログラマー(第2版)』より
委譲した例
直近公開したOSS kurenai (紅)
google-researchの rouge-score のラッパー
デフォルトで日本語テキストからもROUGEという指標を算出したい
rouge-scoreとインターフェースを揃えつつ、日本語対応という機能追加
>>> from rouge_score.rouge_scorer import RougeScorer
>>> scorer = RougeScorer(["rouge1"])
>>> scorer.score('いぬ ねこ', 'いぬ ねこ')
{'rouge1': Score(precision=0.0, recall=0.0, fmeasure=0.0)}
>>> from kurenai.rouge_scorer import RougeScorer
>>> scorer = RougeScorer(["rouge1"])
>>> scorer.score('いぬ ねこ', 'いぬ ねこ')
{'rouge1': Score(precision=1.0, recall=1.0, fmeasure=1.0)}
rouge-score.RougeScorerの継承でなく、 委譲 する
class RougeScorer(BaseScorer): # 本家RougeScorerと同じインターフェース(scoreメソッド持つ)
def __init__(self, rouge_types: list[str]) -> None:
self._scorer = OriginalRougeScorer(
rouge_types, tokenizer=AllCharacterSupportTokenizer()
)
def score(self, target, prediction): # 委譲による実装
return self._scorer.score(target, prediction)
https://github.com/ftnext/kurenai/blob/v0.0.1/src/kurenai/rouge_scorer.py#L9-L16
まとめ🌯 難所からの学び
インターフェースで 使い方 を小さく示す
具象ではなく、インターフェースに依存する(依存性注入からの発展)
継承は最後の選択肢(インターフェースや委譲を使う)
NEXT 変更しやすいコードを チーム で書く
次のわからん殺し(今の思考のダンプです)
コードはチームで書くもの
ソフトウェア開発はチームスポーツである(『Team Geek』p.3)
チームで、変更しやすいコードを書きたい!🔥
常時ペアプロ という環境
私は変更しやすいコードを書きたい!で、ソフトウェアについて知った部分が増えた。チームにも還元できる
nikkieがいなくなっても、ソフトウェアが書け、さらによりよくしていけるチーム、どうやる??
easyが必要な可能性?
小さな部品、その組合せはまずい(操作ミス)という事象
小さな部品を使いやすく組合せて提供する?
🌯まとめ:ソフトウェアを作りたかった私へ
振る舞いを変更しやすい 構造 のコードを書くには
小さい 部品(関数・クラス)を組み合わせた構造を採用した
🌯変更しやすいコードを書くコツが見えてきた今伝えられること 1/2
単一責務は恣意的
入出力と計算判断を 分ける
使うと作るを 分ける
🌯変更しやすいコードを書くコツが見えてきた今伝えられること 2/2
インターフェースとは 使い方。使うと作るを分けた先
継承は(理解するまでは)初手で使ってはいけません
謝辞❤️
登壇練習会に協力いただいた開発チーム有志(Hさん、Nさん、Hさん)
OOC 2024 スタッフの皆さま
知恵を書籍という形で伝えてくださった著者の皆さま
読書会共同主催のnibuさん、これまでの参加者の皆さま
And You!!
ご清聴ありがとうございました
皆に 幸 あれ!
引き続きOOCを楽しみましょう
お前、誰よ(詳細版)
毎月の みんなのPython勉強会 スタッフ
代表作:Sphinx拡張 sphinx-new-tab-link
コード例にした自作ライブラリ
この発表の元になったアウトプット
この発表の元になったアウトプット
登壇準備中のアウトプット
ソフトウェアを作りたかった私へ