dataclass と型ヒント
`@dataclass` が自動で書いてくれるもの、`field(default_factory=...)` が必要な理由、`frozen=True` が与えるもの、関数への型ヒント、そして型ヒントはプログラムの実行中には何もせず、型チェッカにとってはすべてであるということの意味。
どうやって学びますか?
解決する問題
前の章の Book クラスを全部書き出すと、こうなります。
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
def __repr__(self):
return f"Book({self.title!r}, {self.author!r}, {self.pages!r})"
def __eq__(self, other):
return (self.title, self.author, self.pages) == (other.title, other.author, other.pages)
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book('one', 'rafi', 300)
True13 行もあるのに、本当の判断は一つもありません。各フィールドの名前が何度も書かれています — __init__ のパラメータとして、self. の行で、__repr__ で、そして __eq__ で。
フィールドを 1 つ増やすなら、それらすべての場所に追加しなければなりません。忘れても何もエラーになりません — __repr__ や __eq__ が黙って不完全になるだけです。
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
a = Book("one", "rafi", 300)
print(a)
print(a == Book("one", "rafi", 300))Book(title='one', author='rafi', pages=300)
True7 行で、それぞれのフィールドの名前は 1 回だけ書かれています。
この章を終える頃には
@dataclassを書き、それが何を生成するか説明できるfield(default_factory=...)を使い、なぜ必要か説明できるfrozen=Trueが何を与えるか説明できる- 関数に型ヒントを書ける —
list[str]、dict[str, float]、int | None - 型ヒントはプログラムの実行中には何もしないが、型チェッカにとっては大きな役割を持つと説明できる
asdictで dataclass を JSON に変換できる
前提条件: クラス.
@dataclass が自動で書いてくれるもの
その 7 行と引き換えに、次のものが手に入ります。
__init__— パラメータはフィールドの順番どおり__repr__— すべてのフィールドを名前付きで表示__eq__— すべてのフィールドが一致したら、2 つのオブジェクトを等しいとみなす
前の章の問題のうち 3 つ — <object at 0x...>、a == b が False になること、そして同じ名前を何度も書くこと — が、まとめて解決されます。
メソッドは今までとまったく同じように書きます。
from dataclasses import dataclass
@dataclass
class OrderLine:
name: str
price: float
quantity: int
def total(self) -> float:
return round(self.price * self.quantity * 1.15, 2)
line = OrderLine("pen", 15.0, 3)
print(line)
print(line.total())OrderLine(name='pen', price=15.0, quantity=3)
51.75デフォルト値も使えます。ルールは 20 章と同じです。デフォルト値のあるフィールドは最後に置きます。
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int = 1
print(Book("one"))
print(Book("one", 300))Book(title='one', pages=1)
Book(title='one', pages=300)型ヒントはプログラムの実行中には何もしない
名前からは別のことを想像しがちなので、最初にはっきりさせておきます。
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
b = Book("one", "three hundred")
print(b)
print(b.pages + 1)Book(title='one', pages='three hundred')
TypeError: can only concatenate str (not "int") to strpages: int と書いてあるのに、文字列がそのまま入ってしまいました。Python は型ヒントをチェックしません。 型ヒントは保存するだけで、それを使うのは別の仕事です。
エラーはずっと後になって届きました。その値で計算しようとしたときです。その間、値はそのまま表示されていました — 実際のプログラムでは、ログに記録されたり、ファイルに書き込まれたりしたかもしれません。
ここで型チェッカが役に立ちます。mypy は別のプログラムで、コードを実行せずに読みます。上のコードを main.py として保存し、mypy main.py を実行してください。
main.py:10: error: Argument 2 to "Book" has incompatible type "str"; expected "int" [arg-type]行番号、どの引数か、何が渡されたか、何が期待されていたか — そして、プログラムは一度も実行されていません。
考え方は 2 文で言えます:型ヒントはプログラムの実行中には何もしません。型チェッカにとっては、それがすべてです。 型ヒントを書くことは、インタプリタへの約束ではなく、型チェッカと読み手への約束です。
保存された型ヒントは中身を見ることができます。
def shout(text: str) -> str:
return text.upper()
print(shout("pen"))
print(shout.__annotations__)PEN
{'text': <class 'str'>, 'return': <class 'str'>}ミュータブルなデフォルト — 三度目
21 章では、関数のデフォルト引数でした。26 章ではクラスレベルの属性でした。今度は dataclass のフィールドです — そして今回は、Python 自身がそれを止めます。
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []ValueError: mutable default <class 'list'> for field books is not allowed: use default_factoryオブジェクトは一つも作られていないことに注目してください — エラーはクラスが定義されている最中に出ました。Python はこの間違いをあまりに多く見てきたので、ここでは単に受け付けないのです。
正しい方法は ファクトリ を渡すことです — できあがったものではなく、何を作るかを渡します。
from dataclasses import dataclass, field
@dataclass
class Shelf:
name: str
books: list[str] = field(default_factory=list)
a = Shelf("a")
b = Shelf("b")
a.books.append("one")
print(a)
print(b)Shelf(name='a', books=['one'])
Shelf(name='b', books=[])field(default_factory=list) は「新しいオブジェクトごとに list() を一度呼ぶ」という意味です。21 章の if basket is None: basket = [] と同じ仕事を、1 行で、間違えようがない形で行います。
frozen=True
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print({p, Point(1, 2)})
p.x = 5Point(x=1, y=2)
{Point(x=1, y=2)}
dataclasses.FrozenInstanceError: cannot assign to field 'x'ここでは 2 つのことが同時に起きていて、それらはつながっています。
オブジェクトはもう変更できません。 p.x = 5 はエラーで止まりました — タプルと同じです。
そして、オブジェクトをセットに入れられるようになりました。 前の章の最後の問題を思い出してください:__eq__ を書くとハッシュが失われる、というものです。ここでは「等しい」2 つの点がセットの中で 1 つになりましたが、エラーは一切ありませんでした。
frozen がない場合は次のとおりです。
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
print({Point(1, 2)})TypeError: unhashable type: 'Point'理由は 16 章にあります:変わりうるものは、ハッシュ化しても安全ではありません。 セットに入れた後で値が変わると、二度と見つけられなくなってしまいます。
frozen=True は「これは変わらない」と宣言するので、Python はハッシュを提供してくれます。
ルールは単純です:作られた後に変わるべきでないオブジェクトは、必ず凍結する。 保証が得られ、それによってそのオブジェクトをセットの要素や辞書のキーとして使えるようになります。
関数への型ヒント
def totals(rows: list[tuple[str, float]]) -> dict[str, float]:
result: dict[str, float] = {}
for name, amount in rows:
result[name] = result.get(name, 0.0) + amount
return resultlist[str]、dict[str, float]、tuple[str, float] — こう書くと、中に何が入るかも伝えられます。それこそが型ヒントを役立つものにしています。
ただの list は「リストである」としか言いません。list[tuple[str, float]] は、ループの各回で何が渡されるかを言っています。
間違ったものを渡してみましょう。たとえば main.py の 9 行目が totals("pen") だとします。すると mypy はこう言います。
main.py:9: error: Argument 1 to "totals" has incompatible type "str"; expected "list[tuple[str, float]]" [arg-type]「あるかもしれないし、ないかもしれない」を表すには | を使います。
def find(names: list[str], wanted: str) -> str | None:
for name in names:
if name == wanted:
return name
return None
print(find(["pen"], "pen"))
print(find(["pen"], "bag"))pen
Noneここで型ヒントが最も役立ちます。None をチェックせずに結果を使ってみましょう。たとえば 8 行目を print(find(["pen"], "pen").upper()) に変えると、mypy はこう言います。
main.py:8: error: Item "None" of "str | None" has no attribute "upper" [union-attr]これは実際には AttributeError: 'NoneType' object has no attribute 'upper' です — 実際の現場で最もよく見るエラーの一つです。しかしここでは、プログラムが実行される前に、しかも None を生む入力に一度も出会うことなく捕まえられています。
dataclass から JSON へ
import json
from dataclasses import dataclass, asdict
@dataclass
class Book:
title: str
pages: int
print(asdict(Book("one", 300)))
print(json.dumps(asdict(Book("one", 300))))
print(json.dumps(Book("one", 300))){'title': 'one', 'pages': 300}
{"title": "one", "pages": 300}
TypeError: Object of type Book is not JSON serializable25 章のリストを思い出してください。自分で作ったオブジェクトは、そのままでは JSON に入りません。asdict が橋渡しをします。中にネストされた dataclass も、辞書に変換してくれます。
完全な例
前の章の注文を、dataclass と型ヒントで作り直したものです。
"""The order of chapter twenty-six, rebuilt with dataclasses and hints."""
import json
from dataclasses import dataclass, field, asdict
TAX_RATE = 0.15
@dataclass(frozen=True)
class OrderLine:
"""Frozen: a line never changes once made, so it can live in a set."""
name: str
price: float
quantity: int
def __post_init__(self) -> None:
if self.quantity < 1:
raise ValueError(f"quantity must be at least 1: {self.quantity}")
def total(self) -> float:
return round(self.price * self.quantity * (1 + TAX_RATE), 2)
@dataclass
class Order:
"""Not frozen: lines are added over time."""
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, name: str, price: float, quantity: int) -> "Order":
self.lines.append(OrderLine(name, price, quantity))
return self
def total(self) -> float:
return round(sum(line.total() for line in self.lines), 2)
def largest(self) -> OrderLine | None:
if not self.lines:
return None
return max(self.lines, key=lambda line: line.total())
def main() -> None:
order = Order("rafi")
order.add("pen", 15.0, 3)
order.add("bag", 850.0, 1)
order.add("ink", 120.0, 2)
for line in order.lines:
print(f"{line.name:<6} {line.total():>9.2f}")
print(f"{'total':<6} {order.total():>9.2f}")
print()
print(order.largest())
print(json.dumps(asdict(order)))
print(len({OrderLine("pen", 15.0, 3), OrderLine("pen", 15.0, 3)}))
try:
order.add("clip", 5.0, 0)
except ValueError as err:
print("rejected:", err)
if __name__ == "__main__":
main()pen 51.75
bag 977.50
ink 276.00
total 1305.25
OrderLine(name='bag', price=850.0, quantity=1)
{"customer": "rafi", "lines": [{"name": "pen", "price": 15.0, "quantity": 3}, {"name": "bag", "price": 850.0, "quantity": 1}, {"name": "ink", "price": 120.0, "quantity": 2}]}
1
rejected: quantity must be at least 1: 0注目すべき 5 点です。
検証は __post_init__ に書きます。 @dataclass が __init__ を自分で書くので、そこにチェックを入れる場所がありません。__post_init__ がその隙間を埋めます — フィールドが設定された直後に実行されます。
だから、前の章の保証はそのまま成り立ちます:OrderLine を持っているなら、その数量は少なくとも 1 です。
一方は凍結され、もう一方は凍結されていません — 意図してそうしています。 明細は一度作られたら変わるべきではありません。しかし注文には、新しい明細が次々と追加されます。問いはいつも同じです:このものは、作られた後に変わるのか?
最後の 1 が、凍結が効いていることを証明しています。 同じ 2 つの OrderLine が、セットの中で 1 つになりました。
前の章の OrderLine で同じことを実行すると、2 が表示されるでしょう。エラーは出ません。あのクラスには __eq__ がなかったので、デフォルトのハッシュのままでした。しかし別々の 2 つのオブジェクトは決して等しくならず、セットは両方を単に保持してしまうのです。
list[OrderLine] は飾りではありません。 これによって型チェッカは line.total() が正しいと分かります。line.totl() と書けば、コードを実行せずに見つけてくれます。
"Order" は引用符で囲まれています。 add のシグネチャに Order という名前を書くとき、クラスの定義はまだ終わっていません — その名前はまだ存在しないのです。引用符の中であれば、Python はその瞬間に名前を探しません。
このコースで使う Python 3.12 では、クラス内の型ヒントでそのクラス自身の名前を使う方法が 2 つあります。名前を引用符で囲むか、ファイルの一番上に from __future__ import annotations と書くかです。
うまくいかないとき
ValueError: mutable default ... use default_factory = [] や = {} を書いてしまっています。field(default_factory=list) を使ってください。
TypeError: non-default argument follows default argument デフォルト値のあるフィールドが、必須のフィールドより前にあります。20 章のルールをもう一度:必須のフィールドを先に書きます。
dataclasses.FrozenInstanceError frozen=True のオブジェクトに代入しました。変更が必要なときは、dataclasses.replace(obj, x=5) が新しいオブジェクトを作ってくれます。
TypeError: unhashable type frozen=True のない dataclass は、セットに入れられません。
型の違う値が入ったのに何も起きなかった 想定どおりです — 型ヒントはプログラムの実行中には何もしません。mypy を実行してください。
mypy がファイルを見てくれない パスを渡してください — mypy main.py。それでも最初の実行でメッセージが大量に出るなら、mypy --ignore-missing-imports から始めてください。
TypeError: Object of type X is not JSON serializable asdict(obj) を渡してください。
クラスの中で、自分のクラスの名前を使うと NameError 名前を引用符で囲んでください:-> "Order"。またはファイルの先頭に from __future__ import annotations と書いてください。
ステップ 4 / 6 — 予想する
理解度をチェック
7 行の dataclass です。この 2 行は何を表示しますか?
from dataclasses import dataclass
@dataclass
class Book:
title: str
pages: int
print(Book("one", 300))
print(Book("one", 300) == Book("one", 300))- A<__main__.Book object at 0x...> False
- BBook(title='one', pages=300) True
- CBook(title='one', pages=300) False
- DBook('one', 300) True
オブジェクトは作られず、クラスを書いただけです。何が起きますか?
from dataclasses import dataclass
@dataclass
class Shelf:
books: list = []- A何も起きない — 問題は最初のオブジェクトが作られたときに現れる
- B`ValueError` — クラスが定義されている最中に
- C`TypeError`
- D何も起きない — すべてのインスタンスが 1 つのリストを共有する
frozen=True が設定されています。何が起きますか?
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int
p = Point(1, 2)
print({p, Point(1, 2)})
p.x = 5- A要素が 1 つのセットが表示され、その後 `FrozenInstanceError`
- B要素が 2 つのセットが表示され、その後 `FrozenInstanceError`
- C`TypeError: unhashable type` — セットを作るところで
- D要素が 1 つのセットが表示され、その後 `p.x` が `5` になる
回答にはアカウントが必要です
ログインして答えを確認
問題は上にあります。頭の中で答えを考えることがいちばん大切です。ログインすると、正解・解説・3 段階のヒントを確認できます。
やってみよう
26 章の Book と Shelf を、dataclass として書き直してください。
Book は frozen=True とし、title: str、author: str、pages: int を持ちます。そして pages < 1 のとき ValueError を投げる __post_init__ を持ちます。Shelf は name: str と books: list[Book] = field(default_factory=list) を持ち、さらに add、total_pages() -> int、longest() -> Book | None、by_author(name: str) -> list[Book] を持ちます。
次に pip install mypy を実行し、mypy library.py を実行して、何も報告されなくなるまで直してください。
それから 6 つの実験です。
pagesに文字列を渡してみてください —Book("one", "rafi", "300")。プログラムは動くでしょうか? そしてmypyは何と言うでしょうか?booksのfield(default_factory=list)を= []に置き換えてください。エラーはいつ届くでしょうか — オブジェクトが作られたときでしょうか、それとも、その前でしょうか?- 同じ
Bookを 2 つ作り、セットに入れてください。いくつ残るでしょうか? 次にfrozen=Trueを外して、もう一度試してください。 - 空の棚で、
longest()の結果を直接使ってください —shelf.longest().title。プログラムは何と言うでしょうか? そしてmypyは事前に何と言っていたでしょうか? asdict(shelf)をjson.dumpsで書き出し、json.loadsで読み戻してください。戻ってきたものはShelfでしょうか?- スペルを間違えた属性を読んでください —
book.pagse。mypyはそれを見つけるでしょうか?
最後の 2 つで、この章の限界と強みの両方が見えます。JSON から戻ってくるのは dataclass ではなく辞書です — 変換は一方向だけで、戻す方法は自分で書かなければなりません。しかし 26 章では埋められなかった隙間 — 間違った名前はコードが実行されたときにしか捕まらない — は、型チェッカがコードの実行前に埋めてくれます。