ソフトウェアを作りたかった私へ

変更しやすいコードを書くコツが見えてきた今伝えられること

Event:

Object-Oriented Conference 2024

Presented:

2024/03/24 nikkie

聞きに来ていただき、ありがとうございます

変更しやすいコードを書きたいですか〜?📣

コール&レスポンス スライド

お前、誰よ(自己紹介)

  • nikkie / 毎日 ブログ 執筆、490日突破

  • ソフトウェア開発 2016年〜(歴8年)

  • 2019年〜 データサイエンティスト。 Python ・機械学習・ソフトウェアエンジニアリング(We're hiring!

../_images/uzabase-white-logo.png

Pythonでこんな開発しています

  • ファイルを入力し、別のファイルに出力する処理(を組合せた、機械学習パイプライン)

  • CLI(小さいライブラリを個人開発)

  • テストコード 書いてます(リファクタリング ゴリゴリ)

  • 経験が薄いもの:がっつりWebアプリ開発、DDD

変更しやすいコードを書きたい!🔥

変更しやすいコードというのはずっと憧れでした。(fortee

(そのコードがユーザに価値があるという前提で)

ソフトウェア(soft + ware)

  • ware:製品

  • soft: 振る舞いを変更 できる

ref: 『Clean Architecture』第2章

ソフトウェアの 2つ の価値

  • 振る舞い:ユーザが認知

  • 構造:開発者が認知

振る舞いと構造

  • ソフトさ(振る舞いを変更できるか)は、 構造による

  • 構造がソフトウェアをソフトにする

ref: 『Clean Architecture』第15章

振る舞いを変更できるコードを書きたい! のに

  • 過去の自分が最善を尽くしたコードの構造が、それを 妨げる 😢

  • 振る舞いの変更がめちゃくちゃ大変。ハードウェアじゃん

  • わからん殺し されている

セルフわからん殺し(ミノ駆動さん)

自分の外側に知識を求めた

  • Python使いの観点から、アウトプット中心の 読書会 を共同主催(2022年〜)

  • シーズン1 ミノ駆動本

  • シーズン2 ちょうぜつ本(次回最終回 予定)

本トーク「ソフトウェアを作りたかった私へ」では

  • 過去の私向け(振る舞いを変更しやすいコードを書きたいのに、作った構造に阻まれてしまう方)

  • 変更しやすいコードの 構造 について、セルフわからん殺しを共有・言語化

  • 👉 過去の私 にとっての、ソフトウェアを作る 知の高速道路

道程(お品書き)

  • 指針を得る(小さい

  • 小さな部品の作り方の気づき(3点)

  • 難所(インターフェース、継承)

訪れた書籍(本トークにおける 呼称まとめ

  • ミノ駆動本:『良いコード/悪いコードで学ぶ設計入門』

  • ちょうぜつ本:『ちょうぜつソフトウェア設計入門』

  • 増田本:『現場で役立つシステム設計の原則』

指針を得るパート

  1. シンプル

  2. クラスの使い所

1️⃣シンプル

変更しやすいコードの構造についての考え方

⚠️世間一般に言う「シンプル」と違う意味で使ってます

このトークで言うシンプルは、 LEGOブロック のイメージ

../_images/wikipedia-commons-512px-LEGO-01.jpg

https://commons.wikimedia.org/wiki/File:LEGO-01.jpg

LEGOブロック

  • ブロックの1つ1つは単純(2x2, 2x4)

  • ブロックを組合せることで どんな大きなものでも作れる (例:城)

../_images/11_Article_Biggest_Sets_Desktop_Hero_Std_desktop.jpg

世界のベスト レゴ®アーティスト より

小さな部品を組み合わせる

  • 変更しやすい構造のコードを書くために採用した戦略

  • 1つ1つの部品はLEGOブロックのようにして、組合せて機能を実現する

影響:Rich Hickeyの「Simple Made Easy」

参考:『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  # インスタンス変数

Python チュートリアル「9.3.5. クラスとインスタンス変数」

よい構造のコードとしてのクラスの例(ミノ駆動本 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:
        """回復する"""

クラスは データとロジックをまとめる もの

  • 文法を学べば、処理系が動作するクラスは書ける

  • コードの構造としてよいクラスも悪いクラスも、同様に動作してしまう

  • 良し悪しの 判断基準 「まとめているか」(過去の私はこれが持てていなかった)

データだけのクラスに注意⚠️

  • ロジックを持たない

  • クラスを使うコードがデータを取り出して加工してしまっている

  • 私はよくやってました

データクラス

  • 悪しき構造として知られる(増田本 第3章・ミノ駆動本 第1章)

  • リファクタリングでは、データを取り出して加工するコードを クラスの中へ移動する

クラスを使う側のコードよ、 してこうぜ

クラスにデータとロジックをまとめる効能

参考:「コーヒーちょうだい」

参考:増田本 第1章

使う側のプログラムの記述がかんたんになるように、使われる側のクラスに便利なメソッドを用意する (Kindle版 p.54)

まとめ🌯 得た指針

  • 小さい部品を組合せる (シンプル。LEGOのように)

  • クラスでデータとメソッドをまとめる

  • 👉関数やクラスを小さい部品として使っていく

道程

  • 指針を得る(小さい)

  • 小さな部品の作り方の気づき (3点)

  • 難所(インターフェース、継承)

小さな部品の作り方

  1. 小さい責務とは

  2. 入出力と計算判断

  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() の拡張で済まないのはどうして?

🧭生成と使用の分離

生成と使用

生成(作る):

処理が依存するモノ(例: 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. クラスの継承(失敗談)

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を実装する必要がある
    ...

Python実装例の全容

なぜインターフェースを小さくする?

  • 使うときは概念の一部しか見えない から

  • 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を楽しみましょう

お前、誰よ(詳細版)

コード例にした自作ライブラリ

この発表の元になったアウトプット

この発表の元になったアウトプット

登壇準備中のアウトプット

ソフトウェアを作りたかった私へ

EOF