Riepilogo
Le due lezioni precedenti hanno insegnato che cos’è una funzione e come entrano ed escono i suoi dati; questa insegna a decidere quali funzioni scrivere. Il metodo è il raffinamento successivo: si scrive un main breve che chiama funzioni ancora inesistenti, ciascuna con il suo contratto, e si scende in ciascuna fino ad arrivare a parti che si scrivono in poche righe. La lezione fissa i criteri di una buona interfaccia, la regola «una funzione, un compito» con i segnali che ne tradiscono la violazione, e l’overloading, che dà uno stesso nome allo stesso compito su tipi diversi. Con questi criteri riscrive gli algoritmi dell’unità 4, che vivevano dentro main, e li testa separatamente. Il concetto che unifica tutto è la scomposizione discendente: un programma ben progettato si legge come la propria specifica.
Obiettivi di apprendimento
- Scomporre un problema in sottoproblemi per raffinamento successivo e scrivere l’albero della scomposizione.
- Progettare l’interfaccia di ogni funzione (nome, parametri, ritorno e contratto) prima di scriverne il corpo.
- Riscrivere un programma monolitico dell’unità 4 come un insieme di funzioni coese, e testare ciascuna separatamente con un
maindi prova. - Valutare un progetto secondo la regola «una funzione, un compito» e l’assenza di effetti nascosti.
- Sovraccaricare una funzione, prevedere quale versione sceglie ogni chiamata e spiegare perché una chiamata risulta ambigua.
Raffinamento successivo
Dall’enunciato a un main di poche righe
I programmi dell’unità 4 sono stati scritti interamente dentro main. Finché stanno in uno schermo, lo si tollera; tuttavia, un programma di duecento righe scritto così esige di tenere tutto in testa nello stesso momento, e ammette soltanto il test dell’insieme. Il raffinamento successivo, o progettazione discendente, inverte l’ordine del lavoro: prima si scrive ciò che il programma fa, nel linguaggio del problema, come una sequenza di chiamate a funzioni che ancora non esistono; poi si prende ciascuna di quelle funzioni come un problema nuovo, più piccolo, e si ripete il procedimento fino ad arrivare a funzioni così semplici che il loro corpo si scrive senza scomporre nulla.
Il problema seguente serve da esempio. Un intero è perfetto se la somma dei suoi divisori propri (quelli positivi diversi da ) è uguale a , difettivo se è minore e abbondante se è maggiore: è perfetto; l’8, con , è difettivo; il 12, con , abbondante. Si vuole un programma che legga e scriva la somma dei suoi divisori propri e la sua categoria. Il primo livello del progetto è main, e si scrive prima di tutto:
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// Somma dei divisori positivi di n.// Precondizione: n >= 1.// Postcondizione: restituisce la somma di tutti i divisori positivi di n, compreso n.std::int64_t divisorSum(int n);// Categoria di n secondo la somma dei suoi divisori propri.// Precondizione: n >= 1.// Postcondizione: restituisce deficient, perfect o abundant a seconda che la somma sia minore, uguale o maggiore di n.Kind classify(int n);// Nome di una categoria, per scriverlo.// Precondizione: nessuna.// Postcondizione: restituisce «difettivo», «perfetto» o «abbondante».std::string kindName(Kind kind);int main(){ int n{}; std::cin >> n; std::println("Somma dei divisori propri di {}: {}", n, divisorSum(n) - n); std::println("{} è {}", n, kindName(classify(n)));}/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':main.cpp:27:(.text+0x22a): undefined reference to `divisorSum(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x3fd): undefined reference to `classify(int)'/usr/bin/x86_64-linux-gnu-ld.bfd: main.cpp:28:(.text+0x40d): undefined reference to `kindName[abi:cxx11](Kind)'collect2: error: ld returned 1 exit statusIl programma si compila senza alcun avviso, perché ogni chiamata concorda con la sua dichiarazione, che è l’unica cosa che il compilatore verifica, come ha insegnato la lezione Funzioni. Ciò che fallisce è il collegamento: il linker cerca le definizioni e non le trova («undefined reference», riferimento non definito; l’aggiunta [abi:cxx11] è un marchio interno di GCC per le funzioni che restituiscono std::string). Questo stato è esattamente quello del progetto a questo punto: le interfacce sono decise, i corpi no. Se le cose stanno così, main si legge già come la specifica del programma, e le tre dichiarazioni sono i compiti in sospeso, ciascuno più piccolo del problema originale.
L’albero della scomposizione
Il secondo livello scende in ogni dichiarazione. kindName è una decisione a tre casi. classify si appoggia a divisorSum: un numero è perfetto quando la somma di tutti i suoi divisori è . E divisorSum è il ciclo dei divisori a coppie della lezione Algoritmi numerici, che si ferma a . Il risultato si disegna come un albero: la radice è il problema completo; i figli di ogni nodo, i sottoproblemi che lo risolvono; le foglie, operazioni che il linguaggio offre già.
La figura dispiega l’albero livello per livello, nell’ordine in cui è stato progettato.
Dimostrazione
Il primo livello è il problema intero, a carico di
main, che ancora non si scompone.Il secondo livello è il
maindi cinque operazioni: leggere, sommare, classificare, nominare e scrivere. Ogni nodo è una chiamata; quelle della libreria esistono già, le altre sono dichiarate e in sospeso.Il terzo livello scompone
classify, che non fa altro che chiamaredivisorSume confrontare. La stessa funzione compare due volte nell’albero, e si scrive una sola volta: il riuso si vede nell’albero prima di scrivere codice.L’ultimo livello sono le foglie: la prova di divisibilità e il limite delle coppie, operazioni del linguaggio. Arrivati a esse, il raffinamento termina, perché non resta nulla da scomporre.
Con l’albero completo, si scrivono le definizioni dal basso verso l’alto, in modo che ogni funzione si appoggi ad altre già scritte:
#include <cstdint>#include <iostream>#include <print>#include <string>enum class Kind { deficient, perfect, abundant };// Somma dei divisori positivi di n.// Precondizione: n >= 1.// Postcondizione: restituisce la somma di tutti i divisori positivi di n, compreso n.std::int64_t divisorSum(int n){ std::int64_t sum{0}; for (int d{1}; d <= n / d; ++d) { if (n % d == 0) { sum += d; if (d != n / d) { sum += n / d; } } } return sum;}// Categoria di n secondo la somma dei suoi divisori propri.// Precondizione: n >= 1.// Postcondizione: restituisce deficient, perfect o abundant a seconda che la somma sia minore, uguale o maggiore di n.Kind classify(int n){ const std::int64_t proper{divisorSum(n) - n}; if (proper < n) { return Kind::deficient; } if (proper == n) { return Kind::perfect; } return Kind::abundant;}// Nome di una categoria, per scriverlo.// Precondizione: nessuna.// Postcondizione: restituisce «difettivo», «perfetto» o «abbondante».std::string kindName(Kind kind){ if (kind == Kind::deficient) { return "difettivo"; } if (kind == Kind::perfect) { return "perfetto"; } return "abbondante";}int main(){ int n{}; std::cin >> n; std::println("Somma dei divisori propri di {}: {}", n, divisorSum(n) - n); std::println("{} è {}", n, kindName(classify(n)));}28Somma dei divisori propri di 28: 2828 è perfettoTre tratti del risultato si devono al metodo. Primo, main non è cambiato quando si è scritto ciò che sta sotto. Secondo, la somma si restituisce come std::int64_t (da <cstdint>, l’intero a 64 bit che usa anche la lezione Ricorsione), perché per vicino al massimo di int la somma dei suoi divisori può superarlo, e deciderlo nell’interfaccia protegge tutti quelli che chiamano. Terzo, ogni funzione si verifica separatamente: divisorSum(28) deve dare 56, divisorSum(1) deve dare 1 e divisorSum(36) deve dare 91, dove la coppia si conta una sola volta; l’ultima parte della lezione trasforma queste verifiche in un programma.
Caricamento del contenuto…