Liskov Substitution Principle (LSP) がオブジェクト指向設計の基本原則であると聞いたことがあります。それは何であり、その使用例は何ですか?
35 に答える
LSP を説明する素晴らしい例 (最近聞いたポッドキャストでボブおじさんによって提供されたもの) は、自然言語では正しく聞こえるものが、コードではまったく機能しないことがあるというものでした。
数学では、aSquare
はRectangle
です。確かに、それは長方形の特殊化です。「is a」は、これを継承でモデル化したいと思わせます。ただし、コード内でSquare
から派生させた場合Rectangle
、Square
は が期待される場所ならどこでも使用できるはずですRectangle
。これにより、いくつかの奇妙な動作が発生します。
基本クラスにSetWidth
とSetHeight
メソッドがあると想像してください。Rectangle
これは完全に論理的に思えます。ただし、Rectangle
参照が a を指している場合は、一方を設定すると他方がそれに一致するように変更されるためSquare
、と は意味がSetWidth
ありSetHeight
ません。この場合Square
、リスコフ置換テストは失敗し、継承Rectangle
元の抽象化は悪いものです。Square
Rectangle
Y'all は、他の貴重なSOLID Principles Motivational Postersをチェックする必要があります。
Liskov Substitution Principle (LSP、lsp ) は、オブジェクト指向プログラミングの概念であり、次のように述べています。
基本クラスへのポインターまたは参照を使用する関数は、派生クラスのオブジェクトを知らなくても使用できる必要があります。
LSP の核心は、インターフェイスとコントラクト、および目標を達成するためにクラスを拡張するか、構成などの別の戦略を使用するかを決定する方法です。
この点を説明するために私が見た中で最も効果的な方法は、Head First OOA&Dでした。彼らは、戦略ゲームのフレームワークを構築するプロジェクトの開発者であるというシナリオを提示します。
彼らは、次のようなボードを表すクラスを提示します。
すべてのメソッドは、X 座標と Y 座標をパラメーターとして取り、 の 2 次元配列でタイルの位置を特定しますTiles
。これにより、ゲーム開発者はゲーム中にボード内のユニットを管理できます。
この本はさらに要件を変更し、ゲームのフレームワークは飛行を伴うゲームに対応するために 3D ゲームボードもサポートする必要があると述べています。そのため、 を拡張するThreeDBoard
クラスが導入されますBoard
。
一見、これは良い決断のように思えます。とプロパティBoard
の両方を提供し、Z 軸を提供します。Height
Width
ThreeDBoard
から継承された他のすべてのメンバーを見ると、それが壊れるところですBoard
。AddUnit
、などのメソッドはすべてGetTile
、クラスGetUnits
内で X パラメータと Y パラメータの両方を取りますが、 Z パラメータも必要です。Board
ThreeDBoard
したがって、Z パラメータを使用してこれらのメソッドを再度実装する必要があります。Z パラメータにはクラスに対するコンテキストがなく、Board
クラスから継承されたメソッドはBoard
意味を失います。ThreeDBoard
クラスをその基本クラスとして使用しようとするコード単位は、Board
非常に運が悪いでしょう。
たぶん、別のアプローチを見つける必要があります。を拡張する代わりにBoard
、オブジェクトThreeDBoard
で構成する必要がありBoard
ます。Board
Z 軸の単位ごとに 1つのオブジェクト。
これにより、カプセル化や再利用などの優れたオブジェクト指向の原則を使用でき、LSP に違反しません。
代入可能性はオブジェクト指向プログラミングの原則であり、コンピューター プログラムでは、S が T のサブタイプである場合、型 T のオブジェクトを型 S のオブジェクトに置き換えることができると述べています。
Java で簡単な例を見てみましょう。
悪い例
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
アヒルは鳥だから空を飛べるが、これはどうだろうか。
public class Ostrich extends Bird{}
Ostrich は鳥ですが、飛ぶことはできません。Ostrich クラスは Bird クラスのサブタイプですが、fly メソッドを使用できないはずです。つまり、LSP の原則に違反しています。
良い例え
public class Bird{}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
LSP は不変条件に関係します。
古典的な例は、次の疑似コード宣言によって与えられます (実装は省略されています)。
class Rectangle {
int getHeight()
void setHeight(int value) {
postcondition: width didn’t change
}
int getWidth()
void setWidth(int value) {
postcondition: height didn’t change
}
}
class Square extends Rectangle { }
インターフェイスは一致しますが、問題が発生しました。その理由は、正方形と長方形の数学的定義に由来する不変条件に違反したためです。ゲッターとセッターが機能する方法として、aRectangle
は次の不変式を満たす必要があります。
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
ただし、この不変条件 (および明示的な事後条件)は、 の正しい実装によって違反される必要Square
があるため、 の有効な代替物ではありませんRectangle
。
Robert Martin は、Liskov Substitution Principle に関する優れた論文を書いています。原則に違反する可能性のある微妙な方法とそれほど微妙ではない方法について説明します。
論文のいくつかの関連部分 (2 番目の例は非常に凝縮されていることに注意してください):
LSP違反の簡単な例
この原則に対する最も明白な違反の 1 つは、C++ ランタイム型情報 (RTTI) を使用して、オブジェクトの型に基づいて関数を選択することです。すなわち:
void DrawShape(const Shape& s) { if (typeid(s) == typeid(Square)) DrawSquare(static_cast<Square&>(s)); else if (typeid(s) == typeid(Circle)) DrawCircle(static_cast<Circle&>(s)); }
明らかに、
DrawShape
関数の形成が不適切です。クラスのすべての可能な派生物を認識してShape
いる必要があり、新しい派生物Shape
が作成されるたびに変更する必要があります。実際、多くの人がこの関数の構造をオブジェクト指向設計の忌み嫌うものと見なしています。正方形と長方形、より微妙な違反。
ただし、LSP に違反する、はるかに巧妙な方法が他にもあります。
Rectangle
以下に説明するクラスを使用するアプリケーションを考えてみましょう。class Rectangle { public: void SetWidth(double w) {itsWidth=w;} void SetHeight(double h) {itsHeight=w;} double GetHeight() const {return itsHeight;} double GetWidth() const {return itsWidth;} private: double itsWidth; double itsHeight; };
[...] ある日、ユーザーが長方形に加えて正方形を操作する機能を要求すると想像してみてください。[...]
明らかに、正方形はすべての通常の意図と目的において長方形です。
Square
ISA の関係が成り立つため、クラスを から派生したものとしてモデル化するのは論理的Rectangle
です。[...]
Square
SetWidth
および関数を継承しSetHeight
ます。Square
正方形の幅と高さが同じであるため、これらの関数は a にはまったく不適切です。これは、設計に問題があることを示す重要な手がかりになるはずです。ただし、問題を回避する方法があります。上書きすることができSetWidth
、SetHeight
[...]ただし、次の関数を検討してください。
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Square
オブジェクトへの参照をこの関数に 渡すSquare
と、高さが変更されないため、オブジェクトが破損します。これは明らかな LSP 違反です。この関数は、その引数の導関数に対しては機能しません。[...]
LSP は、一部のコードが type のメソッドを呼び出しているT
と認識し、知らないうちに type のメソッドを呼び出す可能性がある場合S
に必要ですS extends T
(つまりS
、スーパータイプを継承、派生、またはそのサブタイプであるT
)。
たとえば、これは、 type の入力パラメーターを持つ関数が typeT
の引数値で呼び出される (つまり、呼び出される) 場合に発生しS
ます。または、 type の識別子に typeT
の値が割り当てられますS
。
val id : T = new S() // id thinks it's a T, but is a S
LSP は、タイプ (eg ) のメソッドの期待値 (つまり、不変条件) を必要とし、タイプT
(eg )のメソッドが代わりに呼び出されRectangle
たときに違反しないようにします。S
Square
val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation
不変フィールドを持つ型でも不変条件があります。たとえば、不変のRectangle セッターは寸法が個別に変更されることを期待しますが、不変のSquare セッターはこの期待に違反します。
class Rectangle( val width : Int, val height : Int )
{
def setWidth( w : Int ) = new Rectangle(w, height)
def setHeight( h : Int ) = new Rectangle(width, h)
}
class Square( val side : Int ) extends Rectangle(side, side)
{
override def setWidth( s : Int ) = new Square(s)
override def setHeight( s : Int ) = new Square(s)
}
LSP では、サブタイプの各メソッドにS
反変入力パラメーターと共変出力が必要です。
反変とは、分散が継承の方向と逆であることを意味します。つまり、Si
サブタイプの各メソッドの各入力パラメータのタイプS
が同じであるか、スーパータイプのTi
対応するメソッドの対応する入力パラメータのタイプのスーパータイプである必要があります。 T
.
共分散とは、分散が継承の同じ方向にあることを意味します。つまり、So
サブタイプの各メソッドの出力の type は、スーパータイプの対応するメソッドの対応する出力のタイプと同じかサブタイプでなければS
なりません。To
T
これは、呼び出し元が typeを持っているT
と考え、 のメソッドを呼び出していると考えている場合、 typeT
の引数を提供Ti
し、出力を type に割り当てるためTo
です。の対応するメソッドを実際に呼び出すと、S
各Ti
入力引数がSi
入力パラメーターにSo
割り当てられ、出力が型に割り当てられTo
ます。したがって、Si
が に対して反変でない場合Ti
、 のサブタイプXi
ではないサブタイプを にSi
割り当てることができTi
ます。
さらに、型ポリモーフィズム パラメーター (つまりジェネリック) に定義サイト分散注釈がある言語 (Scala や Ceylon など) の場合、型の各型パラメーターの分散注釈の同方向または逆方向は、反対または同じ方向T
でなければなりません。型パラメータの型を持つ( のすべてのメソッドの) すべての入力パラメータまたは出力にそれぞれ適用されます。T
さらに、関数型を持つ入力パラメーターまたは出力ごとに、必要な分散方向が逆になります。このルールは再帰的に適用されます。
サブタイプは、不変条件を列挙できる場合に適しています。
不変条件をモデル化し、コンパイラーによって強制されるようにする方法については、多くの研究が進行中です。
Typestate (3 ページを参照) は、型に直交する状態不変条件を宣言し、強制します。あるいは、アサーションを型に変換することで、不変条件を強制することもできます。たとえば、ファイルを閉じる前に開いていることをアサートするために、File.open() は、File では使用できない close() メソッドを含む OpenFile 型を返すことができます。三目並べ APIは、型付けを使用してコンパイル時に不変条件を適用するもう 1 つの例です。型システムは、 Scalaのようにチューリング完全である場合もあります。依存型付け言語と定理証明者は、高階型付けのモデルを形式化します。
extension を抽象化するためのセマンティクスが必要なため、型付けを使用して不変条件をモデル化する、つまり統一された高次の表示セマンティクスは、Typestate よりも優れていると期待しています。「拡張」とは、調整されていないモジュラー開発の無制限の順列構成を意味します。共有セマンティクスを表現するために相互に依存する 2 つのモデル (型と Typestate など) を持つことは、統合と自由度のアンチテーゼであるように私には思われるため、拡張可能な構成のために互いに統合することはできません。 . たとえば、Expression Problemのような拡張は、サブタイピング、関数のオーバーロード、およびパラメトリック型付けのドメインで統合されました。
私の理論的な立場は、知識が存在するためには(セクション「中央集権化は盲目的で不適切」を参照)、チューリング完全なコンピューター言語で可能なすべての不変条件を 100% カバーできる一般的なモデルは決して存在しないというものです。知識が存在するためには、予想外の可能性がたくさん存在します。つまり、無秩序とエントロピーが常に増加している必要があります。これがエントロピー力です。潜在的な拡張のすべての可能な計算を証明することは、アプリオリにすべての可能な拡張を計算することです。
これが、停止定理が存在する理由です。つまり、チューリング完全なプログラミング言語で可能なすべてのプログラムが終了するかどうかは決定できません。特定のプログラムが終了することを証明できます (すべての可能性が定義され、計算されたプログラム)。しかし、そのプログラムの拡張の可能性がチューリング完全でない限り (例えば、従属型付けを介して)、そのプログラムのすべての可能な拡張が終了することを証明することは不可能です。チューリング完全性の基本的な要件は無制限の再帰であるため、ゲーデルの不完全性定理とラッセルのパラドックスが拡張にどのように適用されるかを理解することは直感的です。
これらの定理の解釈は、エントロピー力の一般化された概念的理解にそれらを組み込みます。
- ゲーデルの不完全性定理: すべての算術的真理を証明できる形式理論は矛盾しています。
- ラッセルのパラドックス: セットを含むことができるセットのすべてのメンバーシップ ルールは、各メンバーの特定のタイプを列挙するか、それ自体を含みます。したがって、セットは拡張できないか、無制限の再帰です。たとえば、ティーポットではないすべてのセットは、それ自体を含み、それ自体を含み、それ自体を含みます…. したがって、規則が (セットを含む可能性があり) 特定の型を列挙せず (つまり、すべての未指定の型を許可する)、無制限の拡張を許可しない場合、その規則は矛盾しています。これは、それ自体のメンバーではないセットのセットです。このように、すべての可能な拡張に対して一貫性がなく、完全に列挙できないことが、ゲーデルの不完全性定理です。
- Liskov Substition Principle : 一般に、セットが別のセットのサブセットであるかどうかは決定できない問題です。つまり、継承は一般に決定不可能です。
- Linsky 参照: 何かが記述または認識されるとき、その計算が何であるかを決定することはできません。つまり、認識 (現実) には絶対的な基準点がありません。
- コースの定理: 外部基準点は存在しないため、無制限の外部可能性に対する障壁は失敗します。
- 熱力学の第 2 法則: 宇宙全体 (閉じたシステム、つまりすべて) は、最大の無秩序、つまり最大の独立した可能性に向かっています。
LSP は、クラスのコントラクトに関するルールです。基本クラスがコントラクトを満たす場合、LSP により、派生クラスもそのコントラクトを満たさなければなりません。
疑似パイソンで
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
Derived オブジェクトで Foo を呼び出すたびに、arg が同じである限り、Base オブジェクトで Foo を呼び出した場合とまったく同じ結果が得られる場合、LSP を満たします。
LSPの重要な使用例は、ソフトウェア テストです。
B の LSP 準拠のサブクラスであるクラス A がある場合、B のテスト スイートを再利用して A をテストできます。
サブクラス A を完全にテストするには、おそらくさらにいくつかのテスト ケースを追加する必要がありますが、少なくとも、スーパークラス B のすべてのテスト ケースを再利用できます。
これを実現する方法は、McGregor が「テスト用の並列階層」と呼んでいるものを構築することです: My ATest
class will inherit from BTest
. 次に、タイプ B ではなくタイプ A のオブジェクトでテスト ケースが機能することを確認するために、なんらかの形式の注入が必要になります (単純なテンプレート メソッド パターンで十分です)。
すべてのサブクラスの実装にスーパー テスト スイートを再利用することは、実際には、これらのサブクラスの実装が LSP に準拠していることをテストする方法であることに注意してください。したがって、サブクラスのコンテキストでスーパークラス テスト スイートを実行する必要があると主張することもできます。
Stackoverflow の質問に対する回答も参照してください。
基本クラスへのポインターまたは参照を使用する関数は、派生クラスのオブジェクトを知らなくても使用できる必要があります。
私が最初に LSP について読んだとき、これは非常に厳密な意味であり、本質的にインターフェイスの実装とタイプ セーフなキャストと同等であると想定していました。これは、LSP が言語自体によって保証されているか、保証されていないかのいずれかであることを意味します。たとえば、この厳密な意味では、コンパイラに関する限り、ThreeDBoard は確かに Board の代わりになります。
概念について詳しく読んだ後、LSP は一般的にそれよりも広く解釈されることがわかりました。
つまり、クライアント コードが、ポインターの背後にあるオブジェクトがポインター型ではなく派生型であることを "認識" することの意味は、タイプ セーフに限定されません。LSP への準拠は、オブジェクトの実際の動作を調べてテストすることもできます。つまり、オブジェクトの状態とメソッド引数がメソッド呼び出しの結果に与える影響、またはオブジェクトからスローされる例外の種類を調べます。
もう一度例に戻ると、理論的には、Board メソッドは ThreeDBoard で問題なく動作するように作成できます。ただし、実際には、ThreeDBoard が追加しようとしている機能を妨げずに、クライアントが適切に処理できない動作の違いを防ぐことは非常に困難です。
この知識があれば、LSP の遵守を評価することは、既存の機能を継承するのではなく、構成が拡張するのに適切なメカニズムである場合を判断するための優れたツールとなります。
この LSP の定式化は強すぎます。
型 S の各オブジェクト o1 に対して型 T のオブジェクト o2 が存在し、T に関して定義されたすべてのプログラム P について、o1 が o2 に置き換えられたときに P の動作が変更されない場合、S は T のサブタイプです。
これは基本的に、S が T とまったく同じものを完全にカプセル化した別の実装であることを意味します。そして、パフォーマンスは P の動作の一部であると大胆に判断することもできます...
したがって、基本的に、遅延バインディングを使用すると LSP に違反します。ある種類のオブジェクトを別の種類のオブジェクトに置き換えたときに、異なる動作を取得することが OO の要点です!
プロパティはコンテキストに依存し、必ずしもプログラムの動作全体を含むとは限らないため、ウィキペディアで引用されている式の方が優れています。
この原則は 1987 年にBarbara Liskovによって導入され、スーパークラスとそのサブタイプの動作に焦点を当てることで Open-Closed 原則を拡張します。
その重要性は、違反した場合の結果を考えると明らかになります。次のクラスを使用するアプリケーションを考えてみましょう。
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
ある日、クライアントが長方形に加えて正方形を操作する機能を要求したとします。正方形は長方形なので、正方形クラスは Rectangle クラスから派生する必要があります。
public class Square : Rectangle
{
}
ただし、これを行うと、次の 2 つの問題が発生します。
正方形は、長方形から継承された高さと幅の両方の変数を必要としないため、数十万の正方形オブジェクトを作成する必要がある場合、これによりメモリが大幅に浪費される可能性があります。正方形の幅と高さは同じであるため、長方形から継承された幅と高さのセッター プロパティは正方形には不適切です。高さと幅の両方を同じ値に設定するために、次のように 2 つの新しいプロパティを作成できます。
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
誰かが正方形のオブジェクトの幅を設定すると、それに応じて高さが変化し、その逆も同様です。
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
先に進み、この他の関数を考えてみましょう:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
正方形オブジェクトへの参照をこの関数に渡すと、LSP に違反します。これは、関数がその引数の導関数に対して機能しないためです。幅と高さのプロパティは、四角形で仮想として宣言されていないため、ポリモーフィックではありません (高さが変更されないため、正方形のオブジェクトは破損します)。
ただし、setter プロパティを virtual として宣言すると、別の違反である OCP に直面することになります。実際、派生クラスの正方形を作成すると、基本クラスの長方形が変更されます。
補遺:
派生クラスが従わなければならない基本クラスの Invariant 、事前条件、および事後条件について誰も書かなかったのはなぜだろうか。派生クラス D が基本クラス B によって完全に置き換え可能であるためには、クラス D は特定の条件に従う必要があります。
- 基本クラスのインバリアントは、派生クラスによって保持される必要があります
- 基本クラスの前提条件は、派生クラスによって強化されてはなりません
- 基本クラスの事後条件は、派生クラスによって弱められてはなりません。
したがって、派生クラスは、基本クラスによって課される上記の 3 つの条件を認識している必要があります。したがって、サブタイプのルールは事前に決定されています。つまり、「IS A」関係は、サブタイプが特定のルールに従う場合にのみ従う必要があります。これらの規則は、不変条件、事前条件、および事後条件の形式で、正式な「設計契約」によって決定する必要があります。
これに関する詳細な議論は、私のブログで入手できます: Liskov Substitution Principle
Board の配列に関して ThreeDBoard を実装すると、それほど便利でしょうか?
おそらく、さまざまな面にある ThreeDBoard のスライスをボードとして扱いたいと思うかもしれません。その場合、Board のインターフェイス (または抽象クラス) を抽象化して、複数の実装を可能にすることができます。
外部インターフェイスに関しては、TwoDBoard と ThreeDBoard の両方の Board インターフェイスを除外したい場合があります (ただし、上記の方法はどれも当てはまりません)。
私がこれまでに見つけた LSP の最も明確な説明は、「Liskov Substitution Principle は、派生クラスのオブジェクトは、システムにエラーをもたらしたり、基本クラスの動作を変更したりすることなく、基本クラスのオブジェクトを置き換えることができるべきであると述べています。 「ここから。この記事では、LSP に違反して修正するためのコード例を示します。
記事を読むことをお勧めします: Liskov Substitution Principle (LSP) の違反.
Liskov Substitution Principle とは何かの説明、すでに違反しているかどうかを推測するのに役立つ一般的な手がかり、およびクラス階層をより安全にするのに役立つアプローチの例を見つけることができます。