Djangoプログラマーの強力な味方、Python 3.11の新機能Data Class Transforms（PEP 681）の紹介

[Django Advent Calendar 2022](https://qiita.com/advent-calendar/2022/django) 5日目は、Python 3.11の新機能Data Class Transforms（PEP 681）がDjangoを使うプログラマーにとっていかに嬉しいか、という話をします。

## Python 3.11の新機能Data Class Transforms（PEP 681）とは何か
この解説のサンプルコードで使用している環境は以下のとおりです。
- Python 3.11.0
- Django 4.1.3
- pyright 1.1.282

pyrightはNode.js製なのでnpmかyarnでインストールします。

### 「Django Model初期化時の型チェック問題」とは？
Django ModelはPythonの[データクラス](https://docs.python.org/ja/3/library/dataclasses.html)風の構造をしていますが、データクラスそのものではないため、初期化時に型ヒントの恩恵を受けられない問題があります。
本記事では、この問題を「Django Model初期化時の型チェック問題」と呼びます（なお、私が勝手に命名したもので一般的な呼称ではありません）。[^1]

[^1]: ネーミングセンスがなさすぎてアレですが、許してください…

「Django Model初期化時の型チェック問題」が発生する具体例について、以下で解説します。

#### Django Modelの`__init__`メソッドには型情報がない

以下のサンプルコードを見てください。

```python:books/models.py
from django.db import models

class Book(models.Model):
    title = models.CharField(max_length=50)
    price = models.IntegerField()
```

次に、上記のModelを使った以下のサンプルコードを見てください。

```python:example1.py
from books.models import Book

Book(title="Python実践レシピ", price="本体2700円+税")
```

`price`は`IntegerField`として定義しているのに関わらず、初期化時に整数ではなく文字列の値を渡しています。これは型が合わないので、データクラスであれば型チェックでエラーになります。

pyrightに上記コードを読ませてみましょう。何のエラーも発生しません。`Book.__init__`メソッドの引数には型情報がないためです。以下は`help(Book.__init__)`の実行結果です。

```python
Help on function __init__ in module django.db.models.base:

__init__(self, *args, **kwargs)
    Initialize self.  See help(type(self)) for accurate signature.
```

```shell
$ pyright example1.py
（省略）
0 errors, 0 warnings, 0 informations
Completed in 0.462sec
✨  Done in 0.58s.
```

#### 一方、同じコードをデータクラスで書いてみると…

データクラスではどうなるのかも見ておきましょう。前述の`Book`クラスを模したコードを以下のように書きました。

```python:example2.py
from dataclasses import dataclass

@dataclass
class Book:
    title: str
    price: int

Book(title="Python実践レシピ", price="本体2700円+税")
```

こちらは`__init__`メソッドに型情報があるので、pyrightでのチェックでエラーになります。

```shell
$ pyright example2.py
（省略）
/***/example2.py
  /***/example2.py:8:33 - error: Argument of type "Literal['本体2700円+税']" cannot be assigned to parameter "price" of type "int" in function "__init__"
    "Literal['本体2700円+税']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.454sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```

以下は`help(Book.__init__)`の実行結果です。

```python
Help on function __init__ in module builtins:

__init__(self, title: str, price: int) -> None
```

### 「Django Model初期化時の型チェック問題」解決のため試行錯誤してみる

以下で、Django Modelでもデータクラスのような型チェックができるようにならないか、試行錯誤してみます（いきなりネタバレしてしまいますが、いずれも失敗に終わっています）。

#### 【試行錯誤その1】Django Modelをデータクラスそのものにしてしまえばいいのでは？

データクラスなら型チェックできるというなら、Django側で対応してModelクラスに`dataclasses.dataclass`を付けられるようにしてしまえばいいのでは？ と思うかもしれません。以下のコードを見てください。

```python:example3.py
from dataclasses import dataclass

class Model:
    def __init__(self, *args, **kwargs):
        print("Model")

@dataclass
class Book(Model):
    title: str
    price: int

Book(title="Python実践レシピ", price="本体2700円+税")
```

上記`Book`クラスはデータクラスなので型チェックは効きますが、基底クラスの`Model`に定義された`__init__`メソッドが呼ばれません。`dataclasses.dataclass`によって勝手に`Book.__init__`メソッドが作られてしまうからです。

```shell
$ python3.11 example3.py  # Model.__init__に書いたprint関数が呼ばれない

```

#### 【試行錯誤その2】`dataclasses.dataclass`を使わずに型アノテーションだけ書いてみる

それでは、以下のように`dataclasses.dataclass`は使わず型アノテーション（`title: str`とか`price: int`のこと）だけ書いてみてはどうでしょう？

```python:example4.py
class Model:
    pass

class Book(Model):
    title: str
    price: int

Book(title="Python実践レシピ", price="本体2700円+税")
```

これはデータクラスではないので基底クラスの`__init__`メソッドは呼ばれます。
しかし、pyrightを実行してみると、「コンストラクタに引数がない」旨のエラーが出ます。今度はデータクラスなら作られるはずの引数付き`Book.__init__`メソッドがないため型チェックができません。

```shell
$ pyright example4.py
（省略）
/***/example4.py
  /***/example4.py:8:1 - error: Expected no arguments to "Book" constructor (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.442sec
error Command failed with exit code 1.
```

### 「Django Model初期化時の型チェック問題」解決の切り札「Data Class Transforms（PEP 681）」

色々試してみましたが、Django Modelとしての機能を維持しつつ、初期化する際に型チェックを受けるのはどうやってもできません。
この状況を打破してくれるのが、Python 3.11の新機能[Data Class Transforms（PEP 681）](https://peps.python.org/pep-0681/)です。

PEP 681で追加されたのは[typing.dataclass_transform](https://docs.python.org/3.11/library/typing.html#typing.dataclass_transform)というデコレーターです。前述の`example4.py`に`typing.dataclass_transform`を使ってみます。`typing.dataclass_transform`を使う方法はいくつかありますが、今回はメタクラスを使う方法を採用します。[^2]
[^2]: 実際のDjangoのコードでも`Model`クラスにメタクラス`ModelBase`を定義しているので、これに近い形を選びました。

以下のコードを見てください。

```python:example5.py
from typing import dataclass_transform

# これを追加
@dataclass_transform()
class ModelBase(type):
    pass

class Model(metaclass=ModelBase):  # metaclassにModelBaseを追加
    pass

class Book(Model):
    title: str
    price: int

Book(title="Python実践レシピ", price="本体2700円+税")
```

`typing.dataclass_transform`を使うことで、型チェックツールに対して「このクラスはデータクラスではないが、データクラスのように扱ってほしい」という情報を伝えることができます。
上記のコードは`Book.__init__`メソッドに型情報はありませんが、型チェックツールはクラスに定義されている型アノテーションを元に、型情報付きの`Book.__init__`メソッドがあるかのように扱ってくれます。

上記コードをpyrightに読ませると、`price`に指定した値の型が間違っている旨のエラーが発生します。

```shell
$ pyright example5.py
（省略）
/***/example5.py
  /***/example5.py:15:33 - error: Argument of type "Literal['本体2700円+税']" cannot be assigned to parameter "price" of type "int" in function "__init__"
    "Literal['本体2700円+税']" is incompatible with "int" (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Completed in 0.439sec
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
```

これで、Modelの初期化時でも型チェックができるようになりました。あとは、Django側でクラスに型アノテーションを書いたら`CharField`や`IntegerField`のようなDjango独自のクラスを使ったフィールドを生やす仕組みを導入すれば、よりデータクラスに近いModelを定義できるようになるかもしれませんね！