LoginSignup
2
2

More than 1 year has passed since last update.

mypy に怒られない unpack(NamedTuple のススメ)

Last updated at Posted at 2023-02-09

下のような関数があったとする。

def intro(age: int, name: str) -> None:
	print(f"{name} is {age} years old.")
    return

この関数を呼び出す時に、引数をいちいち指定するのが面倒臭かったとする。
(あまりにも引数の数が多いとか、他の所で作ったデータをそのまま入れたい時とか)

そういう時に、下記のようなことをすると思う。
しかも mypy さんに怒られないように、とても丁寧にコードを書くと、こうなる。

person: Dict[str, Union[str, int]] = {
	"name": "Taro",
	"age": 3,
}
intro(**person)

何も悪いことはない。普通に動く。
でも肝心の mypy さんにメチャ怒られる。メチャ忖度してるのに!

> mypy hoge.py
hoge.py:5: error: Argument 1 to "intro" has incompatible type "**Dict[str, Union[str, int]]"; expected "int"  [arg-type]
hoge.py:5: error: Argument 1 to "intro" has incompatible type "**Dict[str, Union[str, int]]"; expected "str"  [arg-type]
Found 2 errors in 1 file (checked 1 source file)

なんでや。

で、まじで「なんでや」を調べたら泥沼にはまったけど、その泥沼は良い沼だったという話。

(まぁ、この例にはオチがあるんだけど)

以下、私の解釈を載せるけど、あくまで私個人の解釈なので間違っているかもしれない。(逃げ)

unpack の話

まずは mypy に怒られている **person の所。アスタリスクの部分。

言うまでもなく unpack で、person という辞書の中身を展開してる。Python の教科書のワリと最初の方に載っているはず。でも、よく考えると unpack って意味があまり分からない。

調べまくって考えまくった結果、この * 演算子(*-operator1は、関数への引数でしか許されない特殊な演算子だ、という結論になった。単項演算子で、もちろん2項演算子である掛け算の * (mul) とは違う。

演算子と言っても、言語リファレンスの演算子の項目 にも 標準ライブラリリファレンスの組み込み型の項目 にも記載がない。

演算子というより、その後に続く変数と合わせて starred expression 、すなわち式(expression)だと理解した方が正しいのだと思う。で、その式は関数の引数の部分でしか許されない、と。

関数の引数でしか許されないというのは簡単に調べることが出来る。

>>> a = [1, 2]
>>> b = *a
  File "<stdin>", line 1
SyntaxError: can't use starred expression here

特に意味はないけど、なんか b という変数に a の中の値が展開されて入っていてもおかしくない気がする。でもダメ。こんな所では使えない。

unpack と暗黙的な Tuple

混乱するのは、この starrd expression を使わなくても unpack は出来るっぽいこと。

>>> a = [1, 2]
>>> b, c = a
>>> d = a
>>> print(f"{a} : {b} : {c} : {d}")
[1, 2] : 1 : 2 : [1, 2]

2行目のように、式の左辺に右辺の中身の数と同じ数の変数があれば、自動的に unpack できるように「見える」。もちろん3行目のように変数が1個だけだったら List 全体が代入される。

この場合、右辺の中身の数と左辺の変数の数が違うと怒られる。

>>> a = [1, 2, 3]

>>> b, c = a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 2)

>>> b, c, d, e = a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 4, got 3)

この場合もエラーメッセージに unpack と出てるので混乱する。

じゃあこれは何なのかと考えると、何の括弧も無く , というデリミタで変数を繋げると、Python は暗黙的にそれらを Tuple だと判断しているのだ。

>>> def dummy():
...     return 1, 2

>>> a = dummy()
>>> type(a)
<class 'tuple'>

だからさっきのは正確には unpack ではなく、Tuple に List の内容を代入している。そりゃ数が合わないと怒られるよ。

>>> a = [1, 2]
>>> b, c = a
>>> (b, c) = a  # 2行目は本当はこういうことをしている

だから、unpack というのは厳密には starred expression でしか行われていない(はず)。

starred expression の定義、関数の定義

じゃあこの starred expression はどこで説明されているかというと、言語リファレンスの 8.7 章、関数定義言語リファレンスの 6.3.4章、呼び出し(call)の所になる。やはり starred expression は関数の定義の一部なのだ。

で、そこを読めば出てくるんだけど、非常に分かりにくい。しかも文法として(書き方が)載っているだけで、意味は分からない。一番わかりやすいのが チュートリアルの 4.8章、関数定義についてもう少し になる。

この章をまとめると、Python での関数の定義のやり方は以下になる。2

def func(pos..., /, pos_or_kwd..., *, kwd...):

そもそも関数の引数(Argument)には3種類ある。

  • Positional Argument(位置引数)
    • 関数の引数として何番目に書かれたかによって、呼び出された関数の変数として格納される引数
    • 名前付きのキーワード形式(pos=1)で書いてはダメで、必ず値だけを入れなければならない
  • Keyword Argument(キーワード引数)
    • 何番目に書かれたかは全く関係なく、キーワード形式の名前で格納される変数が決まる引数
  • Positional or Keyword Argument
    • キーワード形式で与えられればその名前をもつ変数に、値が直接入っていたらその順番に書かれている変数として格納される引数

これら3つの引数の種類を見分けるのは、/* というキーワードのみ。

  • / の前にある引数はすべて Positional Argument。すなわち名前付きでは受け入れられない
    • ただし / をつける必要はなく、その場合はすべての引数が Keyword もしくは Positional or Keyword
  • * の後にある引数はすべて Keyword Argument。すなわち名前付きでないと受け入れられない
    • ただし * をつける必要はなく、その場合はすべての引数が Positional もしくは Positional or Keyword

私は、デフォルト値を設定すると Keyword Argument になると思っていた。勘違いだった。見た目だけでは本当に分からないんだ。ただ、デフォルト値をつけた Argument より後の Argument は、全部デフォルト値をつけなければならない。

>>> def func(a=0, /):
...     print(a)

>>> func()
0

>>> func(a=1)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: func() got some positional-only arguments passed as keyword arguments: 'a'

>>> def func(a=0, /, b):
  File "<stdin>", line 1
    def func(a=0, /, b):
                     ^
SyntaxError: non-default argument follows default argument

そしていよいよ starred expression。(ここまで来るまでが長過ぎる)

starred expression を含む関数の定義は以下になる。

def func(pos..., /, pos_or_kwd..., *args, **kwargs)
  • * というキーワードが含まれると、starred exporession は SyntaxError になる
  • *args という形で与えられた変数 args には、それまでの Positional Argument には入り切らなかった Positional Argument(名前がついてない Argument)が全部入ってくる
    • 型は Tuple
  • **kwargs という形で与えられた変数 kwargs には、それまでの Keyword Argument には名前が無かった Keyword Arguement が全部入ってくる
    • 型は Dict
>>> def func(a, b, *args):
...     print(type(args))
...     print(args)

>>> func(1, 2, 3, 4, 5)
<class 'tuple'>
(3, 4, 5)

>>> def func(a, b, **kwargs):
...     print(type(kwargs))
...     print(kwargs)

>>> func(a=1, b=2, c=3, d=4, e=5)
<class 'dict'>
{'c': 3, 'd': 4, 'e': 5}

まぁ、「知ってた」って言われると思うが。

starred expression にも Type hint を付け…たい

この記事の目的はとにかく mypy さんに媚を売ることなので、律儀に starred expression にも Type hint を付けていきたい。で、「たぶん」こうやればいいのだろう。(間違っている可能性が大)

#!/usr/bin/env python3
# -*- coding:utf-8 -*-
import typing as t
import typing_extensions as te


class IntroKwargs(t.TypedDict):
    fullname: str
    birthyear: int


def intro(
    age: int,
    name: str,
    *args: te.Unpack[t.Tuple[int, ...]],
    **kwargs: te.Unpack[IntroKwargs],
) -> None:
    print(f"{name} is {age} years old.")
    print(args)
    print(kwargs)
    return


def main() -> None:
    intro("Taro", 3, 1, 2, fullname="Yamada Taro", birthyear=2020)
    return

で、「たぶん」って言ってるのは、これ、動かないからだ。もちろんプログラム的には動くけど、mypy が違う意味で通らない。

  • Unpack が本格的に typing ライブラリに組み込まれるのは Python 3.11 からで、それまでは上のように typing_extensions から持ってこなきゃいけない
  • Python 3.11 にして typing から import したとしても、mypy が Unpack に対応していない
    • mypy に --enable-incomplete-feature=Unpack というオプションをつけなければならない
  • *args はオプションつければ何とかなるけど、**kwargs のチェックで mypy が落ちる
    • Unpack は必ず引数をひとつつけなきゃいけない
      • *args は Tuple、**kwargs は TypedDict
    • ただし、TypedDict をそのまま使う(Unpack[TypedDict] とする)と、error: Variable "typing.TypedDict" is not valid as a type [valid-type] というエラーになる
      • typing.TypedDict や typing.NamedTuple は typing の中でも特殊で、型というよりかは class に近い
    • じゃあ上記のようにちゃんと作ると、INTERNAL ERROR で落ちる
      • github からソースコード持ってきてコンパイルしてみてくれ、って言われる

ということで、starred expression に type hint を付けるには、まだ時代が早すぎたんだ。(2023年2月8日)

まぁこんな泥沼にハマってしまった訳だが、決して悪いことばかりじゃない。

Python 3.11 から、続々と面白そうなヤツが typing に組み込まれていくようだ。Never, NoReturn, Self なんてコードの可読性がさらに向上しそうだし、今まで無理やり raise Exception していた所が自然な感じになりそう。

さらにこんな記事もあって、Python でどんどん type-safe なプログラミングができそうである。Python 3.11 がデフォルトになってくると、また違った世界が拓けてくるのではないか。

ただし、何度も言うように、「まだ早い」。

(追記:2023年2月9日)この記事を書いている間に mypy が 0.991 → 1.0.0 にバージョンアップして、上記の mypy が成功するようになった!こんなことってある?!!でも --enable-incomplete-feature のオプションが必要なので、まだ早いには変わりがない。

(余談の余談)ローカル変数の型宣言

というように、mypy 沼にハマっている私だが、ちょっと困ったことがあった。以下のような場合である。

if flag is True:
	result: int = func_flag()
else:
	result: int = func_noflag()

result ってのはここで初めて出てくるローカル変数なので、Type hint 付けなきゃいけない。で、上のようにすると mypy 様が「2行目でもう result に int ってつけてるから、4行目でつけんじゃねぇよ」って言ってお怒りになられる。素直に4行目のヒントを消せば問題ないのだが、釈然としない。

たぶん最も良い方法は、「変数の宣言時には必ず初期値を入れて初期化する」だ。初期化することで意図しないバグが潰せたりもするので、mypy 様はたぶんそういうことを仰ってる。正しくて正しくて震える。

result: int = 0
if flag is True:
	result = func_flag()
else:
	result = func_noflag()

でも、どうしても初期値が無いこともあるだろう。この = 0 がどうしても入れられない。

そんな時はこうすれば良い。

result: int
if flag is True:
	result = func_flag()
else:
	resutl = func_noflag()

当たり前?いや、俺、すげービックリしたんだが。マジで目から鱗が落ちた。

考えてみりゃ、class 変数では死ぬほどこんなことしてる。でもそれがローカル変数でも出来るとは思わなかった。なんだろな、伝わらないかな、この驚き。

閑話休題すぎた。時を戻そう。

手当たり次第に unpack してみる

呼び出される側の関数の starred expression についてはだいたい分かったが、じゃあ呼び出す側の unpack とは何なのか。それを探るために、組み込み型collectionstyping を読み込んで、思いつく限りの型を unpack してみることにした。たぶんこんな馬鹿なことするの俺しかいない。

で、今回もすでにプログラムは書いてます。

具体的には、変数を複数(nameage)持てるタイプの型のインスタンス person を作り、それを print(person), print(*person), print(**person) してみたらどうなるのか。試した型は以下。

  • Tuple
  • List
  • Set
  • Dict
  • collections.namedtuple
  • typing.NamedTuple
    • NamedTuple を継承した class を作り、そのインスタンスを使う
  • typing.NamedTuple._asdict()
    • 上記のインスタンスに _asdict() したものを使う
  • typing.TypedDict
    • TypedDict を継承した class を作り、そのインスタンスを使う
  • class
    • nameage という class 変数を持つ class を作り、そのインスタンスを使う
  • vars(class)
    • 上記のインスタンスを vars() 関数にかけたものを使う
  • pydantic.BaseModel
    • BaseModel を継承した class を作り、そのインスタンスを使う
  • pydantic.BaseModel.dict()
    • 上記のインスタンスに dict() したものを使う

さすがの俺でも FrozenSet, collections.defaultdict, collections.OrderedDict は違うなと思ったので、やってない。

こうやって説明していても分からないと思うので、詳しくはソースを参照してください。

結果。

personの型 print(person) print(*person) print(**person)
Typle ('Taro', 3) Taro 3 ERROR
"must be mapping"
List ['Taro', 3] Taro 3 ERROR
"must be mapping"
Set {3, 'Taro'} 3 Taro ERROR
"must be mapping"
Dict {'name': 'Taro', 'age': 3} name age ERROR
"invalid keyword argument"
namedtuple Person(name='Taro', age=3) Taro 3 ERROR
"must be mapping"
NamedTuple NamedTuple_Person(name='Taro, age=3') Taro 3 ERROR
"must be mapping"
NamedTuple._asdict {'name': 'Taro', 'age': 3} name age ERROR
"invalid keyword argument"
TypedDict {'name': 'Taro', 'age': 3} name age ERROR
"invalid keyword argument"
class ERROR
"must be iterable"
ERROR
"must be mapping"
vars(class) {'name': 'Taro', 'age': 3} name age ERROR
"invalid keyword argument"
BaseModel name='Taro' age=3 ('name', 'Taro') ('age', 3) ERROR
"must be mapping"
BaseModel.dict {'name': 'Taro', 'age': 3} name age ERROR
"invalid keyword argument"

当たり前だと思われるかもしれないけど、色々気付くことがある。

  • 星ひとつの unpack(*person)が期待しているものは、class でエラーが出ているように、Iterable
    • Iterable は collections.abc に属するものなので、__subclasses__() とかには出てこない
    • でも iter() で簡単に調べられる
      • iter(list()) is not NoneTrue
    • 要するに、__iter__ を提供しているので、for 文の目的として使えるヤツ
  • 星ふたつの unpack(**person)が期待しているものは、list とかでエラーが出ているように、Mapping
    • 同じくMapping も collections.abs に属する
    • __iter__ に加え、__getitem____len__ を提供しているもの
  • Mapping は __iter__ を提供しているので、Iterable になれてしまう
    • *person しても怒られない
  • ただし、Mapping の星ひとつ unpack を見ると、key だけが出てきている
  • これは Dict(やその仲間)がどうやって作られているかを表している
    • Dict は、そのインスタンスの中で key が一意だ
      • 同じ key に対して別の value を入れると、上書きされる
    • だから、Dict はその key を Set として持っている
      • だから Dict も Key も {...} で作ることが出来る
    • Set は、その中身は任意の変数だけど、Dict は、その中身は必ず Mapping
      • {x: y} で辞書になるが、この場合の :mapping を表す(=__getitem__
    • そう言えば、Type hint も全部 x: y の形で表すけど、これも「対応」って意味なんだろうなぁ

というように、Python ってどうやって作られているのかという所まで少しは理解できるようになった。非常に興味深い。特に collections.abc なんて眺めていると、たぶん Python を作った人は脳みそのレベルが違うんだろうな、と思ってしまう。

mypy に怒られない unpack

話がまた脇道にそれそうなのを強引に戻して、やっと今回の本命の話に。

今なら分かるが、実は一番最初の intro() という関数は、引数に * というキーワードを設定していないので、nameage も Positional Argument, Keyword Argument のどちらでも良い状態になっている。なので、名前を指定できない Set も Tuple も List も *person で関数に引数を与えられてしまう。(そしてわざと順番を逆にしているので 3 is Taro years old. なんて変テコな文章が出力される)

折角なので、ちゃんと表にしてみよう。まずは Positional Artument の場合。インスタンスをそのまま入れた場合は、全部揃って引数の数が足りないエラー(missing 1 required positional argument)になるので、省略する。

personの型 intro(*person) intro(**person)
Typle 3 is Taro years old ERROR
"must be mapping"
List 3 is Taro years old ERROR
"must be mapping"
Set 3 is Taro years old ERROR
"must be mapping"
Dict age is name years old Taro is 3 years old
namedtuple 3 is Taro years old ERROR
"must be mapping"
NamedTuple 3 is Taro years old ERROR
"must be mapping"
NamedTuple._asdict age is name years old Taro is 3 years old
TypedDict age is name years old Taro is 3 years old
class ERROR
"must be iterable"
ERROR
"must be mapping"
vars(class) age is name years old Taro is 3 years old
BaseModel ('age', 3) is ('name', 'Taro') years old ERROR
"must be mapping"
BaseModel.dict age is name years old Taro is 3 years old

要するに、上で "invalid keyword arguments" になっているものが、正常な(期待した)出力になっている。次に、ちゃんと * を引数の一番先頭に置いて、全部が Keyword Argument だとした場合。今度は intro(*person) が全部 "takes 0 positional arguments but X was given"という、Positional Keyward が与えられたエラーになるので、省略。

personの型 intro(**person)
Typle ERROR
"must be mapping"
List ERROR
"must be mapping"
Set ERROR
"must be mapping"
Dict Taro is 3 years old
namedtuple ERROR
"must be mapping"
NamedTuple ERROR
"must be mapping"
NamedTuple._asdict Taro is 3 years old
TypedDict Taro is 3 years old
class ERROR
"must be mapping"
vars(class) Taro is 3 years old
BaseModel ERROR
"must be mapping"
BaseModel.dict Taro is 3 years old

ということで、ちゃんと引数を与えられたのは、以下の5種類しかない。

  • Dict
  • typing.NamedTuple._asdict()
  • typing.TypedDict
  • vars(class)
  • pydantic.BaseModel.dict()

そしてこの中で、mypy 様がお怒りになられないのは、下の4つである。

要するに、ちゃんと名前を与えられて、しかも変数毎に Type hint をちゃんと渡せるやつである。

そりゃそうだ。ここまでしないと分からなかったんかい、と言ってしまいそうな、単純な結果である。

その中でのオススメ

vars() は、class 変数を参照して、その名前と型をローカル空間の Dict にしてくれるので、簡単に unpack できる便利関数である。私は argparse.ArgumentParser が parse_args() で生成する Namespace を引数に与える時に良く使う。

しかし、そのためだけに class を作るのは違う。そもそも class ってそういう風に使うものではない。Namespace みたいに、既にある時に使うのは良いと思うけど。

そうなってくると typing.NamedTuple か typing.TypedDict か pydantic.BaseModel になる。

pydantic.BaseModel は、外部とのデータのやり取りのプロトコル(API)を定義して、受信したデータの validation をしたり、送信するデータのチェックする用途には、非常に有用である。もうこれしか考えられないぐらい。(要するに FastAPI だ)

ただし、内部で生成したデータを入れるには、ちょっとオーバースペックかな、と思う。全然使っても良いんだけど。

すると typing.NamedTuple か typing.TypedDict かになるんだけど、こうなると圧倒的に typing.NamedTuple だろう。

なんてったって immutable である。これ以上に最高な点は無い。一旦データを入れたら変更できないので、余計なバグが発生しようがない。Type hint も付いているので、変なデータを入れることも出来ない。プログラム内部のデータ保持にはコレで決まり。

それに比べると typing.TypedDict は、プログラム的には利用価値は少なくなってくると思う。TypedDict 使うぐらいなら NamedTuple を使う。ただし、上の Unpack の定義に使ったように、TypeAliasAnnotated と併用して、内部のデータ構造を記述するのに使うんだろうなぁと思う。

まとめると、こうなる。

  • 内部のデータ保持・受け渡し:typing.NamedTuple
  • 外部とのデータやり取り・チェック:pydantic.BaseModel
  • 既に class に保持している変数を再利用したい:vars(class)
  • 複雑なデータ構造を記述する:typing.TypedDict

今回、ちょっとした mypy のエラーから Python の奥地まで調べちゃったけど、改めて Python には面白いことが一杯詰まっているし、まだまだ進化途中でこれから発展していくんだろうなと思った。Python 3.11 が defact standard になると、NamedTuple や TypedDict, TypeAlias を使って、Python と言えども強固で堅牢なサーバが構築できるようになるんじゃないかと。そんな未来。

オチ

一番最初の例、こう書くと mypy さん、全然怒らない。下の3種類とも全然怒らない。

person_1: Dict[str, Any] = {
	"name": "Jiro",
	"age": 4,
}
intro(**person_1)

person_2: Dict[Any, Any] = {
	"name": "Saburo",
	"age": 5,
}
intro(**person_2)

person_3: Dict = {
	"name": "Shiro",
	"age": 6,
}
intro(**person_3)

DictDict[Any, Any] だと解釈されるし、Any は凄く強い免罪符で、何を渡しても mypy さんは許しちゃう。mypy さん意外と懐が深い。

  1. 演算子という記述があるのは、Python 公式ドキュメントを調べた限り、チュートリアルの 4.8.5. 引数リストのアンパックのみ。

  2. Python で ...Ellipsisという組み込み定数だが、まぁここは勘弁して欲しい

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2