Pythonの型ヒントと共変性(covariance)と反変性(contravariance)、変性(variance)

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

はじめに

Pythonだけでなくジェネリクスを持つ型システムを保有するプログラミング言語を触ると共変と反変という概念が出てくる。
これは型の表現力を最大限上げつつ同時に実行時にエラーを起こさないための機能である。
一方これはジェネリクスの上に乗っかっている複雑な概念であるためそもそもの理解が難しかったり、メリットが感じづらい。

この記事では共変と反変を含む変性(variance)についてどのような機能なのか、何が嬉しいのかを解説する。

前提となるジェネリクスとその課題

ジェネリクスの仕組み

そもそもジェネリクスとはデータ型を表現する型の一部を変数化する機能である。
変数化することによって具体的な型ごとにコードを書く手間が省けるなどのメリットが有る。

例えばPythonでは以下のようにしてジェネリクスを使用する。

from typing import Generic, TypeVar

T = TypeVar("T")

class MyList(Generic[T]):
    def __init__(self, *xs: T):
        self.l = list(xs)

    # listの先頭を返す
    def head(self) -> T:
        return self.l[0]

int_list = MyList(1, 2, 3)
str_list = MyList("a", "b", "c")

# intに型付けられる
head_int: int = int_list.head()
# strに型付けられる
head_str: str = str_list.head()

headメソッドに見るように、MyList[T]が保持するlistの中身の型によってTが変化するので、メソッドの戻り値が柔軟に変化してくれる。

仮にジェネリクスなしでこれと同じようなコードを書くと以下の様に具体的なTごとにコードを書く必要が有り非常に面倒。

# 中身のクラスごとに同じようなコードを何回も書かないといけない
class IntList:
    def __init__(self, *xs: int):
        self.l = list(xs)

    def head(self) -> int:
        return self.l[0]

class StrList:
    def __init__(self, *xs: str):
        self.l = list(xs)

    def head(self) -> str:
        return self.l[0]

int_list2 = IntList(1, 2, 3)
str_list2 = StrList("a", "b", "c")

# intにstrを、またはstrにintを渡せない
IntList("a")
# mypy
# error: Argument 1 to "IntList" has incompatible type "str"; expected "int"  [arg-type]
# pyright
# error: Argument of type "Literal['a']" cannot be assigned to parameter "xs" of type "int" in function "__init__"
#   "Literal['a']" is incompatible with "int" (reportGeneralTypeIssues)
StrList(1)
# mypy
# error: Argument 1 to "StrList" has incompatible type "int"; expected "str"  [arg-type]
# pyright
# error: Argument of type "Literal[1]" cannot be assigned to parameter "xs" of type "str" in function "__init__"
#   "Literal[1]" is incompatible with "str" (reportGeneralTypeIssues)

head_int2: int = int_list.head()
head_str2: str = str_list.head()

同じようなコードを何回も書かないといけないことに加え、中に入れたいデータ型が増えるたびに更に同じ様なコードが増えていく。

この様にジェネリクスを使うことでlistなどのようにある種「コンテナ」の様に振る舞うデータ型の中身の型を抽象化出来る。
Pythonの標準ライブラリでもlistやdict等の多くの方がジェネリクスを用いて表現されている。

また忘れてはならないのが関数のような呼び出し可能なデータ型もジェネリクスとして表現出来るということである。
例えばPythonではCallableとして引数と戻り値の方が型パラメータで抽象化されている。

ジェネリクスの課題

ここまででジェネリクスの仕組みはわかったのだが、ジェネリクスそれ自体だけでは課題があることもすぐに分かる。

ジェネリクスの課題の一つとしてそのままでは中身の型に対してサブタイピング関係が効かなくなってしまうことがある。

class Animal:
    def __init__(self, name: str):
        self.name = name

class Dog(Animal):
    def bow_name(self) -> None:
        print(f"わんわん、{self.name}です")

animal_list = MyList(Animal("ぽち"), Animal("たま"))
dog_list = MyList(Dog("たろう"), Dog("はなこ"))

def print_animals(animals: MyList[Animal]) -> None:
    for animal in animals.l:
        print(animal.name)

# ok
print_animals(animal_list)

# Dog <: AnimalなのにMyList[Dog] <: MyList[Animal]ではないと判定されて型エラーになる
print_animals(dog_list)
# mypy
# error: Argument 1 to "print_animals" has incompatible type "MyList[Dog]"; expected "MyList[Animal]"  [arg-type]
# pyright
# error: Argument of type "MyList[Dog]" cannot be assigned to parameter "animals" of type "MyList[Animal]" in function "print_animals"
#   "MyList[Dog]" is incompatible with "MyList[Animal]"
#   Type parameter "T@MyList" is invariant, but "Dog" is not the same as "Animal" (reportGeneralTypeIssues)

Dog <: Animalだしprint_animalsMyList[Animal]を受け入れるのだからMyList[Dog]も受け入れてほしいものである。
しかしジェネリクスはそのままでは完全に同じ型、この場合で言うとMyList[Animal]にはMyList[Animal]しか渡せない。

このような場合、MyList[Animal]としてMyList[Dog]を渡せるのが便利だし自然に思えるが、一方次のような疑問も湧いてくる。

  1. この様に中身の型によってジェネリクス型=MyList自体のサブタイピング関係を同じ様に決めてしまうのはどのような場面においても正しいのか?
  2. またMyListでは良いかもしれないが、他のジェネリクスに対しても常に正しいと言えるのか?

これらの課題と疑問を解決するために使用される機能が型パラメータの変性(variance)である。

型パラメータの変性

まずは型パラメータの変性を理解するために共変(covariance)、反変(contravariance)、非変(invariance)の仕組みについて知る。

共変の仕組み

共変性は型パラメータのサブタイピング関係がそのままジェネリクス型のサブタイピング関係に反映されるような性質である。
つまり、「A <: BならばMyList[A] <: MyList[B]となる」が成り立つようになる。

PythonにおいてはTypeVarの引数covariant=Trueを渡すと共変になる。

# ここでcovariant=Trueにすると共変になる
T_co = TypeVar("T_co", covariant=True)

class CoList(Generic[T_co]):
    def __init__(self, *xs: T_co):
        self.l = list(xs)

def print_animals_co(animals: CoList[Animal]) -> None:
    for animal in animals.l:
        print(animal.name)

dogs_list = CoList(Dog("たろう"), Dog("はなこ"))
# CoList[Dog] <: CoList[Animal]と判定されて引数に渡せる
print_animals_co(dogs_list)

これは上のMyListの例等のように直感的にも自然に感じるし、メリットも想像し易い。
ただし落とし穴が無いわけではないので、それは後述する。

反変の仕組み

反変性は型パラーメータのサブタイピング関係が逆になってジェネリクスに反映されるような性質。
つまり「A <: BならばMyList[B] <: MyList[A]となる」が成り立つ。

PythonにおいてはTypeVarの引数にcontravariant=Trueを渡すと反変になる。

# ここでcontravariant=Trueにすると反変になる
T_contra = TypeVar("T_contra", contravariant=True)

class ContraList(Generic[T_contra]):
    def __init__(self, *xs: T_contra):
        self.l = list(xs)

# わざと引数をContraList[Dog]にしている
def print_animals_contra(animals: ContraList[Dog]) -> None:
    for animal in animals.l:
        print(animal.name)

# ContraList[Animal] <: ContraList[Dog]と判定されて引数に渡せる
animals_list = ContraList(Animal("ぽち"), Animal("たま"))
print_animals_contra(animals_list)

これは直感的に分かりづらいし、どういうときに役に立つのか初見では全くわからない。
この有用性と使いどころについては後述する。

非変の仕組み

非変性はパラメータのサブタイピング関係が何であろうとも、ジェネリクスの中身の型が全く同じときしか受け入れないような性質。

Pythonにおいては何もせずにそのままTypeVarを使用すると非変になる。

A = TypeVar("A")

class InvList(Generic[A]):
    def __init__(self, *xs: A):
        self.l = list(xs)

def print_animals_inv(animals: InvList[Animal]) -> None:
    for animal in animals.l:
        print(animal.name)

def print_dogs_inv(dogs: InvList[Dog]) -> None:
    for dog in dogs.l:
        print(dog.name)

animals_list2 = InvList(Animal("ぽち"), Animal("たま"))
dogs_list2 = InvList(Dog("たろう"), Dog("はなこ"))

# InvList[Animal] <: InvList[Animal]、InvList[Dog] <: InvList[Dog]なのでそれぞれ引数に渡せる
print_animals_inv(animals_list2)
print_dogs_inv(dogs_list2)

# InvList[Animal]とInvList[Dog]は同一ではないので渡せない
print_animals_inv(dogs_list2)
# mypy
# error: Argument 1 to "print_animals_inv" has incompatible type "InvList[Dog]"; expected "InvList[Animal]"  [arg-type]
# pyright
# error: Argument of type "InvList[Dog]" cannot be assigned to parameter "animals" of type "InvList[Animal]" in function "print_animals_inv"
#   "InvList[Dog]" is incompatible with "InvList[Animal]"
#   Type parameter "A@InvList" is invariant, but "Dog" is not the same as "Animal" (reportGeneralTypeIssues)
print_dogs_inv(animals_list2)
# mypy
# error: Argument 1 to "print_dogs_inv" has incompatible type "InvList[Animal]"; expected "InvList[Dog]"  [arg-type]
# pyright
# error: Argument of type "InvList[Animal]" cannot be assigned to parameter "dogs" of type "InvList[Dog]" in function "print_dogs_inv"
#   "InvList[Animal]" is incompatible with "InvList[Dog]"
#   Type parameter "A@InvList" is invariant, but "Animal" is not the same as "Dog" (reportGeneralTypeIssues)

その他の変性

その他に双変(bivariance)という変性があるがPythonで使用されることはほとんど無く、あまり使うべきでもないのでこの記事では扱わない。
双変はTypeScriptで活用されているので興味がある人は以下の記事を見てみると面白いかもしれない。

https://zenn.dev/pixiv/articles/what-is-bivariance-hack

変性の対応表

A <: Bという条件の元で以下の関係性になる。

変性 ジェネリクス型の関係
共変 G[A] <: G[B]
反変 G[B] <: G[A]
非変 G[A] <: G[B]でもG[B] <: G[A]でもない
双変 G[A] <: G[B]かつG[B] <: G[A]

変性の使い分けと落とし穴

共変の使い所と落とし穴

共変はlistのようにジェネリクス型が何らかの値を格納するようなコンテナっぽいデータ型のときに使うと便利。
中身の型のサブタイプ関係によって柔軟にジェネリクス型自体のサブタイが決まるので、直感的にサブタイピング関係が働いてほしいところでうまく動く。

class MutableContainer(Generic[T_co]):
    def __init__(self, value: T_co):
        self.value = value

    def get(self) -> T_co:
        return self.value

    def set(self, value: T_co) -> None:
        self.value = value

def print_container(container: MutableContainer[Animal]) -> None:
    print(container.get().name)

print_container(MutableContainer(Animal("ぽち")))
# 中身のデータ型のサブタイプ関係がそのまま使えて便利
print_container(MutableContainer(Dog("たろう")))

ただ常にコンテナ型のときに共変を使うべきかというとそうではない。
例えば上の例では以下の様なときに型チェックは通っても実行時にエラーになってしまう。

dog_container = MutableContainer(Dog("たろう"))

def set_animal(container: MutableContainer[Animal]):
    # 中身がdogであろうがAnimalをsetする
    container.set(Animal("ぽち"))

set_animal(dog_container)

# dog_containerは依然MutableContainer[Dog]のままだが、中身にAnimalが入っているので型チェックは合格しても実行時エラーになる
dog_container.value.bow_name()
# Traceback (most recent call last):
#     dog_container.value.bow_name()
# AttributeError: 'Animal' object has no attribute 'bow_name'

原因はコンテナ型自体がmutableであることに起因する。
中身のデータ型を親子関係に基づいて自由に変えられると、上の例ではDogでいてほしいものがAnimalに変わってしまって存在しないメソッドの呼び出しにつながる。

したがって共変を使うべきときは中身の値が変わらないimmutableなときに限られる。

# 中身の値を変えるインターフェースが無いのでImmutableContainerは共変でよい
class ImmutableContainer(Generic[T_co]):
    def __init__(self, value: T_co):
        self.value = value

    def get(self) -> T_co:
        return self.value

それ以外のmutableなコンテナ型のときは共変でも反変でもなく非変であるべきである。

# mutableなので非変にする
class MutableInvContainer(Generic[A]):
    def __init__(self, value: A):
        self.value = value

    def get(self) -> A:
        return self.value

    def set(self, value: A) -> None:
        self.value = value

加えて、実はMutableであるかどうかはsetメソッドの様に引数に型パラメータが現れていると検知出来る。
そのためtype checkerによりその様なコードはエラーが吐かれ、最初のMutableContiainerは実は定義ができなくなっている。

class MutableContainer(Generic[T_co]):
    def __init__(self, value: T_co):
        self.value = value

    def get(self) -> T_co:
        return self.value

    def set(self, value: T_co) -> None:
        # mypy
        # error: Cannot use a covariant type variable as a parameter  [misc]
        # pyright
        # error: Covariant type variable cannot be used in parameter type (reportGeneralTypeIssues)
        self.value = value

反変の使い所

反変は仕組みの理解とメリットの理解が難しいが、使い所はある。
具体的には共変を使うべきではなかったところ、関数の引数が型パラーメタになっている様なときに使える。

例えば以下のコードは型チェックも通るし実行も正常に行われる。

class Printer(Generic[T_contra]):
    # 引数の型がパラメタライズされている。
    @abstractmethod
    def print(self, value: T_contra) -> None:
        raise NotImplemented

class AnimalPrinter(Printer[Animal]):
    def print(self, value: Animal) -> None:
        print(value.name)

class DogPrinter(Printer[Dog]):
    def print(self, value: Dog) -> None:
        value.bow_name()

def print_dog(printer: Printer[Dog]):
    printer.print(Dog("たろう"))

# 当然dog_printerはPrinter[Dog]なので渡せる
print_dog(DogPrinter())

# AnimalPrinterはPrinter[Animal]で反変性によりPrinter[Animal] <: Printer[Dog]なので渡せる
print_dog(AnimalPrinter())

これだけ見てもこの様なユースケースがなぜ反変でなければいけないのかの理解が難しいので、共変にした場合にどうなるかを考えてみる。
上の例を共変版で書き直してみる。

class PrinterCo(Generic[T_co]):
    @abstractmethod
    def print(self, value: T_co) -> None:
        raise NotImplemented

class AnimalPrinterCo(PrinterCo[Animal]):
    def print(self, value: Animal) -> None:
        print(value.name)

class DogPrinterCo(PrinterCo[Dog]):
    def print(self, value: Dog) -> None:
        value.bow_name()

def print_animal_co(printer: PrinterCo[Animal]):
    # printerがPrinter[Dog]の場合はここでエラーになる
    printer.print(Animal("たろう"))

# 当然animal_printerはPrinterCo[Animal]なので渡せるしちゃんと動く
print_animal_co(AnimalPrinterCo())
# ここでPrinterCo[Dog] <: PrinterCo[Animal]と判定されて渡せるが、実行時にエラーになる
print_animal_co(DogPrinterCo())
# Traceback (most recent call last):
#   print_dog_co(DogPrinterCo())
#   File ~ in print_dog_co
#     printer.print(Animal("たろう"))
#   File ~ in print
#     value.bow_name()
# AttributeError: 'Animal' object has no attribute 'bow_name'

このコードは型チェックは通るものの実行時にエラーになる。
つまり関数の引数で反変を使用するべき理由は実行時のプログラムの安全性のためである。

もう少し深掘って考えてみる。
関数の引数の型Tが何らかの特定の型、例えばAnimalDogであるということは、その関数がその型の値を渡して呼び出せるということである。
Printerで考えてみると、Printer[Animal]Animalを、Printer[Dog]Dogを受け取って何らかの処理を実行する能力を持っている。

このとき、Printer[Animal]Printer[Dog]の適切なサブタイプ関係を考えるためにどちらがより広い範囲の値を処理できるかを考える。
AnimalDogより広い範囲の値を取る。なぜなら仮にAnimalを直接継承させたCatを定義すれば、AnimalCatのインスタンス値を取りうるが、Dogは取り得ない。
つまりAnimalのほうがDogより広い値を取るのだから、Printer[Animal]Printer[Dog]より広い範囲の値を処理出来ると言える。

サブタイプの親子関係は親の振る舞いを子が代替出来ることで決まる。
Printerの例を考えると、Printer[Animal]Printer[Dog]を代替出来るが、逆は成り立たない。
なぜならPrinter[Animal]Dogを含むAnimal全てを取り扱えるが、Printer[Dog]Dogの範囲しか取り扱えないから。
つまりPrinter[Animal]Printer[Dog]として振る舞うことができ、逆は不可能なので、Printer[Animal] <: Printer[Dog]が望ましい。

名称未設定ファイル (3)

これはまさに反変性を用いて表現出来ること、つまり中身の型Tに対してジェネリクス型のサブタイプ関係が逆になるという性質である。
関数の引数の型パラメータが反変であるほうが望ましい理由は、この様に関数自体の能力に関係がある。

非変の使い所

コンテナ型のように振る舞うがミュータブルなデータ型に対しては無理に共変にせずに非変にしておくほうが良い。
実際、Pythonの標準ライブラリでもlistなどのジェネリクス型はミュータブルなので非変である。

非変にしたほうが良い理由は共変の使い所で書いた様に、ミュータブルなデータ型に対して共変にしてしまうとランタイムエラーが起きるから。

共変、非変、反変の使い方まとめ

対象の型パラメータの位置 推奨される変性
immutableなコンテナ型の要素 共変
関数の戻り値の型 共変
関数の引数の型 反変
mutableなコンテナ型の要素 非変

Python標準ライブラリのデータ型の変性

SequenceやCollection等のimmutableなインターフェースを持つデータ型は基本的には型パラーメータが共変になっている。
またMutableSequence等のmutableなインターフェースが生えたものは反変。

Callableは引数の位置に現れているものは反変になっている。

print_animal_name: Callable[[Animal], None] = lambda animal: print(animal.name)
print_dog_name: Callable[[Dog], None] = lambda dog: dog.bow_name()

def call_animal(func: Callable[[Animal], None]) -> None:
    func(Animal("ぽち"))

def call_dog(func: Callable[[Dog], None]) -> None:
    func(Dog("たろう"))

# ok
call_animal(print_animal_name)

# Callableは引数の型に対して非変なのでCallable[[Animal], None] <: Callable[[Dog], None]となり型エラー
call_animal(print_dog_name)
# mypy
# error: Argument 1 to "call_animal" has incompatible type "Callable[[Dog], None]"; expected "Callable[[Animal], None]"  [arg-type]
# pyright
# error: Argument of type "(dog: Dog) -> None" cannot be assigned to parameter "func" of type "(Animal) -> None" in function "call_animal"
#   Type "(dog: Dog) -> None" cannot be assigned to type "(Animal) -> None"
#   Parameter 1: type "Animal" cannot be assigned to type "Dog"
#   "Animal" is incompatible with "Dog" (reportGeneralTypeIssues)

# ok
call_dog(print_dog_name)
# ok
call_dog(print_animal_name)

これらはどれも上で議論した変性の使い分けに適っている。

参考

info-outline

お知らせ

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