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