Zagadnienie koło-elipsa - Circle–ellipse problem

Problem koło-elipsa w tworzeniu oprogramowania (nazywany czasem problemem kwadrat-prostokąt ) ilustruje kilka pułapek, które mogą się pojawić podczas używania polimorfizmu podtypów w modelowaniu obiektów . Problemy najczęściej występują podczas korzystania z programowania obiektowego (OOP). Z definicji problem ten jest naruszeniem zasady substytucji Liskova , jednej z zasad SOLID .

Problem dotyczy tego, jaki podtyp lub związek dziedziczenia powinien istnieć między klasami reprezentującymi koła i elipsy (lub analogicznie kwadraty i prostokąty ). Ogólnie rzecz biorąc, problem ilustruje trudności, które mogą wystąpić, gdy klasa bazowa zawiera metody, które mutują obiekt w sposób, który może unieważnić (silniejszy) niezmiennik znaleziony w klasie pochodnej, powodując naruszenie zasady podstawienia Liskova.

Istnienie problemu koło-elipsa jest czasami wykorzystywane do krytykowania programowania obiektowego. Może to również sugerować, że hierarchiczne taksonomie są trudne do uczynienia uniwersalnymi, co oznacza, że ​​sytuacyjne systemy klasyfikacji mogą być bardziej praktyczne.

Opis

Centralną zasadą analizy i projektowania zorientowanego obiektowo jest to, że podtyp polimorfizm , który jest implementowany w większości języków obiektowych poprzez dziedziczenie , powinien być używany do modelowania typów obiektów, które są wzajemnie podzbiorami; jest to powszechnie określane jako relacja is-a . W niniejszym przykładzie zbiór okręgów jest podzbiorem zbioru elips; okręgi można zdefiniować jako elipsy, których główna i mniejsza oś mają tę samą długość. W związku z tym kod napisany w języku obiektowym, który modeluje kształty, często wybiera, aby klasa Circle stała się podklasą klasy Ellipse , tj. dziedziczyła po niej.

Podklasa musi zapewniać wsparcie dla wszystkich zachowań wspieranych przez superklasę; podklasy muszą implementować wszelkie metody mutatora zdefiniowane w klasie bazowej. W tym przypadku metoda Ellipse.stretchX zmienia długość jednej z jej osi w miejscu. Jeśli Circle dziedziczy po Ellipse , musi mieć również metodę stretchX , ale rezultatem tej metody byłaby zmiana okręgu w coś, co już nie jest okręgiem. Koło klasa nie może jednocześnie spełniać swoje własne niezmiennik i behawioralnych wymagania Ellipse.stretchX metody.

Powiązany problem z tym dziedziczeniem pojawia się przy rozważaniu wdrożenia. Elipsa wymaga opisania większej liczby stanów niż okrąg, ponieważ pierwszy potrzebuje atrybutów do określenia długości i obrotu osi głównej i mniejszej, podczas gdy okrąg potrzebuje tylko promienia. Można tego uniknąć, jeśli język (taki jak Eiffel ) sprawia, że ​​stałe wartości klasy, funkcje bez argumentów i składowe danych są wymienne.

Niektórzy autorzy sugerowali odwrócenie relacji między okręgiem a elipsą na tej podstawie, że elipsa jest kołem o większych możliwościach. Niestety, elipsy nie spełniają wielu niezmienników okręgów; jeśli Circle ma metodę radius , Ellipse również musi ją teraz podać.

Możliwe rozwiązania

Problem można rozwiązać poprzez:

  • zmiana modelu
  • przy użyciu innego języka (lub istniejącego lub napisanego na zamówienie rozszerzenia jakiegoś istniejącego języka)
  • używając innego paradygmatu

Dokładna odpowiednia opcja będzie zależeć od tego, kto napisał Circle i kto napisał Ellipse . Jeśli ten sam autor projektuje je od podstaw, to autor będzie mógł zdefiniować interfejs, który poradzi sobie z tą sytuacją. Jeśli obiekt Ellipse został już napisany i nie można go zmienić, opcje są bardziej ograniczone.

Zmień model

Zwróć wartość sukcesu lub niepowodzenia

Zezwalaj obiektom na zwracanie wartości „sukcesu” lub „niepowodzenia” dla każdego modyfikatora lub zgłaszanie wyjątku w przypadku niepowodzenia. Jest to zwykle wykonywane w przypadku plików I/O, ale może być również pomocne tutaj. Teraz Ellipse.stretchX działa i zwraca „true”, podczas gdy Circle.stretchX po prostu zwraca „false”. Jest to ogólnie dobra praktyka, ale może wymagać, aby pierwotny autor Ellipse przewidział taki problem i zdefiniował mutatory jako zwracające wartość. Ponadto wymaga, aby kod klienta przetestował wartość zwracaną pod kątem obsługi funkcji stretch, co w efekcie przypomina testowanie, czy obiekt, do którego się odwołuje, jest kołem lub elipsą. Innym sposobem spojrzenia na to jest to, że jest to jak umieszczanie w umowie, że umowa może lub nie może zostać spełniona w zależności od obiektu implementującego interfejs. Ostatecznie jest to tylko sprytny sposób na ominięcie ograniczenia Liskova poprzez stwierdzenie z góry, że warunek postu może być poprawny lub nie.

Alternatywnie, Circle.stretchX może zgłosić wyjątek (ale w zależności od języka, może to również wymagać, aby oryginalny autor Ellipse zadeklarował, że może zgłosić wyjątek).

Zwróć nową wartość X

Jest to podobne rozwiązanie do powyższego, ale jest nieco mocniejsze. Ellipse.stretchX zwraca teraz nową wartość swojego wymiaru X. Teraz Circle.stretchX może po prostu zwrócić swój aktualny promień. Wszystkie modyfikacje muszą być wykonane za pomocą Circle.stretch , który zachowuje niezmiennik okręgu.

Pozwól na słabszy kontrakt na Ellipse

Jeśli umowa interfejsu dla Ellipse stwierdza tylko, że „rozciąganieX modyfikuje oś X”, a nie stwierdza „i nic innego się nie zmieni”, wówczas Circle może po prostu wymusić, aby wymiary X i Y były takie same. Circle.stretchX i Circle.stretchY modyfikują zarówno rozmiar X, jak i Y.

Circle::stretchX(x) { xSize = ySize = x; }
Circle::stretchY(y) { xSize = ySize = y; }

Przekształć okrąg w elipsę

Jeśli wywoływana jest Circle.stretchX , Circle zmienia się w Ellipse . Na przykład w Common Lisp można to zrobić za pomocą metody CHANGE-CLASS . Może to być jednak niebezpieczne, jeśli jakaś inna funkcja oczekuje, że będzie to Krąg . Niektóre języki wykluczają tego typu zmiany, a inne nakładają ograniczenia na klasę Ellipse, aby była akceptowalnym zamiennikiem Circle . W przypadku języków, które umożliwiają niejawną konwersję, takich jak C++ , może to być tylko częściowe rozwiązanie rozwiązujące problem z wywołaniem przez kopię, ale nie z wywołaniem przez odwołanie.

Uczyń wszystkie instancje stałymi

Można zmienić model tak, aby instancje klas reprezentowały wartości stałe (tzn. były niezmienne ). Jest to implementacja używana w programowaniu czysto funkcjonalnym.

W takim przypadku metody takie jak stretchX muszą zostać zmienione w celu uzyskania nowego wystąpienia, zamiast modyfikowania wystąpienia, na którym działają. Oznacza to, że zdefiniowanie Circle.stretchX nie stanowi już problemu , a dziedziczenie odzwierciedla matematyczną zależność między okręgami i elipsami.

Wadą jest to, że zmiana wartości instancji wymaga wtedy przypisania , co jest niewygodne i podatne na błędy programistyczne, np.

Orbita(planeta[i]) := Orbita(planeta[i]).stretchX

Drugą wadą jest to, że takie przypisanie koncepcyjnie wiąże się z wartością tymczasową, która może zmniejszyć wydajność i być trudna do optymalizacji.

Wyłącz modyfikatory

Można zdefiniować nową klasę MutableEllipse i umieścić w niej modyfikatory z Ellipse . Koło dziedziczy tylko zapytania z Ellipse .

Ma to wadę polegającą na wprowadzeniu dodatkowej klasy, w której jedyne, co jest pożądane, to określenie, że Circle nie dziedziczy modyfikatorów z Ellipse .

Nałóż warunki wstępne na modyfikatory

Można określić, że Ellipse.stretchX jest dozwolone tylko w instancjach spełniających Ellipse.stretchable , aw przeciwnym razie zgłosi wyjątek . Wymaga to przewidzenia problemu podczas definiowania Ellipse.

Wydziel wspólną funkcjonalność do abstrakcyjnej klasy bazowej

Utwórz abstrakcyjną klasę bazową o nazwie EllipseOrCircle i umieść w tej klasie metody, które działają z Circle s i Ellipse . Funkcje, które mogą zajmować się dowolnym typem obiektu, będą oczekiwać EllipseOrCircle , a funkcje korzystające z wymagań specyficznych dla Ellipse lub Circle będą używać klas potomnych. Jednak Circle nie jest już podklasą Ellipse , co prowadzi do opisanej powyżej sytuacji „ Krąg nie jest rodzajem Ellipse ”.

Usuń wszystkie relacje dziedziczenia

To rozwiązuje problem za jednym zamachem. Wszelkie wspólne operacje wymagane zarówno dla Circle, jak i Ellipse mogą być wyabstrahowane do wspólnego interfejsu, który implementuje każda klasa, lub do mixinów .

Można również dostarczyć metody konwersji, takie jak Circle.asEllipse , która zwraca zmienny obiekt Ellipse zainicjowany przy użyciu promienia okręgu. Od tego momentu jest to osobny obiekt i można go bez problemu zmutować niezależnie od oryginalnego kręgu. Metody przekształcające się w drugą stronę nie muszą wiązać się z jedną strategią. Na przykład może istnieć zarówno Ellipse.minimalEnclosingCircle i Ellipse.maximalEnclosedCircle , jak i dowolna inna pożądana strategia.

Połącz klasę Circle z klasą Ellipse

Następnie, gdziekolwiek wcześniej używano okręgu, użyj elipsy.

Okrąg może być już reprezentowany przez elipsę. Nie ma powodu, aby mieć klasę Circle, chyba że wymaga ona pewnych metod specyficznych dla okręgu, których nie można zastosować do elipsy, lub jeśli programista nie chce skorzystać z konceptualnych i/lub wydajnościowych zalet prostszego modelu okręgu.

Dziedziczenie odwrotne

Majorinc zaproponował model, który dzieli metody na modyfikatory, selektory i metody ogólne. Tylko selektory mogą być automatycznie dziedziczone z nadklasy, podczas gdy modyfikatory powinny być dziedziczone z podklasy do nadklasy. W ogólnym przypadku metody muszą być jawnie dziedziczone. Model może być emulowany w językach z wielokrotnym dziedziczeniem przy użyciu klas abstrakcyjnych .

Zmień język programowania

Ten problem ma proste rozwiązania w wystarczająco potężnym systemie programowania OO. Zasadniczo problem okręgu-elipsy polega na synchronizacji dwóch reprezentacji typu: typu de facto opartego na właściwościach obiektu oraz typu formalnego związanego z obiektem przez system obiektów. Jeśli te dwie informacje, które ostatecznie są tylko bitami w maszynie, są zsynchronizowane tak, że mówią to samo, wszystko jest w porządku. Oczywiste jest, że okrąg nie może spełnić wymaganych niezmienników, podczas gdy jego metody elipsy bazowej pozwalają na mutację parametrów. Istnieje jednak możliwość, że gdy okrąg nie może spełnić niezmienników okręgu, jego typ można zaktualizować tak, aby stał się elipsą. Jeśli okrąg, który stał się de facto elipsą, nie zmienia typu, to jego typem jest informacja, która jest już nieaktualna, odzwierciedlająca historię obiektu (jak był kiedyś skonstruowany), a nie jego obecną rzeczywistość ( co od tego czasu zmutowało).

Wiele systemów obiektowych w powszechnym użyciu opiera się na projekcie, który zakłada za pewnik, że obiekt nosi ten sam typ przez cały okres użytkowania, od budowy do finalizacji. Nie jest to ograniczenie OOP, ale tylko poszczególnych implementacji.

Poniższy przykład używa systemu Common Lisp Object System (CLOS), w którym obiekty mogą zmieniać klasę bez utraty tożsamości. Wszystkie zmienne lub inne lokalizacje pamięci, które przechowują referencje do obiektu, nadal przechowują referencje do tego samego obiektu po zmianie klasy.

Modele okręgu i elipsy są celowo uproszczone, aby uniknąć rozpraszania szczegółów, które nie są istotne dla problemu okręg-elipsa. Elipsą ma dwie półosie zwane h oś i v-oś w kodzie. Będąc elipsą, okrąg dziedziczy je, a także ma właściwość promienia , której wartość jest równa wartości osi (która oczywiście musi być równa).

(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)))

;;;
;;; A circle has a radius, but also a h-axis and v-axis that
;;; it inherits from an ellipse. These must be kept in sync
;;; with the radius when the object is initialized and
;;; when those values change.
;;;
(defmethod initialize-instance ((c circle) &key radius)
  (setf (radius c) radius)) ;; via the setf method below

(defmethod (setf radius) :after ((new-value real) (c circle))
  (setf (slot-value c 'h-axis) new-value
        (slot-value c 'v-axis) new-value))

;;;
;;; After an assignment is made to the circle's
;;; h-axis or v-axis, a change of type is necessary,
;;; unless the new value is the same as the radius.
;;;
(defmethod (setf h-axis) :after ((new-value real) (c circle))
  (unless (= (radius c) new-value)
    (change-class c 'ellipse)))

(defmethod (setf v-axis) :after ((new-value real) (c circle))
  (unless (= (radius c) new-value)
    (change-class c 'ellipse)))

;;;
;;; Ellipse changes to a circle if accessors
;;; mutate it such that the axes are equal,
;;; or if an attempt is made to construct it that way.
;;;
;;; EQL equality is used, under which 0 /= 0.0.
;;;
;;;
(defmethod initialize-instance :after ((e ellipse) &key h-axis v-axis)
  (if (= h-axis v-axis)
    (change-class e 'circle)))

(defmethod (setf h-axis) :after ((new-value real) (e ellipse))
  (unless (typep e 'circle)
    (if (= (h-axis e) (v-axis e))
      (change-class e 'circle))))

(defmethod (setf v-axis) :after ((new-value real) (e ellipse))
  (unless (typep e 'circle)
    (if (= (h-axis e) (v-axis e))
      (change-class e 'circle))))

;;;
;;; Method for an ellipse becoming a circle. In this metamorphosis,
;;; the object acquires a radius, which must be initialized.
;;; There is a "sanity check" here to signal an error if an attempt
;;; is made to convert an ellipse which axes are unequal
;;; with an explicit change-class call.
;;; The handling strategy here is to base the radius off the
;;; h-axis and signal an error.
;;; This doesn't prevent the class change; the damage is already done.
;;;
(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)))

Ten kod można zademonstrować podczas sesji interaktywnej, używając implementacji CLISP języka Common Lisp.

$ 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: When calling #<STANDARD-GENERIC-FUNCTION RADIUS>
      with arguments (#<ELLIPSE #x2188C5F6>), no method is applicable.
The following restarts are available:
RETRY          :R1      try calling RADIUS again
RETURN         :R2      specify return values
ABORT          :R3      Abort main loop
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: When calling #<STANDARD-GENERIC-FUNCTION RADIUS>
      with arguments (#<ELLIPSE #x2188C5F6>), no method is applicable.
The following restarts are available:
RETRY          :R1      try calling RADIUS again
RETURN         :R2      specify return values
ABORT          :R3      Abort main loop
Break 1 [16]> :a
[17]>

Zakwestionuj przesłankę problemu

Choć na pierwszy rzut oka może wydawać się oczywiste, że okrąg to elipsa, rozważmy następujący analogiczny kod.

class Person
{
    void walkNorth(int meters) {...}
    void walkEast(int meters) {...}
}

Więzień to oczywiście osoba. Logicznie więc można utworzyć podklasę:

class Prisoner extends Person
{
    void walkNorth(int meters) {...}
    void walkEast(int meters) {...}
}

Oczywiście prowadzi to do kłopotów, ponieważ więzień nie może swobodnie przemieszczać się na arbitralną odległość w żadnym kierunku, jednak umowa klasy Osoby mówi, że osoba może.

W ten sposób klasa Person mogłaby lepiej nazwać FreePerson . Gdyby tak było, to pomysł, że klasa Prisoner rozszerza FreePerson jest wyraźnie błędny.

Przez analogię więc Okrąg nie jest Elipsą, ponieważ brakuje mu takich samych stopni swobody jak Elipsa.

Stosując lepsze nazewnictwo, zamiast tego okrąg może być nazwany OneDiameterFigure i elipsą TwoDiameterFigure . Przy takich nazwach jest teraz bardziej oczywiste, że TwoDiameterFigure powinien rozszerzać OneDiameterFigure , ponieważ dodaje do niego kolejną właściwość; podczas gdy OneDiameterFigure ma jedną właściwość średnicy, TwoDiameterFigure ma dwie takie właściwości (tj. długość osi głównej i małej).

To zdecydowanie sugeruje, że dziedziczenie nigdy nie powinno być używane, gdy podklasa ogranicza wolność niejawną w klasie bazowej, ale powinno być używane tylko wtedy, gdy podklasa dodaje dodatkowe szczegóły do ​​koncepcji reprezentowanej przez klasę bazową, jak w 'Małpa' jest -zwierzę'.

Jednak stwierdzenie, że więzień nie może przemieścić się na arbitralną odległość w żadnym kierunku, a osoba może, jest po raz kolejny błędną przesłanką. Każdy obiekt poruszający się w dowolnym kierunku może napotkać przeszkody. Właściwym sposobem modelowania tego problemu byłoby posiadanie kontraktu WalkAttemptResult walkToDirection (liczniki int, kierunek kierunku) . Teraz, wdrażając walkToDirection dla podklasy Prisoner, możesz sprawdzić granice i zwrócić prawidłowe wyniki spaceru.

Bibliografia

Linki zewnętrzne

  • https://web.archive.org/web/20150409211739/http://www.parashift.com/c++-faq-lite/proper-inheritance.html#faq-21.6 Popularna strona C++ FAQ autorstwa Marshalla Cline . Stwierdza i wyjaśnia problem.
  • Konstruktywna Dekonstrukcja Subtyping przez Alistair Cockburn na własnej stronie internetowej. Techniczne/matematyczne omówienie typowania i podtypowania, z zastosowaniami do tego problemu.
  • Henney, Kevlin (2003-04-15). „Od mechanizmu do metody: całkowita elipsa” . Dr Dobb's .
  • http://orafaq.com/usenet/comp.databases.theory/2001/10/01/0001.htm Początek długiego wątku (należy skorzystać z linków Może odpowiedź :) w FAQ Oracle omawiającym ten problem. Odnosi się do pism CJ Date. Pewne uprzedzenie wobec Smalltalka .
  • LiskovSubstitutionPrinciple na WikiWikiWeb
  • Podtypy, podklasy i problemy z OOP , esej omawiający pokrewny problem: czy zestawy powinny dziedziczyć z toreb?
  • Subtyping by Constraints in Object-Oriented Databases , esej omawiający rozszerzoną wersję problemu okręgu-elipsy w środowisku obiektowych baz danych.