El problema de círculo-elipse en el desarrollo de software (a veces llamado problema de cuadrado-rectángulo ) ilustra varios errores que pueden surgir cuando se usa polimorfismo de subtipo en el modelado de objetos . Los problemas se encuentran con mayor frecuencia cuando se utiliza la programación orientada a objetos (OOP). Por definición, este problema es una violación del principio de sustitución de Liskov , uno de los principios SOLID .
El problema se refiere a qué subtipos o relación de herencia debe existir entre las clases que representan círculos y elipses (o, de manera similar, cuadrados y rectángulos ). De manera más general, el problema ilustra las dificultades que pueden ocurrir cuando una clase base contiene métodos que mutan un objeto de una manera que puede invalidar un invariante (más fuerte) encontrado en una clase derivada, causando que se viole el principio de sustitución de Liskov.
La existencia del problema círculo-elipse se utiliza a veces para criticar la programación orientada a objetos. También puede implicar que las taxonomías jerárquicas son difíciles de universalizar, lo que implica que los sistemas de clasificación situacional pueden ser más prácticos.
Descripción
Es un principio central del análisis y diseño orientado a objetos que el polimorfismo de subtipos , que se implementa en la mayoría de los lenguajes orientados a objetos a través de la herencia , debe usarse para modelar tipos de objetos que son subconjuntos entre sí; esto se conoce comúnmente como la relación es-a . En el presente ejemplo, el conjunto de círculos es un subconjunto del conjunto de elipses; los círculos se pueden definir como elipses cuyos ejes mayor y menor tienen la misma longitud. Por lo tanto, el código escrito en un lenguaje orientado a objetos que modela formas elegirá frecuentemente para hacer de la clase Circle una subclase de clase Elipse , es decir, heredando de ella.
Una subclase debe brindar apoyo a todo comportamiento apoyado por la superclase; las subclases deben implementar cualquier método mutador definido en una clase base. En el presente caso, el método Ellipse.stretchX altera la longitud de uno de sus ejes en su lugar. Si Circle hereda de Elipse , también debe tener un método stretchX , pero el resultado de este método sería cambiar un círculo en algo que ya no es un círculo. La La clase del círculo no puede satisfacer simultáneamente su propia invariante y los requisitos de comportamiento del Método Ellipse.stretchX .
Un problema relacionado con esta herencia surge al considerar la implementación. Una elipse requiere que se describan más estados que un círculo, porque el primero necesita atributos para especificar la longitud y rotación de los ejes mayor y menor, mientras que un círculo solo necesita un radio. Puede ser posible evitar esto si el lenguaje (como Eiffel ) hace que los valores constantes de una clase, funciones sin argumentos y miembros de datos sean intercambiables.
Algunos autores han sugerido invertir la relación entre círculo y elipse, con el argumento de que una elipse es un círculo con más habilidades. Desafortunadamente, las elipses no satisfacen muchas de las invariantes de los círculos; Si Circle tiene un método radio , Ellipse ahora también debe proporcionarlo.
Soluciones posibles
Uno puede resolver el problema de la siguiente manera:
- cambiando el modelo
- usando un idioma diferente (o una extensión existente o escrita a medida de algún idioma existente)
- usando un paradigma diferente
Exactamente qué opción es apropiada dependerá de quién escribió Circle y quién escribió Elipse . Si el mismo autor está diseñando ambos desde cero, entonces el autor podrá definir la interfaz para manejar esta situación. Si el El objeto elipse ya estaba escrito y no se puede cambiar, entonces las opciones son más limitadas.
Cambiar el modelo
Devolver valor de éxito o fracaso
Permita que los objetos devuelvan un valor de "éxito" o "error" para cada modificador o generen una excepción en caso de error. Esto generalmente se hace en el caso de E / S de archivos, pero también puede ser útil aquí. Ahora, Ellipse.stretchX funciona y devuelve "verdadero", mientras que Circle.stretchX simplemente devuelve "falso". En general, esto es una buena práctica, pero puede requerir que el autor original de Ellipse anticipó tal problema y definió a los mutantes como devolviendo un valor. Además, requiere que el código del cliente pruebe el valor de retorno para admitir la función de estiramiento, que en efecto es como probar si el objeto referenciado es un círculo o una elipse. Otra forma de ver esto es que es como poner en el contrato que el contrato puede o no cumplirse dependiendo del objeto que implemente la interfaz. Eventualmente, es solo una forma inteligente de eludir la restricción de Liskov al afirmar por adelantado que la condición de publicación puede o no ser válida.
Alternativamente, Circle.stretchX podría generar una excepción (pero dependiendo del idioma, esto también puede requerir que el autor original de Ellipse declara que puede lanzar una excepción).
Devuelve el nuevo valor de X
Esta es una solución similar a la anterior, pero es un poco más poderosa. Ellipse.stretchX ahora devuelve el nuevo valor de su dimensión X. Ahora, Circle.stretchX simplemente puede devolver su radio actual. Todas las modificaciones deben realizarse mediante Circle.stretch , que conserva el círculo invariante.
Permitir un contrato más débil en Ellipse
Si el contrato de interfaz para Elipse indica solo que "stretchX modifica el eje X", y no indica "y nada más cambiará", entonces El círculo podría simplemente forzar que las dimensiones X e Y sean iguales. Circle.stretchX y Circle.stretchY ambos modifican el tamaño de X e Y.
Círculo :: estiramientoX (x) {xTamaño = yTamaño = x; }Círculo :: estirarY (y) {xTamaño = yTamaño = y; }
Convertir el círculo en una elipse
Si Circle.stretchX se llama, luego El círculo se transforma en un Elipse . Por ejemplo, en Common Lisp , esto se puede hacer a través del Método de cambio de clase . Sin embargo, esto puede ser peligroso si alguna otra función espera que sea una Círculo . Algunos idiomas excluyen este tipo de cambio y otros imponen restricciones a la Clase elipse para ser un reemplazo aceptable para Círculo . Para los lenguajes que permiten la conversión implícita como C ++ , esto puede ser solo una solución parcial que resuelve el problema en la llamada por copia, pero no en la llamada por referencia.
Hacer que todas las instancias sean constantes
Se puede cambiar el modelo para que las instancias de las clases representen valores constantes (es decir, que sean inmutables ). Esta es la implementación que se utiliza en la programación puramente funcional.
En este caso, métodos como stretchX debe cambiarse para producir una nueva instancia, en lugar de modificar la instancia sobre la que actúan. Esto significa que ya no es un problema definir Circle.stretchX , y la herencia refleja la relación matemática entre círculos y elipses.
Una desventaja es que cambiar el valor de una instancia requiere una asignación , lo cual es inconveniente y propenso a errores de programación, por ejemplo,
Órbita (planeta [i]): = Órbita (planeta [i]). StretchX
Una segunda desventaja es que dicha asignación implica conceptualmente un valor temporal, que podría reducir el rendimiento y ser difícil de optimizar.
Factorizar modificadores
Se puede definir una nueva clase. MutableEllipse , y coloque los modificadores de Elipse en él. La Circle solo hereda consultas de Elipse .
Esto tiene la desventaja de introducir una clase extra donde todo lo que se desea es especificar que Circle no hereda modificadores de Elipse .
Imponer condiciones previas a los modificadores
Se puede especificar que Ellipse.stretchX solo se permite en instancias que satisfagan Ellipse.stretchable y, de lo contrario, lanzará una excepción . Esto requiere anticipar el problema cuando se define Ellipse.
Factoriza la funcionalidad común en una clase base abstracta
Crea una clase base abstracta llamada EllipseOrCircle y poner métodos que funcionen con ambos Círculo sy Elipse s en esta clase. Las funciones que pueden tratar con cualquier tipo de objeto esperarán una EllipseOrCircle y funciones que utilizan Elipse - o Los requisitos específicos del círculo utilizarán las clases descendientes. Sin emabargo, Entonces el círculo ya no es un Subclase de elipse , que conduce a la "a El círculo no es una especie de Situación de elipse "descrita anteriormente.
Eliminar todas las relaciones de herencia
Esto resuelve el problema de un plumazo. Cualquier operación común deseada tanto para un círculo como para una elipse se puede abstraer en una interfaz común que implementa cada clase, o en mixins .
Además, se pueden proporcionar métodos de conversión como Circle.asEllipse , que devuelve un objeto Ellipse mutable inicializado utilizando el radio del círculo. A partir de ese momento, es un objeto separado y se puede mutar por separado del círculo original sin problemas. Los métodos que se convierten al otro lado no necesitan comprometerse con una estrategia. Por ejemplo, puede haber ambos Ellipse.minimalEnclosingCircle y Ellipse.maximalEnclosedCircle y cualquier otra estrategia deseada.
Combinar el círculo de la clase en elipse de la clase
Luego, siempre que se haya usado un círculo antes, use una elipse.
Un círculo ya se puede representar mediante una elipse. No hay razón para tener clase Círculo a menos que necesite algunos métodos círculo-específicas que no se pueden aplicar a una elipse, o menos que los deseos del programador para beneficiarse de las ventajas conceptuales y / o de rendimiento del modelo más simple del círculo.
Herencia inversa
Majorinc propuso un modelo que divide los métodos en modificadores, selectores y métodos generales. Solo los selectores pueden heredarse automáticamente de la superclase, mientras que los modificadores deben heredarse de la subclase a la superclase. En el caso general, los métodos deben heredarse explícitamente. El modelo se puede emular en lenguajes con herencia múltiple , utilizando clases abstractas . [1]
Cambiar el lenguaje de programación
Este problema tiene soluciones sencillas en un sistema de programación OO suficientemente potente. Esencialmente, el problema del círculo-elipse consiste en sincronizar dos representaciones de tipo: el tipo de facto basado en las propiedades del objeto y el tipo formal asociado con el objeto por el sistema de objetos. Si estas dos piezas de información, que en última instancia son solo bits en la máquina, se mantienen sincronizadas para que digan lo mismo, todo está bien. Está claro que un círculo no puede satisfacer las invariantes que se le requieren, mientras que sus métodos de elipse base permiten la mutación de parámetros. Sin embargo, existe la posibilidad de que cuando un círculo no puede cumplir con los invariantes del círculo, su tipo se pueda actualizar para que se convierta en una elipse. Si un círculo que se ha convertido en una elipse de facto no cambia de tipo, entonces su tipo es una pieza de información que ahora está desactualizada, que refleja la historia del objeto (cómo se construyó una vez) y no su realidad actual ( en lo que ha mutado desde entonces).
Muchos sistemas de objetos de uso popular se basan en un diseño que da por sentado que un objeto lleva el mismo tipo durante toda su vida, desde la construcción hasta la finalización. Esta no es una limitación de la programación orientada a objetos, sino solo de implementaciones particulares.
El siguiente ejemplo utiliza el Common Lisp Object System (CLOS) en el que los objetos pueden cambiar de clase sin perder su identidad. Todas las variables u otras ubicaciones de almacenamiento que contienen una referencia a un objeto continúan teniendo una referencia a ese mismo objeto después de que cambia de clase.
Los modelos de círculo y elipse se simplifican deliberadamente para evitar detalles distractores que no son relevantes para el problema círculo-elipse. Una elipse tiene dos semiejes llamados eje h y eje v en el código. Al ser una elipse, un círculo hereda estos, y también tiene un propiedad del radio , cuyo valor es igual al de los ejes (que, por supuesto, debe ser igual).
( defclass elipse () (( eje h : tipo real : accesor eje h : initarg : eje h ) ( eje v : tipo real : accesor eje v : initarg : eje v )))( defclass circle ( elipse ) (( radius : type real : accessor radius : initarg : radius )));;; ;;; Un círculo tiene un radio, pero también un eje h y un eje v que ;;; hereda de una elipse. Estos deben mantenerse sincronizados ;;; con el radio cuando se inicializa el objeto y ;;; cuando esos valores cambian. ;;; ( defmethod initialize-instance (( círculo c ) & radio clave ) ( setf ( radio c ) radio )) ;; a través del método setf a continuación ( defmethod ( setf radio ) : after (( valor nuevo real ) ( c círculo )) ( setf ( valor de ranura c 'eje h ) nuevo valor ( valor de ranura c ' eje v ) nuevo valor ));;; ;;; Después de hacer una asignación al círculo ;;; eje h o eje v, es necesario un cambio de tipo ;;; a menos que el nuevo valor sea el mismo que el radio. ;;; ( defmethod ( setf h-axis ) : after (( nuevo valor real ) ( c círculo )) (a menos que ( = ( radio c ) nuevo valor ) ( cambio de clase c 'elipse )))( defmethod ( setf v-axis ) : after (( nuevo valor real ) ( c círculo )) (a menos que ( = ( radio c ) nuevo valor ) ( cambio de clase c 'elipse )));;; ;;; La elipse cambia a un círculo si los accesos ;;; mutarlo de manera que los ejes sean iguales ;;; o si se intenta construirlo de esa manera. ;;; ;;; Se utiliza la igualdad EQL, bajo la cual 0 / = 0.0. ;;; ;;; ( defmethod initialize-instance : after (( e elipse ) & key eje v-eje h ) ( if ( = eje h-eje v ) ( cambio de clase e 'círculo )))( defmethod ( setf h-axis ) : after (( valor nuevo real ) ( e elipse )) (a menos que ( escribap e 'círculo ) ( if ( = ( h-axis e ) ( v-axis e )) ( change- círculo de clase e ' ))))( defmethod ( setf v-axis ) : after (( valor nuevo real ) ( e elipse )) (a menos que ( escribap e 'círculo ) ( if ( = ( h-axis e ) ( v-axis e )) ( change- círculo de clase e ' ))));;; ;;; Método para que una elipse se convierta en un círculo. En esta metamorfosis, ;;; el objeto adquiere un radio, que debe inicializarse. ;;; Aquí hay una "comprobación de cordura" para señalar un error si se intenta ;;; está hecho para convertir una elipse cuyos ejes son desiguales ;;; con una llamada de clase de cambio explícita. ;;; La estrategia de manejo aquí es basar el radio en ;;; eje h y señalizar un error. ;;; Esto no evita el cambio de clase; El daño ya esta hecho. ;;; ( defmethod update-instance-for-different-class : after (( old-e elipse ) ( new-c circle ) & key ) ( setf ( radius new-c ) ( h-axis old-e )) (a menos que ( = ( eje h antiguo-e ) ( eje v antiguo-e )) ( error "¡las elipse ~ s no se pueden convertir en un círculo porque no es uno!" antiguo-e )))
Este código se puede demostrar con una sesión interactiva, utilizando la implementación CLISP de Common Lisp.
$ clisp -q -i circle-ellipse.lisp [1]> (make-instance 'elipse: eje v 3: eje h 3) # # x218AB566> [2]> (make-instance' elipse: v -eje 3: eje h 4) # # x218BF56E> [3]> (defvar obj (make-instance 'elipse: eje v 3: eje h 4)) OBJ [4]> (clase de obj ) # [5]> (radio obj)*** - MÉTODO NO APLICABLE: Cuando se llama a # con argumentos (# ), no se aplica ningún método. Están disponibles los siguientes reinicios: RETRY: R1 intenta llamar a RADIUS nuevamente RETURN: R2 especifica valores de retorno ABORT: R3 Abort main loop Break 1 [6]>: a [7]> (setf (v-axis obj) 4) 4 [8 ]> (radio obj) 4 [9]> (clase-de obj) # írculo>[10]> (setf (radio obj) 9) 9 [11]> (eje v obj) 9 [12 ]> (eje h obj) 9 [13]> (setf (eje h obj) 8) 8 [14]> (clase-de obj) # [15]> (radio obj)*** - MÉTODO NO APLICABLE: Cuando se llama a # con argumentos (# ), no se aplica ningún método. Están disponibles los siguientes reinicios: RETRY: R1 intenta llamar a RADIUS nuevamente RETURN: R2 especifica valores de retorno ABORT: R3 Abort main loop Break 1 [16]>: a [17]>
Desafía la premisa del problema
Si bien a primera vista puede parecer obvio que un círculo es una elipse, considere el siguiente código análogo.
class Person { void walkNorth ( int meters ) {...} void walkEast ( int meters ) {...} }
Ahora bien, un prisionero es obviamente una persona. Entonces, lógicamente, se puede crear una subclase:
clase Prisionero extiende Persona { void walkNorth ( int meters ) {...} void walkEast ( int meters ) {...} }
También, obviamente, esto conduce a problemas, ya que un prisionero no es libre de moverse una distancia arbitraria en cualquier dirección, sin embargo, el contrato del La clase de persona indica que una persona puede.
Por lo tanto, la clase La persona podría ser mejor nombrada FreePerson . Si ese fuera el caso, entonces la idea de que Class Prisoner extiende FreePerson está claramente equivocado.
Entonces, por analogía, un círculo no es una elipse, porque carece de los mismos grados de libertad que una elipse.
Aplicando un mejor nombre, entonces, un Círculo podría ser nombrado OneDiameterFigure y una elipse se podrían nombrar TwoDiameterFigure . Con tales nombres ahora es más obvio que TwoDiameterFigure debe extenderse OneDiameterFigure , ya que le agrega otra propiedad; mientras que OneDiameterFigure tiene una propiedad de diámetro único, TwoDiameterFigure tiene dos de estas propiedades (es decir, una longitud de eje mayor y una menor).
Esto sugiere fuertemente que la herencia nunca debe usarse cuando la subclase restringe la libertad implícita en la clase base, pero solo debe usarse cuando la subclase agrega detalles adicionales al concepto representado por la clase base como en 'Monkey' es -un animal'.
Sin embargo, afirmar que un preso no puede moverse una distancia arbitraria en ninguna dirección y que una persona sí puede es una premisa equivocada una vez más. Cualquier objeto que se mueva en cualquier dirección puede encontrar obstáculos. La forma correcta de modelar este problema sería tener un WalkAttemptResult walkToDirection (int meters, Direction direction) contrato. Ahora, al implementar walkToDirection para la subclase Prisoner, puede verificar los límites y devolver los resultados de caminata adecuados.
Referencias
- Robert C. Martin , El principio de sustitución de Liskov , Informe C ++, marzo de 1996.
- ^ Kazimir Majorinc, Dilema del círculo elipse y herencia inversa, ITI 98, Actas de la 20a Conferencia Internacional de Interfaces de Tecnología de la Información, Pula, 1998
enlaces externos
- https://web.archive.org/web/20150409211739/http://www.parashift.com/c++-faq-lite/proper-inheritance.html#faq-21.6 Un popular sitio de preguntas frecuentes sobre C ++ de Marshall Cline . Declara y explica el problema.
- Deconstrucción constructiva de subtipos por Alistair Cockburn en su propio sitio web. Discusión técnico-matemática de mecanografía y subtipificación, con aplicaciones a este problema.
- Henney, Kevlin ( 15 de abril de 2003 ). "De mecanismo a método: elipse total" . Dr. Dobb's .
- http://orafaq.com/usenet/comp.databases.theory/2001/10/01/0001.htm Comienzo de un hilo largo (siga los enlaces Quizás responder :) en las preguntas frecuentes de Oracle que discuten el problema. Se refiere a los escritos de CJ Date. Algún sesgo hacia Smalltalk .
- Liskov Principio de sustitución en WikiWikiWeb
- Subtipado, subclasificación y problemas con la programación orientada a objetos , un ensayo que discute un problema relacionado: ¿deberían los conjuntos heredar de las bolsas?
- Subtipado por restricciones en bases de datos orientadas a objetos , un ensayo que discute una versión extendida del problema círculo-elipse en el entorno de bases de datos orientadas a objetos.