¿Por qué Square heredar de Rectangle sería problemático si anulamos los métodos SetWidth y SetHeight?

105

Si un cuadrado es un tipo de rectángulo, ¿por qué no puede heredar un cuadrado de un rectángulo? ¿O por qué es un mal diseño?

He escuchado a gente decir:

Si hiciste que Square derivara de Rectángulo, entonces un Square debería poder usarse en cualquier lugar donde esperes un rectángulo

¿Cuál es el problema aquí? ¿Y por qué Square sería utilizable en cualquier lugar donde espere un rectángulo? Solo sería utilizable si creamos el objeto Square, y si anulamos los métodos SetWidth y SetHeight para Square, ¿por qué habría algún problema?

Si tenía los métodos SetWidth y SetHeight en su clase base Rectangle y su referencia Rectangle apuntaba a un cuadrado, entonces SetWidth y SetHeight no tienen sentido porque establecer uno cambiaría el otro para que coincida. En este caso, Square falla la prueba de sustitución de Liskov con rectángulo y la abstracción de que Square herede de rectángulo es mala.

¿Alguien puede explicar los argumentos anteriores? Nuevamente, si anulamos los métodos SetWidth y SetHeight en Square, ¿no resolvería este problema?

También he escuchado / leído:

El verdadero problema es que no estamos modelando rectángulos, sino más bien "rectángulos reestructurables", es decir, rectángulos cuyo ancho o alto se pueden modificar después de la creación (y todavía consideramos que es el mismo objeto). Si nos fijamos en la clase de rectángulo de esta manera, está claro que un cuadrado no es un "rectángulo reestructurable", porque un cuadrado no puede ser reformado y sigue siendo un cuadrado (en general). Matemáticamente, no vemos el problema porque la mutabilidad ni siquiera tiene sentido en un contexto matemático.

Aquí creo que "redimensionable" es el término correcto. Los rectángulos son "redimensionables" y también lo son los cuadrados. ¿Me estoy perdiendo algo en el argumento anterior? Un cuadrado puede redimensionarse como cualquier rectángulo.

usuario793468
fuente
15
Esta pregunta parece terriblemente abstracta. Hay una infinidad de formas de usar clases y herencia, ya sea que hacer que una clase herede o no de alguna clase es una buena idea, generalmente depende principalmente de cómo desea usar esas clases. Sin un caso práctico, no puedo ver cómo esta pregunta puede obtener una respuesta relevante.
aaaaaaaaaaaa
2
Usando un poco de sentido común, se recuerda que el cuadrado es un rectángulo, por lo que si el objeto de su clase cuadrada no se puede usar donde se requiere un rectángulo, probablemente sea un defecto de diseño de la aplicación de todos modos.
Cthulhu
77
Creo que la mejor pregunta es Why do we even need Square? Es como tener dos plumas. Un bolígrafo azul y uno rojo, azul, amarillo o verde. El bolígrafo azul es redundante, incluso más en el caso del cuadrado, ya que no tiene ningún beneficio de costo.
Gusdor
2
@eBusiness Su abstracción es lo que la convierte en una buena pregunta de aprendizaje. Es importante poder reconocer qué usos del subtipo son malos independientemente de los casos de uso particulares.
Doval
55
@Cthulhu No realmente. El subtipo tiene que ver con el comportamiento y un cuadrado mutable no se comporta como un rectángulo mutable. Es por eso que la metáfora "es un ..." es mala.
Doval

Respuestas:

189

Básicamente queremos que las cosas se comporten con sensatez.

Considere el siguiente problema:

Me dan un grupo de rectángulos y quiero aumentar su área en un 10%. Entonces, lo que hago es establecer la longitud del rectángulo a 1.1 veces lo que era antes.

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    rectangle.Length = rectangle.Length * 1.1;
  }
}

Ahora, en este caso, todos mis rectángulos ahora tienen su longitud aumentada en un 10%, lo que aumentará su área en un 10%. Desafortunadamente, alguien realmente me pasó una mezcla de cuadrados y rectángulos, y cuando cambió la longitud del rectángulo, también lo hizo el ancho.

Mis pruebas unitarias pasan porque escribí todas mis pruebas unitarias para usar una colección de rectángulos. Ahora he introducido un error sutil en mi aplicación que puede pasar desapercibido durante meses.

Peor aún, Jim de contabilidad ve mi método y escribe otro código que usa el hecho de que si pasa cuadrados a mi método, obtiene un aumento de tamaño del 21%. Jim es feliz y nadie es más sabio.

Jim es promovido por su excelente trabajo a una división diferente. Alfred se une a la empresa como junior. En su primer informe de error, Jill de Advertising ha informado que pasar cuadrados a este método resulta en un aumento del 21% y quiere que se solucione el error. Alfred ve que los cuadrados y los rectángulos se usan en todas partes del código y se da cuenta de que es imposible romper la cadena de herencia. Tampoco tiene acceso al código fuente de Contabilidad. Entonces Alfred corrige el error así:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

Alfred está contento con sus habilidades de piratería súper y Jill firma que el error está solucionado.

El mes próximo, a nadie se le paga porque la Contabilidad dependía de poder pasar cuadrados al IncreaseRectangleSizeByTenPercentmétodo y obtener un aumento en el área del 21%. Toda la empresa pasa al modo "corrección de errores de prioridad 1" para rastrear el origen del problema. Ellos rastrean el problema hasta la solución de Alfred. Saben que deben mantener contentos tanto a la contabilidad como a la publicidad. Entonces arreglan el problema identificando al usuario con la llamada al método de la siguiente manera:

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles)
{
  IncreaseRectangleSizeByTenPercent(
    rectangles, 
    new User() { Department = Department.Accounting });
}

public void IncreaseRectangleSizeByTenPercent(IEnumerable<Rectangle> rectangles, User user)
{
  foreach(var rectangle in rectangles)
  {
    if (typeof(rectangle) == Rectangle || user.Department == Department.Accounting)
    {
      rectangle.Length = rectangle.Length * 1.1;
    }
    else if (typeof(rectangle) == Square)
    {
      rectangle.Length = rectangle.Length * 1.04880884817;
    }
  }
}

Y así sucesivamente y así sucesivamente.

Esta anécdota se basa en situaciones del mundo real que enfrentan los programadores a diario. Las violaciones del principio de sustitución de Liskov pueden introducir errores muy sutiles que solo se detectan años después de que se escriben, momento en el que corregir la violación romperá un montón de cosas y no arreglarlo enojará a su mayor cliente.

Hay dos formas realistas de solucionar este problema.

La primera forma es hacer que Rectángulo sea inmutable. Si el usuario de Rectángulo no puede cambiar las propiedades Longitud y Ancho, este problema desaparece. Si desea un rectángulo con una longitud y ancho diferentes, cree uno nuevo. Los cuadrados pueden heredar de rectángulos felizmente.

La segunda forma es romper la cadena de herencia entre cuadrados y rectángulos. Si un cuadrado se define como tener una sola SideLengthpropiedad y rectángulos tienen una Lengthy Widthla propiedad y no hay herencia, que es imposible de romper accidentalmente cosas al esperar un rectángulo y conseguir una plaza. En términos de C #, podría sealsu clase de rectángulo, lo que garantiza que todos los Rectángulos que obtenga sean en realidad Rectángulos.

En este caso, me gusta la forma de "objetos inmutables" de solucionar el problema. La identidad de un rectángulo es su longitud y ancho. Tiene sentido que cuando quiera cambiar la identidad de un objeto, lo que realmente quiere es un objeto nuevo . Si pierde un antiguo cliente y gana un nuevo cliente, no cambia el Customer.Idcampo del antiguo cliente al nuevo, crea uno nuevo Customer.

Las violaciones del principio de sustitución de Liskov son comunes en el mundo real, principalmente porque gran parte del código está escrito por personas incompetentes / bajo presión de tiempo / no les importa / cometen errores. Puede y causa algunos problemas muy desagradables. En la mayoría de los casos, desea favorecer la composición sobre la herencia .

Stephen
fuente
77
Liskov es una cosa, y el almacenamiento es otro problema. En la mayoría de las implementaciones, una instancia de Square heredada de Rectangle requerirá espacio para almacenar dos dimensiones, aunque solo se necesita una.
el.pescado
29
Uso brillante de una historia para ilustrar el punto
Rory Hunter
29
Bonita historia pero no estoy de acuerdo. El caso de uso fue: cambiar el área de un rectángulo. La solución debe agregar un método reemplazable 'ChangeArea' al rectángulo que se especializa en Square. Esto no rompería la cadena de herencia, haría explícito lo que el usuario quería hacer y no habría causado el error introducido por su corrección mencionada (que habría sido detectada en un área de preparación adecuada).
Roy T.
33
@RoyT .: ¿Por qué un rectángulo debe saber cómo establecer su área? Esa es una propiedad derivada completamente de la longitud y el ancho. Y más al grano, ¿qué dimensión debería cambiar: la longitud, el ancho o ambos?
cHao
32
@Roy T. Es muy lindo decir que habrías resuelto el problema de manera diferente, pero el hecho es que este es un ejemplo, aunque simplificado, de situaciones del mundo real que los desarrolladores enfrentan a diario cuando mantienen productos heredados. E incluso si implementó ese método, eso no detendrá a los herederos que violen el LSP e introduzcan errores similares a este. Es por eso que casi todas las clases en .NET Framework están selladas.
Stephen
30

Si todos sus objetos son inmutables, no hay problema. Cada cuadrado es también un rectángulo. Todas las propiedades de un rectángulo también son propiedades de un cuadrado.

El problema comienza cuando agrega la capacidad de modificar los objetos. O realmente, cuando comienzas a pasar argumentos al objeto, no solo leyendo captadores de propiedades.

Hay modificaciones que puede hacer en un Rectángulo que mantienen todos los invariantes de su clase Rectángulo, pero no todos los invariantes Cuadrados, como cambiar el ancho o la altura. De repente, el comportamiento de un rectángulo no es solo sus propiedades, sino también sus posibles modificaciones. No es solo lo que obtienes del Rectángulo, también es lo que puedes poner .

Si su Rectángulo tiene un método setWidthdocumentado que cambia el ancho y no modifica la altura, Square no puede tener un método compatible. Si cambia el ancho y no la altura, el resultado ya no es un cuadrado válido. Si elige modificar tanto el ancho como la altura del Cuadrado cuando lo usa setWidth, no está implementando la especificación de Rectángulo setWidth. Simplemente no puedes ganar.

Cuando observa lo que puede "poner" en un Rectángulo y un Cuadrado, qué mensajes puede enviarles, es probable que encuentre cualquier mensaje que pueda enviar válidamente a un Cuadrado, también puede enviarlo a un Rectángulo.

Es una cuestión de covarianza vs. contravarianza.

Los métodos de una subclase adecuada, una en la que se pueden usar instancias en todos los casos en que se espera la superclase, requieren que cada método:

  • Devuelve solo los valores que devolvería la superclase, es decir, el tipo de retorno debe ser un subtipo del tipo de retorno del método de superclase. El retorno es covariante.
  • Acepte todos los valores que aceptaría el supertipo, es decir, los tipos de argumento deben ser supertipos de los tipos de argumento del método de superclase. Los argumentos son contra-variantes.

Entonces, volviendo a Rectángulo y Cuadrado: si Cuadrado puede ser una subclase de Rectángulo depende completamente de los métodos que tenga Rectángulo.

Si Rectangle tiene setters individuales para ancho y alto, Square no será una buena subclase.

Del mismo modo, si hace que algunos métodos sean covariantes en los argumentos, como tener compareTo(Rectangle)un Rectángulo y compareTo(Square)un Cuadrado, tendrá un problema al usar un Cuadrado como un Rectángulo.

Si diseñas tu Square y Rectangle para que sea compatible, probablemente funcionará, pero deberían desarrollarse juntos, o apuesto a que no funcionará.

lrn
fuente
"Si todos sus objetos son inmutables, no hay problema" - esta es una declaración aparentemente irrelevante en el contexto de esta pregunta, que menciona explícitamente los establecedores de ancho y alto
mosquito
11
Esto me pareció interesante, incluso cuando es "aparentemente irrelevante"
Jesvin Jose
14
@gnat Yo diría que es relevante porque el valor real de la pregunta es reconocer cuando hay una relación de subtipo válida entre dos tipos. Eso depende de qué operaciones declare el supertipo, por lo que vale la pena señalar que el problema desaparece si los métodos mutantes desaparecen.
Doval
1
@gnat también, los setters son mutadores , por lo que lrn esencialmente dice: "No hagas eso y no es un problema". Estoy de acuerdo con la inmutabilidad de los tipos simples, pero usted hace un buen punto: para los objetos complejos, el problema no es tan simple.
Patrick M
1
Considérelo de esta manera, ¿cuál es el comportamiento garantizado por la clase 'Rectángulo'? Que pueden cambiar el ancho y la altura INDEPENDIENTES el uno del otro. (es decir, setWidth y setHeight) método. Ahora, si Square se deriva de Rectángulo, Square debe garantizar este comportamiento. Como el cuadrado no puede garantizar este comportamiento, es una mala herencia. Sin embargo, si los métodos setWidth / setHeight se eliminan de la clase Rectangle, entonces no existe tal comportamiento y, por lo tanto, puede derivar la clase Square de Rectangle.
Nitin Bhide
17

Hay muchas buenas respuestas aquí; La respuesta de Stephen en particular hace un buen trabajo al ilustrar por qué las violaciones del principio de sustitución conducen a conflictos en el mundo real entre equipos.

Pensé que podría hablar brevemente sobre el problema específico de los rectángulos y cuadrados, en lugar de usarlo como una metáfora de otras violaciones del LSP.

Hay un problema adicional con el cuadrado-es-un-tipo-especial-de-rectángulo que rara vez se menciona, y es: ¿por qué nos detenemos con cuadrados y rectángulos ? Si estamos dispuestos a decir que un cuadrado es un tipo especial de rectángulo, entonces seguramente también deberíamos estar dispuestos a decir:

  • Un cuadrado es un tipo especial de rombo: es un rombo con ángulos cuadrados.
  • Un rombo es un tipo especial de paralelogramo: es un paralelogramo con lados iguales.
  • Un rectángulo es un tipo especial de paralelogramo: es un paralelogramo con ángulos cuadrados
  • Un rectángulo, un cuadrado y un paralelogramo son todos un tipo especial de trapecio: son trapecios con dos conjuntos de lados paralelos.
  • Todos los anteriores son tipos especiales de cuadriláteros.
  • Todos los anteriores son tipos especiales de formas planas.
  • Y así; Podría seguir por algún tiempo aquí.

¿Qué demonios deberían estar todas las relaciones aquí? Los lenguajes basados ​​en herencia de clase como C # o Java no fueron diseñados para representar este tipo de relaciones complejas con múltiples tipos diferentes de restricciones. Es mejor evitar la pregunta por completo al no tratar de representar todas estas cosas como clases con relaciones de subtipo.

Eric Lippert
fuente
3
Si los objetos de formas son inmutables, uno podría tener un IShapetipo que incluye un cuadro delimitador, y se puede dibujar, escalar y serializar, y tener un IPolygonsubtipo con un método para informar el número de vértices y un método para devolver un IEnumerable<Point>. Entonces se podría tener un IQuadrilateralsubtipo que deriva de IPolygon, IRhombusy IRectangle, deriva de eso, y ISquarederiva de IRhombusy IRectangle. La mutabilidad arrojaría todo por la ventana, y la herencia múltiple no funciona con clases, pero creo que está bien con interfaces inmutables.
supercat
Estoy efectivamente en desacuerdo con Eric aquí (aunque no es suficiente para un -1). Todas esas relaciones son (posiblemente) relevantes, como menciona @supercat; es solo un problema de YAGNI: no lo implementa hasta que lo necesita.
Mark Hurd
Muy buena respuesta! Debería ser mucho más alto.
andrew.fox
1
@ MarkHurd: no es un problema de YAGNI: la jerarquía basada en la herencia propuesta tiene la forma de la taxonomía descrita, pero no se puede escribir para garantizar las relaciones que la definen. ¿Cómo IRhombusgarantiza que todo lo Pointdevuelto por lo Enumerable<Point>definido por IPolygoncorresponda a bordes de igual longitud? Debido a que la implementación de la IRhombusinterfaz por sí sola no garantiza que un objeto concreto sea un rombo, la herencia no puede ser la respuesta.
A. Rager
14

Desde una perspectiva matemática, un cuadrado es un rectángulo. Si un matemático modifica el cuadrado para que ya no se adhiera al contrato del cuadrado, cambia a un rectángulo.

Pero en el diseño OO, esto es un problema. Un objeto es lo que es, y esto incluye comportamientos y estado. Si sostengo un objeto cuadrado, pero alguien más lo modifica para que sea un rectángulo, eso viola el contrato del cuadrado sin culpa mía. Esto hace que sucedan todo tipo de cosas malas.

El factor clave aquí es la mutabilidad . ¿Puede cambiar una forma una vez que se construye?

  • Mutable: si se permite que las formas cambien una vez construidas, un cuadrado no puede tener una relación is-a con un rectángulo. El contrato de un rectángulo incluye la restricción de que los lados opuestos deben tener la misma longitud, pero los lados adyacentes no necesitan serlo. El cuadrado debe tener cuatro lados iguales. Modificar un cuadrado a través de una interfaz rectangular puede violar el contrato del cuadrado.

  • Inmutable: si las formas no pueden cambiar una vez construido, entonces un objeto cuadrado también debe cumplir siempre el contrato de rectángulo. Un cuadrado puede tener una relación is-a con rectángulo.

En ambos casos, es posible pedirle a un cuadrado que produzca una nueva forma basada en su estado con uno o más cambios. Por ejemplo, uno podría decir "crear un nuevo rectángulo basado en este cuadrado, excepto que los lados opuestos A y C son dos veces más largos". Como se está construyendo un nuevo objeto, el cuadrado original continúa adhiriéndose a sus contratos.


fuente
1
This is one of those cases where the real world is not able to be modeled in a computer 100%. ¿Porque? Todavía podemos tener un modelo funcional de un cuadrado y un rectángulo. La única consecuencia es que tenemos que buscar una construcción más simple para abstraer sobre esos dos objetos.
Simon Bergot
66
Hay más en común entre rectángulos y cuadrados que eso. El problema es que la identidad de un rectángulo y la identidad de un cuadrado son sus longitudes laterales (y el ángulo en cada intersección). La mejor solución aquí es hacer que los cuadrados hereden de los rectángulos, pero hacer que ambos sean inmutables.
Stephen
3
@Stephen De acuerdo. En realidad, hacerlos inmutables es lo más sensato, independientemente de los problemas de subtipificación. No hay razón para hacerlos mutables: no es más difícil construir un nuevo cuadrado o rectángulo que mutar uno, así que ¿por qué abrir esa lata de gusanos? Ahora no tiene que preocuparse por los alias / efectos secundarios y puede usarlos como teclas para mapas / dictados si es necesario. Algunos dirán "rendimiento", a lo que diré "optimización prematura" hasta que realmente hayan medido y demostrado que el punto caliente está en el código de forma.
Doval
Lo siento chicos, era tarde y estaba súper cansado cuando escribí la respuesta. Lo reescribí para decir lo que realmente quise decir, el quid de la cuestión es la mutabilidad.
13

¿Y por qué Square sería utilizable en cualquier lugar donde espere un rectángulo?

Porque eso es parte de lo que significa ser un subtipo (ver también: principio de sustitución de Liskov). Puede hacer, necesita poder hacer esto:

Square s = new Square(5);
Rect r = s;
doSomethingWith(r); // written assuming a Rect, actually calls Square methods

Realmente haces esto todo el tiempo (a veces incluso más implícitamente) cuando usas OOP.

y si anulamos los métodos SetWidth y SetHeight para Square, ¿por qué habría algún problema?

Porque no puedes anularlas sensiblemente Square. Porque un cuadrado no puede "redimensionarse como cualquier rectángulo". Cuando cambia la altura de un rectángulo, el ancho permanece igual. Pero cuando cambia la altura de un cuadrado, el ancho debe cambiar en consecuencia. El problema no es solo ser redimensionable, sino también redimensionable en ambas dimensiones de forma independiente.

Deduplicador
fuente
En muchos idiomas, ni siquiera necesita la Rect r = s;línea, solo puede doSomethingWith(s)y el tiempo de ejecución usará cualquier llamada spara resolver cualquier Squaremétodo virtual .
Patrick M
1
@PatrickM No lo necesita en ningún lenguaje sensato que tenga subtipos. Incluí esa línea para la exposición, para ser explícito.
Entonces anule setWidthy setHeightcambie tanto el ancho como la altura.
ApproachingDarknessFish
@ValekHalfHeart Esa es precisamente la opción que estoy considerando.
77
@ValekHalfHeart: Esa es precisamente la violación del Principio de sustitución de Liskov que te perseguirá y te hará pasar varias noches sin dormir tratando de encontrar un error extraño dos años después, cuando has olvidado cómo se suponía que debía funcionar el código.
Jan Hudec
9

Lo que estás describiendo va en contra de lo que se llama el principio de sustitución de Liskov . La idea básica del LSP es que siempre que use una instancia de una clase en particular, siempre debería poder intercambiar en una instancia de cualquier subclase de esa clase, sin introducir errores.

El problema del rectángulo cuadrado no es realmente una muy buena forma de presentar a Liskov. Trata de explicar un principio amplio utilizando un ejemplo que en realidad es bastante sutil y está en conflicto con una de las definiciones intuitivas más comunes en todas las matemáticas. Algunos lo llaman el problema de Ellipse-Circle por esa razón, pero es solo un poco mejor en lo que respecta a esto. Un mejor enfoque es retroceder un poco, utilizando lo que yo llamo el problema del paralelogramo-rectángulo. Esto hace que las cosas sean mucho más fáciles de entender.

Un paralelogramo es un cuadrilátero con dos pares de lados paralelos. También tiene dos pares de ángulos congruentes. No es difícil imaginar un objeto de paralelogramo a lo largo de estas líneas:

class Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Una forma común de pensar en un rectángulo es como un paralelogramo con ángulos rectos. A primera vista, esto podría parecer que Rectangle sea un buen candidato para heredar de Parallelogram , de modo que pueda reutilizar todo ese delicioso código. Sin embargo:

class Rectangle extends Parallelogram {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* BUG: Liskov violations ahead */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

¿Por qué estas dos funciones introducen errores en Rectángulo? El problema es que no puedes cambiar los ángulos en un rectángulo : se definen como siempre de 90 grados, por lo que esta interfaz no funciona para Rectángulo heredando de Paralelogramo. Si cambio un Rectángulo a un código que espera un Paralelogramo, y ese código intenta cambiar el ángulo, es muy probable que haya errores. Tomamos algo que se podía escribir en la subclase y lo hicimos de solo lectura, y eso es una violación de Liskov.

Ahora, ¿cómo se aplica esto a cuadrados y rectángulos?

Cuando decimos que puede establecer un valor, generalmente queremos decir algo un poco más fuerte que simplemente poder escribir un valor en él. Implicamos un cierto grado de exclusividad: si establece un valor, salvo algunas circunstancias extraordinarias, se mantendrá en ese valor hasta que lo establezca nuevamente. Hay muchos usos para los valores en los que se puede escribir pero que no permanecen establecidos, pero también hay muchos casos que dependen de que un valor permanezca donde está una vez que lo establece. Y ahí es donde nos encontramos con otro problema.

class Square extends Rectangle {
    function getSideA() {};
    function getSideB() {};
    function getAngleA() {};
    function getAngleB() {};

    /* BUG: More Liskov violations */
    function setSideA(newLength) {};
    function setSideB(newLength) {};

    /* Liskov violations inherited from Rectangle */
    function setAngleA(newAngle) {};
    function setAngleB(newAngle) {};
}

Nuestra clase Square heredó errores de Rectangle, pero tiene algunos nuevos. El problema con setSideA y setSideB es que ninguno de estos ya es realmente configurable: aún puede escribir un valor en cualquiera de ellos, pero cambiará por debajo de usted si se escribe en el otro. Si cambio esto por un paralelogramo en el código que depende de poder establecer lados independientemente uno del otro, se volverá loco.

Ese es el problema, y ​​es por eso que hay un problema con el uso de Rectangle-Square como una introducción a Liskov. Rectangle-Square depende de la diferencia entre poder escribir en algo y poder configurarlo , y esa es una diferencia mucho más sutil que poder configurar algo en lugar de que sea de solo lectura. Rectangle-Square todavía tiene valor como ejemplo, porque documenta un problema bastante común que debe ser observado, pero no debe usarse como un ejemplo introductorio . Deje que el alumno se base primero en lo básico y luego arroje algo más duro a ellos.

El más cuchara
fuente
8

El subtipo se trata de comportamiento.

Para que el tipo Bsea ​​un subtipo de tipo A, debe admitir todas las operaciones que el tipo Aadmite con la misma semántica (charla elegante para "comportamiento"). Usando el argumento de que cada B es un A qué no funciona - la compatibilidad comportamiento tiene la última palabra. La mayoría de las veces "B es una especie de A" se superpone con "B se comporta como A", pero no siempre .

Un ejemplo:

Considere el conjunto de números reales. En cualquier idioma, podemos esperar que apoyan las operaciones +, -, *, y /. Ahora considere el conjunto de enteros positivos ({1, 2, 3, ...}). Claramente, cada entero positivo también es un número real. ¿Pero es el tipo de enteros positivos un subtipo del tipo de números reales? Veamos las cuatro operaciones y veamos si los enteros positivos se comportan de la misma manera que los números reales:

  • +: Podemos agregar enteros positivos sin problemas.
  • -: No todas las restas de enteros positivos resultan en enteros positivos. Por ej 3 - 5.
  • *: Podemos multiplicar enteros positivos sin problemas.
  • /: No siempre podemos dividir enteros positivos y obtener un entero positivo. Por ej 5 / 3.

Entonces, a pesar de que los enteros positivos son un subconjunto de números reales, no son un subtipo. Se puede hacer un argumento similar para enteros de tamaño finito. Claramente, cada entero de 32 bits también es un entero de 64 bits, pero 32_BIT_MAX + 1le dará diferentes resultados para cada tipo. Entonces, si te di algún programa y cambiaste el tipo de cada variable entera de 32 bits a enteros de 64 bits, hay una buena probabilidad de que el programa se comporte de manera diferente (lo que casi siempre significa mal ).

Por supuesto, podría definir +entradas de 32 bits para que el resultado sea un entero de 64 bits, pero ahora tendrá que reservar 64 bits de espacio cada vez que agregue dos números de 32 bits. Eso puede o no ser aceptable para usted dependiendo de sus necesidades de memoria.

¿Por qué importa esto?

Es importante que los programas sean correctos. Podría decirse que es la propiedad más importante para un programa. Si un programa es correcto para algún tipo A, la única forma de garantizar que el programa continuará siendo correcto para algún subtipo Bes si se Bcomporta como Aen todos los sentidos.

Entonces tiene el tipo de Rectangles, cuya especificación dice que sus lados se pueden cambiar de forma independiente. Usted escribió algunos programas que usan Rectanglesy asumen que la implementación sigue la especificación. Luego introdujo un subtipo llamado Squarecuyos lados no se pueden cambiar de tamaño de forma independiente. Como resultado, la mayoría de los programas que redimensionan rectángulos ahora estarán equivocados.

Doval
fuente
6

Si un cuadrado es un tipo de rectángulo, ¿por qué no puede heredar un cuadrado de un rectángulo? ¿O por qué es un mal diseño?

Primero lo primero, pregúntese por qué cree que un cuadrado es un rectángulo.

Por supuesto, la mayoría de la gente aprendió eso en la escuela primaria, y parecería obvio. Un rectángulo es una forma de 4 lados con ángulos de 90 grados, y un cuadrado cumple con todas esas propiedades. Entonces, ¿no es un cuadrado un rectángulo?

Sin embargo, la cuestión es que todo depende de cuál sea su criterio inicial para agrupar objetos, qué contexto está viendo estos objetos. En geometría las formas se clasifican en función de las propiedades de sus puntos, líneas y ángeles.

Entonces, incluso antes de decir "un cuadrado es un tipo de rectángulo", primero tiene que preguntarse si esto se basa en criterios que me interesan .

En la gran mayoría de los casos, no va a ser lo que te importa en absoluto. La mayoría de los sistemas que modelan formas, como GUI, gráficos y videojuegos, no se ocupan principalmente de la agrupación geométrica de un objeto, sino de su comportamiento. ¿Alguna vez has trabajado en un sistema que importaba que un cuadrado fuera un tipo de rectángulo en sentido geométrico? ¿Qué te daría eso, sabiendo que tiene 4 lados y ángulos de 90 grados?

No está modelando un sistema estático, está modelando un sistema dinámico donde las cosas van a suceder (las formas se van a crear, destruir, alterar, dibujar, etc.). En este contexto, le importa el comportamiento compartido entre los objetos, porque su principal preocupación es lo que puede hacer con una forma, qué reglas se deben mantener para tener un sistema coherente.

En este contexto, un cuadrado definitivamente no es un rectángulo , porque las reglas que gobiernan cómo se puede alterar el cuadrado no son las mismas que el rectángulo. Entonces no son el mismo tipo de cosas.

En cuyo caso, no los modele como tal. ¿Por que lo harias? No gana nada más que una restricción innecesaria.

Solo sería utilizable si creamos el objeto Square, y si anulamos los métodos SetWidth y SetHeight para Square, ¿por qué habría algún problema?

Si lo hace, aunque prácticamente está indicando en código que no son lo mismo. Su código estaría diciendo que un cuadrado se comporta de esta manera y un rectángulo se comporta de esa manera, pero siguen siendo los mismos.

Claramente no son lo mismo en el contexto que le interesa porque acaba de definir dos comportamientos diferentes. Entonces, ¿por qué fingir que son iguales si solo son similares en un contexto que no te importa?

Esto resalta un problema importante cuando los desarrolladores llegan a un dominio que desean modelar. Es muy importante aclarar en qué contexto está interesado antes de comenzar a pensar en los objetos del dominio. ¿Qué aspecto le interesa? Hace miles de años, los griegos se preocuparon por las propiedades compartidas de las líneas y los ángeles de las formas, y las agruparon en función de ellas. Eso no significa que se vea obligado a continuar esa agrupación si no es lo que le interesa (que en el 99% del tiempo no le interesará modelar en software).

Muchas de las respuestas a esta pregunta se centran en subtipar sobre el comportamiento de agrupación porque les da las reglas .

Pero es tan importante comprender que no está haciendo esto solo para seguir las reglas. Estás haciendo esto porque en la gran mayoría de los casos esto es lo que realmente te importa también. No te importa si un cuadrado y un rectángulo comparten los mismos ángeles internos. Te importa lo que pueden hacer mientras siguen siendo cuadrados y rectángulos. Te importa el comportamiento de los objetos porque estás modelando un sistema que se centra en cambiar el sistema en función de las reglas del comportamiento de los objetos.

Cormac Mulhall
fuente
Si las variables de tipo Rectanglesolo se usan para representar valores , entonces es posible que una clase Squareherede Rectangley cumpla completamente su contrato. Desafortunadamente, muchos lenguajes no distinguen entre variables que encapsulan valores y aquellas que identifican entidades.
supercat
Posiblemente, pero entonces, ¿por qué molestarse en primer lugar? El punto del problema del rectángulo / cuadrado no es tratar de descubrir cómo hacer que funcione la relación "un cuadrado es un rectángulo", sino darse cuenta de que la relación en realidad no existe en el contexto en el que está utilizando los objetos. (conductualmente), y como advertencia sobre no imponer relaciones irrelevantes en tu dominio.
Cormac Mulhall
O para decirlo de otra manera: no intentes doblar la cuchara. Eso es imposible. En cambio, solo trate de darse cuenta de la verdad, que no hay cuchara. :-)
Cormac Mulhall
1
Tener un Squaretipo inmutable que herede de un Rectnagletipo inmutable podría ser útil si hubiera algún tipo de operaciones que solo pudieran realizarse sobre cuadrados. Como un ejemplo realista del concepto, considere un ReadableMatrixtipo [tipo base, una matriz rectangular que podría almacenarse de varias maneras, incluso escasamente], y un ComputeDeterminantmétodo. Puede tener sentido ComputeDeterminanttrabajar solo con un ReadableSquareMatrixtipo derivado ReadableMatrix, lo que consideraría un ejemplo de Squarederivación de a Rectangle.
supercat
5

Si un cuadrado es un tipo de rectángulo, ¿por qué no puede heredar un cuadrado de un rectángulo?

El problema radica en pensar que si las cosas están relacionadas de alguna manera en la realidad, deben estar relacionadas exactamente de la misma manera después del modelado.

Lo más importante en el modelado es identificar los atributos comunes y los comportamientos comunes, definirlos en la clase básica y agregar atributos adicionales en las clases secundarias.

El problema con su ejemplo es que es completamente abstracto. Mientras nadie sepa, para qué planea usar esas clases, es difícil adivinar qué diseño debe hacer. Pero si realmente desea tener solo altura, ancho y cambio de tamaño, sería más lógico:

  • define Square como clase base, con widthparámetro y resize(double factor)redimensionando el ancho por el factor dado
  • define la clase Rectangle y la subclase de Square, porque agrega otro atributo heighty anula su resizefunción, que llama super.resizey luego cambia el tamaño de la altura por el factor dado

Desde el punto de vista de la programación, no hay nada en Square que Rectangle no tenga. No tiene sentido hacer un cuadrado como la subclase de Rectángulo.

Marinero danubiano
fuente
+1 El hecho de que un cuadrado sea un tipo especial de rect en matemáticas no significa que sea igual en OO.
Lovis
1
Un cuadrado es un cuadrado y un rectángulo es un rectángulo. Las relaciones entre ellos también deberían mantenerse en el modelado, o tienes un modelo bastante pobre. Los problemas reales son: 1) si los hace mutables, ya no está modelando cuadrados y rectángulos; 2) suponiendo que solo porque alguna relación "es una" se mantiene entre dos tipos de objetos, puede sustituir uno por otro indiscriminadamente.
Doval
4

Porque por LSP, la creación de una relación de herencia entre los dos y la anulación setWidthy setHeightpara garantizar que el cuadrado tenga ambas cosas, introduce un comportamiento confuso y no intuitivo. Digamos que tenemos un código:

Rectangle r = createRectangle(); // create rectangle or square here
r.setWidth(10);
r.setHeight(20);
print(r.getWidth()); // expect to print 10
print(r.getHeight()); // expect to print 20

Pero si el método createRectangleregresó Square, porque es posible gracias a la Squareherencia de Rectange. Entonces las expectativas se rompen. Aquí, con este código, esperamos que el ajuste de ancho o alto solo cause un cambio en ancho o alto respectivamente. El objetivo de OOP es que cuando trabajas con la superclase, no tienes conocimiento de ninguna subclase debajo de ella. Y si la subclase cambia el comportamiento para que vaya en contra de las expectativas que tenemos sobre la superclase, entonces hay una alta probabilidad de que ocurran errores. Y ese tipo de errores son difíciles de depurar y corregir.

Una de las ideas principales sobre OOP es que es el comportamiento, no los datos que se heredan (que también es una de las principales ideas erróneas de la OMI). Y si nos fijamos en el cuadrado y el rectángulo, no tienen un comportamiento que podamos relacionar en relación de herencia.

Eufórico
fuente
2

Lo que dice LSP es que cualquier cosa que herede Rectangledebe ser a Rectangle. Es decir, debe hacer lo que sea que Rectanglehaga.

Probablemente la documentación para Rectangleestá escrita para decir que el comportamiento de un Rectanglenombrado res el siguiente:

r.setWidth(10);
r.setHeight(20);
print(r.getWidth());  // prints 10

Si su Square no tiene el mismo comportamiento, entonces no se comporta como a Rectangle. Entonces LSP dice que no debe heredar de Rectangle. El lenguaje no puede hacer cumplir esta regla, porque no puede evitar que hagas algo mal en una anulación de método, pero eso no significa "está bien porque el lenguaje me permite anular los métodos" es un argumento convincente para hacerlo.

Ahora, sería posible escribir la documentación de Rectangletal manera que no implique que el código anterior imprima 10, en cuyo caso tal vez Squarepodría ser un Rectangle. Es posible que vea documentación que diga algo como "esto hace X. Además, la implementación en esta clase hace Y". Si es así, tiene un buen caso para extraer una interfaz de la clase y distinguir entre lo que garantiza la interfaz y lo que la clase garantiza además de eso. Pero cuando la gente dice "un cuadrado mutable no es un rectángulo mutable, mientras que un cuadrado inmutable es un rectángulo inmutable", básicamente asumen que lo anterior es de hecho parte de la definición razonable de un rectángulo mutable.

Steve Jessop
fuente
esto parece simplemente repetir los puntos explicados en una respuesta publicada hace 5 horas
mosquito
@gnat: ¿preferiría que edite esa otra respuesta a aproximadamente esta brevedad? ;-) No creo que pueda sin eliminar los puntos que otros respondedores presumiblemente sienten que son necesarios para responder la pregunta y creo que no.
Steve Jessop
1

Los subtipos y, por extensión, la programación OO, a menudo se basan en el Principio de sustitución de Liskov, que cualquier valor de tipo A se puede utilizar donde se requiere un B, si A <= B. Esto es más o menos un axioma en la arquitectura OO, es decir. se supone que todas las subclases tendrán esta propiedad (y si no, los subtipos tienen errores y deben corregirse).

¡Sin embargo, resulta que este principio es poco realista / no representativo de la mayoría del código, o de hecho imposible de satisfacer (en casos no triviales)! Este problema, conocido como el problema del rectángulo cuadrado o el problema de la elipse circular ( http://en.wikipedia.org/wiki/Circle-ellipse_problem ) es un famoso ejemplo de lo difícil que es cumplirlo.

Tenga en cuenta que podríamos implementar Cuadrados y Rectángulos más y más equivalentes a la observación, pero solo descartando cada vez más funcionalidades hasta que la distinción sea inútil.

Como ejemplo, vea http://okmij.org/ftp/Computation/Subtyping/

Warbo
fuente