AI時代のクラス設計
最近は、AIに指示してコードを書いていますが、なんか出力されるコードがパッと見て「うーん」となることが多いです。
Pythonというのも関係があるのかもしれませんが、関数だけ書かれていて、クラス設計がされていないことが多いです。
そのせいで、冗長なコードがみられたり、関数が肥大化、保守性の低いコードになったりします。
改めて、クラス設計の基本を整理して、実務に落とし込むことを意識してみます。
なぜクラス設計が必要か
Pythonは関数だけでも十分に開発できるため、クラスを使う判断が後回しになりがちです。
AIだとコードを書く力が凄まじいので、フラットに関数で書かれて処理が機能してしまいます。
フラットに書かれてしまうとどの処理と関連しているのか、汎用的に使えるのか、コードを少し修正したいとき関係ない処理にまで影響してしまうことがあります。
そのため、クラス設計を意識して、責務分離をすることが大事です。
(クラスが当たり前なJavaとかだと、AIも意識してクラス設計してくれるのかな?)
この記事でのゴールは「クラスを使うこと」ではなく、「変更に強い責務分離を実現すること」です。
クラスの基本整理
クラスは「状態」と「振る舞い」をひとかたまりで扱うための設計単位です。
たとえば車なら、状態は速度や燃料、振る舞いは走る・止まる・曲がるです。
ここで大切なのは、機能を増やすことではなく、役割の境界を明確にすることです。
境界が曖昧だと、変更の影響範囲が読めず、デバッグやテストのコストが急増します。
継承・オーバーライド・多態性
継承は、共通の振る舞いを再利用しつつ、子クラスで拡張するための仕組みです。
実務では「共通化できるか」だけでなく、「将来の差分を安全に追加できるか」で判断すると失敗しにくいです。
親クラスの契約を崩さずに子クラスで差し替えることが、多態性の価値になります。まずは最小例でイメージを固めます。
class Car:
def run(self) -> None:
print("車が走る")
class Ambulance(Car):
def run(self) -> None:
print("救急車が走る")
def siren(self) -> None:
print("サイレンを鳴らす")
補足として、PythonにはJavaのようなシグネチャ違いによるメソッドオーバーロードは標準ではありません。
必要ならデフォルト引数、*args、functools.singledispatchなどを使って表現します。
この違いを理解しておくと、言語間の設計移植で混乱しにくくなります。
抽象化で設計の軸を作る
抽象化は「何を満たせば同じ種類とみなすか」を定義する行為です。
実装方法ではなく、契約を先に固定することで、チーム開発でも実装の差し替えが容易になります。
abc.ABCと@abstractmethodは、設計の意図をコードとして明示できるため、後から読む人にも責務が伝わりやすくなります。
from abc import ABC, abstractmethod
class Vehicle(ABC):
@abstractmethod
def run(self) -> None:
raise NotImplementedError
class Ambulance(Vehicle):
def run(self) -> None:
print("救急車が走る")
カプセル化は「禁止」ではなく「保護」
カプセル化は、内部状態の不正な変更を防ぎ、クラスの整合性を守るための考え方です。
Pythonは厳密なアクセス修飾子を持ちませんが、命名規約やpropertyで十分に意図を表現できます。
外部公開したい操作だけをインターフェースに残すと、仕様変更時の影響点を限定でき、長期運用で効いてきます。
class BankAccount:
def __init__(self, balance: int = 0) -> None:
self._balance = balance # 外部から直接触らない内部状態
@property
def balance(self) -> int:
return self._balance
def deposit(self, amount: int) -> None:
if amount <= 0:
raise ValueError("amount must be positive")
self._balance += amount
実務で使える設計例: ReaderとParserの分離
ここからが本題です。クラス設計で迷ったら、まず機能を言葉に分割します。
たとえばファイルを処理するといっても「ファイルを読む」と「内容を解釈する」は別責務です。
これを1クラスに詰め込むと、フォーマット追加のたびに修正箇所が増えます。
ReaderとParserを分離すると、CSV追加やTSV追加が局所変更で済み、テストもしやすくなります。
from abc import ABC, abstractmethod
from pathlib import Path
class FileParser(ABC):
@abstractmethod
def parse(self, text: str) -> list[list[str]]:
raise NotImplementedError
class CSVParser(FileParser):
def parse(self, text: str) -> list[list[str]]:
return [line.split(",") for line in text.splitlines()]
class TSVParser(FileParser):
def parse(self, text: str) -> list[list[str]]:
return [line.split("\t") for line in text.splitlines()]
class FileReader:
def __init__(self, parser: FileParser) -> None:
self._parser = parser
def read(self, file_path: str) -> list[list[str]]:
text = Path(file_path).read_text(encoding="utf-8")
return self._parser.parse(text)
この構成は継承よりも合成を優先しています。
実務では、拡張点が明確なら「継承で派生を増やす」より「依存オブジェクトを差し替える」ほうが保守しやすいケースが多いです。
設計の正解は1つではありませんが、責務分離と変更容易性を軸にすると判断が安定します。
実務だと、なんらかの処理ラインに対して、具体的な処理を差し替えたいケースが多いです。
まとめ
クラスの考えですが、難しく考える必要はありません。
ふんわりと処理のまとまりを意識して、責務を分離したいなと思うときに、クラスが使えるのではと考えるくらいで十分です。
設計に対して、正解があるわけでもないですし、実装背景を知る機会などがあればいいのですが、会社に入るとレガシーコードに触れる機会が多くあります。
大抵、レガシーすぎて当時の設計意図がわからないことが多いです
なので、残念すぎるコードを見ても、こうすればよかったのにと思えたら、次の設計に活かせると思います!
もし興味があれば、デザインパターンの本を読んでみると、設計の考え方がより深く理解できると思います。