Pythonのジェネリクスで型が抽象化されたクラスやデータを定義する

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

はじめに

Pythonでtype hintのあるコードを書いていると良くlist[int]の様に[]が付けられた型アノテーションに出会う。
これらはジェネリクスといい、普通のintstr等の様な型とは違って抽象的な型を定義して再利用するための仕組みである。

ジェネリクスはよく使われ便利だが、初めて静的型のある言語を書く初心者にとっては理解が少しむずかしい。

この記事ではジェネリクスがなぜ必要なのか、どの様に機能するかを確認し、ジェネリクスに関連する応用的な機能まで紹介する。

題材となるクラスと課題

ジェネリクスの有用性を理解するための例として、以下のようなクラスを考える。

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

このクラスを使用すればintstr等の任意の型を受け取って、型の精密さを失わないままに同じ様なコードを共通化してクラスの定義が出来る。

# 任意の型の値を渡せる
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がintstrに指定されればその情報を保持し続けるので、間違った型の値を渡すようなことも防げる。

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

これはまだ型パラメータTintなどの何らかの具体型に固定されておらず、この関数の使用時に具体型に推論される。

# 引数として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を渡したときでもCAnimalとして推論されている。
この部分がboundとは違う挙動。

variance

ジェネリックな型同士のサブタイプ関係を考えたいときがある。
例えばlist[Dog]list[Animal]はどちらがスーパータイプでサブタイプかのようなもの。
この問題は一見シンプルに見えるが意外と複雑。
そのためにvarianceという機能が用意されているが、この話題はこれだけで非常に長くなるので以下を参照。

https://kdotdev.com/kdotdev/python-covariance-contravariance-variance

info-outline

お知らせ

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