Articoli approfonditi sulla tecnologia che plasma il futuro.

Il gap tra specifica e implementazione è dove vivono i bug

La maggior parte dei bug nasce nel divario tra specifica e codice. Scopri come le specifiche formali riducono questo gap.

Una scogliera di progetti si affaccia su una scogliera di circuiti, con i bug nascosti nel vuoto tra le due

Ogni tanto, nei circoli di teoria dei linguaggi di programmazione, riemerge un'affermazione: 'una specifica sufficientemente dettagliata è indistinguibile dal codice.' L'implicazione è provocatoria: il confine tra descrivere cosa dovrebbe fare un programma e farlo davvero è più sottile di quanto pensiamo. Se la specifica è abbastanza precisa da eliminare ogni ambiguità, di fatto hai già scritto il programma.

Non è solo filosofia. Ha implicazioni pratiche su come costruiamo il software, sul ruolo che giocano le specifiche e sul perché la maggior parte dei bug non sono errori di codifica, ma lacune nella specifica. Capire dove finiscono le specifiche e dove iniziano le implementazioni cambia il modo in cui pensi a test, tipi e correttezza.

Da dove nascono davvero i bug

Chiedi a uno sviluppatore da dove vengono i bug e probabilmente risponderà 'dal codice'. Ma gli studi sui difetti del software dicono altro: la maggior parte dei bug nasce nel divario tra intenzione e implementazione. Il codice fa esattamente ciò che lo sviluppatore gli ha detto di fare. Solo che gli ha detto di fare la cosa sbagliata, perché la specifica, formale o informale che fosse, era ambigua, incompleta o fraintesa.

Un esempio classico: una specifica dice 'il sistema deve gestire le richieste concorrenti'. Cosa significa 'gestire'? Elaborarle in ordine? In parallelo? Metterle in coda se sono troppe? Quante sono 'troppe'? Cosa succede alle richieste che arrivano quando la coda è piena? Non sono dettagli implementativi, sono lacune nella specifica. E ogni domanda senza risposta è un potenziale bug, perché lo sviluppatore risponderà implicitamente con la sua implementazione, e la sua risposta potrebbe non coincidere con quella che si aspettano gli utenti o gli altri sviluppatori.

Lo spettro della precisione delle specifiche

Le specifiche si collocano su uno spettro che va dall'informale al formale.

  • Linguaggio naturale. 'Il sistema di login deve essere sicuro e facile da usare.' Non è quasi una specifica, è un desiderio. Ogni parola è ambigua. Cosa vuol dire 'sicuro'? Cosa vuol dire 'facile da usare'? Sono giudizi soggettivi travestiti da requisiti.
  • Linguaggio naturale strutturato. 'Quando un utente inserisce una password errata tre volte entro 10 minuti, bloccare l'account per 30 minuti.' Più preciso, ma ancora ambiguo. 'Password errata' include gli invii vuoti? I tre tentativi devono essere consecutivi? La finestra di 10 minuti è mobile o fissa?
  • Firme di tipo. authenticate(username: string, password: string) -> Result<User, AuthError>. Specifica con precisione l'interfaccia della funzione, ma non dice nulla sul suo comportamento. Conosci la forma di ciò che entra e di ciò che esce, non la relazione tra le due.
  • Specifiche basate su proprietà. 'Per ogni coppia username-password valida nel database, authenticate restituisce Ok(user) dove user.username == username.' Vincola il comportamento in modo più preciso, usando quantificatori logici.
  • Specifica formale. Un modello matematico completo del comportamento del sistema: ogni stato, ogni transizione, ogni invariante. A questo livello, specifica e implementazione sono quasi la stessa cosa.

Più ci si avvicina alla specifica formale, meno spazio resta per l'ambiguità e, di conseguenza, per i bug. Ma ogni passo richiede più impegno e competenze più specialistiche. La domanda pratica è: quanto lontano lungo questo spettro conviene spingersi per un dato progetto?

I tipi come specifiche leggere

I sistemi di tipi sono la forma di specifica più diffusa nella programmazione quotidiana. Non descrivono il comportamento, ma lo vincolano. Una funzione che restituisce Option<User> invece di User dichiara che potrebbe non trovare un utente, e il compilatore obbliga ogni chiamante a gestire questa possibilità.

// The type signature IS a specification.
// This function takes a user ID and might not find a user.
// The compiler ensures every caller handles the None case.
fn find_user(id: UserId) -> Option<User> { ... }
// Compare with the stringly-typed version:
// fn find_user(id: &str) -> User  // What if ID is invalid?
//                                  // What if user not found?
//                                  // Does it panic? Return null?
//                                  // The type tells you nothing.
// Richer types encode more specification:
enum WithdrawError {
InsufficientFunds { available: Money, requested: Money },
AccountFrozen { reason: String, until: DateTime },
DailyLimitExceeded { limit: Money, spent: Money },
}
fn withdraw(account: &Account, amount: Money) -> Result<Transaction, WithdrawError>
// The error type specifies exactly what can go wrong and what
// information is available when it does. This is specification
// through the type system.

Linguaggi come Rust, Haskell e TypeScript mostrano come sistemi di tipi espressivi riducano il gap della specifica senza richiedere una formazione in metodi formali. Tipi algebrici, vincoli sui generici e pattern matching esaustivo, insieme, specificano gran parte del comportamento del programma a livello di tipo, e il compilatore lo verifica automaticamente.

L'intuizione chiave: ogni annotazione di tipo è una specifica che viene controllata automaticamente. Ogni comportamento che riesci a codificare nel sistema di tipi è una classe di bug che non avrai mai.

Property-based testing: specificare il comportamento

I unit test sono specifiche basate su esempi: 'dato questo input specifico, aspettati questo output specifico'. Sono utili, ma intrinsecamente incompleti: puoi testare solo i casi che ti vengono in mente. Il property-based testing ribalta l'approccio: specifichi le proprietà che devono valere per tutti gli input, e il framework di test genera input casuali per trovare le violazioni.

from hypothesis import given, strategies as st
# Unit test: one example
def test_sort_specific():
assert sort([3, 1, 2]) == [1, 2, 3]
# Property-based test: specifies what sort MEANS
@given(st.lists(st.integers()))
def test_sort_properties(lst):
result = sort(lst)
# Property 1: output has same length as input
assert len(result) == len(lst)
# Property 2: output is ordered
for i in range(len(result) - 1):
assert result[i] <= result[i + 1]
# Property 3: output contains the same elements
assert sorted(result) == sorted(lst)
# These three properties together SPECIFY sorting.
# Any function that satisfies all three IS a sort function.
# The property-based test IS the specification.

Le tre proprietà di quel test non si limitano a testare l'ordinamento: lo definiscono. Qualunque funzione che soddisfi tutte e tre è, per definizione, un ordinamento corretto. È l'idea di 'specifica come codice' nella sua forma più pratica: scrivi proprietà che definiscono il comportamento corretto, poi lascia che il framework verifichi che la tua implementazione le soddisfi.

TLA+ e model checking

Per i sistemi in cui i bug costano caro (sistemi distribuiti, piattaforme finanziarie, infrastrutture critiche), strumenti di specifica formale come TLA+ offrono garanzie più forti. TLA+ (Temporal Logic of Actions) permette di specificare il comportamento di un sistema come macchina a stati e poi di verificarlo con il model checking: esplorare in modo esaustivo ogni possibile esecuzione per controllare che gli invarianti siano sempre rispettati.

Amazon ha usato TLA+ per verificare algoritmi in S3, DynamoDB e altri servizi critici di AWS. Ha descritto pubblicamente bug che sarebbero stati praticamente impossibili da trovare con i test, bug che si manifestano solo con specifiche interleaving di operazioni concorrenti che capitano una volta su milioni di esecuzioni.

La specifica TLA+ di un protocollo di consenso come Raft è di solito lunga poche centinaia di righe. Descrive ogni transizione di stato legale, ogni invariante (ad esempio, 'al massimo un leader per term') e ogni proprietà di sicurezza. Il model checker esplora poi miliardi di possibili percorsi di esecuzione, verificando che nessuno violi un invariante. È una specifica davvero vicina al codice, abbastanza precisa da poter essere tradotta meccanicamente in un'implementazione.

Design by Contract

Il Design by Contract (DbC) di Bertrand Meyer occupa un terreno intermedio e pratico. Ogni funzione specifica le precondizioni (ciò che deve essere vero prima della chiamata), le postcondizioni (ciò che la funzione garantisce al momento del ritorno) e gli invarianti (ciò che deve essere sempre vero per l'oggetto). Questi contratti vengono verificati a runtime durante lo sviluppo e possono essere disattivati in produzione per motivi di prestazioni.

def transfer(from_account, to_account, amount):
"""Transfer money between accounts.
Preconditions:
amount > 0
from_account.balance >= amount
from_account != to_account
Postconditions:
from_account.balance == old(from_account.balance) - amount
to_account.balance == old(to_account.balance) + amount
from_account.balance + to_account.balance ==
old(from_account.balance) + old(to_account.balance)
"""
# The postconditions above fully specify what this function does.
# The last postcondition (conservation) catches a class of bugs
# (money created or destroyed) that unit tests might miss.
assert amount > 0, "Transfer amount must be positive"
assert from_account.balance >= amount, "Insufficient funds"
assert from_account != to_account, "Cannot transfer to same account"
from_account.balance -= amount
to_account.balance += amount

La postcondizione di conservazione, secondo cui il totale di denaro prima e dopo il trasferimento è lo stesso, è il tipo di invariante che gli unit test raramente coprono. Specifica una proprietà fondamentale del sistema (il denaro non si crea né si distrugge) che ogni implementazione corretta deve soddisfare.

Raccomandazioni pratiche

La specifica formale completa non è pratica per la maggior parte del software. Ma puoi ridurre in modo significativo il gap della specifica con tecniche che si inseriscono in un normale flusso di sviluppo.

  1. Usa il sistema di tipi in modo aggressivo. Codifica i vincoli come tipi ogni volta che è possibile. Preferisci NonEmptyList a List quando la lista non deve essere vuota. Usa i newtype per distinguere UserId da OrderId, anche se entrambi sono stringhe. Ogni vincolo codificato nei tipi è un vincolo che il compilatore controlla gratis.
  2. Scrivi property-based test per la logica centrale. Individua gli invarianti che il sistema deve mantenere ed esprimili come property test. Anche poche proprietà ben scelte catturano bug che migliaia di test basati su esempi non vedono.
  3. Specifica i confini. Le specifiche più importanti sono quelle ai confini del sistema: contratti API, schemi di database, formati dei messaggi. Usa OpenAPI, Protocol Buffers o JSON Schema per renderle verificabili dalle macchine.
  4. Usa TLA+ o Alloy per i sistemi concorrenti critici. Se stai progettando un algoritmo di consenso, un lock distribuito o un sistema di transazioni finanziarie, il costo della specifica formale è piccolo rispetto al costo dei sottili bug di correttezza in produzione.
  5. Scrivi la specifica prima dei test. Se non sai dire con precisione come appare il comportamento corretto, non puoi verificarlo. Dedicare 30 minuti a una specifica chiara di ciò che una funzione deve fare (tutti i casi, inclusi quelli limite) fa spesso emergere problemi di progettazione prima di scrivere una sola riga di codice.

L'affermazione che 'una specifica sufficientemente dettagliata è codice' non è del tutto corretta: è più accurato dire che una specifica sufficientemente dettagliata elimina lo spazio per i bug. Il divario tra specifica e implementazione è dove vive l'ambiguità, e l'ambiguità è terreno fertile per i bug. Ogni tecnica che riduce quel divario (tipi, proprietà, contratti, metodi formali) rende il software più corretto. Non perché impedisca gli errori di codifica, ma perché previene gli errori di specifica da cui gli errori di codifica di solito derivano.