PythonにTypeHintが導入されてから年月が経ち、TypeHintを書くのは当たり前とも言える時代になってきました。
それに伴いPythonでも型を意識することが多くなる訳ですが、その中で「共変」「反変」といった概念に出会います。
でも気になって調べてみても難しい言葉ばかりで何もわからないし、サンプルコードもPython以外ばっかり......
そこで今回は「引数と戻り値」の「共変と反変」をふんわりと、Pythonのみを使用してまとめてみます。
更新履歴
2023/12/03 : 初版
環境
- Windows10
- Python3.10
- PyCharm Community Edition 2021.2.2
1. リスコフの置換原則
共変や反変を理解するためには、この「リスコフの置換原則」が重要になります。
とはいえ全てを理解する必要はなく、ふんわり雰囲気がわかれば大丈夫です。
この原則が何を言っているかというと、「子クラスは親クラスの代わりができるように設計しよう」ということです。
下にPythonにおけるクラス継承関係図を表示します。
<-抽象的 具体的->
object
└ int
└ bool
-
int
はobject
を継承している -
bool
はint
を継承している - (推移的に)
bool
はobject
を継承している
つまり、
-
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
問題」です。
list
とtuple
は同じように扱えることが多いですが、上の例でarray
にtuple
を指定すると警告が出てしまいます。
実行時にエラーは発生しないにも関わらず、です。
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
クラスです。
そしてlist
やtuple
は、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]
list
とtuple
はSequence
クラスを継承しているので、共に引数array
に指定可能です。
# どちらも警告なく使用OK!
value = sample([0, 1, 2])
value = sample((0, 1, 2))
このように、list
やtuple
といった具体的なクラスではなく、「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
を使える場面が増え、再利用性の向上に繋がるということです。
再利用性、コードを書く上では非常に重要ですよね。
以上の話をまとめると、引数に関しては、list
やtuple
といった具体的な型より、「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!
int
がbool
の親クラスであることからリスコフの置換原則がうまく噛み合い、Parent
とChild
もリスコフの置換原則を守ることができるのです。
では逆に、戻り値を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!
Parent
をChild
に置換しても、引数として与える値は変わらないことに注意してください。
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!
object
がint
の親クラスであることからリスコフの置換原則がうまく噛み合い、Parent
とChild
もリスコフの置換原則を守ることができるのです。
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. 最後に
今回は引数と戻り値の共変と反変についてまとめてみました。
思ったより長くなってしまいましたね......
なお、今回の説明は全てリスコフの置換原則が成り立っていることを前提にしていることには注意してください。
また、言語によっては共変戻り値や反変引数がサポートされていない場合もあるので注意してください。
それではまた。