Capitolo 6 - Progettare con le funzioni

Capitolo 6
Progettare con le funzioni
In questo capitolo:
• Spiegare perché le funzioni sono utili nella strutturazione del codice di un programma
• Impiegare la metodologia di progettazione top-down per assegnare i compiti alle
funzioni
• Definire una funzione ricorsiva
• Illustrare l'uso dei namespace in un programma e il modo di impiegarli efficacemente
• Definire una funzione con parametri obbligatori e facoltativi
• Utilizzare le funzioni di ordine superiore per la mappatura, il filtraggio e la riduzione
La progettazione è importante in molti settori. L’architetto che progetta un edificio,
l’ingegnere che progetta un ponte o una nuova automobile, il direttore pubblicitario o il
generale delle forze armate che progettano la prossima campagna devono tutti organizzare
la struttura di un sistema e coordinarne gli attori per raggiungere gli obiettivi prefissati.
La progettazione è altrettanto importante nella costruzione dei sistemi software, alcuni
dei quali sono i manufatti più complessi mai realizzati dall’uomo. In questo capitolo,
esploriamo l’utilizzo delle funzioni nella progettazione di un sistema software.
6.1 Le funzioni come meccanismo di astrazione
Fino ad ora, i nostri programmi sono stati composti da algoritmi e strutture di dati espressi
nel linguaggio di programmazione Python. Gli algoritmi sono costituiti alternativamente
da operatori interni, strutture di controllo, chiamate di funzioni predefinite e funzioni
definite dall’utente introdotti nel Capitolo 5.
A rigor di termini, le funzioni non sono elementi indispensabili. È possibile costruire
qualsiasi algoritmo utilizzando solamente operatori interni e strutture di controllo.
Tuttavia, in ogni programma di un certo rilievo, il codice risulterebbe estremamente
complesso, difficile da controllare e pressoché impossibile da mantenere.
Il problema è che il cervello umano può concentrarsi solo su pochi elementi alla volta
(gli psicologi parlano di tre elementi senza difficoltà fino a un massimo di sette). La complessità viene affrontata sviluppando meccanismi per semplificarla o nasconderla. Questi
156
Capitolo 6
meccanismi vengono chiamati astrazioni. In parole povere, l’astrazione nasconde dei
dettagli consentendo così di trattare più elementi come se fossero uno solo. Utilizziamo
le astrazioni per fare riferimento a molte azioni nella vita di tutti i giorni. Per esempio,
consideriamo l’espressione “fare il bucato”. Questa espressione è semplice, ma raggruppa
una serie di procedimenti più complessi che riguardano la raccolta degli indumenti da
lavare da un apposito contenitore, separarli in bianchi e colorati, metterli nella lavatrice
e quindi nell’asciugabiancheria, poi stirarli e infine riporli nei cassetti. Invero, senza le
astrazioni molte delle attività quotidiane sarebbero impossibili da descrivere, pianificare e
portate a compimento. In modo analogo, i progettisti efficienti devono inventare opportune astrazioni per tenere sotto controllo la complessità. In questo paragrafo esaminiamo
i vari modi in cui le funzioni vengono impiegate come meccanismi di astrazione in un
programma.
6.1.1 Funzioni per eliminare le ridondanze
Il primo modo nel quale le funzioni servono da meccanismo di astrazione è l’eliminazione
del codice ridondante o ripetitivo. Per esaminare il concetto di ridondanza, facciamo
riferimento alla funzione sum, che restituisce la somma dei numeri appartenenti a una
data sequenza numerica. Ecco la definizione della funzione sum, seguita da una sessione
che ne illustra l’utilizzo:
def sum(lower, upper):
“””
Argomenti: un limite inferiore e un limite superiore
Risultati: la somma dei numeri compresi tra gli argomenti
estremi inclusi
“””
result = 0
while lower <= upper:
result += lower
lower += 1
return result
>>> sum(1, 4)
10
>>> sum(50, 100)
3825
# La sommatoria della sequenza 1...4
# La sommatoria della sequenza 50...100
Se la funzione sum non fosse stata definita, il programmatore avrebbe dovuto scrivere
l’intero algoritmo ogni qualvolta avesse avuto la necessità di calcolare una sommatoria. In
un programma che deve calcolare molte sommatorie, lo stesso codice dovrebbe apparire,
quindi, molte volte. In altre parole, nel programma verrebbe inserito un codice ridondante. Il codice ridondante è svantaggioso per diverse ragioni. Per prima cosa, costringe
il programmatore a scrivere o copiare ripetutamente lo stesso codice e verificarne, ogni
volta, la correttezza. In seguito, se il programmatore decidesse di migliorare l’algoritmo
per aggiungere nuove caratteristiche o per renderlo più efficiente, dovrebbe modificare
ogni istanza del codice ridondante nell’intero programma. Come potete immaginare, la
manutenzione del codice diventerebbe un incubo.
Progettare con le funzioni
157
Facendo riferimento alla definizione di una singola funzione, al posto di multiple istanze
di codice ridondante, il programmatore può scrivere un singolo algoritmo in un unico
luogo, per esempio in un modulo di libreria. Qualsiasi altro modulo o programma può
quindi importare la funzione e utilizzarla. Una volta importata, la funzione può essere
chiamata ogni qualvolta si renda necessario. Quando il programmatore avrà bisogno di
correggere o migliorare la funzione, sarà sufficiente modificare e verificare solamente la
definizione della funzione. Non sarà necessario agire sulla parti del programma dove la
funzione viene chiamata.
6.1.2 Funzioni per nascondere la complessità
Un altro modo in cui le funzioni servono da meccanismo di astrazione è quello di nascondere i dettagli complessi. Per comprendere perché ciò sia vero, torniamo ancora una
volta alla funzione sum. Sebbene l’idea di sommare un intervallo di numeri sia semplice,
il codice per calcolare la sommatoria potrebbe non esserlo affatto. Non stiamo parlando
soltanto della quantità o della lunghezza del codice, ma del numero di componenti
che interagiscono. Ci sono tre variabili da manipolare e la logica dei cicli controllati da
contatore da costruire.
Adesso supponiamo, in maniera non realistica, che in un programma sia implementata
una sola sommatoria e che questa non sia utilizzata in nessun altro programma.A che cosa
serve una funzione in questo caso? Tutto dipende dalla complessità del codice circostante.
Ricordate che i programmatori responsabili del mantenimento del programma possono
concentrarsi solo su pochi elementi alla volta. Se anche il codice per la sommatoria fosse
posto in un contesto in cui il codice esistente fosse poco complesso, l’aumento della
complessità potrebbe essere sufficiente a provocare un sovraccarico concettuale per i
poveri programmatori.
Una chiamata di funzione indica al programmatore l’idea che sta dietro al processo, senza
costringerlo a farsi strada attraverso il complesso codice che realizza quell’idea. Come
in altre aree della scienza e dell’ingegneria, il sistema più semplice è anche il migliore.
6.1.3 Funzioni supportano metodi generali con variazioni sistematiche
Un algoritmo è un metodo generale per risolvere una determinata classe di problemi.
Gli specifici problemi che compongono una classe di problemi sono noti come istanze
del problema. Le istanze del problema per il nostro algoritmo di somma sono individuate dalle coppie di numeri che specificano il limite inferiore e quello superiore della
sequenza di interi da sommare. Le istanze del problema per un dato algoritmo possono
variare da un programma all’altro, e persino tra le differenti parti di uno stesso programma.
Quando si progetta un algoritmo, questo dovrebbe essere sufficientemente generale da
essere utilizzato nel maggior numero possibile di istanze del problema, non solamente
per una o per alcune di esse. In altre parole, una funzione dovrebbe fornire un metodo
generale per le variazioni sistematiche.
La funzione sum contiene sia il codice per l’algoritmo di somma sia gli strumenti per
applicarlo alle varie istanze del problema. Le istanze del problema sono i dati passati
come argomenti alla funzione. I nomi dei parametri o argomenti nell’intestazione della
funzione si comportano come variabili che attendono di essere valorizzate ogni qualvolta
la funzione viene chiamata.
158
Capitolo 6
Se progettato correttamente, il codice di una funzione implementa un algoritmo come
metodo generale per la risoluzione di una determinata classe di problemi. Gli argomenti
della funzione forniscono gli strumenti per modificare in maniera sistematica le istanze
del problema che l’algoritmo risolve. Ulteriori argomenti possono espandere la classe dei
problemi risolvibili. Per esempio, la funzione sum potrebbe prevedere un terzo parametro
che specifica l’incremento nella sequenza dei numeri da sommare. Esamineremo a breve
come prevedere argomenti aggiuntivi che non aumentino la complessità nel caso di un
utilizzo standard della funzione.
6.1.4 Funzione che supportano la divisione del lavoro
In un sistema ben organizzato, sia che si tratti di una cosa vivente o di qualcosa creato
dall’uomo, ciascuna parte deve compiere il proprio lavoro o giocare il proprio ruolo
collaborando per il raggiungimento di un obiettivo comune. Specifici compiti vengono
suddivisi e assegnati ad agenti specializzati. Alcuni agenti possono assumere il ruolo di
gestire i compiti di altri agenti o coordinarli in qualche altro modo. Ma, indipendentemente dal compito, i buoni agenti si occupano alle proprie attività e non tentano di
svolgere il lavoro degli altri.
Un sistema male organizzato, al contrario, soffre a causa di agenti che svolgono compiti
per i quali non sono stati progettati o che non si occupano delle proprie attività. La
divisione del lavoro non viene rispettata.
In un programma per computer, le funzioni possono aiutare a ottenere una divisione del
lavoro. Idealmente, ciascuna funzione svolge un singolo compito, per esempio calcolare
una sommatoria o formattare una tabella di dati da visualizzare. Ogni funzione ha il
compito di utilizzare certi dati, di calcolare determinati risultati e di restituirli alle parti
del programma che li hanno richiesti. Qualsiasi compito richiesto da un sistema può
essere assegnato a una funzione, inclusi quelli di gestire e coordinare l’utilizzo di altre
funzioni. Nel paragrafo successivo esamineremo diverse strategie di progettazione che
utilizzano le funzione per ottenere una divisione del lavoro nei programmi.
Esercizi
1. Anna si lamenta perché definire le funzioni da utilizzare nei suoi programmi richiede troppo lavoro extra. Dice che potrebbe completare i suoi programmi molto più
velocemente se potesse scriverli utilizzando solamente operatori di base e strutture
di controllo. Elencate tre motivi per dire che Anna è in errore.
2. Spiegate come un algoritmo sia in grado di risolvere una classe generale di problemi
e come la definizione di una particolare funzione possa supportare questa proprietà
degli algoritmi.
Progettare con le funzioni
159
6.2 Risoluzione dei problemi con la progettazione top-down
La progettazione top-down è una nota metodologia di progettazione per programmi
di dimensioni e complessità significative. Questa metodologia parte da una visione di
insieme dell’intero problema e lo suddivide in sottoproblemi più piccoli e gestibili – un
processo noto come scomposizione del problema. Non appena ciascun sottoproblema
è isolato, la sua risoluzione è affidata a una funzione. La scomposizione del problema può
continuare anche nei livelli successivi, perché un sottoproblema a sua volta può essere
scomposto in due o più sottoproblemi da risolvere. Sviluppando le funzioni per risolvere ciascun sottoproblema, si costruisce gradualmente la soluzione dell’intero problema.
Questo procedimento è chiamato anche affinamento progressivo.
I nostri primi esempi di programmi descritti nei Capitoli 1-4 sono abbastanza semplici
da essere scomposti in tre parti: inserimento dei dati, elaborazione e visualizzazione dei
risultati. Nessuna di queste parti richiede più di una o due istruzioni di codice e appaiono
tutte in una singola sequenza di istruzioni.
Tuttavia, a cominciare dal programma di analisi dei testi del Capitolo 4, il nostro caso di
studio è diventato abbastanza complicato da giustificare la scomposizione e l’impiego di
funzioni definite dal programmatore. Poiché ogni problema ha una struttura differente,
la progettazione della soluzione segue percorsi leggermente diversi. Questo paragrafo
rivisita ciascun programma per analizzare come si procede nella progettazione.
6.2.1 Progettazione del programma di analisi dei testi
Sebbene il programma di analisi dei testi (Paragrafo 4.6) non sia stato strutturato in
termini di funzioni definite dal programmatore, esaminiamo ora come si potrebbe fare.
Il programma richiede componenti di input e output abbastanza semplici che possono
essere espresse come istruzioni all’interno della funzione main. Tuttavia, il trattamento
dei dati di input è abbastanza complesso da essere scomposto in sottoprocessi più piccoli,
come il conteggio delle frasi, delle parole e delle sillabe e il calcolo della misura della
leggibilità dei testi. In generale, occorre sviluppare una nuova funzione per ciascuno di
questi compiti. Le relazioni tra le funzioni in questo progetto sono espresse nel diagramma di struttura mostrato nella Figura 6.1. Un diagramma di struttura è uno schema che
mostra le relazioni tra le funzioni di un programma e il passaggio dei dati tra di esse.
Ogni riquadro del diagramma di struttura possiede un’etichetta che corrisponde al
nome di una specifica funzione. La funzione main in alto è il punto nel quale il progetto
ha inizio; la scomposizione ci conduce alle funzioni di livello più basso dalle quali la
funzione main dipende. Le linee che collegano i riquadri sono chiamate con i nomi dei
tipi di dati e le frecce indicano il flusso dei dati tra le funzioni. Per esempio, la funzione
contaFrasi riceve una stringa come argomento e restituisce il numero di frasi in quella
stringa. Notate che tutte le funzioni, tranne una, si trovano al livello immediatamente
inferiore a quello della funzione main. Poiché questo programma non possiede una
struttura che si sviluppa in profondità, il programmatore può implementarlo, in modo
rapido, semplicemente pensando ai risultati che la funzione main ha bisogno di ottenere
dai suoi collaboratori.
160
Capitolo 6
main
stringa
int
stringa
int
stringa
int
contaFrasi
contaParole
contaSillabe
stringa
int
3 int
float
3 int
float
inSillabe
indiceFlesch
livelloScolatico
Figura 6.1 Diagramma di struttura per il programma di analisi dei testi.
6.2.2 Progetto del programma che genera frasi
Da una prospettiva globale, il programma che genera frasi (Paragrafo 5.3) è costituito da
un ciclo principale nel quale le frasi vengono generate per il numero di volte specificato
dall’utente, fino a quando questi non digita 0. L’input/output e la logica del ciclo sono
sufficientemente semplici che possono essere posti nella funzione main. Il resto della
progettazione riguarda la generazione delle frasi.
A questo punto, scomponiamo il problema seguendo semplicemente le regole grammaticali per le proposizioni. Per generare una frase, utilizziamo una locuzione sostantivale
seguita da una locuzione verbale e così via. Ogni regola grammaticale individua un problema che viene risolto da una particolare funzione. La progettazione top-down deriva
dalla struttura top-down della grammatica. Il diagramma di struttura per il generatore
delle frasi è mostrato nella Figura 6.2.
La struttura di un problema spesso può offrire un modello per la progettazione della
struttura del programma per risolverlo. Nel caso del generatore di frasi, la struttura del
problema discende dalle regole grammaticali, nonostante esse non siano esplicitamente
delle strutture di dati del programma. Nei successivi capitoli vedremo molti esempi di
progetti di programmi che rispecchiano la struttura dei dati che devono essere gestiti.
La progettazione del generatore di frasi differisce da quella dell’analizzatore dei testi per
un altro importante aspetto. Tutte le funzioni nell’analizzatore dei testi ricevono i dati
Progettare con le funzioni
161
main
stringa
frase
stringa
stringa
locVerbale
stringa
stringa
stringa
locPreposizionale
locSostantivale
stringa
stringa
articoli
stringa
nomi
stringa
preposizioni
verbi
Serbatoio di dati
Figura 6.2 Diagramma di struttura per il programma che genera frasi.
dalla funzione main come parametri o argomenti. Viceversa, le funzioni nel generatore
di frasi ricevono i dati da un serbatoio di dati comuni definito all’inizio del modulo,
come mostra la parte inferiore della Figura 6.2. Questo serbatoio di dati potrebbe anche
essere definito all’interno della funzione main e passato come argomento a ogni altra
funzione. Questa alternativa, tuttavia, richiederebbe il passaggio degli argomenti anche a
quelle funzioni che non li utilizzano. Per esempio, locPreposizionale dovrebbe ricevere
come argomento articoli, nomi e preposizioni, in modo che possa trasmettere le prime due strutture a locSostantivale. L’utilizzo di un comune serbatoio di dati, anziché
gli argomenti di una funzione, in questo caso, semplifica la progettazione e agevola la
manutenzione del programma.
6.2.3 Progettazione del programma doctor
Al livello superiore, la progettazione del programma doctor (Paragrafo 5.5) e del programma generatore di frasi sono simili. Entrambi i programmi hanno un ciclo principale
che accetta un singolo input dall’utente e visualizza un risultato. Il diagramma di struttura
per il programma doctor è mostrato nella Figura 6.3.
Il programma doctor elabora l’input rispondendo a questo come farebbe un agent in
una conversazione. Quindi, la responsabilità della risposta è delegata alla funzione reply.
Notate che le due funzioni main e reply hanno responsabilità distinte. L’attività di main
si concentra sulla gestione dell’interazione tra l’utente e il programma, mentre reply è
responsabile dell’implementazione della “logica del dottore” per la generazione di una
risposta appropriata. La ripartizione dei ruoli e delle responsabilità tra differenti attori
in un programma è anche chiamata progettazione guidata da responsabilità. La divisione
162
Capitolo 6
Serbatoio di dati
dichiarazioni
qualificatori
sostituzioni
stringa
stringa
main
stringa
stringa
stringa
reply
stringa
stringa
cambiaPronome
Figura 6.3 Diagramma di struttura per il programma doctor.
delle responsabilità tra le funzioni che gestiscono l’interazione dell’utente e quelle che
elaborano i dati è un elemento che vedremo più volte nei capitoli successivi.
Se ci fosse un solo modo di rispondere all’utente, il problema di rispondere non sarebbe
scomposto ulteriormente. Tuttavia, poiché esistono almeno due opzioni, alla funzione
reply è assegnato il compito di implementare la logica della scelta della risposta più appropriata con l’ausilio di altre funzioni, come cambiaPronome, per elaborare ciascuna opzione.
Separare la logica della scelta di un compito dal processo di svolgere quel compito rende
il programma più semplice da mantenere. Per aggiungere una nuova strategia di risposta,
basta aggiungere una nuova scelta alla logica di reply e poi aggiungere la funzione che
elabora questa opzione. Se si vuole modificare la probabilità di una certa opzione, basta
modificare una linea di codice in reply.
Lo schema di flusso dei dati utilizzato nel programma doctor combina le strategie impiegate nell’analizzatore dei testi e nel generatore delle frasi. Le funzioni del programma
doctor ricevono i dati attraverso due sorgenti. La stringa di input del paziente viene
passata come argomento alle funzioni reply e cambiaPronome, mentre i qualificatori, le
dichiarazioni e le sostituzioni vengono cercati nel serbatoio comune dei dati definito
all’inizio del modulo. Ancora una volta, l’impiego di un serbatoio di dati comune permette al programma di crescere con facilità, quando nuove sorgenti di dati (come la lista
delle conversazioni tra dottore e paziente proposta nel Progetto 5.10) vengono aggiunte
al programma.
Concludiamo questo paragrafo con un vecchio adagio che cattura l’essenza della metodologia di progettazione top-down. In caso di dubbi circa la soluzione di un problema,
passa la palla a qualcun altro. Se scegli gli operatori con accortezza, alla fine la palla si
ferma presso un operatore che non ha dubbi su come risolvere il problema.
Progettare con le funzioni
163
Esercizi
1. Disegnate un diagramma di struttura per una delle soluzioni dei Progetti dei Capitoli 4 e 5. Il programma deve includere almeno le definizioni di due funzioni oltre
alla funzione main.
2. Descrivete i processi di progettazione top-down e affinamento progressivo. Da dove
inizia il progetto e come continua?
6.3 Progettare con le funzioni ricorsive
Nella progettazione top-down si scompone un problema complesso in un insieme di
problemi più semplici e si risolvono con l’ausilio di funzioni differenti. In qualche caso
è possibile scomporre un problema complesso in problemi più piccoli ma della stessa
forma. In questi casi i sottoproblemi possono essere risolti utilizzando la stessa funzione.
Questa strategia di progettazione è chiamata progettazione ricorsiva e le funzioni
risultanti sono chiamate funzioni ricorsive.
6.3.1 Definizione di una funzione ricorsiva
Una funzione ricorsiva è una funzione che chiama se stessa. Per impedire che una funzione si ripeta indefinitamente deve contenere almeno un’istruzione di selezione. Questa
istruzione verifica una condizione chiamata caso base per determinare se continuare
con un altro passo ricorsivo.
Vediamo come convertire un algoritmo iterativo in una funzione ricorsiva. Ecco la
definizione di una funzione displayRange che mostra i numeri compresi nell’intervallo
tra due estremi (limite inferiore e limite superiore):
def displayRange(lower, upper):
“””Restituisce i numeri compresi tra lower e upper.”””
while lower <= upper:
print(lower)
lower = lower + 1
Come dovremmo procedere per convertire questa funzione in una ricorsiva? Per prima
cosa, notiamo due fatti importanti:
1. Il corpo del ciclo viene eseguito se lower <= upper.
2. Quando viene eseguita la funzione, la variabile lower viene incrementata di 1, mentre
upper non cambia.
La funzione ricorsiva equivalente effettua operazioni primitive simili, ma il ciclo è rimpiazzato con un’istruzione di selezione e le istruzioni di assegnamento sono sostituite
con una chiamata ricorsiva della funzione. Ecco il codice con queste modifiche:
def displayRange(lower, upper):
“””Restituisce i numeri compresi tra lower e upper.”””
if lower <= upper:
print(lower)
displayRange(lower + 1, upper)
164
Capitolo 6
Sebbene la sintassi e il progetto delle due funzioni appaiano differenti, viene eseguito lo
stesso algoritmo. Ogni chiamata della funzione ricorsiva prende in esame il successivo
numero della sequenza, proprio come fa il ciclo nella versione iterativa della funzione.
La maggior parte delle funzioni ricorsive richiedono almeno un argomento. Questo
valore viene utilizzato per la verifica del caso base che termina il processo iterativo e
viene inoltre modificato in qualche maniera prima di ogni passo ricorsivo. La variazione
di tale valore dovrebbe consentire alla funzione di raggiungere il caso base. Nel caso della
funzione displayRange, il valore dell’argomento lower viene incrementato prima di ogni
chiamata ricorsiva fino a superare il valore dell’argomento upper.
Il nostro prossimo esempio è una funzione ricorsiva che costruisce e restituisce un valore.
In precedenza abbiamo definito una versione iterativa della funzione sum che richiede
due argomenti chiamati lower e upper. La funzione sum calcola e restituisce la somma
dei numeri compresi tra questi due valori. Nella versione ricorsiva, sum restituisce 0 se
lower supera upper (il caso base); altrimenti, la funzione aggiunge lower alla somma di
lower + 1 e upper e restituisce questo risultato. Ecco il codice di questa funzione:
def sum(lower, upper):
“””Restituisce la somma dei numeri tra lower e upper.”””
if lower > upper:
return 0
else:
return lower + sum(lower + 1, upper)
La chiamata ricorsiva di sum somma i numeri da lower + 1 fino a upper. La funzione
aggiunge quindi lower a questo risultato e poi ne restituisce il valore.
6.3.2 Tracciare una funzione ricorsiva
Per ottenere una migliore comprensione del modo di operare di una funzione ricorsiva
è utile tracciarne le chiamate. Facciamolo per la versione ricorsiva della funzione sum.
Aggiungiamo un argomento per l’indentazione e un’istruzione print per tracciare i due
argomenti e il valore restituito in ogni chiamata. La prima istruzione in ogni chiamata
calcola l’indentazione, che poi viene utilizzata nella visualizzazione degli argomenti. Il
valore calcolato è visualizzato con la stessa indentazione appena prima che ogni chiamata
termini. Ecco il codice, seguito da un esempio del suo utilizzo:
def sum(lower, upper, margin):
“””Restituisce la somma dei numeri tra lower e upper,
visualizza gli argomenti passati e restituisce i valori in ogni chiamata. “””
blanks = “ “ * margin
print(blanks, lower, upper)
if lower > upper:
print(blanks, 0)
return 0
else:
result = lower + sum(lower + 1, upper, margin + 4)
print(blanks, result)
return result