Va mai bene violare l'LSP?


10

Sto seguendo questa domanda , ma sto spostando la mia attenzione dal codice a un principio.

Dalla mia comprensione del principio di sostituzione di Liskov (LSP), qualunque sia il metodo nella mia classe di base, devono essere implementati nella mia sottoclasse e, secondo questa pagina, se si ignora un metodo nella classe di base e non fa nulla o si genera un eccezione, stai violando il principio.

Ora, il mio problema può essere riassunto in questo modo: ho un abstract Weapon classe due classi Sworde Reloadable. Se Reloadablecontiene uno specifico method, chiamato Reload(), dovrei effettuare il downcast per accedervi methode, idealmente, dovresti evitarlo.

Ho quindi pensato di usare il Strategy Pattern. In questo modo ogni arma era a conoscenza solo delle azioni che è in grado di compiere, quindi ad esempio Reloadableun'arma può ovviamente ricaricare, ma Swordnon può e non è nemmeno a conoscenza di a Reload class/method. Come ho affermato nel mio post Stack Overflow, non devo effettuare il downcast e posso mantenere una List<Weapon>raccolta.

Su un altro forum , la prima risposta ha suggerito di consentire Sworddi essere consapevoli Reload, semplicemente non fare nulla. La stessa risposta è stata data nella pagina Stack Overflow che ho collegato sopra.

Non capisco bene perché. Perché violare il principio e consentire a Sword di essere consapevole Reloade lasciarlo in bianco? Come ho detto nel mio post Stack Overflow, l'SP ha praticamente risolto i miei problemi.

Perché non è una soluzione praticabile?

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) ;
    }

}

Interfaccia di attacco e implementazione:

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
    }
}

2
Si può fare class Weapon { bool supportsReload(); void reload(); }. I clienti testerebbero se supportati prima di ricaricare. reloadè definito contrattualmente per lanciare iff !supportsReload(). Ciò aderisce all'LSP se le classi guidate aderiscono al protocollo che ho appena delineato.
usr

3
Sia che si lasci reload()vuoto o che standardActionsnon contenga un'azione di ricarica è solo un meccanismo diverso. Non c'è alcuna differenza fondamentale. Puoi fare entrambe le cose. => La tua soluzione è praticabile (quale era la tua domanda) .; Sword non ha bisogno di sapere di ricaricare se Arma contiene un'implementazione predefinita vuota.
usr

27
Ho scritto una serie di articoli che esplorano una varietà di problemi con varie tecniche per risolvere questo problema. La conclusione: non cercare di catturare le regole del tuo gioco nel sistema di tipi di lingua . Cattura le regole del gioco in oggetti che rappresentano e applicano regole a livello della logica di gioco, non a livello del sistema di tipi . Non c'è motivo di credere che qualunque tipo di sistema tu stia usando è abbastanza sofisticato da rappresentare la tua logica di gioco. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert

2
@EricLippert - Grazie per il tuo link. Mi sono imbattuto in questo blog così tante volte, ma alcuni dei punti sollevati non capisco bene, ma non è colpa tua. Sto imparando OOP da solo e mi sono imbattuto in principi SOLIDI. La prima volta che mi sono imbattuto nel tuo blog, non l'ho capito per niente, ma ho imparato un po 'di più e ho letto di nuovo il tuo blog, e lentamente ho iniziato a capire parti di ciò che veniva detto. Un giorno, comprenderò appieno tutto in quella serie. Spero: D

6
@SR "se non fa nulla o genera un'eccezione, sei in violazione" - Penso che tu abbia letto male il messaggio di quell'articolo. Il problema non era direttamente che setAltitude non faceva nulla, era che non riusciva a soddisfare la postcondizione "l'uccello verrà disegnato all'altitudine impostata". Se definisci il postcondizionamento di "ricaricare" come "se fossero disponibili munizioni sufficienti, l'arma può attaccare di nuovo", non fare nulla è un'implementazione perfettamente valida per un'arma che non usa munizioni.
Sebastian Redl,

Risposte:


16

LSP è preoccupato per il sottotipo e il polimorfismo. Non tutto il codice utilizza effettivamente queste funzionalità, nel qual caso l'LSP è irrilevante. Due casi d'uso comuni di costrutti del linguaggio ereditario che non sono un sottotipo sono:

  • L'ereditarietà era utilizzata per ereditare l'implementazione di una classe base, ma non la sua interfaccia. In quasi tutti i casi dovrebbe essere preferita la composizione. Linguaggi come Java non possono separare l'eredità dell'implementazione e dell'interfaccia, ma ad esempio il C ++ ha privateereditarietà.

  • Ereditarietà utilizzata per modellare un tipo / unione di somma, ad esempio: a Baseè o CaseAo CaseB. Il tipo di base non dichiara alcuna interfaccia rilevante. Per usare le sue istanze, devi lanciarle sul tipo di calcestruzzo corretto. Il casting può essere eseguito in modo sicuro e non è un problema. Sfortunatamente, molti linguaggi OOP non sono in grado di limitare i sottotipi della classe base ai soli sottotipi previsti. Se il codice esterno può creare un CaseC, allora il codice presuppone che a Basepossa essere solo un CaseAo CaseBsia errato. Scala può farlo in sicurezza con il suo case classconcetto. In Java, questo può essere modellato quando si Basetratta di una classe astratta con un costruttore privato e le classi statiche nidificate ereditano quindi dalla base.

Alcuni concetti come le gerarchie concettuali di oggetti del mondo reale si associano molto male a modelli orientati agli oggetti. Pensieri come “pistola A è un'arma, e una spada è un'arma, quindi avrò una Weaponclasse base da cui Gune Swordereditano” da indurre in errore: real-word is-a rapporti non implicano un rapporto del genere nel nostro modello. Un problema correlato è che gli oggetti possono appartenere a più gerarchie concettuali o possono cambiare la loro affiliazione gerarchica durante il runtime, che la maggior parte delle lingue non può modellare poiché l'ereditarietà è di solito per classe non per oggetto e definita in fase di progettazione non di runtime.

Quando si progettano modelli OOP non dovremmo pensare alla gerarchia o al modo in cui una classe “estende” un'altra. Una classe di base non è un luogo per fattorizzare le parti comuni di più classi. Invece, pensa a come verranno utilizzati i tuoi oggetti, ovvero al tipo di comportamento di cui hanno bisogno gli utenti di questi oggetti.

Qui, gli utenti potrebbero aver bisogno di attack()armi e forse di reload()loro. Se vogliamo creare una gerarchia di tipi, entrambi questi metodi devono essere nel tipo base, sebbene le armi non ricaricabili possano ignorare quel metodo e non fare nulla quando vengono chiamate. Quindi la classe base non contiene le parti comuni, ma l'interfaccia combinata di tutte le sottoclassi. Le sottoclassi non differiscono nella loro interfaccia, ma solo nella loro implementazione di questa interfaccia.

Non è necessario creare una gerarchia. I due tipi Gune Swordpossono essere completamente indipendenti. Considerando che solo una Gunlattina fire()e reload()una Swordmaggio strike(). Se è necessario gestire questi oggetti polimorficamente, è possibile utilizzare il modello di adattatore per acquisire gli aspetti rilevanti. In Java 8 questo è possibile piuttosto convenientemente con interfacce funzionali e riferimenti lambda / metodo. Ad esempio potresti avere una Attackstrategia per la quale fornisci myGun::fireo () -> mySword.strike().

Infine, a volte è sensato evitare qualsiasi sottoclasse, ma modellare tutti gli oggetti attraverso un singolo tipo. Ciò è particolarmente rilevante nei giochi perché molti oggetti di gioco non si adattano perfettamente a nessuna gerarchia e possono avere molte capacità diverse. Ad esempio, un gioco di ruolo può avere un oggetto che è sia un oggetto missione, che migliora le tue statistiche con +2 di forza quando equipaggiato, ha una probabilità del 20% di ignorare qualsiasi danno ricevuto e fornisce un attacco in mischia. O forse una spada ricaricabile perché è * magia *. Chissà cosa richiede la storia.

Invece di cercare di capire una gerarchia di classi per quel casino, è meglio avere una classe che fornisce slot per varie funzionalità. Questi slot possono essere modificati in fase di esecuzione. Ogni slot sarebbe una strategia / callback come OnDamageReceivedo Attack. Con le armi, noi possiamo avere MeleeAttack, RangedAttacke Reloadgli slot. Questi slot possono essere vuoti, nel qual caso l'oggetto non fornisce questa funzionalità. Gli slot sono poi chiamati condizionale: if (item.attack != null) item.attack.perform().


Un po 'come la SP in un certo senso. Perché lo slot deve svuotare? Se il dizionario non contiene l'azione, semplicemente non fare nulla

@SR Non importa se uno slot è vuoto o non esiste e dipende dal meccanismo utilizzato per implementare questi slot. Ho scritto questa risposta con le ipotesi di un linguaggio abbastanza statico in cui gli slot sono campi di istanza ed esistono sempre (ovvero il normale design di classe in Java). Se scegli un modello più dinamico in cui gli slot sono voci in un dizionario (come usare un HashMap in Java o un normale oggetto Python), gli slot non devono esistere. Si noti che approcci più dinamici rinunciano a molta sicurezza del tipo, che di solito non è desiderabile.
Amon,

Sono d'accordo che gli oggetti del mondo reale non modellano bene. Se capisco il tuo post, il tuo detto che posso usare il modello di strategia?

2
@SR Sì, il modello di strategia in qualche forma è probabilmente un approccio sensato. Confronta anche il tipo di oggetto Tipo correlato: gameprogrammingpatterns.com/type-object.html
amon

3

Perché avere una strategia per attacknon è sufficiente per le tue esigenze. Certo, ti consente di sottrarre le azioni che l'oggetto può fare, ma cosa succede quando devi conoscere il raggio dell'arma? O la capacità delle munizioni? O che tipo di munizioni ci vuole? Sei tornato al downcasting per capirlo. E avere quel livello di flessibilità renderà l'interfaccia utente un po 'più difficile da implementare, dal momento che dovrà avere un modello di strategia simile per gestire tutte le funzionalità.

Detto questo, non sono particolarmente d'accordo con le risposte alle tue altre domande. Avere swordereditato da weaponOO è orribile e ingenuo che porta invariabilmente a metodi no-op o controlli di tipo sparsi sul codice.

Ma alla radice della questione, nessuna soluzione è sbagliata . Puoi utilizzare entrambe le soluzioni per creare un gioco funzionante e divertente da giocare. Ognuno viene con il proprio set di compromessi, proprio come qualsiasi soluzione tu scelga.


Penso che sia perfetto Posso usare l'SP, ma sono dei compromessi, devo solo essere consapevole di loro. Vedi la mia modifica, per quello che ho in mente.

1
Fwiw: una spada ha munizioni infinite: puoi continuare a usarla senza leggere per sempre; ricaricare non fa nulla perché all'inizio hai un uso infinito; un raggio di uno / mischia: è un'arma da mischia. Non è impossibile pensare a tutte le statistiche / azioni in un modo che funzioni sia in mischia che a distanza. Tuttavia, quando sono cresciuto, uso sempre meno eredità a favore di interfacce, competizione e qualunque sia il nome per usare una singola Weaponclasse con un'istanza di spada e pistola.
CAD97,

Fwiw in Destiny 2 spade usano munizioni per qualche motivo!

@ CAD97 - Questo è il tipo di pensiero che ho visto riguardo a questo problema. Avere una spada con munizioni infinite, quindi non ricaricare. Questo semplicemente aggira il problema o lo nasconde. E se introduco una granata, e allora? Le granate non hanno munizioni o spari e non dovrebbero essere a conoscenza di tali metodi.

1
Sono con CAD97 su questo. E creerebbe un WeaponBuilderche potrebbe costruire spade e pistole componendo un'arma di strategie.
Chris Wohlert,

3

Naturalmente è una soluzione praticabile; è solo una pessima idea.

Il problema non è se hai questa singola istanza in cui metti ricaricare la tua classe base. Il problema è che devi anche mettere "swing", "shoot" "parry", "knock", "polish", "disassemble", "sharpen" e "sostituire i chiodi dell'estremità appuntita del club" metodo sulla tua classe base.

Il punto di LSP è che i tuoi algoritmi di livello superiore devono funzionare e avere un senso. Quindi se ho un codice come questo:

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

Ora, se ciò genera un'eccezione non implementata e causa l'arresto anomalo del programma, allora è una pessima idea.

Se il tuo codice è simile al seguente,

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

allora il tuo codice può essere ingombro di proprietà molto specifiche che non hanno nulla a che fare con l'idea astratta di "arma".

Tuttavia, se stai implementando uno sparatutto in prima persona e tutte le tue armi possono sparare / ricaricare tranne quel coltello allora (nel tuo contesto specifico) ha molto senso che il ricaricare il tuo coltello non faccia nulla poiché questa è l'eccezione e le probabilità di avere la classe di base ingombra di proprietà specifiche è bassa.

Aggiornamento: prova a pensare al caso / ai termini astratti. Ad esempio, forse ogni arma ha un'azione di "preparazione" che è una ricarica per pistole e una guaina per spade.


Diciamo che ho un dizionario di armi interno che contiene le azioni per le armi, e quando l'utente passa in "Ricarica" ​​controlla il dizionario, ad es. esso. Piuttosto che una classe di armi con più dichiarazioni if

Vedi modifica sopra. Questo è ciò che avevo in mente durante l'utilizzo di SP

0

Ovviamente va bene se non si crea una sottoclasse con l'intento di sostituire un'istanza della classe base, ma se si crea una sottoclasse usando la classe base come comodo repository di funzionalità.

Ora, se questa è una buona idea o meno è molto discutibile, ma se non si sostituisce mai la sottoclasse alla base, allora il fatto che non funzioni non è un problema. Potresti avere problemi, ma LSP non è il problema in questo caso.


0

L'LSP è buono perché consente al codice chiamante di non preoccuparsi di come funziona la classe.

per esempio. Posso chiamare Weapon.Attack () su tutte le armi montate sul mio BattleMech e non preoccuparmi che alcuni di loro possano generare un'eccezione e bloccare il mio gioco.

Ora nel tuo caso vuoi estendere il tuo tipo di base con nuove funzionalità. Attack () non è un problema, perché la classe Gun può tenere traccia delle sue munizioni e smettere di sparare quando si esaurisce. Ma Reload () è qualcosa di nuovo e non fa parte dell'essere un'arma.

La soluzione semplice è il downcast, non penso che tu debba preoccuparti eccessivamente delle prestazioni, non lo farai in ogni frame.

In alternativa puoi rivalutare la tua architettura e considerare che in astratto tutte le armi sono ricaricabili e alcune armi non hanno mai bisogno di essere ricaricate.

Quindi non stai più estendendo la classe per le pistole o violando l'LSP.

Ma è problematico a lungo termine perché sei tenuto a pensare a casi più speciali, Gun.SafteyOn (), Sword.WipeOffBlood () ecc. E se li metti tutti in Arma, allora hai una classe base generalizzata super complicata che tieni dover cambiare.

modifica: perché il modello di strategia è Bad (tm)

Non lo è, ma considera l'installazione, le prestazioni e il codice generale.

Devo avere qualche configurazione da qualche parte che mi dice che una pistola può ricaricare. Quando istanzio un'arma, devo leggere quella configurazione e aggiungere dinamicamente tutti i metodi, controllare che non ci siano nomi duplicati, ecc.

Quando chiamo un metodo, devo scorrere quell'elenco di azioni ed eseguire una corrispondenza di stringa per vedere quale chiamare.

Quando compilo il codice e chiamo Weapon.Do ("atack") invece di "attack" non riceverò un errore durante la compilazione.

Può essere una soluzione adatta per alcuni problemi, ad esempio se hai centinaia di armi tutte con diverse combinazioni di metodi casuali, ma perdi molti dei vantaggi di OO e digitazione forte. In realtà non ti risparmia nulla sul downcasting


Penso che l'SP possa gestire tutto ciò (vedi modifica sopra), la pistola avrebbe SafteyOn()e Swordavrebbe dovuto wipeOffBlood(). Ogni arma non è a conoscenza degli altri metodi (e non dovrebbero esserlo)

l'SP va bene, ma equivale al downcast senza sicurezza del tipo. Immagino che stavo rispondendo a una domanda diversa, fammi aggiornare
Ewan il

2
Di per sé il modello di strategia non implica la ricerca dinamica di una strategia in un elenco o dizionario. Vale a dire entrambi weapon.do("attack")e il tipo sicuro weapon.attack.perform()possono essere esempi del modello di strategia. La ricerca di strategie per nome è necessaria solo quando si configura l'oggetto da un file di configurazione, sebbene l'utilizzo di reflection sia ugualmente sicuro per i tipi.
Amon,

che non funzioneranno in questa situazione in quanto vi sono due azioni distinte: attacco e ricarica, che è necessario associare ad alcuni input dell'utente
Ewan
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.