【Python】引数と戻り値の共変と反変をふんわりと理解する

PythonにTypeHintが導入されてから年月が経ち、TypeHintを書くのは当たり前とも言える時代になってきました。
それに伴いPythonでも型を意識することが多くなる訳ですが、その中で「共変」「反変」といった概念に出会います。
でも気になって調べてみても難しい言葉ばかりで何もわからないし、サンプルコードもPython以外ばっかり......
そこで今回は「引数と戻り値」の「共変と反変」をふんわりと、Pythonのみを使用してまとめてみます。


# 更新履歴
2023/12/03 : 初版


# 環境
* Windows10
* Python3.10
* PyCharm Community Edition 2021.2.2


# 1. リスコフの置換原則

共変や反変を理解するためには、この「リスコフの置換原則」が重要になります。
とはいえ全てを理解する必要はなく、ふんわり雰囲気がわかれば大丈夫です。
この原則が何を言っているかというと、「子クラスは親クラスの代わりができるように設計しよう」ということです。
下にPythonにおけるクラス継承関係図を表示します。

```text:クラス継承関係図
<-抽象的 具体的->
object
└ int
   └ bool
```

* `int`は`object`を継承している
* `bool`は`int`を継承している
* （推移的に）`bool`は`object`を継承している

つまり、

* `object`（親） - `int` （子）
* `int`（親） - `bool` （子）
* `object`（親） - `bool` （子）

という関係になります。
リスコフの置換原則に従うなら、

* `object`にできることは`int`にもできる
* `int`にできることは`bool`にもできる
* `object`にできることは`bool`にもできる

ように設計する必要があります。
そして実際、これらのクラスはこの原則を満たすように設計されています。
もちろん、この原則を破って設計することも可能です。
しかし原則を守ることによるメリットが大きいため、広く一般的に受け入れらています。

では、実際のコードでリスコフの置換原則を確認してみましょう。
ここに、`int`型として宣言された変数`value`があります。

```Python
value: int
```

変数`value`はこの後`int`型として扱われるので、`int`型の値を代入すれば何も問題はありません。
ですが、ここで発想を変えてみましょう。
「`int`型として扱われたとき問題が起きない保証があれば、`int`型以外の値を代入しても良いのでは？」と。
そこでリスコフの置換原則の出番です。
上で「`int`にできることは`bool`にもできる」と書きました。
つまり、<font color="red">`int`型の変数に`bool`型の値を代入できる</font>のです。

```Python
value: int
value = 0  # OK
value = True  # これもOK!
```

もちろん、子クラスでない型の値を代入することはできません。

```text:クラス継承関係図
<-抽象的 具体的->
object    <- intの子クラスではない
├ int
│  └ bool
...
└ str     <- intの子クラスではない
```

```Python
value: int
value = 'abc'  # これはNG
value = object()  # 親クラスもNG
```

以下、<font color="red">リスコフの置換原則が守られていることを前提</font>にして説明を進めます。




# 2. 引数と戻り値を観察しよう

簡単な関数を基に、引数と戻り値を観察してみましょう。

## 2.1. 戻り値

戻り値の方が直感的に理解しやすいため、先に扱います。
ここに`int`型の値を返す関数があります。

```Python
def sample() -> int:
    pass
```

1節で説明した通り、`bool`型は`int`型の代わりができます。
なので、戻り値に`int`型を要求されたとしても、`bool`型の値を返すことができます。

```Python
def sample() -> int:
    return True  # これは（リスコフの置換原則的には）OK
```

......とはいえ、どちらの方がわかりやすいでしょうか？

```Python
def sample() -> int:
    return True

def sample() -> bool:
    return True
```

もっと極端な例を考えてみましょう。
`object`型の値を返す関数があります。

```Python
def sample() -> object:
    pass
```

`int`型、`bool`型、それに限らずあらゆる型は`object`型の代わりができます。
なので、戻り値に`object`型を要求された場合、あらゆる型の値を返すことができます。

```Python
# 全て（リスコフの置換原則的には）OK

def sample() -> object:
    return 0

def sample() -> object:
    return True

def sample() -> object:
    return 'abc'

def sample() -> object:
    return [0, 1, 2]
```

明らかに、次のように書いた方が使い勝手が良いでしょう。

```Python
def sample() -> int:
    return 0

def sample() -> bool:
    return True

def sample() -> str:
    return 'abc'

def sample() -> list[int]:
    return [0, 1, 2]
```

今回の例では関数`sample`は単純な処理ですが、普通関数の処理を把握するのは非常に大変です。
そんな中戻り値を抽象的な型で書かれると、関数を使う人からすれば何が返ってくるかわからず不便です。
つまり、<font color="red">戻り値は具体的な型を書いた方が便利でわかりやすい</font>ということです。

## 2.2. 引数

ここに`list`の引数を要求する関数があります。
リストが空であれば`None`を、そうでなければ0番目の要素を返します。
（`list`の中身の型、及び戻り値の型に関しては今回省略します。）

```Python
def sample(array: list):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

さて、Pythonを使っているとよく遭遇するのは<font color="red">「`list`/`tuple`問題」</font>です。
`list`と`tuple`は同じように扱えることが多いですが、上の例で`array`に`tuple`を指定すると警告が出てしまいます。
実行時にエラーは発生しないにも関わらず、です。
`tuple`に対しても`len`関数や角括弧アクセス(`array[0]`)が使えますからね。

```Python
array = (0, 1, 2)
value = sample(array)  # 引数arrayに型不一致警告
```

では、どのようにすればこの警告を解消できるでしょうか。

①型ごとに関数を用意

```Python
def sample_list(array: list):
    if len(array) == 0:
        return None
    else:
        return array[0]

def sample_tuple(array: tuple):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

②TypeHintに追加

```Python
def sample(array: list | tuple):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

上の①、②の問題は、引数`array`で受け取りたい型が増えたとき、毎回コードを修正する必要があることです。
面倒ですし、修正時にバグを生みかねません。
10個とか追加されたらもう酷いことになります。
ここでも発想を変えてみましょう。

③抽象への依存
引数`array`に対しては`len`関数と角括弧アクセスを使っています。
ではもし、「`len`関数と角括弧アクセスをサポートするクラス」が存在するとしたら？

```Python
# LenAndGetItemSupportsクラスはlen関数と角括弧アクセスが使用可能
def sample(array: LenAndGetItemSupports):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

リスコフの置換原則から、`LenAndGetItemSupports`クラスを継承したクラスは全て`len`関数や角括弧アクセスが使用可能で、引数`array`に指定可能ということになります。

```text:クラス継承関係図
<-抽象的 具体的->
LenAndGetItemSupports
├ Hoge
│  └ HogeHoge
└ Fuga
```

```Python
# 全部arrayに指定可能！
value = sample(Hoge(0, 1, 2))
value = sample(HogeHoge(0, 1, 2))
value = sample(Fuga(0, 1, 2))
```

`LenAndGetItemSupports`クラスを継承していれば何でも良い。
クラスごとに関数を用意したり、長々とTypeHintを書く必要もない。
1つの関数を色々な場面で使え、再利用性が非常に高いです。
それってとても便利だとは思いませんか？

そんな`LenAndGetItemSupports`クラス、**Pythonに実装されています**。
名前は違いますが、それが`collections.abc`モジュールの`Sequence`クラスです。
そして`list`や`tuple`は、`Sequence`クラスを継承しています。

```text:クラス継承関係図
<-抽象的 具体的->
Sequence
├ list
└ tuple
```

つまりこの`sample`関数のTypeHintは、次のように書けるのです。

```Python
from collections.abc import Sequence

def sample(array: Sequence):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

`list`と`tuple`は`Sequence`クラスを継承しているので、共に引数`array`に指定可能です。

```Python
# どちらも警告なく使用OK！
value = sample([0, 1, 2])
value = sample((0, 1, 2))
```

このように、`list`や`tuple`といった具体的なクラスではなく、「`len`関数と角括弧アクセスをサポートするクラス」といった抽象的なクラスを使用することで、複数のクラスにまとめて対応することが可能になりました。
これを専門用語で「**抽象への依存**」と言います。

さて、引数`array`が対応すべきクラスとして、`list`を継承した`MyList`クラスが追加されたとしましょう。
このとき上に挙げた3つの方法では、どのような対応を取る必要があるでしょうか。

```Python
class MyList(list):
    pass

# ①型ごとに関数を用意：処理は同じなのに関数増やすの？
def sample_my_list(array: MyList):
    if len(array) == 0:
        return None
    else:
        return array[0]

# ②TypeHintに追加：TypeHintがどんどん長くなってくよ？
def sample(array: list | tuple | MyList):
    if len(array) == 0:
        return None
    else:
        return array[0]

# ③抽象への依存：MyListもSequenceを継承しているので変更の必要なし！
from collections.abc import Sequence

def sample(array: Sequence):
    if len(array) == 0:
        return None
    else:
        return array[0]
```

抽象への依存、これによりコード修正の手間を軽減できていることがわかると思います。
引数`array`が様々なクラスに対応しやすいということは、関数`sample`を使える場面が増え、再利用性の向上に繋がるということです。
再利用性、コードを書く上では非常に重要ですよね。

以上の話をまとめると、<font color="red">引数に関しては</font>、`list`や`tuple`といった具体的な型より、「`len`関数と角括弧アクセスをサポートする」といった<font color="red">特定の制約だけを持つ抽象的な型を指定した方が便利</font>に扱えるのです。


## 2.3 つまり？

思ったより長くなってしまいましたが......

* <font color="red">戻り値は具体的な方が良い！</font>
* <font color="red">引数は抽象的な方が良い！</font>

ということです。


# 3. 共変・反変とは？

## 3.1. 戻り値の共変・反変

戻り値（引数もですが）の共変・反変という言葉が現れるのは、メソッドのオーバーライドを考えているときです。
ここでは`Parent`クラスと`Child`クラスの間に継承関係があるとして、`Child`クラスで`Parent`クラスの`sample`メソッドをオーバーライドします。
まず、オーバーライド時の引数と戻り値は、親クラス側と一致させるのが基本です。

```Python
class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

    def sample(self) -> int:  # ここの戻り値は親に合わせてint
        pass
```

ですが、「オーバーライドしたときに戻り値の型を変えたいな......」ということは多々あると思います。
まず、戻り値を`bool`型にしたい場合から見てみましょう。

```Python
class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

    def sample(self) -> bool:
        # ごにょごにょ
        return True  # boolを返したい！
```

継承関係と戻り値型の取り換えをまとめるとこんな感じ。

```text
継承関係：Parent（親）-> Child（子）
戻り値型：int（親）   -> bool（子）
```

継承したときに、戻り値の型を子クラスに取り換える。
これを「戻り値を<font color="red">共変的</font>に取り換える」と呼んでいます。
「共」とは、継承の方向と型を取り換える方向が同じ（親->子と親->子）であることを表している訳です。

ただ、この型の取り換えが許されるのか？というのは別問題です。
が、<font color="red">戻り値を共変的に取り換えるのはOK</font>です。

```Python
parent = Parent()
value = parent.sample()  # valueにはint型の値が入ることが期待される

# リスコフの置換原則を満たすなら、ParentをChildで置換してもいいはず
parent = Child()
value = parent.sample()  # 実はbool型の値が返るが、これはint型として扱える！

# ChildはParentと同じように使えるからOK!
```

`int`が`bool`の親クラスであることからリスコフの置換原則がうまく噛み合い、`Parent`と`Child`もリスコフの置換原則を守ることができるのです。

では逆に、戻り値を`object`型にしたい場合を見てみましょう。

```Python
class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

    def sample(self) -> object:
        # ごにょごにょ
        return object()  # objectを返したい！
```

継承関係と戻り値型の取り換えをまとめるとこんな感じ。

```text
継承関係：Parent（親）-> Child（子）
戻り値型：object（親）<- int（子）
```

継承したときに、戻り値の型を親クラスに取り換える。
これを「戻り値を<font color="red">反変的</font>に取り換える」と呼んでいます。
「反」とは、継承の方向と型を取り換える方向が逆（親->子と子->親）であることを表している訳です。

では、この型の取り換えが許されるのか？というと、<font color="red">戻り値を反変的に取り換えるのはNG</font>です。

```Python
parent = Parent()
value = parent.sample()  # valueにはint型の値が入ることが期待される

# リスコフの置換原則を満たすなら、ParentをChildで置換してもいいはず
parent = Child()
value = parent.sample()  # 実はobject型の値が返る
value = value + 1  # object型は足し算ができないのでエラー！

# ChildはParentと同じように使えないからNG!
```

このように、「`int`型にはできるが`object`型にはできないこと」を実行しようとするとエラーになってしまいます。
関数`sample`を使う人は戻り値が`int`型だと思っているので、これは困ってしまいますね。
ということで、戻り値を反変的に取り換えることはできません。


## 3.2. 引数の共変・反変

引数の共変・反変という言葉も、メソッドのオーバーライドを考えているときに現れます。
戻り値のときと同様、`Parent`クラスと`Child`クラスの間に継承関係があるとして、`Child`クラスで`Parent`クラスの`sample`メソッドをオーバーライドします。
まず、オーバーライド時の引数と戻り値は、親クラス側と一致させるのが基本です。

```Python
class Parent:

    def sample(self, value: int) -> None:
        pass


class Child(Parent):

    def sample(self, value: int) -> None:  # ここの引数は親に合わせてint
        pass
```

ですが、「オーバーライドしたときに引数の型を変えたいな......」ということは多々あると思います。
まず、引数を`bool`型にしたい場合から見てみましょう。

```Python
class Parent:

    def sample(self, value: int) -> None:
        pass


class Child(Parent):

    def sample(self, value: bool) -> None:  # valueとしてboolが欲しい！
        # valueに、boolに対してだけ可能な（intにはできない）処理をする
        pass
```

継承関係と引数型の取り換えをまとめるとこんな感じ。

```text
継承関係：Parent（親）-> Child（子）
引数型　：int（親）   -> bool（子）
```

継承したときに、引数の型を子クラスに取り換える。
これを「引数を<font color="red">共変的</font>に取り換える」と呼んでいます。
「共」の意味は戻り値の場合と同様、継承の方向と型を取り換える方向が同じ（親->子と親->子）であることを表しています。

さて、この型の取り換えが許されるのか？というと、<font color="red">引数を共変的に取り換えるのはNG</font>です。

```Python
parent = Parent()
parent.sample(value=0)  # valueにはint型の値が入ることが期待される

# リスコフの置換原則を満たすなら、ParentをChildで置換してもいいはず
parent = Child()
parent.sample(value=0)  # bool型の値が欲しいのにint型の値を指定されてしまう！

# ChildはParentと同じように使えないからNG!
```

`Parent`を`Child`に置換しても、引数として与える値は変わらないことに注意してください。
`sample`メソッドを使う人は、引数`value`には`int`型の値を指定すればいいと思っています。
それなのに「実は`bool`型が必要なんだよね～」とか言われても困ってしまいますよね。
ということで、引数を共変的に取り換えることはできません。

では逆に、引数を`object`型にしたい場合を見てみましょう。

```Python
class Parent:

    def sample(self, value: int) -> None:
        pass


class Child(Parent):

    def sample(self, value: object) -> None:  # valueとしてobjectが欲しい！
        pass
```

継承関係と引数型の取り換えをまとめるとこんな感じ。

```text
継承関係：Parent（親）-> Child（子）
引数型　：object（親）<- int（子）
```

継承したときに、引数の型を親クラスに取り換える。
これを「引数を<font color="red">反変的</font>に取り換える」と呼んでいます。
「反」の意味は戻り値の場合と同様、継承の方向と型を取り換える方向が逆（親->子と子->親）であることを表しています。

さて、この型の取り換えが許されるのか？というと、<font color="red">引数を反変的に取り換えるのはOK</font>です。

```Python
parent = Parent()
parent.sample(value=0)  # valueにはint型の値が入ることが期待される

# リスコフの置換原則を満たすなら、ParentをChildで置換してもいいはず
parent = Child()
parent.sample(value=0)  # object型の値が欲しい、でもint型はobject型の代わりができる！

# ChildはParentと同じように使えるからOK!
```

`object`が`int`の親クラスであることからリスコフの置換原則がうまく噛み合い、`Parent`と`Child`もリスコフの置換原則を守ることができるのです。

## 3.3. まとめると？

* 戻り値の共変はOK!
* 戻り値の反変はNG!
* 引数の共変はNG!
* 引数の反変はOK!

となります。
もう一度サンプルコードを見てみましょう。
まずは戻り値から。

```Python
class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

    def sample(self) -> bool:  # 共変戻り値はOK!
        pass


class Child(Parent):

    def sample(self) -> object:  # 反変戻り値はNG!
        pass
```

共変戻り値の場合、**戻り値がより具体的に**なっていますね。
ここで2章を思い出してください。
**戻り値は具体的な方が便利**でしたよね。

引数側も見てみましょう。

```Python
class Parent:

    def sample(self, value: int) -> None:
        pass


class Child(Parent):

    def sample(self, value: bool) -> None:  # 共変引数はNG!
        pass


class Child(Parent):

    def sample(self, value: object) -> None:  # 反変引数はOK!
        pass
```

反変引数の場合、**引数がより抽象的に**なっていますね。
ここで2章を思い出してください。
**引数は抽象的な方が便利**でしたよね。

このように、共変戻り値は「戻り値は具体的な方が良いよね！」を表現するための概念、反変引数は「引数は抽象的な方が良いよね！」を表現するための概念、と言い換えることができます。
そして、反変戻り値と共変引数は（Pythonでは）定義自体はできるものの、「使っても嬉しくない」ということもわかると思います。


# 4. 最後に

今回は引数と戻り値の共変と反変についてまとめてみました。
思ったより長くなってしまいましたね......
なお、今回の説明は全てリスコフの置換原則が成り立っていることを前提にしていることには注意してください。
また、言語によっては共変戻り値や反変引数がサポートされていない場合もあるので注意してください。
それではまた。