Progettazione di sistemi estesi utilizzando il principio di sostituzione di Liskov

La capacità di aggiungere nuovi comportamenti senza riscrivere il codice esistente separa le architetture manutenbili da quelle fragili. Il principio di sostituzione di Liskov (LSP) fornisce una base rigorosa per raggiungere questo obiettivo definendo regole precise per il comportamento del subtipo. Quando applicato correttamente, LSP assicura che i nuovi componenti possano essere introdotti con fiducia, preservando la correttezza in tutto il sistema.

Comprendere il principio di sostituzione di Liskov

Barbara Liskov ha introdotto il principio che porta il suo nome in un documento di conferenza del 1987 intitolato "Data Abstraction and Hierarchy". La definizione formale afferma: "Se per ogni oggetto o1 di tipo S c'è un oggetto o2 di tipo T che per tutti i programmi P definiti in termini di T, il comportamento di P è invariato quando o1 è sostituito per o2, allora S è un sottotipo di oggetti di base intermi.

Il principio si estende oltre le semplici firme metodologiche. LSP richiede compatibilità comportamentale: la sottoclasse non deve solo avere gli stessi metodi ma anche onorare le ipotesi che il codice cliente fa sulla classe base. Ciò include precondizioni (cosa deve essere vero prima di chiamare un metodo), postcondizioni (cosa deve essere vero dopo), e invarianti (condizioni che rimangono costanti durante la vita dell'oggetto).

Se si sostiene che un è un ], allora ogni funzione che funziona su un dovrebbe funzionare invariato con un . La violazione classica è il problema di Rectangle-Square, dove un si estende

Le quattro condizioni chiave di LSP

Per garantire la compatibilità del sottotipo comportamentale, LSP impone quattro condizioni specifiche che le sottoclassi devono soddisfare, queste condizioni, derivate dal principio del Design by Contract, forniscono una lista di controllo per la valutazione delle gerarchie di classe.

Le condizioni prestabilite non possono essere rafforzate

Se il metodo di base permette il parametro per essere qualsiasi interi, una sottoclasse che limita a interi positivi rafforza la precondizione. Il codice client scritto contro la classe base può passare un integer negativo e aspettarlo di funzionare, ma la sottoclasse lo scarterà.

Le condizioni postali non possono essere indebolite

Se il metodo di base garantisce un valore di ritorno non nullo, una sottoclasse che a volte restituisce null indebolisce la condizione di post-condizione. I clienti che si affidano al contratto di classe base non mancheranno con un'eccezione nulla di puntatore.

Invarianti devono essere conservati

Gli invarianti sono condizioni che rimangono vere per la vita dell'oggetto. Ad esempio, un ha l'invariante che gli elementi sono sempre ordinati. Se una sottoclasse viola quel invariante (ad esempio, inserendo un elemento fuori dall'ordine), rompe le aspettative del programma.

Il vincolo della storia

Gli oggetti hanno una storia di cambiamenti di stato. Il vincolo di storia afferma che la sottoclasse non deve permettere modifiche statali che la classe base vieta. Ad esempio, se una classe di base non ha metodi di setter, una sottoclasse che aggiunge un setter viola LSP perché il codice client può assumere immutabilità. Il vincolo di storia è spesso trascurato ma è critico quando si tratta di oggetti mutabili in sistemi orientati agli oggetti.

Perché LSP è critico per l'estensione

L'estensione si basa sulla capacità di aggiungere nuovi componenti senza modificare i clienti esistenti. Quando LSP è onorato, il polimorfismo funziona come previsto. Una nuova sottoclasse può essere collegato a un vecchio codice con zero cambiamenti. Questo riduce il rischio di regressione e accelera lo sviluppo. Senza LSP, la gerarchia di classe di base diventa fragile. Gli sviluppatori devono ispezionare ogni sottoclasse per comprendere comportamenti speciali, portando a manutenzione sovraccarico e aumento del potenziale bug.

Considera un sistema che elabora i pagamenti. Una classe di base ] definisce un metodo . I sottoclassi come ] e implementare il metodo. Se tutti i sottoclassi seguono LSP, l'aggiunta di un nuovo è semplice. Ma se una sottoclasse getta un'eccezione quando l'importo supera un limite (a codice di base che sempre eseguirà il codice di base che si applica).

LSP incoraggia anche il design per contratto, che migliora la documentazione e la comunicazione di squadra.Gli sviluppatori possono contare sulle specifiche della classe base senza leggere ogni implementazione di sottoclasse. Ciò è particolarmente prezioso in grandi codebase con molti contributori. Inoltre, LSP supporta la scalabilità del sistema, consentendo ai componenti di essere scambiati per ragioni di performance o funzionalità senza alterare l'architettura generale.

Violazioni LSP comuni e come evitare di loro

Riconoscere le violazioni LSP è essenziale per la scrittura di sistemi manutenbili. Di seguito sono modelli frequenti che spezzano il principio, insieme a strategie per risolverli.

Il problema del rettangolo-quare

Come accennato, modellare un quadrato come una sottoclasse di rettangolo viola LSP perché la larghezza e l'altezza quadrata di vincola la stessa. Un disegno migliore è quello di rendere sia le classi rettangolari che le classi separate quadrate che implementano un'interfaccia condivisa , o di utilizzare un metodo di fabbrica che restituisce oggetti appropriati.

Eccezioni inaspettate

Se il metodo di classe base non dichiara alcuna eccezione, un metodo di sottoclasse che getta un'eccezione controllata viola LSP. Anche gettando un'eccezione non controllata come []] può sorprendere i clienti se la classe di base non lo ha mai fatto.

Metodo Override Restituisce il tipo di bagnante

In lingue come Java e C#, i tipi di ritorno covarianti sono consentiti (un metodo di sottoclasse può restituire un tipo più specifico). Tuttavia, il contrario non è: il ritorno di un tipo più debole o meno specifico rompe il contratto. Ad esempio, se la classe di base restituisce un , una sottoclasse che restituisce un ] viola LSP. Assicurare i tipi di ritorno sono almeno quanto specifico come la classe di base.

Subclass Rimuove il comportamento

A volte una sottoclasse supera un metodo con un corpo vuoto, rimuovendo efficacemente la funzionalità. Se il cliente si affida a quel metodo che ha un effetto, il comportamento cambia. Ad esempio, un che estende un mutabile e sovrascrive per non fare nulla rompe il contratto della classe base.

Rafforzare le condizioni precondizioni

Se la classe base accetta ] per un parametro, una sottoclasse che getta un'eccezione su ] crea una violazione precondizione. Documentando se è consentito e mantenendo tale indennità in tutte le sottoclassi è fondamentale.

Per evitare violazioni, inizia con interfacce che definiscono comportamenti minimi e concentrati. Composizione preferita sull'eredità quando il rapporto "is-a" è discutibile. Scrivere test contrattuali che verificano sia il comportamento di base che quello di sottoclasse, e li esegue in continua integrazione.

Applicare LSP in Progettazione di sistema

La progettazione per LSP richiede un pensiero deliberato sia nell'architettura che nell'implementazione, e qui ci sono linee guida pratiche per incorporare il flusso di lavoro di sviluppo.

Utilizzare contratti astratti

Definire le classi di base o le interfacce che esprimono il comportamento atteso senza applicazione. Includere la documentazione delle condizioni precondizioni, delle condizioni postali e degli invarianti. Nelle lingue che supportano Design by Contract (come Eiffel), è possibile applicare questi contratti. Nella maggior parte delle lingue tradizionali, fare affidamento sulla documentazione e test unitari.

Preferire Composizione sull'eritanza

Quando il rapporto tra due classi non è strettamente "is-a", usa la composizione. Ad esempio, invece di una che si estende [, hanno una classe che contiene una [[FLT: 30] con dimensioni uguali. Questo evita completamente la violazione LSP. La composizione tende anche a produrre sistemi più flessibili che sono più facili da testare.

Scrivere test di contratto

Creare una suite di prova per la classe base che tutte le sottoclassi devono superare. Questi test dovrebbero convalidare che precondizioni, condizioni postali e invarianti tengono. Ad esempio, un test per potrebbe verificare il valore restituito è positivo per le dimensioni date. Qualsiasi sottoclasse deve superare gli stessi test per garantire la conformità LSP. Questa tecnica, spesso chiamata "test sostitutivo", cattura le violazioni presto.

Utilizzare la subtipazione comportamentale

Quando eredite, considerate prima l'aspetto comportamentale. Chiedete: "Se sostituisco un'istanza della classe base con questa sottoclasse, i clienti noteranno alcuna differenza di comportamento?" Se la risposta è sì, ridisegnare l'eredità.

Refactor Quando si trovano le violazioni

Durante le recensioni dei codici o dopo i guasti di prova, rifattore la gerarchia. Estrarre il comportamento comune in una classe di base astratta o interfaccia, e spingere il comportamento specializzato in classi separate. Utilizzare il Metodo di temperatura[]] modello per garantire le sottoclassi seguono un algoritmo coerente, consentendo variazioni in passaggi specifici.

LSP in Lingue di programmazione moderne

Il modo in cui LSP applica varia in base alle differenze di sistemi di digitazione, modelli di eredità e gestione delle eccezioni.

Java e C#

Le violazioni LSP menzionate in precedenza (eccezione indebolimento, precondizione di rafforzamento) sono comuni in Java e C#. Utilizzare l'annunciazione in Java o la parola chiave in C# per evitare le firme accidentali di metodo.

Tipologia

Il sistema di digitazione strutturale di TypeScript rende LSP ancora più critico, poiché la compatibilità del tipo si basa sulla struttura piuttosto che sulla gerarchia nominale, una classe che ha gli stessi metodi ma il comportamento diverso può essere sintetizzabile in modo sintattico ma non comportamentale.

Python

Python è digitato dinamicamente, il che significa che le violazioni LSP diventano evidenti solo a tempo di esecuzione. Senza controlli compilatori, scrivere prove di unità robuste e utilizzare classi di base astratti (ABC) dal modulo []] per definire i metodi richiesti.

Vai.

Un tipo soddisfa un'interfaccia se implementa tutti i metodi. LSP in Go è applicato dal fatto che le interfacce sono piccole e focalizzate. Tuttavia, essere cauti: se due tipi soddisfano la stessa interfaccia ma si comportano in modo diverso, il codice client che aspetta il contratto fallisce.

LSP e altri principi SOLID

LSP non esiste in isolamento, interagisce con gli altri quattro principi SOLID in modi importanti.

Principio di responsabilità individuale (SRP)

SRP aiuta a mantenere le classi focalizzate, che riduce le probabilità di violare LSP. Una classe con una sola responsabilità è più facile da sottoscrivere senza cambiare accidentalmente comportamento. Ad esempio, separare la logica di validazione dall'archiviazione dei dati rende sia la base che le sottoclassi più semplici.

Principio aperto/permesso (OCP)

LSP consente a OCP di estendere il comportamento senza alterare il codice esistente. Se l'SPL viene violato, non è possibile aggiungere in modo sicuro nuove sottoclassi senza cambiare i client, rompendo così il POP. I due principi sono strettamente collegati.

Principio di segregazione dell'interfaccia (ISP)

ISP incoraggia le interfacce grasse ad essere suddivise in quelle più piccole e specifiche, riducendo così la probabilità che una sottoclasse debba implementare metodi irrilevanti, che spesso portano a violazioni LSP (ad esempio, implementazioni vuote o di lancio).

Principio di inversione di dipendenza (DIP)

DIP consiglia a seconda delle astrazioni, non delle concrezioni. Quando si dipende dalle interfacce, LSP assicura che qualsiasi implementazione concreta può essere sostituita liberamente. Senza LSP, lo strato di astrazione diventa inaffidabile, e gli sviluppatori possono dipendere dalle implementazioni direttamente, violando DIP.

Test per la conformità LSP

Tuttavia, metodologie di test sistematici possono aiutare a catturare le violazioni presto. Ecco le strategie per incorporare il test LSP nel flusso di lavoro.

Creare una classe di prova del contratto di base

Scrivere una classe di prova astratta o una suite di prova che esercita il contratto di classe di base. Per ogni condizione e post-condizione, scrivere un test. Ad esempio, se il metodo di classe di base [ su un ] getta un'eccezione quando lo stack è vuoto, includere un test che verifica. Poi, per ogni sottoclasse, eseguire gli stessi test.

Utilizzo di prova basata sulla proprietà

Strumenti come QuickCheck (per Haskell, anche disponibili in altre lingue tramite librerie come [] o []) generano input casuali e verificano che i invarianti si tengono. Per LSP, è possibile esprimere proprietà come: "Per qualsiasi sequenza valida di chiamate metodo, lo stato dopo aver chiamato un metodo sulla sottoclasse corrisponde al comportamento della classe base."

Invariante comportamentale imposizione

In Java, è possibile utilizzare la parola chiave o una libreria come []. In C#, ] o . Questi controlli verificano invarianti e precondizioni durante lo sviluppo, catturando le violazioni in anticipo.

Esempi reali di LSP in azione

Molte librerie e quadri standard si affidano a LSP per funzionare correttamente. Capire questi esempi approfondisce l'apprezzamento per il principio.

Java Collections Framework

[LT:44], ], e `CopyOnWriteArrayList` tutti aderiscono al contratto di interfaccia. Essi sostengono , , , ecc, con la prevista implementazione di nuovi semantici.

Livelli di accesso al database

Quando si implementano repository di database, un'interfaccia di base ] definisce metodi come e ].

Pipeline di elaborazione del flusso

Nella programmazione funzionale, le trasformazioni su flussi (come mappa, filtro, riduzione) sono definite da contratti. Qualsiasi funzione passata a deve essere una pura trasformazione che preserva l’invariante del flusso (ad esempio, non modificando lo stato esterno).

Conclusioni

Il principio di sostituzione di Liskov è più di un concetto teorico, è uno strumento pratico per la progettazione di software estensibili e manutenbili. Garantire che le sottoclassi possono stare in piedi per le loro classi di base senza alterare il comportamento corretto, gli sviluppatori costruiscono sistemi che crescono organicamente con nuove funzionalità.

Per applicare efficacemente l'SP, concentrati sui contratti comportamentali, scrivere test completi e utilizzare la composizione quando l'eredità si sente forzata. Riconoscere le quattro condizioni—precondizioni, postcondizioni, invarianti e vincoli di storia—come controlli per ogni nuova sottoclasse. Con diligenza, LSP diventa una parte naturale del processo di progettazione, portando a architetture resilienti e adattabili.

Per ulteriori informazioni, esplorare il giornale originale di Barbara Liskov ([[]Data Abstraction and Hierarchy]), Robert C. Martin ]] Discussione dei principi SOLID, e Martin Fowler’s ]]] articolo sulla sostitutabilità