【Python】型付きのNamedTupleはいいぞ

この記事は、[株式会社ディーバ　PD部 Advent Calendar 3日目](https://qiita.com/advent-calendar/2022/diva_pd) の記事になります。  

## namedtupleとは
読んで字の如く**名前付きのタプル**のことです。

通常のタプルでは、`user[0]`のようにインデックスで値を取得します。
`user[0]`だけでは、`user`の先頭の要素を取得している以外の情報は分かりません。
```python
# userの0番目には何が入っているのか調査が必要になる
hoge = user[0]
```
名前付きタプルでは、インデックスに名前を付けて`user.name`のようにドットアクセスで値を取得できます。
```python
# 一目見るだけでユーザ名が入っていることが分かる
hoge = user.name
```
このように`namedtuple`を用いると、**可読性が向上する**メリットがあります。  


### 2種類のnamedtuple
`namedtuple`には下記の2つが存在します。
  - `collections.namedtuple`
  - `typing.NamedTuple`

今回はこの2種類に触れつつ、`typing.NamedTuple`の良さを知っていただければと思います。  

## collections.namedtuple
`from collections import namedtuple`を使用します。
```python
from collections import namedtuple

def main():
    User = namedtuple("User", ["name", "age"])
    user = User("Alice", 20)
    print(f"{user.name=}, {user.age=}") # user.name=Alice, user.age=20

if __name__ == "__main__":
    main()
```
定義方法は、第一引数に`tuple`のサブクラス名、第二引数に属性名を定義します(詳細は[ドキュメント](https://docs.python.org/ja/3/library/collections.html#collections.namedtuple)へ)

`namedtuple`の返り値は`tuple`のサブクラスを返しています(直接tupleを返している訳ではありません)
生成したサブクラスを用いて「名前付きのタプル」を生成しています。  

`namedtuple`は`tuple`のサブクラスなので、基本的には普通のタプルと同じ扱いが出来ます。
イミュータブル型なので変更できませんし、アンパック代入も出来ます。
```python
from collections import namedtuple

def main():
    User = namedtuple("User", ["name", "age"])
    user = User("Alice", 20)
    user.name = "Bob" # AttributeError: can't set attribute(変更不可)

    name, age = user # アンパック代入
    print(name, age)  # Alice 20

if __name__ == "__main__":
    main()
```

## collections.namedtupleの問題点
`collections.namedtuple`には、型アノテーションを付けることが出来ません。
「Pythonで型を付けてもコメントみたいなもんだし意味ないのでは？」と思う方もいるかもしれません。
確かに強制力はありませんが、マウスオーバーしたときに型を示してくれるので、「ここには何の型が入るべきだっけ？」という調査が不要になります。  

※型を付けていないと`Any`型の表示となる(→ 何の型が入るべきなのか調査が必要になる)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2716775/8c657edc-36d7-a306-9a34-a4ce2533eb77.png)

`namedtuple`で型を正しく表示させたいときに役立つのが、今回の本題である`typing.NamedTuple`です。 

## typing.NamedTupleとは
`collection.namedtuple`に型が付いたものです。
`from typing import NamedTuple`を使用します。
```python
from typing import NamedTuple

class User(NamedTuple):
    name: str
    age: int

def main():
    user = User("Alice", 20)
    print(user.name, user.age) # Alice 20

if __name__ == "__main__":
    main()
```
これは、上記で紹介した`namedtuple("User", ["name", "age"])`と等価です。  
`NamedTuple`を継承したクラスを作成し、引数に型アノテーションを付けるだけで完成です。  

マウスオーバーした際の型情報も適切に表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2716775/e7423889-7596-aaa9-f2f4-0fd657589dfd.png)

上記で少し触れた通り、型アノテーションには強制力はありませんので、型とは異なる値を入れることは可能です。  
```python
def main():
    user = User(20, "Alice")
    print(user.name) # 20

if __name__ == "__main__":
    main()
```

定義方法が変わっても`namedtuple`であることに変わりませんので、イミュータブル型ですし、アンパック代入も出来ます。
```python
def main():
    user = User("Alice", 20)
    user.name = "Bob"  # AttributeError: can't set attribute(変更不可)

    name, age = user
    print(name, age)  # Alice 20

if __name__ == "__main__":
    main()
```

注意点として、デフォルト値を付ける場合には、**デフォルト値のないフィールドの後でないといけない**というルールがあります。
```python
# NG
class User(NamedTuple):
    name: str = "Alice"
    age: int

# OK
class User(NamedTuple):
    age: int
    name: str = "Alice"
```

## `@dataclass(frozen=True)`との違い
見た目や機能が近しいものに`@dataclass(frozen=True)`があります。
`@dataclass(frozen=True)`をざっくり説明すると、「楽にクラス定義が出来る`dataclass`の機能」に、「変数をイミュータブル型に出来る`frozen=True`」を付けたデコレータです([ドキュメント](https://docs.python.org/ja/3/library/dataclasses.html))。  

これとの大きな違いは`tuple`であるかどうかになります。
`@dataclass(frozen=True)`の方は`tuple`ではありませんので、アンパック代入は出来ません。
```python
from dataclasses import dataclass

@dataclass(frozen=True) # イミュータブル設定
class User:
    name: str
    age: int

def main():
    user = User("Alice", 20)
    print(user.name, user.age) # Alice 20
    name, age = user  # TypeError: cannot unpack non-iterable User object

if __name__ == "__main__":
    main()
```

<details><summary>一応出来なくはないって話</summary><div>

`dataclass`を`astuple()`でタプル変換すればアンパック代入することが出来ます。

```python
from dataclasses import dataclass, astuple


@dataclass(frozen=True)
class User:
    name: str
    age: int


def main():
    user = User("Alice", 20)
    name, age = astuple(user)
    print(name, age)  # Alice 20


if __name__ == "__main__":
    main()
```

</div></details>


## 補足
`namedtuple`を紹介しましたが、**全てのタプルをnamedtupleにするべき**ということはありません。
スコープが狭かったり、変数名を見るだけでどこに何が格納されているものかがはっきり分かるものは不要だと自分は思います。
このあたりの裁量は開発者に依る部分が大きいですが、「`namedtuple`というものがある」ということを頭の片隅に置いていただければ幸いです。