4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-12-03

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

更新履歴

2023/12/03 : 初版

環境

  • Windows10
  • Python3.10
  • PyCharm Community Edition 2021.2.2

1. リスコフの置換原則

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

クラス継承関係図
<-抽象的 具体的->
object
└ int
   └ bool
  • intobjectを継承している
  • boolintを継承している
  • (推移的に)boolobjectを継承している

つまり、

  • object(親) - int (子)
  • int(親) - bool (子)
  • object(親) - bool (子)

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

  • objectにできることはintにもできる
  • intにできることはboolにもできる
  • objectにできることはboolにもできる

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

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

value: int

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

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

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

クラス継承関係図
<-抽象的 具体的->
object    <- intの子クラスではない
├ int
│  └ bool
...
└ str     <- intの子クラスではない
value: int
value = 'abc'  # これはNG
value = object()  # 親クラスもNG

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

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

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

2.1. 戻り値

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

def sample() -> int:
    pass

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

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

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

def sample() -> int:
    return True

def sample() -> bool:
    return True

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

def sample() -> object:
    pass

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

# 全て(リスコフの置換原則的には)OK

def sample() -> object:
    return 0

def sample() -> object:
    return True

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

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

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

def sample() -> int:
    return 0

def sample() -> bool:
    return True

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

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

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

2.2. 引数

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

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

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

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

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

①型ごとに関数を用意

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に追加

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

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

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

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

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

クラス継承関係図
<-抽象的 具体的->
LenAndGetItemSupports
├ Hoge
│  └ HogeHoge
└ Fuga
# 全部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クラスです。
そしてlisttupleは、Sequenceクラスを継承しています。

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

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

from collections.abc import Sequence

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

listtupleSequenceクラスを継承しているので、共に引数arrayに指定可能です。

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

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

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

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を使える場面が増え、再利用性の向上に繋がるということです。
再利用性、コードを書く上では非常に重要ですよね。

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

2.3 つまり?

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

  • 戻り値は具体的な方が良い!
  • 引数は抽象的な方が良い!

ということです。

3. 共変・反変とは?

3.1. 戻り値の共変・反変

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

class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

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

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

class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

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

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

継承関係:Parent(親)-> Child(子)
戻り値型:int(親)   -> bool(子)

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

ただ、この型の取り換えが許されるのか?というのは別問題です。
が、戻り値を共変的に取り換えるのはOKです。

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

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

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

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

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

class Parent:

    def sample(self) -> int:
        pass


class Child(Parent):

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

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

継承関係:Parent(親)-> Child(子)
戻り値型:object(親)<- int(子)

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

では、この型の取り換えが許されるのか?というと、戻り値を反変的に取り換えるのはNGです。

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メソッドをオーバーライドします。
まず、オーバーライド時の引数と戻り値は、親クラス側と一致させるのが基本です。

class Parent:

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


class Child(Parent):

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

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

class Parent:

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


class Child(Parent):

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

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

継承関係:Parent(親)-> Child(子)
引数型 :int(親)   -> bool(子)

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

さて、この型の取り換えが許されるのか?というと、引数を共変的に取り換えるのはNGです。

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

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

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

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

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

class Parent:

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


class Child(Parent):

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

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

継承関係:Parent(親)-> Child(子)
引数型 :object(親)<- int(子)

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

さて、この型の取り換えが許されるのか?というと、引数を反変的に取り換えるのはOKです。

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

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

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

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

3.3. まとめると?

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

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

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章を思い出してください。
戻り値は具体的な方が便利でしたよね。

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

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. 最後に

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

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?