¿Alguna vez está bien violar el LSP?

10

Estoy siguiendo esta pregunta , pero estoy cambiando mi enfoque del código a un principio.

Desde mi entendimiento del principio de sustitución de Liskov (LSP), cualquier método que esté en mi clase base, debe implementarse en mi subclase, y de acuerdo con esta página, si anula un método en la clase base y no hace nada o arroja un excepción, estás violando el principio.

Ahora, mi problema puede resumirse así: tengo un resumen Weapon class, y dos clases, Swordy Reloadable. Si Reloadablecontiene un específico method, llamado Reload(), tendría que bajar para acceder a eso method, e idealmente, querrás evitarlo.

Entonces pensé en usar el Strategy Pattern. De esta manera, cada arma solo era consciente de las acciones que es capaz de realizar, por lo que, por ejemplo, un Reloadablearma, obviamente, puede recargarse, pero Swordno puede, y ni siquiera es consciente de a Reload class/method. Como dije en mi publicación de Stack Overflow, no tengo que abatir y puedo mantener una List<Weapon>colección.

En otro foro , la primera respuesta sugerida Swordpara ser consciente Reload, simplemente no haga nada. Esta misma respuesta se dio en la página de desbordamiento de pila a la que he vinculado anteriormente.

No entiendo completamente por qué. ¿Por qué violar el principio y permitir que Sword sea consciente Reloady dejarlo en blanco? Como dije en mi publicación Stack Overflow, el SP resolvió mis problemas.

¿Por qué no es una solución viable?

public final Weapon{

    private final String name;
    private final int damage;
    private final List<AttackStrategy> validactions;
    private final List<Actions> standardActions;

    private Weapon(String name, int damage, List<AttackStrategy> standardActions, List<Actions> attacks)
    {
        this.name = name;
        this.damage = damage;
        standardActions = new ArrayList<Actions>(standardActions);
        validAttacks = new ArrayList<AttackStrategy>(validActions);
    }

    public void standardAction(String action){} // -- Can call reload or aim here.  

    public int attack(String action){} // - Call any actions that are attacks. 

    public static Weapon Sword(String name, damage, List<AttackStrategy> standardActions, List<Actions> attacks){
        return new Weapon(name, damage,standardActions, attacks) ;
    }

}

Interfaz de ataque e implementación:

public interface AttackStrategy{
    void attack(Enemy enemy);
}

public class Shoot implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to shoot
    }
}

public class Strike implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to strike
    }
}

fuente
2
Puedes hacer class Weapon { bool supportsReload(); void reload(); }. Los clientes probarían si son compatibles antes de volver a cargar. reloadse define contractualmente para lanzar iff !supportsReload(). Eso se adhiere al LSP si las clases derivadas se adhieren al protocolo que acabo de describir.
usr
3
Si lo deja en reload()blanco o si standardActionsno contiene una acción de recarga es solo un mecanismo diferente. No hay diferencia fundamental. Puedes hacer las dos cosas. => Su solución es viable (que era su pregunta) .; Sword no necesita saber sobre la recarga si Arma contiene una implementación predeterminada en blanco.
usr
27
Escribí una serie de artículos explorando una variedad de problemas con varias técnicas para resolver este problema. La conclusión: no intentes capturar las reglas de tu juego en el sistema de tipos del idioma . Capture las reglas del juego en objetos que representan y hacen cumplir las reglas al nivel de la lógica del juego, no al nivel del sistema de tipos . No hay razón para creer que cualquier sistema de tipos que esté utilizando sea lo suficientemente sofisticado como para representar la lógica de su juego. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert
2
@EricLippert - Gracias por tu enlace. Me he encontrado con este blog muchas veces, pero algunos de los puntos señalados no los entiendo del todo, pero no es tu culpa. Estoy aprendiendo OOP por mi cuenta y me encontré con directores SÓLIDOS. La primera vez que encontré su blog, no lo entendí en absoluto, pero aprendí un poco más y leí su blog nuevamente, y lentamente comencé a comprender partes de lo que se decía. Un día, entenderé completamente todo en esa serie. Espero: D
66
@SR "si no hace nada o arroja una excepción, estás en violación" - Creo que leíste mal el mensaje de ese artículo. El problema no fue directamente que setAltitude no hizo nada, fue que no cumplió con la condición posterior "el pájaro será atraído a la altitud establecida". Si define la condición posterior de "recargar" como "si hubiera suficiente munición disponible, el arma puede atacar nuevamente", entonces no hacer nada es una implementación perfectamente válida para un arma que no usa munición.
Sebastian Redl

Respuestas:

16

El LSP está preocupado por el subtipo y el polimorfismo. No todo el código realmente usa estas características, en cuyo caso el LSP es irrelevante. Dos casos de uso común de construcciones de lenguaje de herencia que no son un caso de subtipo son:

  • La herencia solía heredar la implementación de una clase base, pero no su interfaz. En casi todos los casos se debe preferir la composición. Lenguajes como Java no pueden separar la herencia de la implementación y la interfaz, pero, por ejemplo, C ++ tiene privateherencia.

  • Herencia utilizada para modelar un tipo / unión de suma, por ejemplo: a Basees uno CaseAo CaseB. El tipo base no declara ninguna interfaz relevante. Para usar sus instancias, debe convertirlas al tipo concreto correcto. El casting se puede hacer de forma segura y no es el problema. Desafortunadamente, muchos lenguajes OOP no pueden restringir los subtipos de clase base solo a los subtipos previstos. Si el código externo puede crear un CaseC, entonces el código supone que a Basesolo puede ser a CaseAo CaseBes incorrecto. Scala puede hacer esto de forma segura con su case classconcepto. En Java, esto se puede modelar cuando Basees una clase abstracta con un constructor privado, y las clases estáticas anidadas se heredan de la base.

Algunos conceptos como las jerarquías conceptuales de objetos del mundo real se mapean muy mal en modelos orientados a objetos. Pensamientos como “Una pistola es un arma y una espada es un arma, por lo tanto, voy a tener una Weaponclase base desde la cual Guny Swordhereda” puedan inducir a error: Real-palabra es-a relaciones no implican una relación tal en nuestro modelo. Un problema relacionado es que los objetos pueden pertenecer a múltiples jerarquías conceptuales o pueden cambiar su afiliación jerárquica durante el tiempo de ejecución, que la mayoría de los lenguajes no pueden modelar ya que la herencia generalmente es por clase, no por objeto, y se define en tiempo de diseño, no en tiempo de ejecución.

Al diseñar modelos OOP, no debemos pensar en la jerarquía o en cómo una clase "extiende" a otra. Una clase base no es un lugar para factorizar las partes comunes de múltiples clases. En cambio, piense en cómo se usarán sus objetos, es decir, qué tipo de comportamiento necesitan los usuarios de estos objetos.

Aquí, los usuarios pueden necesitar attack()armas y tal vez reload()ellas. Si queremos crear una jerarquía de tipos, ambos métodos deben estar en el tipo base, aunque las armas no recargables pueden ignorar ese método y no hacer nada cuando se les llama. Entonces, la clase base no contiene las partes comunes, sino la interfaz combinada de todas las subclases. Las subclases no difieren en su interfaz, sino solo en su implementación de esta interfaz.

No es necesario crear una jerarquía. Los dos tipos Guny Swordpueden ser completamente ajenos. Mientras que una Gunlata fire()y reload()un Swordsolo puede strike(). Si necesita administrar estos objetos polimórficamente, puede usar el patrón adaptador para capturar los aspectos relevantes. En Java 8 esto es posible de manera bastante conveniente con interfaces funcionales y referencias lambdas / método. Por ejemplo, puede tener una Attackestrategia para la que suministra myGun::fireo () -> mySword.strike().

Finalmente, a veces es sensato evitar las subclases, pero modelar todos los objetos a través de un solo tipo. Esto es particularmente relevante en los juegos porque muchos objetos del juego no encajan bien en ninguna jerarquía y pueden tener muchas capacidades diferentes. Por ejemplo, un juego de rol puede tener un elemento que es a la vez un elemento de búsqueda, mejora tus estadísticas con +2 de fuerza cuando está equipado, tiene un 20% de posibilidades de ignorar cualquier daño recibido y proporciona un ataque cuerpo a cuerpo. O tal vez una espada recargable porque es * mágica *. Quién sabe lo que requiere la historia.

En lugar de tratar de descubrir una jerarquía de clases para ese desastre, es mejor tener una clase que proporcione ranuras para diversas capacidades. Estas ranuras se pueden cambiar en tiempo de ejecución. Cada ranura sería una estrategia / devolución de llamada como OnDamageReceivedo Attack. Con sus armas, podemos tener MeleeAttack, RangedAttacky Reloadlas franjas horarias. Estas ranuras pueden estar vacías, en cuyo caso el objeto no proporciona esta capacidad. Las ranuras son entonces llamados condicionalmente: if (item.attack != null) item.attack.perform().

amon
fuente
Algo así como el SP de alguna manera. ¿Por qué la ranura tiene que vaciarse? Si el diccionario no contiene la acción, simplemente no haga nada
@SR Si un espacio está vacío o no existe realmente no importa, y depende del mecanismo utilizado para implementar estos espacios. Escribí esta respuesta con los supuestos de un lenguaje bastante estático donde las ranuras son campos de instancia y siempre existen (es decir, diseño de clase normal en Java). Si elige un modelo más dinámico donde las ranuras son entradas en un diccionario (como usar un HashMap en Java o un objeto Python normal), entonces las ranuras no tienen que existir. Tenga en cuenta que los enfoques más dinámicos renuncian a una gran cantidad de seguridad de tipo, que generalmente no es deseable.
amon
Estoy de acuerdo en que los objetos del mundo real no se modelan bien. Si entiendo tu publicación, ¿estás diciendo que puedo usar el Patrón de estrategia?
2
@SR Sí, el patrón de estrategia en alguna forma es probablemente un enfoque sensato. Compare también el patrón de objeto tipo relacionado: gameprogrammingpatterns.com/type-object.html
amon
3

Porque tener una estrategia attackno es suficiente para tus necesidades. Claro, te permite abstraer qué acciones puede hacer el objeto, pero ¿qué sucede cuando necesitas saber el alcance del arma? ¿O la capacidad de munición? ¿O qué tipo de munición se necesita? Has vuelto a abatir para llegar a eso. Y tener ese nivel de flexibilidad hará que la interfaz de usuario sea un poco más difícil de implementar, ya que necesitará tener un patrón de estrategia similar para manejar todas las capacidades.

Dicho todo esto, no estoy particularmente de acuerdo con las respuestas a sus otras preguntas. Haber swordheredado weapones horrible, ingenua OO que invariablemente conduce a métodos no operativos o verificaciones de tipo esparcidas sobre el código.

Pero en la raíz del asunto, ninguna de las soluciones está mal . Puedes usar ambas soluciones para crear un juego funcional que sea divertido de jugar. Cada uno viene con su propio conjunto de compensaciones, al igual que cualquier solución que elija.

Telastyn
fuente
Creo que esto es perfecto Puedo usar el SP, pero son compensaciones, solo tengo que estar al tanto de ellas. Vea mi edición, para lo que tengo en mente.
1
Fwiw: una espada tiene munición infinita: puedes seguir usándola sin leer para siempre; recargar no hace nada porque para empezar tienes un uso infinito; un rango de uno / cuerpo a cuerpo: es un arma cuerpo a cuerpo. No es imposible pensar en todas las estadísticas / acciones de una manera que funcione tanto para cuerpo a cuerpo como a distancia. Aún así, a medida que envejezco, uso cada vez menos la herencia a favor de las interfaces, la competencia y el nombre que sea para usar una sola Weaponclase con una instancia de espada y arma.
CAD97
¡Las espadas de Fwiw in Destiny 2 usan munición por alguna razón!
@ CAD97: este es el tipo de pensamiento que he visto con respecto a este problema. Tener espada con munición infinita, por lo que no hay recarga. Esto simplemente empuja el problema o lo oculta. ¿Qué pasa si introduzco una granada, entonces qué? Las granadas no tienen munición ni disparan, y no deben conocer estos métodos.
1
Estoy con CAD97 en esto. Y crearía una WeaponBuilderque podría construir espadas y armas componiendo un arma de estrategias.
Chris Wohlert
3

Por supuesto, es una solución viable; Es solo una muy mala idea.

El problema no es si tiene esta única instancia donde coloca la recarga en su clase base. El problema es que también necesitas poner el "swing", "shoot", "parry", "knock", "polaco", "desmontar", "afilar" y "reemplazar los clavos del extremo puntiagudo del palo". método en su clase base.

El punto de LSP es que sus algoritmos de nivel superior deben funcionar y tener sentido. Entonces, si tengo un código como este:

if (isEquipped(weapon)) {
   reload();
}

Ahora, si eso arroja una excepción no implementada y hace que su programa se bloquee, entonces es una muy mala idea.

Si su código se ve así,

if (canReload(weapon)) {
   reload();
}
else if (canSharpen(weapon)) {
  sharpen();
}
else if (canPollish(weapon)) {
  polish();
}

entonces su código puede estar repleto de propiedades muy específicas que no tienen nada que ver con la idea abstracta de 'arma'.

Sin embargo, si está implementando un tirador en primera persona y todas sus armas pueden disparar / recargar, excepto ese cuchillo, entonces (en su contexto específico) tiene mucho sentido que la recarga de su cuchillo no haga nada, ya que esa es la excepción y las probabilidades de tener su clase base desordenada con propiedades específicas es baja.

Actualización: intente pensar en el caso / términos abstractos. Por ejemplo, tal vez cada arma tiene una acción de "preparación" que es una recarga para armas y una funda para espadas.

Batavia
fuente
Digamos que tengo un diccionario de armas interno que contiene las acciones para las armas, y cuando el usuario pasa en "Recargar", verifica el diccionario, ej. WeaponActions.containsKey (acción) si es así, toma el objeto asociado con él y hazlo eso. En lugar de una clase de arma con múltiples declaraciones if
Ver edición arriba. Esto es lo que tenía en mente al usar el SP
0

Obviamente, está bien si no crea una subclase con la intención de sustituir una instancia de la clase base, pero si crea una subclase utilizando la clase base como un repositorio conveniente de funcionalidad.

Ahora, si esa es una buena idea o no, es muy discutible, pero si nunca sustituyes la subclase por la clase base, entonces el hecho de que no funcione no es un problema. Puede tener problemas, pero LSP no es el problema en este caso.

gnasher729
fuente
0

El LSP es bueno porque permite que el código de llamada no se preocupe por cómo funciona la clase.

p.ej. Puedo llamar a Weapon.Attack () en todas las armas montadas en mi BattleMech y no preocuparme de que algunas de ellas puedan arrojar una excepción y bloquear mi juego.

Ahora, en su caso, desea ampliar su tipo base con una nueva funcionalidad. Attack () no es un problema, porque la clase Gun puede hacer un seguimiento de su munición y dejar de disparar cuando se agota. Pero Reload () es algo nuevo y no forma parte de ser un arma.

La solución fácil es bajar, no creo que deba preocuparse demasiado por el rendimiento, no lo hará en cada fotograma.

Alternativamente, puede reevaluar su arquitectura y considerar que, en resumen, todas las armas son recargables, y algunas armas simplemente no necesitan recargarse.

Entonces ya no estás extendiendo la clase para armas, o estás violando el LSP.

Pero es problemático a largo plazo porque está obligado a pensar en casos más especiales, Gun.SafteyOn (), Sword.WipeOffBlood (), etc. y si los pone todos en Arma, entonces tiene una clase base generalizada muy complicada que mantiene tener que cambiar

editar: por qué el patrón de estrategia es malo (tm)

No lo es, pero tenga en cuenta la configuración, el rendimiento y el código general.

Tengo que tener alguna configuración en algún lugar que me diga que una pistola puede recargarse. Cuando crea una instancia de un arma, tengo que leer esa configuración y agregar dinámicamente todos los métodos, verificar que no haya nombres duplicados, etc.

Cuando llamo a un método, tengo que recorrer esa lista de acciones y hacer una coincidencia de cadena para ver a qué llamar.

Cuando compilo el código y llamo Weapon.Do ("atack") en lugar de "ataque", no obtendré un error en la compilación.

Puede ser una solución adecuada para algunos problemas, digamos que tiene cientos de armas, todas con diferentes combinaciones de métodos aleatorios, pero pierde muchos de los beneficios de OO y un tipeo fuerte. Realmente no te ahorra nada por abatir

Ewan
fuente
Creo que el SP puede manejar todo eso (ver la edición anterior), el arma tendría SafteyOn()y Swordtendría wipeOffBlood(). Cada arma no es consciente de los otros métodos (y no deberían serlo)
El SP está bien, pero es equivalente a un downcasting sin tipo de seguridad. Creo que estaba respondiendo a una pregunta un poco diferente, que me actualizo
Ewan
2
Por sí solo, el patrón de estrategia no implica la búsqueda dinámica de una estrategia en una lista o diccionario. Es decir, ambos weapon.do("attack")y el tipo seguro weapon.attack.perform()pueden ser ejemplos del patrón de estrategia. Buscar estrategias por nombre solo es necesario al configurar el objeto desde un archivo de configuración, aunque el uso de la reflexión sería igualmente seguro para los tipos.
amon
eso no funcionará en esta situación ya que hay dos acciones separadas de ataque y recarga, que debe vincular a alguna entrada del usuario
Ewan