Pythonのジェネリクスで型が抽象化されたクラスやデータを定義する
はじめに
Pythonでtype hintのあるコードを書いていると良くlist[int]
の様に[]
が付けられた型アノテーションに出会う。
これらはジェネリクスといい、普通のint
やstr
等の様な型とは違って抽象的な型を定義して再利用するための仕組みである。
ジェネリクスはよく使われ便利だが、初めて静的型のある言語を書く初心者にとっては理解が少しむずかしい。
この記事ではジェネリクスがなぜ必要なのか、どの様に機能するかを確認し、ジェネリクスに関連する応用的な機能まで紹介する。
題材となるクラスと課題
ジェネリクスの有用性を理解するための例として、以下のようなクラスを考える。
class StrMaybe:
"""strを保持する可能性のあるクラス"""
def __init__(self, value: str | None):
self.value = value
def has_value(self) -> bool:
return self.value is not None
def print_if_exists(self) -> None:
if self.has_value():
print(self.value)
def get_value_or_else(self, default: str) -> str:
if self.value is not None:
return self.value
else:
return default
このクラスは唯一つのstr
型の値を保持する可能性がある。
保持しない場合は単にNone
を持つ。
値があるときに限ったロジックをメソッドとして実装することで、値がNone
の場合の処理をこのクラスに丸投げ出来るようになる。
has_value_str_maybe = StrMaybe("hello")
no_value_str_maybe = StrMaybe(None)
print(has_value_str_maybe.has_value())
# => True
print(no_value_str_maybe.has_value())
# => False
has_value_str_maybe.print_if_exists()
# => hello
no_value_str_maybe.print_if_exists()
# 何も出力されない
print(has_value_str_maybe.get_value_or_else("default"))
# => hello
print(no_value_str_maybe.get_value_or_else("default"))
# => default
課題
便利なのでstr
に加えてint
のための同じ様なクラスを作りたい。
実装は出来るが、StrMaybe
とほとんど同じ内容を書かなければいけないことに気付く。
# 書いてある内容がほとんど同じで面倒
class IntMaybe:
"""intを保持する可能性のあるクラス"""
def __init__(self, value: int | None):
self.value = value
def has_value(self) -> bool:
return self.value is not None
def print_if_exists(self) -> None:
if self.has_value():
print(self.value)
def get_value_or_else(self, default: int) -> int:
if self.value is not None:
return self.value
else:
return default
型を全部Any
にしてどの様な型でも受け入れられるようにすることも出来るが、その場合にはget_value_or_else
の様なメソッドの型付けが弱くなってしまう。
例えば下の例ではstr
型しか渡したくない場所でint
まで渡せてしまう。
from typing import Any
class AnyMaybe:
"""任意の型を保持する可能性のあるクラス"""
def __init__(self, value: Any | None):
self.value = value
def has_value(self) -> bool:
return self.value is not None
def print_if_exists(self) -> None:
if self.has_value():
print(self.value)
def get_value_or_else(self, default: Any) -> Any:
if self.value is not None:
return self.value
else:
return default
has_value_any_maybe = AnyMaybe("hello")
# Any型なのでdefaultとしてintを渡せてしまう
has_value_any_maybe.get_value_or_else(1)
この様にクラスの定義自体はいろんな型に対応して柔軟に再利用したいが、同時に型の安全さも損ないたくない場合に使用できる機能がジェネリクスである。
ジェネリクスの使い方
ジェネリクスを自分で定義するクラスで使用する際には2つ登場人物がいる。
1つはTypeVar
、もう一つがGeneric
クラスである。
まずはこれらを用いてジェネリクスがどう定義されるかを見て、その後各箇所の解説をする。
ジェネリクスを使用したクラス定義
ジェネリクスを使用してMaybe
を任意の型に対して使用できるように実装してみると以下のようになる。
from typing import Generic, TypeVar
T = TypeVar("T")
class Maybe(Generic[T]):
"""任意の型を保持する可能性があるが、適切に型が付けられるクラス"""
def __init__(self, value: T | None):
self.value = value
def has_value(self) -> bool:
return self.value is not None
def print_if_exists(self) -> None:
if self.has_value():
print(self.value)
def get_value_or_else(self, default: T) -> T:
if self.value is not None:
return self.value
else:
return default
このクラスを使用すればint
やstr
等の任意の型を受け取って、型の精密さを失わないままに同じ様なコードを共通化してクラスの定義が出来る。
# 任意の型の値を渡せる
str_maybe = Maybe("hello")
int_maybe = Maybe[int][1]
print(str_maybe.has_value())
# => True
print(int_maybe.has_value())
# => False
print(str_maybe.get_value_or_else("default"))
# => hello
print(int_maybe.get_value_or_else(1))
# => 1
Any
を用いたときとは違って1度Tがint
やstr
に指定されればその情報を保持し続けるので、間違った型の値を渡すようなことも防げる。
str_maybe.get_value_or_else(1)
# mypy
# error: Argument 1 to "get_value_or_else" of "Maybe" has incompatible type "int"; expected "str" [arg-type]
# pyright
# error: Argument of type "Literal[1]" cannot be assigned to parameter "default" of type "str" in function "get_value_or_else"
# "Literal[1]" is incompatible with "str" (reportGeneralTypeIssues)
ジェネリックなクラスを型アノテーションとして使用したい場合にはクラスに[]
を付けて定義する。
その際にはint
などの具体型を入れることも可能だし、TypeVar
によって作成した型パラメータを用いて抽象化したままにも出来る。
# ジェネリックな型アノテーションを使用する例
def set_if_not_exists(maybe: Maybe[T], value: T) -> None:
if not maybe.has_value():
maybe.value = value
set_if_not_exists(int_maybe, 1)
print(int_maybe.value)
# => 1
# Tによって同じ型の値しか渡せないことが保証されているので、違う型を渡すと型エラーになる
set_if_not_exists(str_maybe, 1)
# mypy
# error: Cannot infer type argument 1 of "set_if_not_exists" [misc]
# pyright
# error: Argument of type "Maybe[str]" cannot be assigned to parameter "maybe" of type "Maybe[T@set_if_not_exists]" in function "set_if_not_exists"
# "Maybe[str]" is incompatible with "Maybe[str | int]"
# Type parameter "T@Maybe" is invariant, but "str" is not the same as "str | int" (reportGeneralTypeIssues)
使い方の解説
ジェネリックなクラスを定義したい場合はまずTypeVar
を使ってそのインスタンスを定義する。
from typing import TypeVar
T = TypeVar("T")
TypeVar
をインスタンス化したT
は型引数とか型パラメータとかと呼ばれる。
このように呼ばれる理由はこれは具体的な型ではなく、後で詳しく見るが何かしらの型が存在することを示すプレースホルダー=引数、パラメータの様に振る舞うから。
似た概念で、例えば関数の引数は何かしらの値が存在することだけを示すプレースホルダとして振る舞う。
その型バージョン。
型パラメータの名前は何でも良くて、T
とかS
のような一文字だけがよく使われるが別にちゃんと名前をつけても良い。ただ慣習的に一文字が多いというだけ。
型パラメータを定義したらそれを実際にクラスの定義で使う。
class Maybe(Generic[T]):
...
ここで使用されるのがGeneric
クラスで、これを継承したクラスは型パラメータの部分にint
等の何かしらの具体型を付けて型アノテーションとすることができるようになる。
float_maybe: Maybe[float] = Maybe(1.0)
また型パラメータを具体化せずに汎用のまま扱いたい場合は、またTypeVar
で作った型パラメータで埋めてあげれば抽象化されたまま使える。
def set_if_not_exists(maybe: Maybe[T], value: T) -> None:
if not maybe.has_value():
maybe.value = value
これはまだ型パラメータT
がint
などの何らかの具体型に固定されておらず、この関数の使用時に具体型に推論される。
# 引数としてMaybe[int]やint型を渡すとTが具体的に定まる
set_if_not_exists(int_maybe, 1)
# こちらはT=strとなる
set_if_not_exists(str_maybe, "bye")
またクラス定義部分で使用した型パラメータはクラス内で使用できる。
例えばMaybe
ではコンストラクタへの引数として使用している。
class Maybe(Generic[T]):
def __init__(self, value: T | None):
self.value = value
...
この様にコンストラクタで型パラメータを使用した場合には、クラスの初期化時に指定した値によって型パラメータが勝手に推論されるようになる。
str_maybe = Maybe("hello")
# Maybe[str]に推論される
Maybe
の場合はNone
を渡すと、T
を推論するための手がかりが無いので自分で指定する必要がある。
int_maybe = Maybe[int][1]
# Noneだと手がかりが無いので自分でintを指定する
コンストラクタ以外のその他のメソッドでも型パラメータが使用できて、Generic
に指定した型パラメータ、今回の場合だとT
はクラスのインスタンス化時に固定化される。
そのため、例えばT
を使用しているget_value_or_else
は固定化された以外の型の値を渡せなくなる。
Maybe[int]型のget_value_or_elseにstr型の値を渡せない
int_maybe.get_value_or_else("default")
# mypy
# error: Argument 1 to "get_value_or_else" of "Maybe" has incompatible type "str"; expected "int" [arg-type]
# pyright
# error: Argument of type "Literal['default']" cannot be assigned to parameter "default" of type "int" in function "get_value_or_else"
# "Literal['default']" is incompatible with "int" (reportGeneralTypeIssues)
標準ライブラリでのジェネリクスの例
listの例
Python標準ライブラリのlistも中身の要素の型によってジェネリックに型が変わる。
int_list: list[int] = [1, 2, 3]
# 上と同じ意味
from typing import List
int_list: List[int] = [1, 2, 3]
昔はtyping.List
を使用してアノテーションを付けていたが、Python3.9から普通のlist
を使用してアノテーションを付けられるようになったため、typing.List
は非推奨になっている。
dictの例
dictもkeyと値の要素の型によってジェネリックに型が変化する。
listと違って型パラメータによって抽象化されている型がkeyと値の分の2つあることに注意。
int_dict: dict[str, int] = {"a": 1, "b": 2, "c": 3}
# 上と同じ意味
from typing import List
int_dict: Dict[str, int] = {"a": 1, "b": 2, "c": 3}
listと同じ様にtyping.Dict
は3.9から非推奨になっている。
Callableの例
Pythonにおいては関数呼び出し(f()
の様な記法)ができる型をtyping.Callable
を使用してアノテーションできる。
Callable
は引数と戻り値の型がそれぞれ型パラメータで抽象化されているので、それぞれを埋めて上げる必要がある。
def call_func(func: Callable[[int], None]) -> None:
func(1)
def print_int(x: int) -> None:
print(f"int: {x}")
print_doubled_int: Callable[[int], None] = lambda x: print_int(x * 2)
def print_int_2(x: int, y: int) -> None:
print(f"int: {x}")
print(f"int: {y}")
call_func(print_int)
# => int: 1
call_func(print_doubled_int)
# => 2
# 引数の型が合わないので型エラー
call_func(print_int_2)
# mypy
# error: Argument 1 to "call_func" has incompatible type "Callable[[int, int], None]"; expected "Callable[[int], None]" [arg-type]
# pyright
# error: Argument of type "(x: int, y: int) -> None" cannot be assigned to parameter "func" of type "(p0: int) -> None" in function "call_func"
# Type "(x: int, y: int) -> None" cannot be assigned to type "(p0: int) -> None"
# Function accepts too few positional parameters; expected 2 but received 1
# Keyword parameter "y" is missing in destination (reportGeneralTypeIssues)
Callable
は引数の数によって配列で渡さなければ行けない型の数が変わるので注意。
ジェネリクスの応用的な機能
ジェネリクス自体そのままでも便利なのだが、上に挙げた機能だけだと不都合が生じることもある。
それらの課題を解決するためにジェネリクスには応用的な機能がいくつかある。
bound
まず以下のようなクラスを考える。
class Animal(ABC):
def eat(self, food_name: str) -> None:
print(f"{self.__class__.__name__} eats {food_name}")
class Dog(Animal):
pass
Animal().eat("meat")
# => Animal eats meat
Dog().eat("meat")
# => Dog eats meat
Animalはeatメソッドを持っていて、その子クラスとしてDogが存在する。
これらに餌を与えて、インスタンスを返す関数を考える。
ナイーブに実装するとジェネリクスを使用して以下のような関数になるが、これでは型エラーが起きる。
A = TypeVar("A")
def feed_banana(animal: A) -> A:
animal.eat("banana")
# mypy
# error: "A" has no attribute "eat" [attr-defined]
# pyright
# error: Cannot access member "eat" for type "object"
# Member "eat" is unknown (reportGeneralTypeIssues)
# error: Type of "eat" is unknown (reportUnknownMemberType)
return animal
理由は単純で、型変数Aによって抽象化されたせいでA型に入ってくる値がeatメソッドを持っているかどうかが判別できなくなってしまったから。
実際このインターフェースの型は以下の様にintを渡しても関数のインターフェースとしては型エラーが起きない。
feed_banana(1)
もちろんintにeatメソッドは無いので、これは実行時にエラーが起きる。
この様にAによって具体的な型を抽象化してしまったのでAに対してeatが呼べるかどうかわからなくなってしまったので型エラーになる。
これを解決するにはAが取りうる型の範囲を絞ってやれば良い。
それを実現するのがTypeVarの引数のbound。
boundには何らかの型を指定する。
そうすると、その型パラメータは指定した型かその子クラスにしかなれなくなる。
B = TypeVar("B", bound=Animal)
def feed_banana_2(animal: B) -> B:
# BはAnimalかそのサブクラスしか取れなくなり、eatメソッドの存在が保証されるので型エラーが出ない
animal.eat("banana")
return animal
実際に使用するときは以下のようになる。
fed_dog = feed_banana_2(Dog())
# Dogとして型推論される
fed_animal = feed_banana_2(Animal())
# Animalとして型推論される
# intなどの関係ない型は渡せない
feed_banana_2(1)
# error: Value of type variable "B" of "feed_banana_2" cannot be "int" [type-var]
boundを使用したときには当然だが、型パラメータは渡した型に合う最も特定化された型として推論される。
例えば上の例ではAnimal
を渡したときにはBはAnimal
として推論され、Dog
を渡したときにはDog
になる。
詳しくは後述するが、この点でconstraintとは挙動が違う。
constraints
型パラメータの範囲を指定する方法はbound以外にもあってそれがconstraints。
constraintsはTypeVarの引数として複数の型を指定すると、そのどれかしか取れなくなる。
例えば以下のようにAnimal
ではないがeat
メソッドをもつHuman
クラスを考える。
class Human:
def eat(self, food_name: str) -> None:
print(f"Human eats {food_name}")
このクラスはeat
メソッドを持つが、Animal
を継承していないため、bound
を使用している状態では型エラーになって引数として渡せない。
# Humanは渡せない
feed_banana_2(Human())
# mypy
# error: Value of type variable "B" of "feed_banana_2" cannot be "Human" [type-var]
# pyright
# error: Argument of type "Human" cannot be assigned to parameter "animal" of type "B@feed_banana_2" in function "feed_banana_2"
# Type "Human" is not compatible with bound type "Animal" for TypeVar "B@feed_banana_2"
# "Human" is incompatible with "Animal" (reportGeneralTypeIssues)
constraintsの機能を使用して、Human
も指定可能にするとAnimal
たちに加えて渡せるようになる。
C = TypeVar("C", Animal, Human)
def feed_banana_3(eater: C) -> C:
eater.eat("banana")
return eater
feed_banana_3(Dog())
# Animalとして型推論される
# constraintに入っているのでHumanを渡せる
feed_banana_3(Human())
constraintsを使用したときの注意としては、親クラスだけを指定しているとそのクラスとして推論が効いてしまう。
つまり上の例ではDog
を渡したときでもC
はAnimal
として推論されている。
この部分がboundとは違う挙動。
variance
ジェネリックな型同士のサブタイプ関係を考えたいときがある。
例えばlist[Dog]
とlist[Animal]
はどちらがスーパータイプでサブタイプかのようなもの。
この問題は一見シンプルに見えるが意外と複雑。
そのためにvarianceという機能が用意されているが、この話題はこれだけで非常に長くなるので以下を参照。