Pythonの型エイリアス(type alias)で複雑な型をシンプルに扱う

 
0
このエントリーをはてなブックマークに追加
Kazuki Moriyama
Kazuki Moriyama (森山 和樹)

始めに

type aliasを使用すれば型シグネチャが複雑なtype hintを簡約にすることが出来る。
この記事ではtype aliasに関して使用方法、メリット、使用上の注意について書く。

Type Aliasとは

簡単に言うと、複雑な型に別名をつけて参照しやすく、見やすくする機能である。

例えば以下のようなコードがあるとする。

from collections.abc import Sequence

def broadcast_message(
    message: str,
    servers: Sequence[tuple[tuple[str, int], dict[str, str]]]) -> None:
    ...

serversの部分が方が複雑で非常に見づらい。
type aliasを使用して別名を与えてあげれば見やすくなる。

from collections.abc import Sequence

ConnectionOptions = dict[str, str]
Address = tuple[str, int]
Server = tuple[Address, ConnectionOptions]

def broadcast_message(message: str, servers: Sequence[Server]) -> None:
    ...

これで複雑な型の各部分が何を表しているかがわかりやすくなった。

Type Aliasの定義方法

通常の変数宣言と同じ様に定義すれば良い。

Ints = list[int]

ファイルのトップレベルに定義することが多いが、クラスの定義内でも定義することが出来る。

class Foo:
    Strs = list[str]

    def print_strs(self, strs: Strs) -> None:
        ...

Foo().print_strs(["hello", "world"])

Foo().print_strs([1, 2, 3])
# mypy
# error: List item 0 has incompatible type "int"; expected "str"  [list-item]
# error: List item 1 has incompatible type "int"; expected "str"  [list-item]
# error: List item 2 has incompatible type "int"; expected "str"  [list-item]
# Found 3 errors in 1 file (checked 1 source file)
# pyright
# error: Argument of type "list[int]" cannot be assigned to parameter "strs" of type "Strs" in function "print_strs"
#   "Literal[1]" is incompatible with "str"
#   "Literal[2]" is incompatible with "str"
#   "Literal[3]" is incompatible with "str" (reportGeneralTypeIssues)

Explicit Type Alias

Python3.10から入った機能として typing.TypeAlias がある。
これを使用することで明示的にある宣言がtype alias定義であるということをmypyやpyrightなどのtype checkerに伝えることが出来る。

from typing import TypeAlias

Floats: TypeAlias = list[float]

これによって従来のtype aliasが抱えていた曖昧さや問題を解決することが可能になる。

そもそもType Alias宣言はどうやって普通の変数宣言と区別されるか

type aliasの宣言は見た目上は普通の変数宣言と変わらない。

# type aliasにも変数宣言にも見える
AnotherInt = int

これがtype aliasとして扱われるのは

  1. 変数宣言に型アノテーションがついていない
  2. 値が型として適正

なときである。

つまり上のAnotherIntはこの条件に当てはまっているのでtype aliasとして扱われる。

# 変数に型アノテーションが無く、intは型として適正なのでtype aliasとして使える
AnotherInt = int

x: AnotherInt = 1

逆にこのルールから漏れるとき宣言はただの変数宣言として認識されて、type hintとして使用することはできない。

# 変数に型アノテーションがある場合はただの変数宣言になりtype aliasとして使えない
AnotherStr: str = ""

y: AnotherStr = "hello"
# mypy
# error: Variable "AnotherStr" is not valid as a type  [valid-type]
# note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
# pyright
# error: Variable not allowed in type expression (reportGeneralTypeIssues)
# error: Type of "y" is unknown (reportUnknownVariableType)

# 値が型として適正ではない場合はただの変数宣言になりtype aliasとして使えない
AnotherFloat = 0.1

z: AnotherFloat = 0.1
# mypy
# Variable "AnotherFloat" is not valid as a type  [valid-type]
# note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
# pyright
# error: Variable not allowed in type expression (reportGeneralTypeIssues)
# error: Type of "z" is unknown (reportUnknownVariableType)

これらは全てTypeAliasをつけて明示的につけていない宣言。
明示的ではないtype alias宣言は、上の例のようにもし不正な宣言だった場合はただの変数宣言になる。

TypeAliasによる改善

Forward References

クラス等の型の宣言前にtype alias宣言をするとエラーになる問題。

例えば以下のコードはエラーになる。

MyType = "Dog"
# mypy
# error: Variable "MyType" is not valid as a type  [valid-type]
# note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
# pyright
# error: Variable not allowed in type expression (reportGeneralTypeIssues)
# error: Return type is unknown (reportUnknownParameterType)

def foo() -> MyType:
    ...

class Dog:
    ...

このコードはDogのクラス宣言がtype aliasの後にあるのでtype checkerがMyType宣言をtype aliasだと判定してくれずにエラーが出る。
これは理想的にはクラス宣言が後にあろうとも、MyTypeをtype aliasだと認識してくれると嬉しい。

TypeAliasをつけてあげれば型の宣言が後にある時でもtype aliasとして認識してくれるようになる。

# エラーが出ない
MyType: TypeAlias = "Dog"

def foo() -> MyType:
    ...

class Dog:
    ...

エラーメッセージの改善

前述の通り、不正なtype alias宣言はtype hintでの使用時にtype aliasとして使えない旨のエラーが出る。

TypeAliasで宣言をアノテートすることでただの変数宣言へのフォールバックをなくすことが出来る。
つまり宣言時にエラーを出すことで不正な宣言を発見することが出来る。

# type aliasの使用時ではなく、type aliasの定義時にエラーが出る
AnotherFloat: TypeAlias = 0.1
# mypy
# error: Invalid type: float literals cannot be used as a type  [valid-type]
# pyright
# error: Invalid expression form for type alias definition (reportGeneralTypeIssues)
# error: Expected type expression but received "float" (reportGeneralTypeIssues)
# error: Type of "AnotherFloat" is unknown (reportUnknownVariableType)

TypeAliasが区別するもの

いままでは以下の形式のプログラムが暗黙的に3つの種類のどれかに振り分けられていた。

x = <>

TypeAliasが導入されたことにより、明示的に3種類のうちのどれかを指定することが可能になった。

1. 型付けられた変数宣言

x: int = 1

これは当然ただの型アノテーションがついた変数宣言として扱われる。

2. 型の付いていない変数宣言かつType Alias宣言

x = int

これは型アノテーションが付いていない変数宣言かつtype aliasの宣言として扱われる。
TypeAliasが入ったことによって、常にtype alias宣言に対してはTypeAliasをつけることでこのような形式のプログラムを変数宣言として扱うような仕様にすることも可能。
ただそのような仕様にしてしまうと、この形式で宣言されていたtype alias宣言が全て変数宣言として扱われてしまうことで後方互換性がなくなるので、このような仕様になっている。

3. Type Alias宣言

x: TypeAlias = int

これは常にtype aliasとして扱われる。
不正な宣言は常に宣言時にエラーを吐くようになるので、明示的にTypeAliasをつけることが望ましい。

Type AliasとNewTypeの使い分け

Pythonには派生元の型から新しい型を作成することが出来るNewTypeという仕組みがある。

UserId = NewType("UserId", int)

user_id1: UserId = UserId(1)

Type Aliasとなんとなく似ているため使い分けに迷うかもしれないが、使用方針としては明確に違う。

元の型と異なる型をつくりたいときはNewTypeを使う

NewTypeは派生後の型が派生元の型とは異なる型をつくりたいときに使用することが出来る。

具体的には派生後の型は元の型のサブタイプであるため、完全にそれらの型同士に互換性がない。

# UserId型はintのサブタイプとして定義されている
UserId = NewType("UserId", int)

# 当然UserId型にUserId型の値を代入できる
user_id1: UserId = UserId(1)

# UserId型にint型の値を代入することはできない
user_id2: UserId = 2
# mypy
# error: Incompatible types in assignment (expression has type "int", variable has type "UserId")  [assignment]
# pyright
# error: Expression of type "Literal[2]" cannot be assigned to declared type "UserId"
#   "Literal[2]" is incompatible with "UserId" (reportGeneralTypeIssues)

# int型にUserId型の値を代入することはできる
some_id: int = UserId(3)

このような仕様が嬉しいのは、intやstrのままだと型が曖昧で代入間違いが起こりそうなときにそれを防ぎたいとき。

def get_records(user_id: int, book_id: int):
    ...

user_id = 1
book_id = 2

# idを渡す引数の位置を間違えているが、型がどちらもintなのでエラーにならない
get_records(book_id, user_id)

# NewTypeを使うと、型が区別されるのでエラーになる
UserId = NewType("UserId", int)
BookId = NewType("BookId", int)

def get_records(user_id: UserId, book_id: BookId):
    ...

user_id = UserId(1)
book_id = BookId(2)

# OK
get_records(user_id, book_id)

# 異なる型の値を渡すとエラーになる
get_records(book_id, user_id)
# mypy
# error: Argument 1 to "get_records" has incompatible type "BookId"; expected "UserId"  [arg-type]
# error: Argument 2 to "get_records" has incompatible type "UserId"; expected "BookId"  [arg-type]
# pyright
# error: Argument of type "BookId" cannot be assigned to parameter "user_id" of type "UserId" in function "get_records"
#   "BookId" is incompatible with "UserId" (reportGeneralTypeIssues)
# error: Argument of type "UserId" cannot be assigned to parameter "book_id" of type "BookId" in function "get_records"
#   "UserId" is incompatible with "BookId" (reportGeneralTypeIssues)

元の型と同じだが別名だけ与えたいときはType Aliasを使う

この記事の最初で書いた通りtype aliasは元の型と完全に同じ型として扱われる。
そのため型の意味は変えずに見た目上の煩雑さだけを解消したいときにはNewTypeでなくtype aliasを使用するのがよい。

参考

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。