stringtranslate.com

Задача окружности–эллипса

Проблема круга –эллипса в разработке программного обеспечения (иногда называемая проблемой квадрата–прямоугольника ) иллюстрирует несколько ловушек, которые могут возникнуть при использовании полиморфизма подтипов в моделировании объектов . Проблемы чаще всего встречаются при использовании объектно-ориентированного программирования (ООП). По определению, эта проблема является нарушением принципа подстановки Лисков , одного из принципов SOLID .

Проблема касается того, какие отношения подтипирования или наследования должны существовать между классами , представляющими круги и эллипсы (или, аналогично, квадраты и прямоугольники ). В более общем плане проблема иллюстрирует трудности, которые могут возникнуть, когда базовый класс содержит методы , которые изменяют объект таким образом, что это может сделать недействительным (более сильный) инвариант , обнаруженный в производном классе, что приведет к нарушению принципа подстановки Лисков.

Существование проблемы окружности и эллипса иногда используется для критики объектно-ориентированного программирования. Это также может означать, что иерархические таксономии трудно сделать универсальными, подразумевая, что ситуативные системы классификации могут быть более практичными.

Описание

Центральным принципом объектно-ориентированного анализа и проектирования является то, что полиморфизм подтипов , который реализован в большинстве объектно-ориентированных языков через наследование , должен использоваться для моделирования типов объектов, которые являются подмножествами друг друга; это обычно называется отношением is-a . В данном примере набор кругов является подмножеством набора эллипсов; круги могут быть определены как эллипсы, большая и малая оси которых имеют одинаковую длину. Таким образом, код, написанный на объектно-ориентированном языке, который моделирует фигуры, часто будет выбирать, чтобы сделать класс Circle подклассом класса Ellipse , т. е. наследовать от него.

Подкласс должен обеспечивать поддержку всего поведения, поддерживаемого суперклассом; подклассы должны реализовывать любые методы-мутаторы, определенные в базовом классе. В данном случае метод Ellipse.stretchX изменяет длину одной из своих осей на месте. Если Circle наследуется от Ellipse , он также должен иметь метод stretchX , но результатом этого метода будет изменение круга во что-то, что больше не является кругом. Класс Circle не может одновременно удовлетворять своему собственному инварианту и поведенческим требованиям метода Ellipse.stretchX .

Связанная с этим наследованием проблема возникает при рассмотрении реализации. Эллипс требует описания большего количества состояний, чем круг, поскольку первому нужны атрибуты для указания длины и поворота большой и малой осей, тогда как кругу нужен только радиус. Этого можно избежать, если язык (например, Eiffel ) сделает константные значения класса, функции без аргументов и члены данных взаимозаменяемыми.

Некоторые авторы предложили поменять местами отношения между окружностью и эллипсом, на том основании, что эллипс — это окружность с большими возможностями. К сожалению, эллипсы не удовлетворяют многим инвариантам окружностей; если у Circle есть метод radius , то Ellipse теперь тоже должен его предоставить.

Возможные решения

Решить проблему можно следующим образом:

Какой именно вариант будет подходящим, будет зависеть от того, кто написал Circle , а кто написал Ellipse . Если один и тот же автор разрабатывает их оба с нуля, то он сможет определить интерфейс для обработки этой ситуации. Если объект Ellipse уже был написан и не может быть изменен, то варианты более ограничены.

Изменить модель

Возврат значения успеха или неудачи

Разрешить объектам возвращать значение «успех» или «неудача» для каждого модификатора или вызывать исключение в случае неудачи. Обычно это делается в случае файлового ввода-вывода, но может быть полезно и здесь. Теперь Ellipse.stretchX работает и возвращает «истина», тогда как Circle.stretchX просто возвращает «ложь». Это в целом хорошая практика, но может потребовать, чтобы первоначальный автор Ellipse предвидел такую ​​проблему и определил мутаторы как возвращающие значение. Кроме того, это требует, чтобы клиентский код проверял возвращаемое значение на поддержку функции растяжения, что по сути похоже на проверку того, является ли указанный объект кругом или эллипсом. Другой способ взглянуть на это — это как заключить контракт, что контракт может быть выполнен или не выполнен в зависимости от объекта, реализующего интерфейс. В конечном счете, это всего лишь умный способ обойти ограничение Лисков, заранее указав, что условие поста может быть или не быть действительным.

В качестве альтернативы Circle.stretchX может выдать исключение (но в зависимости от языка это может также потребовать, чтобы первоначальный автор Ellipse объявил, что он может выдать исключение).

Вернуть новое значение X

Это похожее решение на приведенное выше, но немного более мощное. Ellipse.stretchX теперь возвращает новое значение своего измерения X. Теперь Circle.stretchX может просто вернуть свой текущий радиус. Все изменения должны быть сделаны через Circle.stretch , который сохраняет инвариантность окружности.

Разрешить более слабый контракт на Эллипсе

Если в контракте интерфейса для Ellipse указано только, что «stretchX изменяет ось X», и не указано «и ничего больше не изменится», то Circle может просто принудительно сделать размеры X и Y одинаковыми. Circle.stretchX и Circle.stretchY изменяют как размер X, так и размер Y.

Круг::stretchX(x) { xSize = ySize = x; }Круг::stretchY(y) { xSize = ySize = y; }

Преобразовать круг в эллипс

Если вызывается Circle.stretchX , то Circle преобразуется в Ellipse . Например, в Common Lisp это можно сделать с помощью метода CHANGE-CLASS . Однако это может быть опасно, если какая-то другая функция ожидает, что это будет Circle . Некоторые языки исключают этот тип изменений, а другие накладывают ограничения на класс Ellipse , чтобы он был приемлемой заменой для Circle . Для языков, которые допускают неявное преобразование, таких как C++ , это может быть только частичным решением, решающим проблему вызова по копии, но не вызова по ссылке.

Сделать все экземпляры постоянными

Можно изменить модель так, чтобы экземпляры классов представляли постоянные значения (т.е. были неизменяемыми ). Это реализация, которая используется в чисто функциональном программировании.

В этом случае методы, такие как stretchX, должны быть изменены для получения нового экземпляра, а не для изменения экземпляра, на который они действуют. Это означает, что больше не проблема определить Circle.stretchX , и наследование отражает математическую связь между кругами и эллипсами.

Недостатком является то, что изменение значения экземпляра требует назначения , что неудобно и подвержено ошибкам программирования, например:

Орбита(планета[i]) := Орбита(планета[i]).stretchX

Вторым недостатком является то, что такое назначение концептуально подразумевает временное значение, что может снизить производительность и затруднить оптимизацию.

Выносим модификаторы за скобки

Можно определить новый класс MutableEllipse и поместить в него модификаторы из Ellipse . Circle наследует только запросы от Ellipse .

Недостатком этого подхода является введение дополнительного класса, в то время как все, что требуется, — это указать, что Circle не наследует модификаторы от Ellipse .

Налагать предварительные условия на модификаторы

Можно указать, что Ellipse.stretchX допускается только для экземпляров, удовлетворяющих Ellipse.stretchable , а в противном случае будет выдано исключение . Это требует предвосхищения проблемы при определении Ellipse.

Вынести общую функциональность в абстрактный базовый класс

Создайте абстрактный базовый класс EllipseOrCircle и поместите в этот класс методы, которые работают как с Circle s, так и с Ellipse s. Функции, которые могут работать с любым типом объекта, будут ожидать EllipseOrCircle , а функции, которые используют специфические для Ellipse или Circle требования, будут использовать классы-потомки. Однако Circle больше не является подклассом Ellipse , что приводит к ситуации «a Circle is not a sort of Ellipse », описанной выше.

Отменить все наследственные отношения

Это решает проблему одним махом. Любые общие операции, необходимые как для Circle, так и для Ellipse, могут быть абстрагированы в общий интерфейс, который реализует каждый класс, или в mixins .

Также можно предоставить методы преобразования, такие как Circle.asEllipse , который возвращает изменяемый объект Ellipse, инициализированный с использованием радиуса круга. С этого момента он является отдельным объектом и может быть изменен отдельно от исходного круга без проблем. Методы преобразования в другую сторону не обязаны придерживаться одной стратегии. Например, могут быть как Ellipse.minimalEnclosingCircle , так и Ellipse.maximalEnclosedCircle , а также любая другая желаемая стратегия.

Объединить класс Circle в класс Ellipse

Затем везде, где раньше использовался круг, используйте эллипс.

Круг уже может быть представлен эллипсом. Нет смысла иметь класс Circle , если только ему не нужны некоторые методы, специфичные для круга, которые нельзя применить к эллипсу, или если программист не хочет воспользоваться концептуальными и/или производительными преимуществами более простой модели круга.

Обратное наследование

Majorinc предложил модель, которая делит методы на модификаторы, селекторы и общие методы. Только селекторы могут быть автоматически унаследованы от суперкласса, в то время как модификаторы должны наследоваться от подкласса к суперклассу. В общем случае методы должны быть явно унаследованы. Модель может быть эмулирована в языках с множественным наследованием , используя абстрактные классы . [1]

Изменить язык программирования

Эта проблема имеет простые решения в достаточно мощной системе ОО-программирования. По сути, проблема окружности–эллипса — это проблема синхронизации двух представлений типа: фактического типа, основанного на свойствах объекта, и формального типа, связанного с объектом объектной системой. Если эти два фрагмента информации, которые в конечном итоге являются только битами в машине, поддерживаются синхронизированными так, чтобы они говорили одно и то же, все в порядке. Очевидно, что окружность не может удовлетворять требуемым от нее инвариантам, в то время как ее базовые методы эллипса допускают мутацию параметров. Однако существует вероятность, что когда окружность не может удовлетворять инвариантам окружности, ее тип может быть обновлен так, что она станет эллипсом. Если окружность, которая стала фактическим эллипсом, не меняет тип, то ее тип — это фрагмент информации, который теперь устарел, отражающий историю объекта (как он был когда-то построен), а не его нынешнюю реальность (во что он с тех пор мутировал).

Многие популярные объектные системы основаны на дизайне, который принимает как должное, что объект несет один и тот же тип на протяжении всей своей жизни, от построения до финализации. Это не ограничение ООП, а скорее ограничение только определенных реализаций.

В следующем примере используется Common Lisp Object System (CLOS), в которой объекты могут менять класс, не теряя своей идентичности. Все переменные или другие места хранения, которые содержат ссылку на объект, продолжают содержать ссылку на тот же объект после того, как он меняет класс.

Модели окружности и эллипса намеренно упрощены, чтобы избежать отвлекающих деталей, которые не имеют отношения к проблеме окружности-эллипса. Эллипс имеет две полуоси, называемые в коде осью h и осью v . Будучи эллипсом, окружность наследует их, а также имеет свойство радиуса , значение которого равно значению осей (которые, конечно, должны быть равны друг другу).

( defgeneric check-constraints ( shape ))  ;; Средства доступа к объектам 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 ( ellips ) (( 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 вместо методов доступа, чтобы избежать ненужного изменения ;; класса между двумя назначениями; так как окружность ;; будет иметь разные значения осей 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-оси и сигнализировать об ошибке. ;;; Это не предотвращает изменение класса; ущерб уже нанесен. ;;; ( defmethod update-instance-for-different-class :after (( old-e ellips ) ( new-c circle ) &key ) ( setf ( radius new-c ) ( h-axis old-e )) ( unless ( = ( h-axis old-e ) ( v-axis old-e )) ( error "эллипс ~s не может превратиться в окружность, потому что он ею не является!" 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: При вызове #<STANDARD-GENERIC-FUNCTION RADIUS>  с аргументами (#<ELLIPSE #x2188C5F6>) ни один метод не применим. Доступны следующие перезапуски: 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: При вызове #<STANDARD-GENERIC-FUNCTION RADIUS>  с аргументами (#<ELLIPSE #x2188C5F6>) ни один метод не применим. Доступны следующие перезапуски: RETRY :R1 попробовать вызвать RADIUS снова RETURN :R2 указать возвращаемые значения ABORT :R3 Прервать основной цикл Break 1 [16]> :a [17]>

Поставьте под сомнение предпосылку проблемы

Хотя на первый взгляд может показаться очевидным, что окружность — это эллипс, рассмотрим следующий аналогичный код.

класс  Person { void walkNorth ( int метров ) {...} void walkEast ( int метров ) {...} }        

Итак, заключенный, очевидно, является личностью. Поэтому логично, что можно создать подкласс:

класс  Заключенный расширяет Человек { void walkNorth ( int meters ) {...} void walkEast ( int meters ) {...} }          

Также очевидно, что это приводит к проблемам, поскольку заключенный не может свободно перемещаться на произвольное расстояние в любом направлении, хотя контракт класса Person гласит, что Person может это делать.

Таким образом, класс Person лучше было бы назвать FreePerson . Если бы это было так, то идея о том, что класс Prisoner расширяет FreePerson, явно неверна.

По аналогии, тогда окружность не является эллипсом, поскольку у нее нет тех же степеней свободы, что и у эллипса.

Применяя лучшее именование, тогда Circle можно было бы назвать OneDiameterFigure , а ellips — TwoDiameterFigure . С такими именами теперь более очевидно, что TwoDiameterFigure должен расширять OneDiameterFigure , поскольку он добавляет к нему еще одно свойство; в то время как OneDiameterFigure имеет одно свойство диаметра, TwoDiameterFigure имеет два таких свойства (т. е. длину большой и малой оси).

Это настоятельно предполагает, что наследование никогда не следует использовать, когда подкласс ограничивает свободу, подразумеваемую в базовом классе, а следует использовать его только тогда, когда подкласс добавляет дополнительные детали к концепции, представленной базовым классом, как в «Обезьяна» — это «Животное».

Однако утверждение, что заключенный не может перемещаться на произвольное расстояние в любом направлении, а человек может, снова является неверной предпосылкой. Любой объект, движущийся в любом направлении, может столкнуться с препятствиями. Правильным способом моделирования этой проблемы было бы наличие контракта WalkAttemptResult walkToDirection(int meters, Direction direction) . Теперь при реализации walkToDirection для подкласса Prisoner вы можете проверить границы и вернуть правильные результаты ходьбы.

Инвариантность

Концептуально можно считать, что Circle и Ellipse являются изменяемыми типами контейнеров, псевдонимами MutableContainer<ImmutableCircle> и MutableContainer<ImmutableEllipse> соответственно. В этом случае ImmutableCircle можно считать подтипом ImmutableEllipse . Тип T в MutableContainer<T> может быть как записан, так и прочитан, что подразумевает, что он не является ни ковариантным, ни контравариантным, а вместо этого является инвариантным. Следовательно, Circle не является подтипом Ellipse , и наоборот.

Ссылки

  1. ^ Казимир Майоринц, Дилемма эллипса-круга и обратное наследование, ITI 98, Труды 20-й Международной конференции по интерфейсам информационных технологий, Пула, 1998 г.

Внешние ссылки