はじめに
この記事ではpythonをメイン言語とする筆者が、Javaで書かれた設計の名著 「良いコード/悪いコードで学ぶ設計入門」を読んで躓いた急所について、pythonの文脈で解釈を試みます。
同じ境遇のpythonエンジニアの方の助けになればと思っています。
不正確な部分や改善点、アイディアなどありましたら是非コメントいただけますと幸いです。
データクラス (1.3節)
本書ではデータクラス(変数の管理のみを目的としたクラス)の利用を非推奨し、ロジックをまとめたバリューオブジェクトとしての利用を推奨しています。
Pythonにはこれを実現する仕組みとして@dataclasses
があります。
dataclasses
Python 3.7で導入されたdataclassは、基本的なデータハンドリングのメソッドを提供することによりクラス定義を容易にするデコレーターです。
具体的には、インスタンスの初期化(__init__
)、インスタンスの比較(__eq__
)などを自動で定義してくれるため、値の操作が中心となるクラスの定義をきれいにとどめることができます。
from dataclasses import dataclass
@dataclass
class InventoryItem:
name: str
unit_price: float
quantity_on_hand: int = 0
def total_cost(self) -> float:
return self.unit_price * self.quantity_on_hand
dataclassは一見本書のアンチパターンであるデータクラスそのものに見えますが、以下の点でpythonでは活用してよいと考えています。
- 値を操作するメソッドを実装できる
- 変数の管理のみを目的としたクラスの利用は非推奨ですが、pythonのdataclassでは値を操作するメソッドも同一のクラス内で定義することが前提となっているため、アンチパターンに当てはまりません。
- 例えば上記のpythonの例のように独自にメソッドを定義することもできますし、デフォルトの状態でもインスタンス同士の同一性を比較するメソッド(==)が実装されるため、狭義のデータクラスではないといえます。
- immutable にできる
- 本書ではインスタンス変数をimmutableにすることを推奨しています。dataclassデコレーターを用いると、
frozen=True
の引数を渡すだけでこれを実現できます。
- 本書ではインスタンス変数をimmutableにすることを推奨しています。dataclassデコレーターを用いると、
- Value Objectとして扱える
- 本書では値をプリミティブな型(int, str)として扱うのではなく独自に定義することを推奨しています。
- これはバリューオブジェクト(Value Object、値オブジェクト)という概念であり、安全性と拡張性を担保することが期待されます。
- dataclassデコレーターを用いると単なるクラスではなくValue Objectの性質を持つことを明示的に表現できます。
参考
- 公式ドキュメント
- Python3.7からは「Data Classes」がクラス定義のスタンダードになるかもしれない
- dataclassを使って、Pythonで値オブジェクトを実装する
- ValueObjectという考え方
- typing.Finalでimmutableに
型指定(3.2節)
本書では値をプリミティブな型(int, str)として扱うのではなく独自に定義することを推奨しています。
その最大の利点は以下の二点です。
- 値の渡し間違いの防止: 引数に指定した型と異なる型が与えられるとコンパイル時点でエラーが生じます。
- ロジックの集中: Value Objectとして値とロジックを一か所に実装できます。
しかしpythonは動的型付けであるため標準の仕組みでは「1. 値の渡し間違いの防止」を実現することができません。
pythonで型指定を行うには、①型アノテーションの記述と②型チェックツールを利用する必要があります。
そして型チェックを自動で行う仕組みとしてGit Hooksやgitlab-ciがあります。
型アノテーション、型ヒント
pythonでは変数の宣言、引数の宣言、関数の返り値に対して型を記述することができます。
この仕組みはプログラムの動作を変えることなく、コードの見通しを改善する仕組みとして利用されています。
例えば以下のように記述するとnameにはstr, ageにはintで値を渡してインスタンス化する仕様だと見通しが立ちます。
しかし、これとは異なる型の値を渡したからといってコンパイル時点でエラーが出ることはありません。
class person:
def __init__(self, name:str, age:int):
self.name = name
selg.age = age
型チェックツール
型アノテーションを開発者へのヒントに留めず、機械的な整合性のチャックにまで活用する仕組みとして型チェックツールがあります。
型チェックツールは開発中のプログラムを引数として渡すとプログラム内の型宣言と引数として与えられている値の型が整合しているか確認をしてくれるツールです。
デファクトスタンダードのツールとしてmypyがあります。
例えばsample.pyというファイル中で型アノテーションを施し、誤った型の値を渡した場合には以下のようなエラーメッセージが表示されます。
開発者はこのエラーメッセージを参考にコードを修正することができます。
pip install mypy
mypy sample.py
# sample.py:2: error: Incompatible types in assignment (expression has type "str", variable has type "int")
# Found 1 error in 1 file (checked 1 source file)
型チェック自動化
型チェックツールは動的型付けの欠点を補ってくれる有効なツールであるものの、毎回手動でツールを実行するのは手間です。
そこで型チェックを自動で行う仕組みとしてGit Hooksやgitlab-ciがあります。
Git Hooksはgitの実行の前後に指定のスクリプトを自動で実行する仕組みです。
その機能の一部であるpre-commitを設定することで、コミットの直前に型チェックを自動で実行することができます。
またGit HooksはGit標準の機能であるためスタンドアローンで設定が可能ですが、pre-commitというフレークを使うとより簡単に設定ができます。
また、リモートサーバー上で型チェックを自動化する方法もあります。
gitlab-ciはコミットを検知すると設定された処理手順を自動で実行する仕組みです。
GitlabではGitlab runnerと呼ばれる実行環境まで無料で提供されているため、Gitlab上での設定で完結します。
参考
- 型アノテーション
- 型チェックツール
- pre-commit
- Gitlab CI
少し脱線しますが、型チェックの自動化と同じ仕組みでコードの整形やコーディング規則との比較をしてくれる仕組みも存在します。
- linter, formatter
スレッドセーフ (4.2節)
本書ではマルチスレッドで処理した際の予期せぬ挙動を防ぐために、イミュータブル型や返り値として別インスタンスの生成を推奨しています。
マルチスレッドで予期せぬ挙動が生じる理由はスレッド間でインスタンス変数の参照先が同じであり、かつ編集できることに由来します。
このような状態をスレッドセーフでないといい、逆にスレッドごとに独立している状態をスレッドセーフであるといいます。
本書では言及されていませんが、pythonでスレッドセーフにするには排他制御(ロック)という仕組みを利用することもできます。
排他制御、ロック
pythonでロックを行うにはthreadingのLockをインスタンス化し、このコンテキストマネージャー内でインスタンス変数を操作します。
import threading
from concurrent.futures import (ThreadPoolExecutor, wait)
class Counter:
lock = threading.Lock()
def __init__(self):
self.count = 0
def count_up_to_1000000(self):
for _ in range(1_000_000):
with self.lock:
self.count += 1
def worker(counter):
counter.count_up_to_1000000()
def main():
counter = Counter()
with ThreadPoolExecutor() as executor:
features = [executor.submit(worker, counter) for _ in range(3)]
print(counter.count)
if __name__ == '__main__':
main()
(コードはこちら から拝借しました)
参考
ファクトリメソッド (5.2節)
本書ではコントラクタ以外のstatic methodによってインスタンスを生成する方法を紹介しており、このmethodをファクトリメソッドと呼んでいます。
なお、日本語でpythonのファクトリメソッドについて調べるとデザインパターンのFactory Methodパターンがよくヒットしますが、本書の指すファクトリメソッドとは異なることにご注意ください。
from math import sin, cos, pi as π
class Vector(object):
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
@classmethod
def from_cyl(cls, ρ, θ, z):
return cls(ρ * cos(θ), ρ * sin(θ), z)
def __repr__(self):
return "<{0.__class__.__name__}({0.x}, {0.y}, {0.z})>".format(self)
(コードはこちらから拝借しました)
なお、@staticmethod
でも同様のファクトリメソッドを実装できます。
インターフェース (6.2節)
本書では、共通の役割を果たすメソッドを有する複数のクラスを統一的な方式で操作する手法として、インターフェースを紹介しています。
これはJava独自の概念でそのままPythonに翻訳できないようですが、Pythonで活用できる類似した手法としてAbstruct BaseクラスとProtocolがあります。
言語のスタイルに基づく多少の差はあれど、いずれもポリモーフィズムを実現するための手法といえます。
Abstruct BaseクラスとProtocol
Abstruct BaseクラスとProtocolはどちらもポリモーフィズムを実現するために活用される手法ですが、その背景には抽象化(Abstruct Baseクラス)とダックタイピング(Protocol)という異なる概念があります。
抽象化(Abstruct Baseクラス)では共通の特徴を持つクラスから共通部分を抽象化してまとめ上げることで操作の統一を図ります。
例えば犬、猫、鳥というクラスを抽象化して「動物」クラスの派生であると解釈し、「移動する」、「食事をする」、「鳴く」といった挙動の命令を統一のメソッド名にします(ポリモーフィズム)。また、これらが実装されていないとプログラム実行時にエラーがでるようにします。
このような仕組みをPythonで実現するには以下のように行います。
- まず抽象基底クラスとなるスーパークラスを定義します。このスーパークラスには、共通部分となるメソッドの定義をし、それに対して@abstractmethodデコレーターを付けます。@abstractmethodデコレーターを付けることで、スーパークラスで定義したメソッドがサブクラスでオーバーライトされることを強制することができます。処理はpassとします。
- 次に、抽象基底クラスを継承したサブクラスを定義します。このサブクラスのなかで、スーパークラスで@abstractmethodのついていたメソッドをオーバーライトして実際の処理を実装します。このようにして、サブクラスでスーパークラスで定義したメソッドを実装することで、共通のメソッド名で操作を統一することができます。
- なお、サブクラスのなかで必要なオーバーライトが成されていない場合には、プログラム実行時にTypeErrorが発生します。
from abc import ABCMeta, abstractmethod
class Animal(metaclass=ABCMeta):
@abstractmethod
def bark(self):
pass
class Cat(Animal):
def bark(self):
print("Meow")
ダックタイピング(Protocol)では共通の機能を持つクラスをグループとして認めることで操作の統一を図ります。
例えば、犬、車のクラスは特徴でみると同じグループではありませんが(すなわち抽象化の対象として不適格)、どちらも質量をもっているため、質量を返す機能を実装できます。この質量を返す機能が実装されているクラスならば統一的な方法で質量を知ることができるため、機能として同じグループと認めます。ダックタイピングの文脈では本や惑星も同じグループとして認められます。
一方である統一的な機能が実装されていないクラスについては同じグループと見做せないため、実行時にエラーとなる仕組みとなっています。
このような仕組みをPythonで実現するには以下のように行います。
- まずグループの機能を表すクラスを定義し、その中で統一的なメソッドを定義します。処理は...とします。
- 個別にクラスを定義し、その中で統一的なメソッドを定義します。クラスごとに具体的な処理を記述する必要があります。
- 統一的なメソッドを実行する関数を定義します。この関数は個別クラスのインスタンスを引数としますが、タイプヒントとしてグループのクラスを型として指定します。
- 統一的なメソッドが実装されていない個別クラスのインスタンスをこの関数の引数とするとエラーが生じます。
from typing import Protocol
class Matter(Protocol):
def get_weight(self) -> str:
...
class Dog():
"実装されている"
def get_weight(self) -> str:
return 10
class Car():
"実装されている"
def get_weight(self) -> str:
return 500
class Song():
"実装されていない"
def get_artist(self) -> str:
return "mozart"
def func(matter: Matter):
matter.get_weight()
func(Dog())
func(Car())
func(Song()) #Error
参考
- PythonでProtocolを使って静的ダック・タイピング
- PythonのABC - 抽象クラスとダック・タイピング
- PythonにおけるProtocol(ダックタイピング)とABC(抽象化)の違い
- 抽象クラスとインターフェース 概念の違いについて【オブジェクト指向】