El problema del círculo-elipse en el desarrollo de software (a veces llamado el problema del cuadrado-rectángulo ) ilustra varios problemas que pueden surgir al usar polimorfismo de subtipo en el modelado de objetos . Los problemas se encuentran más comúnmente cuando se usa programación orientada a objetos (POO). 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é relación de subtipificación o herencia debe existir entre clases que representan círculos y elipses (o, de manera similar, cuadrados y rectángulos ). En términos más generales, el problema ilustra las dificultades que pueden surgir 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, lo que hace que se viole el principio de sustitución de Liskov.
La existencia del problema del círculo y la 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.
Un principio central del análisis y diseño orientado a objetos es que el polimorfismo de subtipos , que se implementa en la mayoría de los lenguajes orientados a objetos a través de la herencia , se debe utilizar para modelar tipos de objetos que son subconjuntos entre sí; esto se conoce comúnmente como la relación es- un. 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 con frecuencia elegirá hacer que la clase Círculo sea una subclase de la clase Elipse , es decir, herede de ella.
Una subclase debe proporcionar soporte para todo el comportamiento admitido 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 el lugar. Si Circle hereda de Ellipse , 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 clase Circle 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 cuando se considera la implementación. Una elipse requiere que se describan más estados que un círculo, porque la primera necesita atributos para especificar la longitud y la rotación de los ejes mayor y menor, mientras que un círculo solo necesita un radio. Es posible evitar esto si el lenguaje (como Eiffel ) hace que los valores constantes de una clase, las funciones sin argumentos y los 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 capacidades. Desafortunadamente, las elipses no satisfacen muchas de las invariantes de los círculos; si Circle tiene un método radio , Ellipse ahora debe proporcionarlo también.
El problema se puede solucionar de la siguiente manera:
La opción adecuada dependerá de quién haya escrito Circle y quién haya escrito Ellipse . Si el mismo autor diseña ambos desde cero, podrá definir la interfaz para manejar esta situación. Si el objeto Ellipse ya estaba escrito y no se puede cambiar, las opciones son más limitadas.
Permitir que los objetos devuelvan un valor de "éxito" o "error" para cada modificador o generar una excepción en caso de error. Esto se hace generalmente en el caso de E/S de archivo, pero también puede ser útil aquí. Ahora, Ellipse.stretchX funciona y devuelve "verdadero", mientras que Circle.stretchX simplemente devuelve "falso". Esto es en general una buena práctica, pero puede requerir que el autor original de Ellipse haya previsto tal problema y haya definido los mutadores como devueltos un valor. Además, requiere que el código del cliente pruebe el valor de retorno para comprobar si es compatible con la función de estiramiento, lo que en efecto es como probar si el objeto al que se hace referencia 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 implementa la interfaz. Al final, es solo una forma inteligente de eludir la restricción de Liskov al indicar de antemano que la condición posterior puede o no ser válida.
Alternativamente, Circle.stretchX podría lanzar una excepción (pero dependiendo del lenguaje, esto también puede requerir que el autor original de Ellipse declare que puede lanzar una excepción).
Esta es una solución similar a la anterior, pero es un poco más potente. Ellipse.stretchX ahora devuelve el nuevo valor de su dimensión X. Ahora, Circle.stretchX puede simplemente devolver su radio actual. Todas las modificaciones deben realizarse a través de Circle.stretch , que conserva la invariancia del círculo.
Si el contrato de interfaz de Ellipse solo establece que "stretchX modifica el eje X" y no establece "y nada más cambiará", entonces Circle podría simplemente forzar que las dimensiones X e Y sean las mismas. Circle.stretchX y Circle.stretchY modifican tanto el tamaño X como el Y.
Círculo::estirarX(x) { xSize = ySize = x; }Círculo::estirarY(y) { xSize = ySize = y; }
Si se llama a Circle.stretchX , Circle se transforma en una Ellipse . Por ejemplo, en Common Lisp , esto se puede hacer a través del método CHANGE-CLASS . Sin embargo, esto puede ser peligroso si alguna otra función espera que sea una Circle . Algunos lenguajes impiden este tipo de cambio y otros imponen restricciones a la clase Ellipse para que sea un reemplazo aceptable de Circle . Para lenguajes que permiten la conversión implícita como C++ , esto puede ser solo una solución parcial que resuelva el problema en la llamada por copia, pero no en la llamada por referencia.
Se puede cambiar el modelo para que las instancias de las clases representen valores constantes (es decir, sean inmutables ). Esta es la implementación que se utiliza en la programación puramente funcional.
En este caso, se deben cambiar métodos como stretchX para generar 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, lo que podría reducir el rendimiento y ser difícil de optimizar.
Se puede definir una nueva clase MutableEllipse y colocar en ella los modificadores de Ellipse . Circle solo hereda las consultas de Ellipse .
Esto tiene la desventaja de introducir una clase extra donde todo lo que se desea es especificar que Circle no hereda modificadores de Ellipse .
Se puede especificar que Ellipse.stretchX solo se permita en instancias que satisfagan Ellipse.stretchable y, de lo contrario, se generará una excepción . Esto requiere la anticipación del problema cuando se define Ellipse.
Cree una clase base abstracta llamada EllipseOrCircle y coloque métodos que funcionen con Circles y Ellipse en esta clase. Las funciones que pueden trabajar con cualquiera de los dos tipos de objetos esperarán un EllipseOrCircle , y las funciones que usan requisitos específicos de Ellipse o Circle usarán las clases descendientes. Sin embargo, Circle ya no es una subclase de Ellipse , lo que lleva a la situación de "un Circle no es un tipo de Ellipse " descrita anteriormente.
Esto resuelve el problema de golpe. Cualquier operación común deseada para un círculo y una elipse se puede abstraer en una interfaz común que cada clase implementa, o en mixins .
También 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 independiente y se puede modificar por separado del círculo original sin problemas. Los métodos que convierten en el sentido inverso no necesitan comprometerse con una estrategia. Por ejemplo, puede haber tanto Ellipse.minimalEnclosingCircle como Ellipse.maximalEnclosedCircle , y cualquier otra estrategia deseada.
Luego, donde antes se utilizó un círculo, utilice una elipse.
Un círculo ya se puede representar mediante una elipse. No hay razón para tener la clase Circle a menos que necesite algunos métodos específicos del círculo que no se puedan aplicar a una elipse, o a menos que el programador desee beneficiarse de las ventajas conceptuales y/o de rendimiento del modelo más simple del círculo.
Majorinc propuso un modelo que divide los métodos en modificadores, selectores y métodos generales. Sólo 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 puede ser emulado en lenguajes con herencia múltiple , utilizando clases abstractas . [1]
Este problema tiene soluciones sencillas en un sistema de programación orientado a objetos lo suficientemente potente. En esencia, el problema 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 de modo que digan lo mismo, todo está bien. Está claro que un círculo no puede satisfacer las invariantes que se le exigen mientras que sus métodos de elipse base permitan la mutación de parámetros. Sin embargo, existe la posibilidad de que cuando un círculo no puede satisfacer las 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 qué se ha transformado desde entonces).
Muchos sistemas de objetos de uso común se basan en un diseño que da por sentado que un objeto conserva el mismo tipo durante toda su vida útil, desde su construcción hasta su finalización. Esto no es una limitación de la programación orientada a objetos, sino más bien 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 conteniendo una referencia a ese mismo objeto después de que cambie de clase.
Los modelos de círculo y elipse se simplifican deliberadamente para evitar detalles que distraigan y que no sean 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 ejes y también tiene una propiedad de radio cuyo valor es igual al de los ejes (que, por supuesto, deben ser iguales entre sí).
( restricciones de comprobación genéricas definidas ( forma )) ;; Los accesores en objetos de forma. Las restricciones en objetos ;; deben comprobarse después de que se establezca el valor de cualquiera de los ejes. ( 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 elipse () (( eje h : tipo real : accesor eje h : inicializar : eje h ) ( eje v : tipo real : accesor eje v : inicializar : eje v ))) ( defclass círculo ( elipse ) (( radio : tipo real : accesor radio : inicializar : radio ))) ;;; ;;; 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 :after (( c circle ) &key radius ) ( setf ( radius c ) radius )) ;; mediante el método setf a continuación ( defmethod ( setf radius ) :after (( new-value real ) ( c circle )) ;; Usamos SLOT-VALUE, en lugar de los accesores, para evitar cambiar ;; la clase innecesariamente entre las dos asignaciones; ya que el círculo ;; tendrá diferentes valores de eje h y eje v entre las ;; asignaciones, y luego los mismos valores después de las asignaciones. ( setf ( slot-value c 'h-axis ) new-value ( slot-value c 'v-axis ) new-value )) ;;; ;;; Después de realizar una asignación al eje h o al eje v del círculo, es necesario un cambio de tipo, ;;; a menos que el nuevo valor sea el mismo que el radio. ;;;( defmethod check-constraints (( c círculo )) ( a menos que ( = ( radio c ) ( eje h c ) ( eje v c )) ( clase-cambio c 'elipse ))) ;;; ;;; La elipse cambia a un círculo si los accesores ;;; la mutan de manera que los ejes sean iguales, ;;; o si se intenta construirla de esa manera. ;;; ( 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 ))) ;;; ;;; 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 "verificación de cordura" para señalar un error si se intenta ;;; convertir una elipse cuyos ejes son desiguales ;;; con una llamada explícita a change-class. ;;; La estrategia de manejo aquí es basar el radio en el ;;; eje h y señalar un error. ;;; Esto no evita el cambio de clase; el daño ya está hecho. ;;; ( defmethod update-instance-for-different-class :after (( old-e elipse ) ( new-c circle ) &key ) ( setf ( radio new-c ) ( eje h old-e )) ( a menos que ( = ( eje h old-e ) ( eje v old-e )) ( error "elipse ~s no puede convertirse en un círculo porque no es uno!" old-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 '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]> (radio obj) *** - NO-APPLICABLE-METHOD: Al llamar a #<STANDARD-GENERIC-FUNCTION RADIUS> con argumentos (#<ELLIPSE #x2188C5F6>), ningún método es aplicable. Los siguientes reinicios están disponibles: RETRY :R1 intenta llamar a RADIUS nuevamente RETURN :R2 especifica valores de retorno ABORT :R3 Aborta el bucle principal 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: Al llamar a #<STANDARD-GENERIC-FUNCTION RADIUS> con argumentos (#<ELLIPSE #x2188C5F6>), 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 cancela el bucle principal Break 1 [16]> :a [17]>
Aunque a primera vista pueda parecer obvio que un círculo es una elipse, considere el siguiente código análogo.
clase Persona { void caminarNorte ( int metros ) {...} void caminarEste ( int metros ) {...} }
Ahora bien, un prisionero es obviamente una persona, por lo que, lógicamente, se puede crear una subclase:
clase Prisionero extiende Persona { void walkNorth ( int metros ) {...} void walkEast ( int metros ) {...} }
Obviamente, esto también genera problemas, ya que un prisionero no es libre de moverse una distancia arbitraria en cualquier dirección, aunque el contrato de la clase Persona establece que una Persona puede hacerlo.
Por lo tanto, la clase Persona podría llamarse mejor PersonaLibre . Si ese fuera el caso, entonces la idea de que la clase Prisionero extiende PersonaLibre es claramente errónea.
Por analogía, entonces, un círculo no es una elipse, porque carece de los mismos grados de libertad que una elipse.
Si se aplica una nomenclatura mejor, un círculo podría llamarse OneDiameterFigure y una elipse podría llamarse TwoDiameterFigure . Con estos nombres, ahora es más obvio que TwoDiameterFigure debería extender OneDiameterFigure , ya que le agrega otra propiedad; mientras que OneDiameterFigure tiene una sola propiedad de diámetro, TwoDiameterFigure tiene dos de estas propiedades (es decir, una longitud de eje mayor y una menor).
Esto sugiere fuertemente que la herencia nunca debería usarse cuando la subclase restringe la libertad implícita en la clase base, sino que sólo debería usarse cuando la subclase agrega detalles adicionales al concepto representado por la clase base, como en "Mono" es un "Animal".
Sin embargo, afirmar que un prisionero no puede moverse una distancia arbitraria en cualquier dirección y que una persona sí puede hacerlo es una premisa errónea 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 contrato WalkAttemptResult walkToDirection(int meters, Direction direction) . Ahora, al implementar walkToDirection para la subclase Prisoner, puede verificar los límites y devolver los resultados de caminata adecuados.
Se puede considerar, conceptualmente, que Circle y Ellipse son tipos de contenedores mutables, alias de MutableContainer<ImmutableCircle> y MutableContainer<ImmutableEllipse> respectivamente. En este caso, ImmutableCircle puede considerarse un subtipo de ImmutableEllipse . El tipo T en MutableContainer<T> puede ser objeto de escritura y lectura, lo que implica que no es covariante ni contravariante, sino invariante. Por lo tanto, Circle no es un subtipo de Ellipse , ni viceversa.