ロバストPythonを読む
1章 ロバストPython入門
クリーンなコードとはその意図を明確かつ簡潔に表現するコードのことだ(明確さのほうが簡潔さよりも重要だ)。コードを読んでその意味を完全に理解したと思うならば、それはクリーンなコードだ。
将来の開発者に意図が伝わっていない状況でこのコードを保守するには、次のどちらかを選択することになる。
- すべての呼び出し元のコードを読んで、この動作に依存しないことを確認してから新しいコードを実装する。このコードが外部から呼ばれる可能性があるライブラリの公開APIならば天運を祈るほかない。
- コードを書き替えてどのような影響が生じるかを見守る。運が良ければ何も悪いことは起きないだろう。運が悪ければユースケースの修正に膨大な時間を費やし、いらだちを隠せなくなるだろう。
コードの意味が分からないと言って私に尋ねに来ることもなくなるだろう。私と話をしなくても何をしているか理解できるはずだ。これは非同期コミュニケーションである。
目的に合わないコレクションを使ってはならない
- 重複が含まれてはいけないものを格納したリスト
- キーから値へのマッピングのために使われていない辞書
静的インデックスとは、定数のリテラルを使ってコレクションの要素を参照すること
- ユーザ定義型が必要になっている兆候
ただし、
- シーケンスの先頭や末尾
- 辞書をJSONやYAMLを読みだす際の中間的なデータ型として利用する場合
- 固定長のチャンクを持つシーケンスの操作
- パフォーマンス
によって許容される場合がある
驚き最小の原則: 未来のコラボレータが動作や実装に驚くことがないようにする
複雑さには必然的な複雑性 と偶発的な複雑性の2種類がある。
偶発的な複雑性を取り除き、必然的な複雑性を狭い範囲に封じ込めることが必要。
2章 Pythonデータ型
- 機械的な表現
- データ型はPython自体に振る舞いと制約を与える
- 意味論的な表現
- データ型は他の開発者に振る舞いと制約を与える
型システム
-
強い型付け
- データ型がサポートする演算しか使えない(使おうとするとコンパイルエラーやランタイムエラー)
-
弱い型付け
- データ型がサポートしない演算を使ったら強制的に演算が意味を持つ他の型に変換される
-
静的型付け
- ビルド時に型情報を埋め込む
- 実行時に型が変化しない
-
動的型付け
- 変数ではなく、値に型情報を埋め込む
ダックタイピング
何らかのインターフェースに準拠する限り、異なる種類のオブジェクトをそのようなものとして扱える性質
- python なら iter() メソッドが実装されていればfor文で使えるとか
ダックタイピングは諸刃の剣
- 抽象化されたインターフェースを構築できれば、様々な特殊ケースへの対応を減らせる
- 濫用すると開発者が信頼できる前提条件が壊れる
3章 型アノテーション
私は苦い教訓を学んだ。確かに小さなプログラムでは動的型付けはすばらしい。しかし、大規模なプログラムではもっと規律の取れたアプローチが必要である。「君は何でも好きにして良いんだよ」などといわずに言語自体がその規律を与えたほうが効果的なのだ
From: https://oreil.ly/1xf01
クリーンアーキテクチャにそんな感じのこと(プログラミングパラダイムとは制約である、みたいな)書いてあった気がする
型アノテーションを書いてmypyを使えと書いてある
4章 型制約
防御的プログラミング自体は良いことだが、…Pythonではあらゆる値がNoneになり得るため、防御的プログラミングを実践するとすべての変数参照の前に is None チェックを入れなければならないが、それはやり過ぎである
Go言語くん…
Optional
- Optional型は値を持つか持たないかの2種類の選択肢を提供する
- 宣言されているものがNoneである可能性を予想しなければならないという危険信号を送れる
- 空値と値の非存在を区別できる
- 要素のないリスト:エラーは発生していないがデータが存在しない
- None: 処理しなければいけないエラーが発生した
- mypy がNoneチェックをできる
- mypy 0.961 の場合、型アノテーションのない関数には関数本体の型チェックが行われないので注意
Union
Union[int, str]
は int
または str
が使えるという意味
- Python 3.10 以降では
int | str
と書ける
Literal
値の取る範囲を制限できる
Literal[1, 2, 3, 4, 5]
Literal["プレッツェル", "ホットドッグ", "べジーバーガー"]
Enum
と何が違うのとおもったけどそれは8章に書いてあるらしい
Annotated
サイズや、値の範囲や、正規表現にマッチするもののみ、などの制限を与える
Annotated[int, ValueRange(3, 5)]
Annotated[str, MatchesRegex("[0-9]{4}")]
現段階では型チェックできない?らしくヒントでしかないらしい?
NewType
NewType は既存のデータ型を受け付け、その既存型のすべてのフィールドとメソッドを持つ新しいデータ型を作る。両者は交換不能である。
- 暗黙の型変換が制限される
ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)
def dispense_to_customer(hot_dog: ReadyToServeHotDog):
- ReadyToServeHotDog が求められているところで HotDog を使えない。
- HotDog の代わりに ReadyToServeHotDog を使える
ReadyToServeHotDogを作るための方法を制限することで安全にはできるが、その方法を開発者に知らせる方法はコメント以外にない。
しかし、次のような場面で役に立つ。
- SQL インジェクションを検知するために str と SanitizedString を区別する。
- User と LoggedInUser を区別する
- 有効なユーザIDをさらわす整数を追跡する
- ちょっとこの例はよくわからなかった
クラス化して制限をもりもり書いた方が汎用性があるがNewTypeのほうが軽量である
Final
値が不変であることを示す
- 変数スコープがモジュール全体のような広い範囲に及ぶ場合に使うとよい
5章 コレクション型
ジェネリクス
from typing import TypeVar
T = TypeVar("T")
def reverse(coll: list[T]) -> list[T]:
return coll[::-1]
- T型の要素のリストを受け取り、同じT型の要素のリストを返すことができる
- 違う型は返せない
- Tは具体的には何でもよい
from collections import defaultdict
from typing import Generic, TypeVar
Node = TypeVar("Node")
Edge = TypeVar("Edge")
# 有向グラフ
class Graph(Generic[Node, Edge]):
def __init__(self):
self.edges: dict[Node, list[Edge]] = defaultdict(list)
def add_relation(self, node: Node, to: Edge):
self.edges[node].append(to)
def get_relations(self, node: Node) -> list[Edge]
return self.edges[node]
- Nodeに入る型は何でもよいがすべて同じであればよい
- Edgeに入る型は何でもよいがすべて同じであればよい
- おそらくインスタンスごとで一貫していればよい、インスタンス間での一貫性は必要ない(保証されない)
こう書けば、あらゆる要素に対するグラフを定義でき、それらに対して型チェックをかけられる
cookbooks: Graph[Cookbook, Cookbook] = Graph()
recipes: Graph[Recipe, Recipe] = Graph()
cookbook_recipes: Graph[Cookbook, Recipe] = Graph()
- このグラフって何を表したいのか?
ジェネリクスのその他の例
APIからのエラーをUnionにして返す場合
T = TypeVar("T")
APIResponse = Union[T, APIError]
def get_nutrition_info(recipe: str) -> APIResponse[NutritionInfo]:
pass
def get_ingredients(recipe: str) -> APIResponse[llist[Ingredient]]:
pass
def get_restaurants_serving(recipe: str) -> APIResponse[list[Restaurant]]:
pass
新しいコレクション型の定義
- 組み込みデータ型を継承すると落とし穴あり
- 組み込みコレクションはパフォーマンスを意識して作られており、多くのメソッドはインラインコードを使っている。
- dict の
__getitem__()
のようなメソッドをオーバーライドしても、そのほかのメソッドはそのオーバライドされたメソッドを使わない。
この場合は、collections.UserDict
を継承するといい感じになる
collections.abc を使うとよりローレベルでいろいろできるが嬉しさがよくわからなかった
ただし、collections.abc.Iterable
は便利そうな感じがする(わざわざcollections をインポートしないといけないのか、これぐらいTypingに入ってほしいけど)
def print_items(items: collections.abc.Iterable):
for item in items:
print(item)
6章 型チェッカのカスタマイズ
まあ困ったらあとで読めばいいか、の話かと思った
- テイント解析する Pysa は便利そうなので使ってみたい。
7章
- monkytype: pythonファイルを実行して実際に入った型から型アノテーションを追加するツール
- Pytype: 静的解析で型アノテーションをつけるツール
- 型チェッカの機能も持っている
8章 列挙型
- Flag 型
from enum import auto, Flag
class Allergen(Flag):
FISH = auto()
SHELLFISH = auto()
TREE_NUTS = auto()
PENUTS = auto()
ビット演算で選択肢を組み合わせられる
allergens = Allergen.FISH | Allergen.SHELLFISH
- 内部的には、auto()が2のべき乗を生成するので、 & や | を使えるようになる
9章 データクラス
データクラスがイミュータブルであるとき、frozen=True
を書く
@dataclass(frozen=True)
class Ingredient:
name: str
amount: float = 1
units: ImperialMeasure = ImperialMeasure.CUP
データクラスの機能
- 文字列への変換 (
__str__()
と__repr__()
) - 等価比較
- 定義の際に
eq=True
を指定する-
指定しないとフィールドが全部一緒でも違うものとして判定されるってこと?- デフォルトでTrueだった。
- https://docs.python.org/ja/3.10/library/dataclasses.html#dataclasses.dataclass
-
- 等価比較動作を変えたいときは
__eq__()
をオーバーライドする
- 定義の際に
- 比較
- デフォルトでサポートしていないが、
order=True
で定義された順の比較を行う -
__lt__()
や__le__()
とかをオーバーライドすると比較動作を変えられる(order=True
にはしない)
- デフォルトでサポートしていないが、
どこでもデータクラスを使うべきだというわけではない。...本当に適切なのはデータクラス内でのメンバーが互いに独立している場合だけだ。...開発者はいつでもデータクラスの値を書き換える可能性があり、それによって無効な状態が生み出される危険性がある。
こういう時はクラスを使おうと書いてある
10章 クラス
クラスは、辞書やデータクラスが簡単には伝えられない重要な情報を伝える。それは不変式だ。
不変式とは、エンティティの生涯にわたって変化しないエンティティの性質のことである。...例えば次のようなものだ。
- すべての従業員は一意なIDを持つ。
- ゲームの敵は、ヒットポイント(HP)が整数の場合以外は攻撃しない。
- 円は正の半径しか持てない。
- ピザのチーズは必ずソースの上に置く。
不変式を維持するには、次の2つの方法がある
- 例外を送出する
- ファクトリメソッドを使って不変式が満たされない場合にNoneを返すこともあり
- データを操作する
次のような場合、クラスで不変式チェックをするべき
- 型システムで検知できない形でデータに制限を加えるべきか
- フィールド間に相互依存関係はあるか
- データに関して提供したい保証はあるか
単一責任の原則は、関連する不変式のグループを定義し、そういったグループごとにクラスを書くとみたされる。不変式と関係のない属性やメソッドを書いていることに気づいたら、責任が多すぎる可能性がある。
不変式の伝え方
- docstring に不変式を自然言語で書くといいよと書いてある
- コンテキストマネージャ?を使ってユニットテストを書く
カプセル化
- パブリック
- プロテクト: クラスと派生クラスがアクセスできる
- アンダースコア(_)を1つ
- プライベート: クラスのオブジェクトだけがアクセスできる
- アンダースコア(_)を2つ
すべての属性にゲッターとセッターをつけるのはよくある間違い。クラスの中身がほとんどゲッターとセッターなら、データクラスを使うべき。
不変式を意識しないメソッドとか、クラスのメンバさえ意識しないメソッドは、おそらく関数にすべきだろう。... すでにクラスが膨れ上がっていても、クラスとは無関係なメソッドをもう1つ追加したくなるが、保守性を追求するなら、クラスとは無関係なメソッドを置くべきではない(こうすると、奇妙な依存の連鎖が発生する。ファイルが別のファイルに依存する理由を改めて考えてみると、答えはこれだったということがよくある)。
@staticmethodと@classmethodは遺構であると言ってる
とてもいい図があったのでmermaidで書いてみる
11章 インターフェース
コードインターフェースのパラドックス
インターフェースを正す機会は1度しかないのに、実際に使われるまでインターフェースが正しいかどうかはわからない。
スコット・メイヤーズの言葉に従う
インターフェースは、正しく使うことが簡単で間違った使い方がしにくいように作れ
12章 部分型
基底クラスのオブジェクトを要求する関数に派生クラスのオブジェクトを渡せて、型チェッカも全く文句を言わないことだ。
あれ、なんか文句言ってきた記憶あるんだけど… 条件があるのかな
正方形長方形問題とLiskov置換原則の話が書いてある
この問題の解決方法は複数ある。
第一の方法はそもそもSquareはRectangleを継承できないと割り切ることだ。...
第二の方法は、フィールドをイミュータブルにするなどの方法でRectangleのメソッドに制限を加え、Squareが矛盾を起こせないようにすることだ。
第三の方法は、両者の間にクラス階層を作らず、Rectangleにis_square()メソッドを設けることだ。
- 不変式
- 部分型は元のデータ型の不変式をすべて守らなければならない
- 事前条件: データを操作する前にみたされるべき条件
- 部分型は元のデータ型の事前条件を厳しくしてはならない
- 事後条件: データを操作した後にみたされるべき条件
- 部分型は元のデータ型の事後条件を緩めてはならない
危険信号の例
- 引数の条件チェック
- 基底クラスよりも厳しい引数の条件チェックを行っている可能性がある
- 早期リターン
- 事後条件のチェックがされていない可能性がある
- 例外送出
- 上位型と同じ例外か派生形しか送出してはいけない
- 基底クラスで例外が起きる可能性を示していない場合に、派生クラスで例外を送出するのは論外
- super()を呼ばない
- 同じ動作をしていない可能性がある
継承するときのガイドライン
基底クラスを書くとき
- 不変式を変えない
- 不変式でプロテクト属性に制限を加える場合には特に注意する
- 不変式をドキュメントに書く
派生クラスを書くとき
- 基底クラスの不変式を把握する
- 基底クラスの機能拡張をする
- 現在の不変式と調和しない機能が追加される場合は、基底クラスに機能を追加すべき
- オーバーライドできるメソッドをサポートしない場合は、基底クラスに機能をサポートするかどうかを示すフラグを追加する
- オーバーライドではsuper()を入れる
コードの再利用のためだけに継承を使うのは誤り。
上位型の代わりに部分型が使えるという関係(is-a関係)をモデリングすることが主目的。
上位型を受け入れるコードで、部分型が使われることがないならば、has-a関係(コンポジション)を検討すべき。
13章 プロトコル
完全に部分型にならないときどうする?
インターフェース使えばいいんじゃない?という案
単純ならばこれでもいいかも
もっと複雑になったらどうするか?
DinnerもSplittableにしたくなったらどうするのか?
ミックスインは解決案の一つである。
class BLTSandwich(Shareable, PickUppable, Substituable, Splittable):
pass
上位クラスを汚さずに下のクラスに責任を転嫁することができる。
型チェックのために必要な変更を最小限に抑えたいところだ。また、基底クラスをインポートする際に物理的な依存関係を持ち込むことになるが、それが望ましくない場合もあるだろう。
ちょっとピンと来ていない
Pythonicなやり方はプロトコルである。
最も慣れ親しんだプロトコルはイテレータプロトコルである。
from collections.abc import Iterator, MutableSequence
from random import shuffle
class ShuffleIterator:
def __init__(self, sequence: MutableSequence):
self.sequence = list(sequence)
shuffle(self.sequence)
def __iter__(self):
return self
def __next__(self):
if not self.sequence:
raise StopIteration
return self.sequence.pop(0)
my_list = [1, 2, 3, 4]
iterator: Iterator = ShuffleIterator(my_list)
for num in iterator:
print(num)
- Iterator を継承しなくてもいい
- ShuffleIterator がイテレータとして使うために必要な2つのメソッドを持っているから
プロトコルとして Splittable を定義すればいい
from __future__ import annotations
from typing import Protocol
class Splittable(Protocol):
cost: int
name: str
def split_in_half(self) -> tuple[Splittable, Splittable]:
"""実装不要"""
pass
BLTSandwitch は Splittable を継承しなくていい。
Splittable に依存する関数にはこう書く
def split_dish(dish: Splittable) -> tuple[Splittable, Splittable]:
pass
これで動的型付けの利点を生かしつつ型チェックできる
でもこれってSplittableの定義を継承ツリーから外しただけで依存性は変わってないからどうなんだろう
型チェッカと人間に読ませるだけであって処理系は読まないから関係ないのかな(実際メソッドの中身は何も定義していないのだし)
契約がデータ型の構造を定義するだけ(特定の属性が定義されていることを要求するだけのSplittableのように)ならば、プロトコルを使う。
上位型の契約が、守らなければならないふるまいとして特定の条件の下での操作方法などを定義するなら、is-a関係をよりよく反映する継承を使うといい。
13.3.3 プロトコルを満たすモジュール
何言っているかわからなかった
14章 pydantic
pydantic は dataclass に制約をつけられるライブラリ。
確かに便利なんだけど、いろんな人が使うライブラリはpydanticに依存すべきかどうか悩んでるのだけど答えはなかった。
本文で触れられていないがv1系とv2系で後方互換性が破壊されたのでまだプリマチュアなきがするんだよな。
HTTPとかで外と区切られているならいいけどimportして使うなら問題になってきそうで怖い。