はじめに
Python の dataclass を使ってコーディングしている際、
ああ、リスコフの置換原則ってこういうことか、って気付いたので雑記です。
気づき
たとえば、実際の処理f
と、f
が受け取るデータ構造C
に関する下記のようなコードがあったとする。
from dataclasses import dataclass
@dataclass
class C:
x: str
y: int
def f(c:C):
...
c = C(x='hoge', y=10)
f(c)
もちろんこれくらいだったらC
のオブジェクトを直接生成すればよいが、フィールドが多くなると一発で生成できず、None
等で仮置きしたい場合が出てくる。
c = C(x='hoge', y=None) # type error
...
c.y = 10
f(c)
ただ、上記のコードでは c = C(x='hoge', y=None)
の部分で型エラーが出てしまう。
そのため、下記のふたつのクラスを分けたい。
- すべてのフィールドに有効な値が埋まった(最終形の)
C
- 一部のフィールドに未定義値があることを示す(生成途中の)
C
(以後、IC
(IncompleteC)で表す)
このとき、直感的には、下記のように記述したくなってしまう(しまった)。
- まず
C
を定義 - 次に
C
を継承してIC
を定義する
### ダメな例
@dataclass
class C:
x: str
y: int
@dataclass
class IC(C):
x: str # 記述しなくてもOK
y: int|None
しかしこう定義してしまうと、f
がIC
も問題なく受け入れてしまう。
(IC
はC
のサブクラスということは、IC
はC
の一種であるから。)
つまり、下記のように記述すべきである。
### 良さそうな例
@dataclass
class IC:
x: str
y: int|None
@dataclass
class C(IC):
x: str # 記述しなくてもOK
y: int
集合として考えると、「最終形の」C
は「生成途中の」IC
の部分集合なので、まぁ確かにそうかも、って感じがする。
リスコフの置換原則に当てはめる
リスコフの置換原則についてWikipediaから引用:
型 S の各オブジェクト o1 に対し、型 T のオブジェクト o2 が存在し、T に関して定義されたすべてのプログラム P が o1 を o2 で置き換えても動作を変えない場合、S は T のサブタイプである。
前述のC
, IC
, f
に当てはめると、
C
はIC
としてそのまま振る舞うこともできる(定義済みの値を未定義として扱っても支障がない)ため、
IC
(T)に関して定義されたすべてプログラムは、C
(S)に対しても動作を変えない、
だから良さそうな例の定義によるC
, IC
の関係はリスコフの置換原則を満たす。
逆に、ダメな例の定義によるC
, IC
の関係はリスコフの置換原則を満たさない。
メソッドについても同様だと気付いた
今回はデータクラスとその値を起点にして考えたが、メソッド(意味論)についてもまったく同様で、
生成途中の状態に対しても有効であるメソッドは、最終形の状態に対しても当然使えるはず。
しかし、逆は成り立たない。
### ダメな例
@dataclass
class C:
x: str
y: int
def __str__(self):
return f'{self.y:05d}/{self.x}'
@dataclass
class IC(C):
x: str # 記述しなくてもOK
y: int|None
ic = IC(x='hoge', y=None)
print(str(ic)) # error!
最後に
なお、今回はデータ型への着目から出発して、リスコフの置換原則を理解したが、
メソッドについても触れたとおり、リスコフの置換原則はデータ型についてのみ言うものではない。
実際、Wikipediaにも下記のような記述がある。
- 事前条件(preconditions)を、派生型で強めることはできない。派生型では同じか弱められる。
- 事後条件(postconditions)を、派生型で弱めることはできない。派生型では同じか強められる。
- 不変条件(invaritants)は、派生型でも保護されねばならない。派生型でそのまま維持される。
- 基底型の例外(exception)から派生した例外を除いては、派生型で独自の例外を投げてはならない。
奥が深いなぁ。
追記: dataclass のフィールドをオーバーライドするにあたって
型チェッカに警告されたので気付いたのだが、何も考えず dataclass のクラスをオーバーライドして型を変えるのはよくない。
なぜならば、スーパークラスではスーパークラスで定義したフィールドの型を期待しているから。
具体例:
@dataclass
class A:
x: int|None
def set_none(self): # こういう手続きがありえる
self.x = None
@dataclass
class B(A):
x: int # ダメ!
b = B(x=0)
b.set_none() # これができてしまい、Bの型制約に違反する!
これを回避するには、frozen=True
でイミュータブルにしてしまうとよい。
@dataclass(frozen=True) # frozen=True をつける
class A:
x: int|None
# そもそもこういうメソッドは許されなくなる。
#def set_none(self):
# self.x = None
@dataclass(frozen=True) # こっちにも
class B(A):
x: int # OK!