0
0

Pythonの dataclass とリスコフの置換原則

Last updated at Posted at 2024-08-21

はじめに

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

しかしこう定義してしまうと、fICも問題なく受け入れてしまう。
(ICCのサブクラスということは、ICCの一種であるから。)
つまり、下記のように記述すべきである。

### 良さそうな例
@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に当てはめると、
CICとしてそのまま振る舞うこともできる(定義済みの値を未定義として扱っても支障がない)ため、
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にも下記のような記述がある。

  1. 事前条件(preconditions)を、派生型で強めることはできない。派生型では同じか弱められる。
  2. 事後条件(postconditions)を、派生型で弱めることはできない。派生型では同じか強められる。
  3. 不変条件(invaritants)は、派生型でも保護されねばならない。派生型でそのまま維持される。
  4. 基底型の例外(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!
0
0
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
0
0