Elviro Rocca Elviro Rocca avatar

12 minute read

Nel suo discorso di accettazione del Premio Turing 1972, dal titolo “The humble programmer", Edsger Wybe Dijkstra, uno dei più celebri computing scientists del 20° secolo (morto nel 2002), affrontò le cause della nota Software Crisis, cioè la crisi che colpì l’industria del software nella seconda metà degli anni sessanta: la potenza e la capacità di elaborazione dei computer stavano crescendo esponenzialmente, molto più rapidamente dell’abilità dei programmatori di gestire la complessità e scrivere software funzionante. Nello stesso discorso, Dijkstra propose anche delle possibili strade da intraprendere che a suo avviso avrebbero portato aziende e università a migliorare la qualità del loro software. Riporto qui una frase che a mio parere riassume abbastanza bene l’intero discorso:

I now suggest that we confine ourselves to the design and implementation of intellectually manageable programs.

Il suggerimento è quindi il seguente: un programmatore dovrebbe limitarsi a lavorare su programmi intellettualmente gestibili, cioè programmi dei quali è possibile stabilire la correttezza semplicemente attraverso il ragionamento.

Indice

La Software Crisis nei decenni

Molti anni sono passati da allora, e la pratica dello sviluppo software ha attraversato varie evoluzioni e trasformazioni. Rispetto ai tempi di FORTRAN e Algol 60, nuovi linguaggi di programmazione sono emersi e si sono affermati come standard; nuove tecniche e paradigmi, come la programmazione orientata agli oggetti, si sono diffuse nell’industria del software dopo un lungo periodo di gestazione in università e centri di ricerca privati; diversi modi di gestire il processo di realizzazione del software e il suo ciclo di vita sono stati elaborati nei decenni, dal classico modello Waterfall alle moderne metodologie Agile.

Ma il problema di fondo espresso in The humble programmer è praticamente rimasto intatto nel corso dei decenni: lo sviluppo di funzionalità elaborate e la progettazione di strutture complesse portano al rilascio di software caratterizzato da molti bug, o per meglio dire, errori dovuti a una difficile, a volte apparentemente impossibile, gestione della complessità. La possibilità di rilasciare software rapidamente e in maniera iterativa ha portato a integrare del tutto questi errori nel processo di sviluppo e rilascio: questa integrazione è realizzata attraverso diverse tecniche, dal debugging, praticato già dai primissimi programmatori oltre mezzo secolo fa, al software testing, che permette, in maniera decisamente più efficace rispetto al debugging, di identificare gli errori commessi e porvi rimedio. Sfortunatamente il testing, pur essendo una pratica ottima per verificare se ci siano errori in un particolare software, non è sufficiente a garantire che questi errori non ci siano.

Notiamo inoltre che l’idea di integrare gli errori nel processo di design è una caratteristica praticamente esclusiva dell’ingegneria del software, che la distingue radicalmente dalle altre discipline ingegneristiche: in esse, tipicamente, si progettano componenti usando metodi matematici rigorosi o software di calcolo numerico che permettono comunque di ottenere dati molto precisi, e poi si applicano margini di sicurezza per tener conto della variabilità dei processi di sviluppo dei materiali, delle imperfezioni di assemblaggio e delle oscillazioni nelle condizioni di esercizio. Da una parte questa differenza rappresenta un grande vantaggio per l’ingegneria del software: non si può “iterare” nella costruzione di un grattacielo! D’altra parte penso sia necessario evitare che lo sviluppo software si trasformi in un banale processo di trial and error perché, per quanto esso possa dimostrarsi efficace in alcuni casi, spesso non ci permette di capire razionalmente cosa stiamo facendo, e può portare alla produzione di codice instabile e difficilmente gestibile. La verifica a posteriori attuata grazie al testing non dovrebbe essere considerata sufficiente a giudicare un software corretto. A mio parere, inoltre, non dovremmo considerare i bug come se fossero concetti filosofici, necessari e impossibili da eliminare. Ovviamente gli esseri umani compiono errori, ma la differenza tra un comune errore dovuto alle non perfette abilità di una persona, e un bug causato dall’eccessiva complessità di un software, è simile alla differenza che c'è tra lo scivolare su un pavimento bagnato perché non si è notato il messaggio di avviso, e lo sfracellarsi al suolo dopo aver tentato una scalata difficilissima senza attrezzatura e senza aver alcuna esperienza di montagna: nel secondo caso, ce la siamo cercata.

In effetti, in un essay scritto nel 1997 per il libro Beyond Calculation: The Next Fifty Years of Computing, pubblicato a celebrazione dei primi 50 anni di vita del computer, lo stesso Dijkstra torna ad affrontare il tema della complessità non gestita, suggerendo che poco è cambiato in merito al problema di fondo che già si era presentato più di 50 anni fa. Raccolgo qui poche parole prese dal essay che, di nuovo, penso ne distillino il succo (grassetto mio):

Computing’s core challenge is how not to make a mess of it. […] Because we are dealing with artefacts, all unmastered complexity is of our own making; there is no one else to blame and so we had better learn how not to introduce the complexity in the first place.

Cause di complessità

Ma quali sono le cause di tutta questa complessità? La fonte che, a mio parere, affronta in maniera più completa il problema della complessità nel software, proponendo soluzioni concrete, è il bellissimo (e lungo) articolo di Ben Moseley e Peter Marks “Out of the Tar Pit”(2006), reperibile QUI. Per poter identificare i punti principali dell’articolo dobbiamo intanto dare una possibile definizione di “complessità", almeno per quanto riguarda lo sviluppo software; un primo tentativo potrebbe essere il seguente:

la complessità di un software rappresenta la difficoltà nel ragionare sul suo funzionamento

In base a questa definizione, ad esempio, nel caso in cui un software presenti un qualche errore potremmo giudicare tale software più o meno complesso in base a quanto sia difficile trovare l’errore utilizzando il solo ragionamento. Basandoci su questa definizione, possiamo estrapolare da Out of the Tar Pit due principali cause di complessità:

  • lo stato delle variabili nel sistema
  • l’ordine di esecuzione delle procedure

In sintesi: la mente umana ha relativa difficoltà a tener traccia del modo in cui evolve nel tempo lo stato di molti parametri in base a vari processi di trasformazione, specialmente se tale stato dipende anche dall’ordine in cui questi processi sono eseguiti. In realtà riusciamo tranquillamente a gestire processi relativamente semplici attraverso una serie di trasformazioni di stato, si pensi all’atto di “fare una torta”: gli ingredienti di base “diventano” torta dopo una serie di stadi di trasformazione, che possiamo tranquillamente a tenere a mente, e se il risultato finale non è quello che ci aspettavamo riusciamo rapidamente a identificare l’errore nella procedura (la torta è troppo cotta, o abbiamo usato il sale al posto dello zucchero). In effetti seguire l’evoluzione di un processo di trasformazione costituito da una serie di procedure è assolutamente intuitivo per la mente umana ed è alla base del paradigma di programmazione noto come programmazione procedurale: nella mia breve esperienza personale ho potuto notare infatti che il metodo più frequentemente adottato nei corsi base di programmazione è proprio quello basato su alberi di scelte binarie if-else, iterazioni e cicli con una o più condizioni di uscita. Purtroppo è facile osservare che questo modo di ragionare, sebbene sia perfettamente adatto a risolvere molti problemi pratici nella vita di tutti i giorni, non costituisca una strategia particolarmente efficace quando si parla di programmi corposi e complessi, e con “corposi” intendo “più lunghi di un centinaio di righe”. È stato necessario introdurre nuove astrazioni, a più alto livello, per riuscire a superare la sempre incombente software crisis, abbandonando ciò che appare intuitivo in favore di qualcosa di maggiormente gestibile ed efficiente.

Pensare ad oggetti

Vediamo in sintesi come il problema della complessità sia affrontato, a grandi linee, dal più diffuso e importante paradigma di sviluppo software adottato a sostituzione di quello procedurale: la programmazione orientata agli oggetti (Object-Oriented Programming: OOP). OOP adotta il principio dell’incapsulamento della complessità: le varie strutture computazionali del nostro software, individualmente più o meno complesse, sono suddivise in porzioni sufficientemente indipendenti, rinchiuse in contenitori stagni collegati tra loro da interfacce chiare e dichiarative. Si noti intanto che questo non è il modo in cui gli esseri umani agiscono; nel suo classico articolo A Laboratory For Teaching Object-Oriented Thinking (1989), il celebre Kent Beck, noto soprattutto per i fondamentali contributi alle metodologie Extreme Programming e Test Driven Development, affronta il problema che si incontra quando si cerca di insegnare agli studenti a “pensare a oggetti”: come essere umani siamo abituati a pensare allo stato globale di una situazione; a spostarci per comunicare direttamente con chiunque; ad affidarci al modo particolare in cui le persone che conosciamo svolgeranno un certo compito. OOP è invece basato su concetti opposti: ogni oggetto dovrebbe “vedere” solo i suoi vicini diretti, e di questi non dovrebbe comunque conoscere alcun dettaglio interno. Osserviamo quindi che il sistema di astrazioni su cui OOP è basato non ha nulla a che fare con la vita “reale” o con il nostro abituale modo di pensare agli oggetti “fisici”, e a ragione: un software è svariati ordini di grandezza più complesso rispetto a una qualsiasi struttura fisica, e richiede strumenti diversi per poter essere compreso e gestito.

In effetti un presunto punto di forza di OOP, spesso citato per fornire una prova dell’accessibilità di questo paradigma di programmazione, è costituito dall’idea che sia possibile trattare gli oggetti nel software come se fossero oggetti reali. Ho potuto osservare, tuttavia, che impostando proprietà e metodi delle classi come se queste fossero template di oggetti reali, si tende a generare entità eccessivamente interdipendenti e poco flessibili. Questo porta a cadere in labirinti di complessità paragonabili a quelli ottenuti in programmazione procedurale, nei quali lo stato mutabile rende molto difficile la gestione, manutenzione e in generale la comprensibilità del sistema. Quindi spesso i concetti di “classe” e “oggetto” sono, purtroppo, usati in contesti sostanzialmente procedurali: non sono rari metodi lunghi centinaia di righe con molti if-else annidati, o riferimenti a dettagli di implementazione di altri oggetti, che dovrebbero rimanere “nascosti” al mondo esterno.

Un tentativo di soluzione a questi problemi è rappresentato dai design patterns, cioè strutture di classi e interfacce che hanno dimostrato eccellente applicabilità e robustezza nell’affrontare molti problemi pratici; ma essi sono appunto pattern, non sono teoremi, la loro capacità di risolvere problemi è verificata dal tempo e dall’esperienza, ma essi non permettono in alcun modo di provare in maniera deterministica che un software sia corretto. Il problema di fondo è che, a causa della continua variazione di stato degli oggetti, la struttura di un software scritto in OOP non può essere rappresentata con un modello matematico: funzioni ed equazioni rappresentano relazioni statiche tra variabili dipendenti e indipendenti, con parametri costanti per ogni relazione; variando i parametri, cambia la relazione. Quindi il sistema di astrazioni su cui OOP si basa non può essere descritto dalla matematica. Ma la matematica è proprio l’antico e potente strumento utilizzato in qualsiasi disciplina scientifica proprio quando è necessario fornire prove formali della correttezza di sistemi che non possono essere concepiti in maniera dettagliata dalla sola intuizione umana.

Un vecchio paradigma: Programmazione Funzionale

La “programmazione funzionale” (Functional Programming: FP) è un paradigma di sviluppo software in parte codificato già alla fine degli anni cinquanta, attraverso il noto linguaggio LISP, e gode di solide basi matematiche nella logica combinatoria e nel lambda calcolo: malgrado ciò, ha trovato difficoltà nel diffondersi sia nei corsi universitari di base che nelle aziende, forse per un’alta barriera d’ingresso, dovuta appunto alle forti basi matematiche e alla manipolazione di concetti molto astratti e poco intuitivi.

FP si basa sull’idea che un software possa essere descritto attraverso l’applicazione di tre tecniche principali:

  • la definizione di un gran numero di funzioni pure, cioè funzioni senza effetti collaterali: una funzione pura ritorna sempre un valore, e tale valore è sempre lo stesso a parità di condizioni di ingresso, quindi non potrà mai succedere che una certa funzione ritorni due diversi valori in due diversi momenti nel corso dell’esecuzione di un software se non cambiano i dati in ingresso alla funzione;
  • l’applicazione di queste funzioni ai dati soggetti a manipolazione nel nostro software, e la loro combinazione attraverso particolari funzioni note come funzioni di più altro ordine, anch’esse pure, che però presentano altre funzioni tra i dati di ingresso e/o di uscita;
  • l’uso di dati immutabili e l’assenza di variazione di stato, che permettono di trattare estese porzioni di un software come se fossero equazioni matematiche;

Funzioni pure, indipendenti dallo stato di esecuzione di un software, rappresentano quindi delle relazioni statiche tra entità: il processo di testing sarà quindi più semplice perché ogni funzione da testare sarà del tutto indipendente dalle altre. In realtà il testing in sé non è particolarmente importante in FP, perché tale paradigma si basa sull’idea di verificare a priori che un programma sia corretto. Un software scritto in FP nasce in primo luogo da una costruzione teorica la cui correttezza è verificabile tramite ragionamento equazionale. Saremo quindi in grado di ottenere un software corretto se:

  • ciascuna funzione sarà stata implementata correttamente;
  • avremo impostato le relazioni corrette tra le entità coinvolte;

In FP le funzioni sono anche dati, nel senso che hanno un tipo associato. Ad esempio, usando la notazione di Swift, possiamo definire il tipo di una funzione square, che permette di elevare al quadrato un numero intero, nel seguente modo:

let square: Int -> Int

Possiamo leggere la definizione di questo type così: una funzione che prende in ingresso un numero intero e ritorna un numero intero. Se nel software che stiamo scrivendo dovessimo trasformare un array di numeri interi in un altro array con gli stessi numeri elevati al quadrato, potremmo definire una funzione map con la seguente notazione:

let map: ([Int], Int -> Int) -> [Int]

Questo type definisce una funzione che prende in ingresso un array di numeri interi e una funzione del tipo Int -> Int (per esempio la funzione square, definita prima). Abbiamo già visto in un precedente articolo come ragionare sui tipi di dati e funzioni coinvolti possa semplificare e irrobustire il design di un software: i prossimi articoli di questa serie serviranno da introduzione alla programmazione funzionale, trattando i principali strumenti utilizzati e mostrando un modo meno diffuso ma forse più efficace di gestire la complessità del software.

comments powered by Disqus