nikkie-ftnextの日記

イベントレポートや読書メモを発信

mypyに怒られて理解を進めるリスコフの置換原則

はじめに

fortee連携 やりました!! nikkieです。

mypyのドキュメントの「Incompatible overrides」を見ていきます。
これはSOLID原則の1つと重なります

目次

リスコフの置換原則

SOLID原則のLです。
正しい継承について伝えています。

ちょうぜつ本の説明がわかりやすかったですね1

あるクラスを継承した派生クラスについて、元のクラスと派生クラスはまったく同じ使い方を保証しなければなりません。

すでに定着している仕様を勝手に変えてしまうのを避ける (ちょうぜつ本 Kindle版 p.168)

全く同じ使い方には、メソッドの引数の型(事前条件)と返り値の型(事後条件)も関わります。
この点をmypyに怒られながら、再確認します。

mypyドキュメントの「Incompatible overrides」

https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides

リスコフの置換原則に抵触する場合が2つ述べられています。

override a method with a more specific argument type

override a method with a more general return type.

ポイントは「派生クラスを元のクラスと同じように使えるか」と理解しました。

前提:型の関係

リスト、シーケンス、イテラブル、3つの型が登場します。

  • 最も狭いのはリスト
    • シーケンス2の例はリストやタプル。リストだけというのは狭まっている
  • 最も広いのはイテラブル
    • シーケンスはイテラブル(for文で反復できる)が、シーケンスでないイテラブルもある(例:ファイルオブジェクト)

事前条件:引数の型は広げてよいが、狭めてはならぬ

基底クラスのメソッドの引数の型はSequence[int]です(例えばlist[int]tuple[int])。

  • この引数の型をIterable[int]へ広げる派生クラスGeneralizedArgument
    • mypy「いいね👍」 問題なし
    • 派生クラスにSequence[int]は渡し続けられる
  • list[int]と狭める派生クラスNarrowerArgument
    • mypyが指摘
    • 派生クラスにtuple[int]が渡せなくなってしまった(なので、Sequence[int]を渡し続けられない)

% python -m mypy lsp_argument.py

lsp_argument.py:16: error: Argument 1 of "test" is incompatible with supertype "A"; supertype defines the argument type as "Sequence[int]"  [override]
lsp_argument.py:16: note: This violates the Liskov substitution principle
lsp_argument.py:16: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides

事後条件:返り値の型は狭めてよいが、広げてはならぬ

基底クラスのメソッドの返り値の型はSequence[str]です。

  • この返り値の型をlist[str]へ狭める派生クラスNarrowerReturn
    • mypy「いいね👍」 問題なし
    • 派生クラスの返り値をSequence[str]と見なせる
  • Iterable[str]と広げる派生クラスGeneralizedReturn
    • mypyが指摘
    • 派生クラスではIterable[str]と広がった
    • シーケンスの動きを満たさないがIterableなオブジェクトが返りうる
      • このとき、Sequence[str]が返るとしてシーケンスのインデックスアクセスをしている既存コードは壊れてしまう

% python -m mypy lsp_return.py

lsp_return.py:16: error: Return type "Iterable[str]" of "test" incompatible with return type "Sequence[str]" in supertype "A"  [override]

いずれも基底クラスの使い方を壊してはならない

リスコフの置換原則に則るには、

  • メソッドの引数の型は、同じか広げる
  • メソッドの返り値の型は、同じか狭める

違反してしまう場合

  • 引数の型を狭めてしまうと、基底クラスと同じ実引数を派生クラスに渡せないことになってしまう
  • 返り値の型を広げてしまうと、返り値の処理のコードに想定していない値が渡ってしまう(動かなくなりうる)

終わりに

mypyのドキュメントに沿って手を動かしながら、リスコフの置換原則の理解を深めました。
基底クラスを使っているコードを派生クラスに置き換えたとき、コードが動き続けられるならばリスコフの置換原則を満たしています。

以下を派生クラスでやってしまうと、既存クラスを使っているコードが動かなくなりうるので、置換原則違反です。

  • 引数の型を狭めてしまう
  • 返り値の型を広げてしまう

動作環境

% python -V
Python 3.10.9
% python -m mypy --version
mypy 1.8.0 (compiled: yes)

P.S. 直近見たSOLID原則

PHPカンファレンス北海道2024より🦀

P.S. Pydanticで違反せざるを得なかった例

PydanticのBaseModelを継承したクラスで__iter__を実装しました。
ここはリスコフの置換原則違反の指摘を受け入れる(ignoreする)選択をしました。

BaseModelの__iter__の返り値の型はtyping.Generator[typing.Tuple[str, Any], None, None]です

BaseModelを継承して定義したクラスで、タプルを要素とするジェネレータ以外を返したい場合、# type: ignore[override]せざるを得ないと考えています。


  1. 『Clean Architecture』など他書籍にもあります
  2. 整数インデクスを使って要素に効率的にアクセスできるのがシーケンスです(supports efficient element access using integer indices