Come allentare i contratti di input per ereditarietà?

4

In base alla wiki di LSP :

Substitutability is a principle in object-oriented programming stating that, in a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S (i.e. an object of type T may be substituted with any object of a subtype S) without altering any of the desirable properties of T (correctness, task performed, etc.).

...

These are detailed in a terminology resembling that of design by contract methodology, leading to some restrictions on how contracts can interact with inheritance:

  • Preconditions cannot be strengthened in a subtype.
  • Postconditions cannot be weakened in a subtype.
  • Invariants of the supertype must be preserved in a subtype.

e questo testo sui contratti :

If a function in a derived class overrides a function in its super class, then only one of the in contracts of the function and its base functions must be satisfied. Overriding functions then becomes a process of loosening the in contracts.

A function without an in contract means that any values of the function parameters are allowed. This implies that if any function in an inheritance hierarchy has no in contract, then in contracts on functions overriding it have no useful effect.

Conversely, all of the out contracts need to be satisfied, so overriding functions becomes a processes of tightening the out contracts.

puoi allentare le precondizioni in un sottotipo, ma le istanze dell'antenato devono essere sostituibili con le istanze del sottotipo.

Mi stavo chiedendo come sia possibile allentare le precondizioni mantenendo lo stesso comportamento? Per esempio, se scrivo un test unitario per la convalida degli argomenti con un metodo, quindi allentare le precondizioni significa che il test unitario fallirà per le istanze della sottoclasse. Quindi allentando una precondizione posso violare LSP.

class T {
    aMethod(x){
        assert(x !== "invalid");
        const y = this.doSomething(x);
        const z = this.doAnotherThing(y);
        return z;
    },
    doSomething(x){
        // ...
        return y;
    },
    doAnotherThing(y){
        // ...
        return z;
    }
}

class S extends T {
    aMethod(x){
        // loosening preconditions by removing the assertion
        const y = this.doSomething(x);
        const z = this.doAnotherThing(y);
        return z;
    }
}

.

class TestCase {
    testInputValidation(C){
        expect(function (){
            const o = new C();
            o.aMethod("invalid");
        }).toThrow();
    }
}

var tc = new TestCase();
tc.testInputValidation(T); // passes
tc.testInputValidation(S); // fails because I loosened the contract

Forse non capisco LSP e contratti, non lo so. Puoi scrivere un esempio (preferibilmente non fittizio) che soddisfi sia la sostituzione che la precondizione?

Conclusione:

Penso che la maggior parte delle descrizioni LSP manchi il punto. La vera domanda è perché abbiamo bisogno di LSP per via ereditaria? Violare LSP porterà a errori imprevisti, perché non saremo in grado di utilizzare istanze di sottoclassi in cui abbiamo usato istanze della classe base. Le istanze di sottoclasse passano il controllo del tipo, quindi avremo degli errori relativi al loro comportamento, che è relativamente difficile da eseguire il debug. Quindi LSP funge da misura preventiva.

L'esempio non era il migliore, voglio dire che non aveva molto senso, perché solo il contratto interno era stato rimosso / allentato. Per fare questo lavoro avrei dovuto scrivere qualcosa del genere:

class S extends T {
    aMethod(x){
        // loosening preconditions by removing the assertion
        const y = this.doSomething(x);
        const z = this.doAnotherThing(y);
        return z;
    },
    doSomething(x){
        if (x == "invalid")
            x = transformToValid(x);
        return super.doSomething(x);
    }
}

Per rispondere alla domanda dal mio punto di vista. Stavo testando l'in-contract della classe base e quel contratto è stato allentato nella sottoclasse, quindi è naturale che il test non abbia funzionato per la sottoclasse. Non dovremmo testare le sottoclassi per gli stessi contratti mentre testiamo la classe base, se testiamo per i contratti. D'altra parte, dobbiamo testare le sottoclassi per gli input che superano la classe base in-contract e le uscite per questi input devono superare l'out-contract della classe base. Applicando LSP, quest'ultimo è assicurato, poiché possiamo solo rafforzare l'out-contract in sottoclassi. Quindi è possibile riutilizzare determinati test per sottoclassi. Dobbiamo scrivere nuovi test solo per la parte allentata del contratto interno, che non fa parte del contratto esterno della classe base. A mio avviso, assert(x !== "invalid"); è un in-contract nella classe base. L'utilizzo dei contratti può essere utile con qualsiasi modifica del codice, in quanto è possibile leggere gli input validi da questi contratti e, se si rafforza una condizione preliminare, allora si saprà che è necessario verificare ogni utilizzo del metodo effettivo, poiché le modifiche possono interromperli . Se si allenta una precondizione, allora saprete che queste modifiche non dovrebbero rompere il codice esistente eccetto se avete sottoclassi che sovrascrivono il metodo attuale. Quindi è meglio rendere espliciti questi contratti nel codice. Grazie per tutte le tue risposte! Ho dato i punti a NickL, perché ha aiutato di più a capire la rilevanza di LSP e dei contratti.

    
posta inf3rno 07.10.2017 - 21:20
fonte

3 risposte

5

Rimuovendo l'affermazione hai effettivamente cambiato la post-condizione. Come puoi vedere nel tuo test, controlli se la percentuale di post-condominio .toThrow() è valida. Cosa dice la funzione: precondizione: dato un input di "invalid", postcondition: lancia l'assertionerror.

Potresti indebolire la precondizione gettando sempre un errore di asserzione. Quello che stai facendo ora sta indebolendo la post-condizione.

Il motivo per cui è possibile considerare l'errore di asserzione come post-condizione è che questo caso è dovuto al fatto che si prevede che sia l'output. Quando si verifica una funzione, si utilizzano dati che soddisfano la condizione preliminare, chiamare la funzione e verificare se la post-condizione è valida per il risultato. Puoi indebolire la precondizione, ma dovresti comunque assicurarti che la post-condizione sia valida (è consentito che sia più strong)

Ecco un esempio scritto come Hoare triple

{ x >= 2 } x := x + 1 { x > 2}

Il contratto per questa funzione potrebbe essere dato un input maggiore o uguale a 2, il risultato sarà maggiore di 2

ora con precondizione più debole:

{ x >= 1 } x := x + 2 { x > 2}

Oppure, potrei interpretare questo cambiamento come post-condizione più strong (che è anche permesso):

{ x >= 2 } x := x + 2 { x > 3}

In entrambi i casi, il contratto della funzione originale è ancora valido. L'intero punto di LSP è che, indipendentemente dal tipo con cui lo si sostituisce, si almeno si ottiene il comportamento di ciò che afferma il contratto. Il mio passaggio al corpo della funzione potrebbe essere interpretato come indebolimento della condizione preliminare o rafforzamento della post-condizione.

Non confondere questo con il comportamento della funzione. Sì, il comportamento verrà modificato e così anche l'output in questo particolare esempio. La prima funzione restituirebbe f(2) = 3 , e la seconda funzione restituirebbe f(2) = 4 . Tuttavia, ricorda che abbiamo definito il contratto come il risultato sarà maggiore di 2 . Non abbiamo definito l'output esatto.

Quando parliamo del rafforzamento dei tipi, intendiamo che un tipo più specifico è più strong di un tipo più astratto. Object è più debole di Number , Double è più strong di Number .

Le precondizioni sono ipotesi che sei autorizzato a fare. L'assunto > = 1 contiene più elementi di AND che contiene gli elementi di > = 2, quindi è più debole. L''errore' generato da assert verifica solo l'ipotesi, poiché la funzione fornisce solo un output valido basato sull'assunzione.

    
risposta data 09.10.2017 - 10:42
fonte
3

Ciò che quei testi di LSP e contratti effettivamente dicono è:

For all valid inputs to aMethod, the implementation in S must behave the same as the implementation in T. When calling o.aMethod(x), the caller should not have to care if o is an object of type T or type S.

Nota che sto parlando solo di input * validi * a aMethod . Se "invalid" è considerato un input valido per T::aMethod (con il risultato definito di generare un'eccezione), il metodo della classe base ha il contratto più debole possibile e non vi è alcun indebolimento che la classe derivata possa eseguire.
Se "invalid" è non considerato come input valido (e l'asserzione esiste solo come controllo di integrità che il chiamante obbedisce alle precondizioni), la classe derivata può definire un comportamento ragionevole per "invalid" . Questo è ciò che si intende per indebolire la precondizione, perché un insieme più ampio di valori è accettabile per la funzione.

Ciò significa che i test negativi contro le precondizioni della classe base (ossia i test che verificano che i valori che non soddisfano le condizioni preliminari non sono accettati) non sono necessari per mostrare lo stesso risultato quando vengono eseguiti contro una classe derivata.

    
risposta data 08.10.2017 - 11:26
fonte
2

Sembra che tu sia confuso riguardo al significato esatto della precondizione. Una precondizione è un concetto astratto usato nel ragionamento formale sul codice. Non è una chiamata assert() . In effetti non è necessario che compaia nel codice.

La precondizione specifica l'insieme di circostanze in cui esiste un significato definito per un oggetto. Se la precondizione non mantiene il significato formale è undefined . La definizione LSP inizia con Let phi(x) be a property provable about objects x of type T . Ciò esclude implicitamente qualsiasi circostanza che violi la precondizione di T, perché nulla può essere provato a partire da undefined .

Di solito non è del tutto chiaro quale sia una precondizione ragionevole per un dato pezzo di codice. L'autore probabilmente ha un'idea più o meno sfocata delle circostanze in cui il codice dovrebbe avere un significato definito. Ma la maggior parte delle volte questa idea non è esplicita nel codice (proprio come nel tuo esempio). assert() potrebbe essere usato per esprimere la precondizione, ma altrettanto bene potrebbe specificare il comportamento per quell'input (anche se considererei quello stile negativo). Senza un elemento esplicito del linguaggio per le precondizioni come in Eiffel o una definizione esplicita della condizione preliminare nella documentazione c'è sempre incertezza e spazio per la discussione.

Il tuo codice di esempio è confuso sull'esistenza di una condizione preliminare. Se assert(x !== "invalid") è inteso come precondizione, qualsiasi test per quel comportamento è privo di significato / indefinito / proibito. Se l'affermazione non è intesa come condizione preliminare, la condizione preliminare è true per tutti gli input. Quale è già la precondizione più debole possibile.

    
risposta data 07.10.2017 - 23:36
fonte

Leggi altre domande sui tag