ソフトウェア開発における円・楕円問題(正方形・長方形問題とも呼ばれる)は、オブジェクトモデリングでサブタイプ多態性を使用する際に発生する可能性のあるいくつかの落とし穴を示しています。これらの問題は、オブジェクト指向プログラミング(OOP)を使用する際に最もよく発生します。定義上、この問題はSOLID原則の一つであるリスコフの置換原則に違反しています。
この問題は、円と楕円(あるいは同様に、正方形と長方形)を表すクラス間に、どのようなサブタイピングまたは継承関係が存在するべきかという点に関係しています。より一般的に言えば、この問題は、基底クラスに、派生クラスに存在する(より強力な)不変条件を無効にする可能性のある方法でオブジェクトを変更するメソッドが含まれている場合に発生する困難さを示しており、リスコフの置換原則に違反することになります。
円と楕円の問題の存在は、オブジェクト指向プログラミングを批判する際に用いられることがある。また、階層的な分類体系を普遍化することは困難であり、状況に応じた分類システムの方がより実用的である可能性を示唆しているとも考えられる。
オブジェクト指向分析および設計の中心的な原則として、ほとんどのオブジェクト指向言語で継承によって実装されているサブタイプ多相性を使用して、互いに部分集合であるオブジェクト型をモデル化する必要があります。これは一般的に「is-a」関係と呼ばれます。この例では、円の集合は楕円の集合の部分集合です。円は、長軸と短軸の長さが同じ楕円として定義できます。したがって、形状をモデル化するオブジェクト指向言語で書かれたコードでは、Circle クラスをEllipse クラスのサブクラス、つまり Ellipse から継承するクラスにすることがよく選択されます。
サブクラスは、スーパークラスがサポートするすべての動作をサポートする必要があります。また、サブクラスは、基底クラスで定義されているすべてのミューテータメソッドを実装する必要があります。今回のケースでは、 Ellipse.stretchXメソッドは、その軸の 1 つの長さをその場で変更します。CircleクラスがEllipseクラスを継承する場合、 CircleクラスにもstretchXメソッドが必要になりますが、このメソッドを実行すると、円が円ではなくなるものに変更されてしまいます。Circleクラスは、自身の不変条件とEllipse.stretchXメソッドの動作要件を同時に満たすことはできません。
この継承に関連する問題は、実装を検討する際に生じます。楕円は円よりも多くの状態を記述する必要があります。なぜなら、楕円は長軸と短軸の長さと回転を指定する属性を必要とするのに対し、円は半径のみを必要とするからです。Eiffelなどの言語が、クラスの定数値、引数のない関数、およびデータメンバーを相互に交換可能にすれば、この問題を回避できる可能性があります。
一部の著者は、楕円はより多くの機能を備えた円であるという理由で、円と楕円の関係を逆転させることを提案している。しかし残念ながら、楕円は円の多くの不変条件を満たしていない。円に半径というメソッドがあるならば、楕円にも同様のメソッドを提供する必要がある。
この問題を解決するには、次の方法があります。
どのオプションが適切かは、 CircleとEllipseをそれぞれ誰が作成したかによって異なります。同じ作者が両方をゼロから設計している場合は、作者がこの状況に対応するためのインターフェースを定義できます。Ellipseオブジェクトが既に作成されていて変更できない場合は、選択肢はより限られます。
オブジェクトが各修飾子に対して「成功」または「失敗」の値を返すか、失敗した場合に例外を発生させるようにします。これは通常ファイルI/Oの場合に行われますが、ここでも役立ちます。これで、Ellipse.stretchXは動作し、「true」を返しますが、Circle.stretchXは単に「false」を返します。これは一般的に良いプラクティスですが、 Ellipseの元の作者がこのような問題を予期し、ミューテーターが値を返すように定義する必要があるかもしれません。また、クライアントコードが戻り値をテストしてストレッチ関数をサポートしていることを確認する必要があり、これは実質的に参照オブジェクトが円か楕円かをテストするのと同じです。別の見方をすると、これはインターフェースを実装するオブジェクトに応じて契約が満たされる場合と満たされない場合があることを契約に記述しているようなものです。結局のところ、これは事後条件が有効である場合と有効でない場合があることを事前に述べることで、リスコフ制約を回避する巧妙な方法にすぎません。
あるいは、Circle.stretchX は例外をスローすることもできます(ただし、言語によっては、Ellipseの元の作者が例外をスローする可能性があることを宣言する必要がある場合もあります)。
これは上記と同様の解決策ですが、若干強力です。Ellipse.stretchXは、X 次元の新しい値を返すようになりました。これにより、Circle.stretchX は現在の半径を返すだけで済みます。すべての変更はCircle.stretchを介して行う必要があり、これにより円の不変条件が維持されます。
Ellipseのインターフェース契約に「stretchX は X 軸を変更する」とだけ記載されていて、「それ以外は何も変更されない」と記載されていない場合、Circle は単に X と Y の寸法を同じに強制することができます。Circle.stretchXとCircle.stretchY はどちらも X と Y の両方のサイズを変更します。
Circle::stretchX(x) { xSize = ySize = x; } Circle::stretchY(y) { xSize = ySize = y; }Circle.stretchXが呼び出されると、 Circle はEllipseに変わります。たとえば、Common Lispでは、 CHANGE-CLASSメソッドを使用してこれを行うことができます。ただし、他の関数がCircleであることを想定している場合、これは危険な場合があります。一部の言語ではこの種の変更が禁止されており、他の言語ではEllipseクラスがCircleの代替として許容されるための制約が課されています。C ++のように暗黙的な型変換を許可する言語では、これはコピー呼び出しの問題は解決するものの、参照呼び出しの問題は解決しないという部分的な解決策にしかならない可能性があります。
クラスのインスタンスが定数値を表すようにモデルを変更することもできます(つまり、インスタンスは不変です)。これは、純粋関数型プログラミングで使用される実装です。
この場合、stretchXなどのメソッドは、操作対象のインスタンスを変更するのではなく、新しいインスタンスを生成するように変更する必要があります。つまり、Circle.stretchXを定義することはもはや問題ではなくなり、継承は円と楕円の数学的な関係を反映したものとなります。
欠点としては、インスタンスの値を変更するには代入が必要となり、これは不便でプログラミングエラーが発生しやすいことです。
Orbit(planet[i]) := Orbit(planet[i]).stretchX
2つ目の欠点は、このような割り当ては概念的に一時的な値を伴うため、パフォーマンスが低下したり、最適化が難しくなったりする可能性があることです。
新しいクラスMutableEllipseを定義し、Ellipseの修飾子をそこに配置することができます。Circle はEllipseからクエリのみを継承します。
これは、 Circle がEllipseから修飾子を継承しないことを指定するだけで済むのに、余分なクラスを導入してしまうという欠点があります。
Ellipse.stretchX はEllipse.stretchable を満たすインスタンスでのみ許可され、それ以外の場合は例外をスローするように指定できます。これは、Ellipse を定義する際に問題が発生することを想定しておく必要があります。
EllipseOrCircleという抽象基底クラスを作成し、 CircleとEllipseの両方を扱うメソッドをこのクラスに記述します。どちらの型のオブジェクトも扱える関数はEllipseOrCircleを引数として受け取り、EllipseまたはCircle固有の要件を使用する関数は派生クラスを使用します。ただし、この場合CircleはEllipse のサブクラスではなくなるため、前述の「CircleはEllipseの一種ではない」という状況が発生します。
これにより、問題は一気に解決します。円と楕円の両方に共通して必要な操作は、各クラスが実装する共通のインターフェース、またはミックスインに抽象化できます。
また、Circle.asEllipseのような変換メソッドを提供することもできます。このメソッドは、円の半径を使用して初期化された可変の Ellipse オブジェクトを返します。このオブジェクトは、それ以降は元の円とは別のオブジェクトとなり、問題なく個別に変更できます。逆方向の変換を行うメソッドは、必ずしも 1 つの戦略に限定する必要はありません。たとえば、Ellipse.minimalEnclosingCircleとEllipse.maximalEnclosedCircle の両方、またはその他の任意の戦略を使用できます。
そして、これまで円が使われていた箇所はすべて楕円に置き換えてください。
円は既に楕円で表現できます。楕円には適用できない円固有のメソッドが必要な場合、またはプログラマが円のよりシンプルなモデルによる概念的および/またはパフォーマンス上の利点を利用したい場合を除き、Circle クラスを作成する理由はありません。
マジョリンツは、メソッドを修飾子、セレクタ、および一般メソッドに分類するモデルを提案した。セレクタのみがスーパークラスから自動的に継承され、修飾子はサブクラスからスーパークラスに継承される必要がある。一般的には、メソッドは明示的に継承されなければならない。このモデルは、抽象クラスを使用して多重継承を持つ言語でエミュレートできる。[ 1 ]
この問題には、十分に強力なオブジェクト指向プログラミングシステムであれば、簡単な解決策があります。本質的に、円と楕円の問題は、型の2つの表現、つまりオブジェクトのプロパティに基づく事実上の型と、オブジェクトシステムによってオブジェクトに関連付けられた形式的な型を同期させる問題です。最終的にマシン上では単なるビットに過ぎないこれら2つの情報が同期され、同じことを表している限り、すべては問題ありません。円は、その基本となる楕円メソッドがパラメータの変更を許容する一方で、円に求められる不変条件を満たすことができないのは明らかです。しかし、円が円の不変条件を満たせない場合、その型を更新して楕円にできる可能性があります。事実上の楕円になった円の型が変更されない場合、その型はもはや古い情報となり、オブジェクトの履歴(かつてどのように構築されたか)を反映しているだけで、現在の現実(その後どのように変化したか)を反映していません。
広く利用されている多くのオブジェクトシステムは、オブジェクトが構築から終了までの全ライフサイクルを通じて同じ型を保持することを前提とした設計に基づいています。これはオブジェクト指向プログラミングの制約ではなく、特定の実装上の制約にすぎません。
以下の例では、オブジェクトがその同一性を失うことなくクラスを変更できる共通Lispオブジェクトシステム(CLOS)を使用しています。オブジェクトへの参照を保持しているすべての変数やその他の記憶場所は、オブジェクトがクラスを変更した後も、同じオブジェクトへの参照を保持し続けます。
円と楕円のモデルは、円と楕円の問題とは関係のない、注意をそらすような詳細を避けるために意図的に簡略化されています。楕円には、コード内でh軸とv軸と呼ばれる2つの半軸があります。円は楕円であるため、これらを継承し、さらに半径プロパティも持ちます。半径の値は、軸の値と等しくなります(もちろん、軸の値は互いに等しくなければなりません)。
( defgeneric check-constraints ( shape ));; シェイプ オブジェクトのアクセサー。オブジェクトの制約は、;; いずれかの軸の値が設定された後にチェックする必要があります。( defgeneric h-axis ( shape )) ( defgeneric ( setf h-axis ) ( new-value shape ) ( : method :after ( new-value shape ) ( check-constraints shape ) )) ( defgeneric v-axis ( shape )) ( defgeneric ( setf v-axis ) ( new-value shape ) ( :method :after ( new-value shape ) ( check-constraints shape )))( defclass ellipse () (( h-axis :type real :accessor h-axis :initarg :h-axis ) ( v-axis :type real :accessor v-axis :initarg :v-axis )))( defclass circle ( ellipse ) (( radius :type real :accessor radius :initarg :radius )));;; ;;; 円には半径だけでなく、楕円から受け継いだh 軸と v 軸もあります。 ;;;オブジェクトが初期化されるとき、およびこれらの値が変更されるときは、これらは半径と同期している必要があります。 ;;; ( defmethod initialize-instance :after (( c circle ) &key radius ) ( setf ( radius c ) radius )) ;; 以下の setf メソッドを介して( defmethod ( setf radius ) :after (( new-value real ) ( c circle )) ;; アクセサではなく SLOT-VALUE を使用することで、;; 2 つの代入の間でクラスが不必要に変更されるのを回避します。 ;; 代入の間では円の h 軸と v 軸の値が異なり、 ;; 代入後には同じ値になります。( setf ( slot-value c 'h-axis ) new-value ( slot-value c 'v-axis ) new-value ));;; ;;; 円のh 軸または v 軸に値が割り当てられた後、 ;;; 新しい値が半径と同じでない限り、型の変更が必要です。 ;;;( defmethod check-constraints (( c circle )) ( unless ( = ( radius c ) ( h-axis c ) ( v-axis c )) ( change-class c 'ellipse )));;; ;;; アクセサーが軸が等しくなるように楕円を変更した場合、;;; または、そのように構築しようとした場合、楕円は円に変わります。 ;;; ( defmethod initialize-instance :after (( e ellipse ) &key ) ( check-constraints e ))( defmethod check-constraints (( e ellipse )) ( when ( = ( h-axis e ) ( v-axis e )) ( change-class e 'circle ))) ;;; ;;; 楕円を円に変換するメソッド。この変換では、;;; オブジェクトは半径を取得します。半径は初期化する必要があります。 ;;;軸が等しくない楕円を明示的な change-class 呼び出しで変換しようとした場合にエラーを通知する「健全性チェック」がここにあります。 ;;; ここでの処理戦略は、半径をh-axis に基づいて決定し、;;; エラーを通知することです。;;; これによりクラス変更が防止されるわけではありません。既にダメージが発生しています。;;; ( defmethod update-instance-for-different-class :after (( old-e ellipse ) ( new-c circle ) &key ) ( setf ( radius new-c ) ( h-axis old-e )) ( unless ( = ( h-axis old-e ) ( v-axis old-e )) ( error "ellipse ~s can't change into a circle because it's not one!" old-e )))このコードは、 Common LispのCLISP実装を用いた対話型セッションで実演できます。
$ clisp -q -i circle-ellipse.lisp [1]> (make-instance 'ellipse :v-axis 3 :h-axis 3) # <CIRCLE #x218AB566> [2]> (make-instance 'ellipse :v-axis 3 :h-axis 4) # <ELLIPSE #x218BF56E> [3]> (defvar obj (make-instance 'ellipse :v-axis 3 :h-axis 4)) OBJ [4]> (class-of obj) # <STANDARD-CLASS ELLIPSE> [5]> (radius obj) *** - NO-APPLICABLE-METHOD:引数 (#<ELLIPSE #x2188C5F6>) を指定して#<STANDARD-GENERIC-FUNCTION RADIUS> を呼び出す場合、適用可能なメソッドはありません。以下の再起動が可能です: RETRY :R1 RADIUS を再度呼び出しますRETURN :R2 戻り値を指定しますABORT :R3 メインループを中止しますBreak 1 [6]> :a [7]> (setf (v-axis obj) 4) 4 [8]> (radius obj) 4 [9]> (class-of obj) # <STANDARD-CLASS CIRCLE> [10]> (setf (radius obj) 9) 9 [11]> (v-axis obj) 9 [12]> (h-axis obj) 9 [13]> (setf (h-axis obj) 8) 8 [14]> (class-of obj) # <STANDARD-CLASS ELLIPSE> [15]> (radius obj)*** - NO-APPLICABLE-METHOD: 引数 (#<ELLIPSE #x2188C5F6>) を指定して #<STANDARD-GENERIC-FUNCTION RADIUS> を呼び出す場合、適用可能なメソッドはありません。以下の再起動が可能です: RETRY :R1 RADIUS を再度呼び出しますRETURN :R2 戻り値を指定しますABORT :R3 メインループを中止しますBreak 1 [16]> :a [17]>一見すると円は楕円であることは明白に思えるかもしれないが、次の類似したコードを考えてみよう。
class Person { void walkNorth ( int meters ) {...} void walkEast ( int meters ) {...} }さて、囚人は明らかに人間です。したがって論理的に、サブクラスを作成できます。
class Prisoner extends Person { void walkNorth ( int meters ) {...} void walkEast ( int meters ) {...} }また、当然のことながら、囚人は任意の方向に任意の距離を自由に移動することはできませんが、 Personクラスの契約では、Person は移動できると規定されているため、問題が生じます。
したがって、 PersonクラスはFreePersonと名付けた方が適切だろう。もしそうであれば、Prisoner クラスが FreePerson を継承しているという考えは明らかに誤りである。
類推的に言えば、円は楕円ではない。なぜなら、円は楕円と同じ自由度を持たないからである。
より適切な命名規則を適用すると、円はOneDiameterFigure、楕円はTwoDiameterFigureと命名できます。このように命名することで、 TwoDiameterFigure がOneDiameterFigureを継承すべきであることがより明確になります。なぜなら、 TwoDiameterFigureは OneDiameterFigureに別のプロパティを追加するからです。OneDiameterFigure には直径というプロパティが 1 つしかありませんが、TwoDiameterFigure にはそのようなプロパティが 2 つあります (つまり、長軸と短軸の長さ)。
これは、サブクラスが基底クラスに内在する自由を制限する場合には継承を使用すべきではなく、サブクラスが基底クラスによって表される概念に詳細を追加する場合(例えば「猿」は「動物」であるなど)にのみ継承を使用すべきであることを強く示唆している。
しかし、囚人は任意の方向に任意の距離を移動できないが、人は移動できるという前提は、やはり誤りです。どの方向に移動する物体も、障害物に遭遇する可能性があります。この問題を正しくモデル化するには、WalkAttemptResult walkToDirection(int meters, Direction direction)という契約を用意するのが良いでしょう。こうすることで、サブクラス Prisoner の walkToDirection メソッドを実装する際に、境界をチェックして適切な移動結果を返すことができます。
概念的には、CircleとEllipse はどちらも可変コンテナ型であり、それぞれMutableContainer<ImmutableCircle>とMutableContainer<ImmutableEllipse>の別名であると考えることができます。この場合、ImmutableCircle はImmutableEllipseのサブタイプとみなすことができます。MutableContainer <T>の型Tは書き込みと読み取りの両方が可能であるため、共変でも反変でもなく、不変です。したがって、Circle はEllipseのサブタイプではなく、その逆もまた同様です。