Vai al contenuto
Topos Uranos

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

  1. Scomporre un problema in sottoproblemi per raffinamento successivo e scrivere l’albero della scomposizione.
  2. Progettare l’interfaccia di ogni funzione (nome, parametri, ritorno e contratto) prima di scriverne il corpo.
  3. Riscrivere un programma monolitico dell’unità 4 come un insieme di funzioni coese, e testare ciascuna separatamente con un main di prova.
  4. Valutare un progetto secondo la regola «una funzione, un compito» e l’assenza di effetti nascosti.
  5. 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 n≥1n \geq 1 è perfetto se la somma dei suoi divisori propri (quelli positivi diversi da nn) è uguale a nn, difettivo se è minore e abbondante se è maggiore: 28=1+2+4+7+1428 = 1 + 2 + 4 + 7 + 14 è perfetto; l’8, con 1+2+4=71 + 2 + 4 = 7, è difettivo; il 12, con 1+2+3+4+6=161 + 2 + 3 + 4 + 6 = 16, abbondante. Si vuole un programma che legga nn e scriva la somma dei suoi divisori propri e la sua categoria. Il primo livello del progetto è main, e si scrive prima di tutto:

Programma in C++
#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)));}
Messaggi del linker
/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 status

Il 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 è 2n2n. E divisorSum è il ciclo dei divisori a coppie (d,n/d)(d, n/d) della lezione Algoritmi numerici, che si ferma a n\sqrt{n}. 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à.

EsempioL’albero del classificatore di interi

La figura dispiega l’albero livello per livello, nell’ordine in cui è stato progettato.

Dimostrazione

  1. Il primo livello è il problema intero, a carico di main, che ancora non si scompone.

  2. Il secondo livello è il main di cinque operazioni: leggere, sommare, classificare, nominare e scrivere. Ogni nodo è una chiamata; quelle della libreria esistono già, le altre sono dichiarate e in sospeso.

  3. Il terzo livello scompone classify, che non fa altro che chiamare divisorSum e 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.

  4. 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:

Programma in C++
#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)));}
Input del programma
28
Output
Somma dei divisori propri di 28: 2828 è perfetto

Tre 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 nn 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 (6,6)(6, 6) si conta una sola volta; l’ultima parte della lezione trasforma queste verifiche in un programma.