risorse | defensive programming

Defensive Programming

John Lakos @ ACCU 2011

Contratti software

Notazione di Hoare:

{pre-condizioni e invarianti} metodo {post-condizioni e invarianti}

Se le pre-condizioni e le invarianti sono soddisfatte, l'esecuzione del metodo garantisce le post-condizioni e le invarianti.

Le post-condizioni definiscono quindi l'effetto dell'esecuzione del metodo nell'ipotesi che le pre-condizioni siano verificate; si parla in questo caso di effetto essenziale del metodo (essential behavior). In realtà l'essential behavior è qualcosa di più specifico delle post-condizioni, in quanto tiene conto anche di altri fattori, come ad esempio le garanzie circa il costo computazionale del metodo; per tale ragione, la verifica dell'essential behavior è in generale più complicata rispetto alla verifica delle post-condizioni. Se le pre-condizioni di un metodo non sono soddisfatte, l'effetto dell'esecuzione del metodo è per definizione non definita (undefined behavior).

Di norma essential e undefined behavior non sono complementari; esiste infatti una zona intermedia, denominata unspecified behavior che separa le due aree:

            +----------------------------+
            | +------------------------+ |
            | | +--------------------+ |<--- defined but
            | | |                    | | |   not essential behavior
            | | | essential behavior | | |
            | | |                    | | |
            | | +--------------------+ | |
            | |                        | |
            | |  unspecified behavior  | |
            | |                        | |
            | +------------------------+ |
            |                            |
            |     undefined behavior     |
            |                            |
            +----------------------------+

Un esempio di undefined behavior si riscontra nel metodo std::vector::front, che ha come pre-condizione il fatto che il vettore sia non vuoto: chiamare il metodo front su un vettore vuoto è perciò undefined behavior. Un esempio di unspecified behavior è invece il seguente:

 // if stream is not valid on entry, print does nothing
 std::ostream& print(std::ostream& stream, const std::vector<int>& values);

print è un metodo senza pre-condizioni, dunque non contempla undefined behavior. Tuttavia non viene specificato cosa accade se l'oggetto stream diventa invalido durante l'esecuzione del metodo: questo è un caso di unspecified behavior.

Programmazione difensiva

Scopo del defensive programming è assicurare che le pre-condizioni siano soddisfatte; la verifica viene effettuata all'inizio di metodo. Per ovvie ragioni di praticità, le invarianti di classe vengono di norma verificate solo nel distruttore.

La pratica del defensive programming richiede la scrittura di codice ridondante, che introduce anche una penalizzazione in termini di efficienza. Secondo la definizione di defensive programming di Lakos:

redundand code that provides runtime checks to detect and report (but not handle or hide) defects in software

Da cosa ci si vuol difendere? Dagli errori nelle librerie di terze parti? No, in questi casi si chiede al fornitore di correggere l'errore. Dagli errori nel codice di nostra proprietà (post-condizioni)? No, questa attività è di pertinenza dei test. Ci si difende dal cattivo uso che terze parti possono fare del nostro codice.

Una domanda lecita che ci si può porre giunti a questo punto è: se le pre-condizioni non sono verificate, è necessario comunque garantire le invarianti? La risposta è no, non ha senso; il più delle volte ci si troverà infatti costretti a compiere scelte del tutto arbitrarie, il cui effetto finale sarà nascondere un errore di programmazione. Si consideri il caso:

 std::size_type length = std::strlen(0);

Cosa ci si dovrebbe aspettare come valore di ritorno? Zero?

 std::size_type std::strlen(const char* s) {
   if (!s)
     return 0;
   ...
 }

Nell'ottica dello sviluppatore, accettare un rilassamento dei vincoli posti dal contratto significa dover scrivere più codice, ed una conseguente diminuzione dell'efficienza. In questo caso si parla di Wide contracts. Restringere i termini del contratto porta invece a codice meno prolisso e più efficiente:

 // behavior is undefined unless s is not 0
 std::size_type std::strlen(const char* s) {
   assert(!s);
   ...
 }

Si noti come i termini del contratto – le pre-condizioni – siano citate esplicitamente, e di conseguenza il codice di gestione del caso limite è stato sostituito da una asserzione, che in produzione avrà effetto nullo. Si parla in questo caso di Narrow contract.

Un aspetto degno di nota è la formulazione della pre-condizione, ovvero behavior is undefined unless…, il cui scopo è circoscrivere l'effetto della funzione all'interno del defined but not essential behavior; la forma behavior is defined if… comprende, oltre all'essential behavior, anche l'unspecified behavior, cosa non desiderabile per quanto visto in precedenza. In altre parole, se le pre-condizioni non sono rispettate, l'esito della chiamata non è definito. L'implicazione ha un risvolto interessante perché, secondo la logica proposizionale, se le pre-condizioni sono soddisfatte, l'effetto non è detto che sia definito.

Un'importante conseguenza delle considerazioni fin qui fatte è che una funzione che per qualunque ragione non riesce a soddisfare il suo contratto non deve terminare normalmente. D'altra parte l'uscita forzata del programma non sempre è accettabile. Le alternative più utilizzate prevedono il sollevamento di un'eccezione o la sospensione temporanea (sleep) nell'attesa del collegamento di un debugger.

Un ultimo aspetto da considerare riguarda l'efficienza del codice preposto alla verifica delle precondizioni. Lakos propone la classificazione delle assert in funzione del peso computazionale richiesto per la verifica, per poter calibrare – per esempio per mezzo di macro – il livello di inefficienza rispetto alla sistematicità delle verifiche. Lui suggerisce le seguenti classi di costo:

macrocosto CPUnote
ASSERT_OPT[IMIZE]<5%sempre attive
ASSERT[_REGULAR]5%÷20%
ASSERT_SAFE>20%da usare nelle funzioni inline
ASSERT_SAFE2>100%O(verifica) > O(codice)

Negative testing

È il codice di test preposto alla verifica della correttezza delle ASSERT. Deve attivarsi se e solo se le asserzioni sono attivate, e deve accertarsi che le segnalazioni arrivino dal componente sotto test e non da un suo subordinato.

Riferimenti

Lakos, John. "Defensive programming done right". Skills Matters: ACCU 2011: John Lakos on Defensive Programming Done Right. <http://skillsmatter.com/podcast/home/defensive-programming-done-right>. Visitato il 21 Ottobre 2011.

Pagina modificata l'8/11/2011