Dobbiamo convalidare l'utilizzo dell'intero modulo o solo argomenti di metodi pubblici?


9

Ho sentito che si consiglia di convalidare gli argomenti dei metodi pubblici:

La motivazione è comprensibile. Se un modulo verrà utilizzato in modo errato, vogliamo lanciare immediatamente un'eccezione invece di qualsiasi comportamento imprevedibile.

Ciò che mi preoccupa è che argomenti sbagliati non sono l'unico errore che può essere fatto durante l'uso di un modulo. Ecco alcuni scenari di errore in cui è necessario aggiungere la logica di controllo se seguiamo i consigli e non vogliamo l'escalation degli errori:

  • Chiamata in arrivo - argomenti imprevisti
  • Chiamata in arrivo: il modulo si trova in uno stato errato
  • Chiamata esterna: vengono restituiti risultati imprevisti
  • Chiamata esterna - effetti collaterali imprevisti (doppia entrata in un modulo chiamante, interruzione di altri stati di dipendenze)

Ho cercato di prendere in considerazione tutte queste condizioni e scrivere un semplice modulo con un metodo (scusate, ragazzi non C #):

public sealed class Room
{
    private readonly IDoorFactory _doorFactory;
    private bool _entered;
    private IDoor _door;
    public Room(IDoorFactory doorFactory)
    {
        if (doorFactory == null)
            throw new ArgumentNullException("doorFactory");
        _doorFactory = doorFactory;
    }
    public void Open()
    {
        if (_door != null)
            throw new InvalidOperationException("Room is already opened");
        if (_entered)
            throw new InvalidOperationException("Double entry is not allowed");
        _entered = true;
        _door = _doorFactory.Create();
        if (_door == null)
            throw new IncompatibleDependencyException("doorFactory");
        _door.Open();
        _entered = false;
    }
}

Ora è sicuro =)

È abbastanza inquietante. Ma immagina quanto può essere inquietante in un vero modulo con dozzine di metodi, stato complesso e molte chiamate esterne (ciao, amanti dell'iniezione di dipendenza!). Si noti che se si chiama un modulo il cui comportamento può essere ignorato (classe non sigillata in C #), si sta effettuando una chiamata esterna e le conseguenze non sono prevedibili nell'ambito del chiamante.

Riassumendo, qual è la strada giusta e perché? Se puoi scegliere tra le opzioni di seguito, rispondi ad ulteriori domande, per favore.

Controlla l'utilizzo dell'intero modulo. Abbiamo bisogno di unit test? Esistono esempi di tale codice? L'iniezione di dipendenza dovrebbe essere limitata nell'uso (poiché causerà una maggiore logica di controllo)? Non è pratico spostare quei controlli nel tempo di debug (non includere nella versione)?

Controlla solo gli argomenti. Dalla mia esperienza, il controllo degli argomenti - in particolare il controllo null - è il controllo meno efficace, poiché l'errore degli argomenti raramente porta a errori complessi e escalation di errori. La maggior parte delle volte otterrai una NullReferenceExceptionriga successiva. Quindi perché i controlli degli argomenti sono così speciali?

Non controllare l'utilizzo del modulo. È un'opinione piuttosto impopolare, puoi spiegare perché?


I controlli dovrebbero essere effettuati durante l'assegnazione dei campi per garantire che gli invarianti siano mantenuti.
Basilevs,

@Basilevs Interessante ... Viene dall'ideologia dei Contratti di codice o qualcosa di più vecchio? Puoi consigliare qualcosa da leggere (correlato al tuo commento)?
astef,

È una separazione di base delle preoccupazioni. Tutti i casi sono coperti, mentre la duplicazione del codice è minima e le responsabilità sono ben definite.
Basilevs,

@Basilevs Quindi, non controllare affatto il comportamento degli altri moduli ma controlla i propri invarianti di stato. Sembra ragionevole. Ma perché non vedo questa semplice ricevuta nelle relative domande sui controlli degli argomenti?
astef,

Bene, alcuni controlli behvorial sono ancora necessari, ma dovrebbero essere eseguiti solo su valori effettivamente utilizzati, non su quelli che vengono inoltrati altrove. Ad esempio, si fa affidamento sull'implementazione dell'elenco per controllare gli errori OOB, anziché per controllare l'indice nel codice client. Di solito si tratta di guasti al framework di basso livello e non richiedono l'emissione manuale.
Basilevs,

Risposte:


2

TL; DR: convalida il cambio di stato, si basa su [validità dello] stato corrente.

Di seguito considero solo le verifiche abilitate al rilascio. Le asserzioni attive solo per il debug sono una forma di documentazione, che è utile a modo suo ed è fuori portata per questa domanda.

Considera i seguenti principi:

  • Buon senso
  • Fallire velocemente
  • ASCIUTTO
  • SRP

definizioni

  • Componente: un'unità che fornisce API
  • Client: utente dell'API del componente

Stato mutevole

Problema

Nelle lingue imperative, il sintomo dell'errore e la sua causa possono essere separati da ore di sollevamento pesante. La corruzione dello stato può nascondersi e mutare per provocare un fallimento inspiegabile, poiché l'ispezione dello stato corrente non può rivelare il processo completo di corruzione e, quindi, l'origine dell'errore.

Soluzione

Ogni cambiamento di stato deve essere attentamente elaborato e verificato. Un modo per gestire lo stato mutevole è di mantenerlo al minimo. Questo risultato è ottenuto da:

  • sistema di tipi (dichiarazioni const e membri finali)
  • introducendo invarianti
  • verifica ogni cambiamento dello stato del componente tramite API pubbliche

Quando si estende lo stato di un componente, considerare di farlo lasciando che il compilatore imponga l'immutabilità di nuovi dati. Inoltre, applicare ogni ragionevole vincolo di runtime, limitando gli stati potenziali risultanti a un insieme ben definito il più piccolo possibile.

Esempio

// Wrong
class Natural {
    private int number;
    public Natural(int number) {
        this.number = number;
    }
    public int getInt() {
      if (number < 1)
          throw new InvalidOperationException();
      return number;
    }
}

// Right
class Natural {
    private readonly int number;
    /**
     * @param number - positive number
     */
    public Natural(int number) {
      // Going to modify state, verification is required
      if (number < 1)
        throw new ArgumentException("Natural number should be  positive: " + number);
      this.number = number;
    }
    public int getInt() {
      // State is guaranteed by construction and compiler
      return number;
    }
}

Ripetizione e coesione di responsabilità

Problema

Il controllo delle condizioni preliminari e post-condizioni delle operazioni porta alla duplicazione del codice di verifica sia nel client che nel componente. La convalida dell'invocazione del componente spesso costringe il cliente ad assumersi alcune delle responsabilità del componente.

Soluzione

Affidati al componente per eseguire la verifica dello stato quando possibile. I componenti devono fornire un'API che non richiede una verifica dell'utilizzo speciale (verifica degli argomenti o applicazione della sequenza operativa, ad esempio) per mantenere ben definito lo stato del componente. Obbligano a verificare gli argomenti di invocazione dell'API come richiesto, a segnalare gli errori con i mezzi necessari e si sforzano di prevenire la corruzione dello stato.

I clienti devono fare affidamento sui componenti per verificare l'uso della loro API. Non solo si evita la ripetizione, il client non dipende più dai dettagli di implementazione aggiuntivi del componente. Considera framework come un componente. Scrivi il codice di verifica personalizzato solo quando gli invarianti del componente non sono abbastanza rigidi o incapsulano l'eccezione dei componenti come dettaglio dell'implementazione.

Se un'operazione non cambia stato e non è coperta da verifiche del cambio di stato, verificare ogni argomento al livello più profondo possibile.

Esempio

class Store {
  private readonly List<int> slots = new List<int>();
  public void putToSlot(int slot, int data) {
    if (slot < 0 || slot >= slots.Count) // Unnecessary, validated by List, only needed for custom error message
      throw new ArgumentException("data");
    slots[slot] = data;
  }
}

class Natural {
   int _number;
   public Natural(int number) {
       if (number < 1)
          number = 1;  //Wrong: client can't rely on argument verification, additional state uncertainity is introduced.  Right: throw new ArgumentException(number);
       _number = number;
   }
}

Risposta

Quando i principi descritti vengono applicati all'esempio in questione, otteniamo:

public sealed class Room
{
    private bool _entered = false;
    // Do not use lazy instantiation if not absolutely necessary, this introduces additional mutable state
    private readonly IDoor _door;
    public Room(IDoorFactory doorFactory)
    {
        // Rely on system null check
        IDoor door = _doorFactory.Create();
        // Modifying own state, verification is required
        if (door == null)
           throw new ArgumentNullException("Door");
        _door = door;
    }
    public void Enter()
    {
        // Room invariants do not guarantee _entered value. Door state is indirectly a part of our state. Verification is required to prevent second door state change below.
        if (_entered)
           throw new InvalidOperationException("Double entry is not allowed");
        _entered = true;     
        // rely on immutability for _door field to be non-null
        // rely on door implementation to control resulting door state       
        _door.Open();            
    }
}

Sommario

Lo stato del cliente è costituito da valori di campi propri e parti dello stato del componente che non sono coperti dai propri invarianti. La verifica deve essere eseguita solo prima del cambio di stato effettivo di un client.


1

Una classe è responsabile del proprio stato. Quindi convalida nella misura in cui mantiene o mette le cose in uno stato accettabile.

Se un modulo verrà utilizzato in modo errato, vogliamo lanciare immediatamente un'eccezione invece di qualsiasi comportamento imprevedibile.

No, non gettare un'eccezione, invece fornire un comportamento prevedibile. Il corollario di dichiarare la responsabilità è rendere la classe / applicazione tanto tollerante quanto pratica. Ad esempio, passando nulla aCollection.Add()? Basta non aggiungere e continuare. Hai nullinput per la creazione di un oggetto? Crea un oggetto null o un oggetto predefinito. Sopra, doorè già open? E allora, continua. DoorFactoryl'argomento è nullo? Creane uno nuovo. Quando creo un enumho sempre un Undefinedmembro. Faccio uso liberale di Dictionarys e enumsdi definire le cose in modo esplicito; e questo fa molto per offrire comportamenti prevedibili.

(ciao, amanti dell'iniezione di dipendenza!)

Sì, anche se cammino attraverso l'ombra della valle dei parametri, non temerò argomenti. Al precedente utilizzo anche i parametri di default e facoltativi il più possibile.

Tutto quanto sopra consente l'elaborazione interna per andare avanti. In una particolare applicazione ho dozzine di metodi tra più classi con un solo posto in cui viene generata un'eccezione. Anche allora, non è a causa di argomenti nulli o che non ho potuto continuare l'elaborazione è perché il codice ha finito per creare un oggetto "non funzionale" / "null".

modificare

citando il mio commento nella sua interezza. Penso che il design non debba semplicemente "arrendersi" quando si incontra "null". Soprattutto usando un oggetto composito.

Dimentichiamo concetti / ipotesi chiave qui - encapsulation& single responsibility. Non esiste praticamente alcun controllo null dopo il primo livello che interagisce con il client. Il codice è tollerante robusto. Le classi sono progettate con stati predefiniti e quindi funzionano senza essere scritte come se il codice di interazione fosse pieno di bug, roba indesiderata. Un genitore composito non deve raggiungere i livelli figlio per valutare la validità (e, di conseguenza, verificare la presenza di null in tutti gli angoli e le fessure). Il genitore sa cosa significa lo stato predefinito di un bambino

fine modifica


1
Non aggiungere un elemento di raccolta non valido è un comportamento molto imprevedibile.
Basilevs,

1
Se tutte le interfacce saranno progettate in modo così tollerante, un giorno, a causa di un banale errore, i programmi si risveglieranno accidentalmente e distruggeranno l'umanità.
astef,

Dimentichiamo concetti / ipotesi chiave qui - encapsulation& single responsibility. Non c'è praticamente alcun nullcontrollo dopo il primo livello che interagisce con il cliente. Il codice è <strike> tollerante </strike> robusto. Le classi sono progettate con stati predefiniti e quindi funzionano senza essere scritte come se il codice di interazione fosse pieno di bug, roba indesiderata. Un genitore composito non deve raggiungere i livelli figlio per valutare la validità (e, implicitamente, verificare la presenza di nulltutti gli angoli e le fessure). Il genitore sa che cosa significa stato di default di un bambino
radarbob
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.