DI RECENTE ACCOMAZZI...
CERCA
» Ricerca avanzata
MAILING LIST

Se vuoi iscriverti alla mailing list di Luca Accomazzi inserisci qui la tua mail:

Vuoi ricevere i messaggi immediatamente (50 invii / giorno) o in differita e in gruppo
(due invii / giorno)?

» Vuoi saperne di più?

La Torre di BaBasic - 9

Nelle nove puntate di questa serie abbiamo passato in rassegna i linguaggi di programmazione, esaminando i più diffusi e scoprendo cosa hanno in comune, in cosa differiscono, e in che direzione stanno andando.
É più che giusto, dunque, dedicare l'ultima puntata di questa serie a scoprire come funziona quello strano programma che ci permette di usare i linguaggi di programmazione: il compilatore e il suo fratello l'interprete.

Cos'è, dopo tutto, un linguaggio? Un mezzo di comunicazione. Nel caso dei computer, un linguaggio di programmazione è un mezzo con il quale un essere umano (il programmatore) spiega al calcolatore cosa desidera che esso faccia.

L'antenato neppure tanto remoto del calcolatore elettronico è la macchina a schede perforate. Inventata da Herman Hollerith poco prima del 1900, la macchina a schede perforate permetteva la memorizzazione di un insieme di dati (per esempio, i dati anagrafici di una persona: essa infatti venne introdotta per risolvere i problemi dell'ufficio censimenti degli Stati Uniti) come fori in una scheda di cartoncino. La macchina permetteva di riordinare le schede, e di calcolare quante di esse condividessero una certa caratteristica, per esempio un foro che indicasse che la persona in questione era di sesso maschile.
Per la cronaca, la ditta di Hollerith, finanziariamente in cattive acque, venne inglobata attorno al 1910 dalla rivale Crt, azienda che sarebbe poi diventata piuttosto famosa con il nome di Ibm.

All'inizio del secolo la programmazione delle macchine a schede perforate avveniva utilizzando interruttori e cavi elettrici dotati di spinotti alle estremità. Manipolando questi oggetti su un pannello, si chiudevano i circuiti elettrici che condizionavano il comportamento della macchina. Presto divennero diffuse delle schede elettriche, con i loro interruttori e spinotti, rimovibili e installabili per incastro: ciascuna di queste schede era per così dire un programma, e l'operatore passava da un programma ad un altro rimuovendo la scheda e inserendone un'altra.

Per una programmazione più simile a quella che viene eseguita oggigiorno, dobbiamo attendere la nascita dei calcolatori elettromeccanici negli anni '40: con queste macchine era possibile introdurre i programmi, come già i dati, facendo uso di schede perforate.

Inizialmente la programmazione dei calcolatori avveniva unicamente utilizzando il linguaggio nativo del computer, il linguaggio macchina. Se le operazioni di base che la macchina fisica sa eseguire sono, poniamo, una cinquantina in tutto, a ciascuna di queste operazioni si fa corrispondere un numero intero, e scrivere un programma significa scrivere una fila di numeri, corrispondenti alla sequenza di operazioni. Non si può certo dire che programmare in questi termini fosse semplice: associare alla sequenza 46 01 25 76 81 29 un programma era assai semplice per il computer ma assai problematico per il povero umano.
Per questo motivo, ben presto si passò a sostituire i numeri con equivalenti mnemonici. Anzichè scrivere il numero 76 per far eseguire una istruzione di salto si sarebbe usata la equivalente etichetta JMP (la contrazione del verbo inglese to jump, che significa saltare). Il programma scritto in questo modo sarebbe stato dato in pasto al calcolatore, che consultando una tabella di equivalenze tra etichette e numeri avrebbe ricavato il programma in linguaggio macchina. Era nato il primo linguaggio di programmazione, lo Assembler.

Il vero passo in avanti venne quando qualcuno pensò che si poteva creare un linguaggio di programmazione evoluto associando a una istruzione del nuovo linguaggio numerose istruzioni macchina. Il programmatore avrebbe potuto scrivere, per esempio, C = A + B, e il computer avrebbe eseguito questa sequenza di operazioni: recupera dalla memoria il contenuto della cella A. Somma a questa il contenuto della cella B. Salva il risultato nella cella C.
Riflettiamo un momento su cosa abbiamo appena introdotto. Noi abbiamo un primo programma, il programma sorgente, che viene scritto dal programmatore. Nel nostro esempio, il programma sorgente è l'istruzione C = A + B. Il sorgente è scritto in un linguaggio di programmazione evoluto, cioé facile da capire per il programmatore, ma che non significa nulla per il calcolatore. A questo punto interviene un programma scritto da un esperto, chiamato compilatore. Il compilatore legge il sorgente e lo traduce in una forma equivalente, non più leggibile dagli esseri umani ma eseguibile dal computer: si tratta di un nuovo programma, scritto dal compilatore in linguaggio macchina.

Detto in questi termini, il compilatore sembra un programma abbastanza semplice. Che ci vuole, in fondo? Si legge nel sorgente una istruzione e si scrive nel compilato la sequenza di istruzioni equivalente.
Non è così semplice: anzi, la creazione di un compilatore è uno dei compiti più complessi per un tecnico, superato probabilmente in difficoltà solo dalla creazione di un sistema operativo.
Il primo componente di un compilatore è chiamato il parser: questo termine inglese può tradursi grossolanamente in "analizzatore". Il parser legge il sorgente carattere per carattere, e cerca di decifrarlo.
In un linguaggio evoluto, infatti, gruppi di lettere rappresentano una parola, esattamente come avviene nei linguaggi umani: "casa" è una parola italiana composta di quattro lettere, e "print" è una parola del Basic composta da cinque. Un'altra osservazione, per quanto scontata: non tutte le parole possibili hanno un senso in un linguaggio. Per esempio, "lkjfy" è una parola che non appartiene a nessun linguaggio.
Il Parser, dunque, ha lo scopo di riconoscere le parole chiave del linguaggio all'interno del sorgente. Non è un compito facile, perché non è facile stabilire quali possono essere le parole chiave; esiste unabranca dell'informatica e della matematica che studia proprio queste problematiche: noi non ne parleremo qui, poiché si tratta di una materia piuttosto complessa e noiosa. In sintesi, le parole chiave del linguaggio devono poter essere riconosciute leggendole dall'inizio alla fine e senza bisogno di ritornare indietro a rileggerne qualcuna. Il parser, infatti, è un semplice automa che viaggia solo in avanti quando legge il testo.
Facciamo un esempio. Se stiamo creando un linguaggio, potremmo stabilire che le parole del linguaggio sono tutte quelle parole che cominciano per al e finiscono per n, oppure iniziano e finiscono con una vocale: al nostro linguaggio apparterrebbero le parole aldebaran, albero o uomo, ma non la parola zingaro. Questo linguaggio non è accettabile come linguaggio di programmazione, perchè il parser trovando la lettera a all'inizio di una parola non sa se seguire la prima o la seconda regola per verificarla; è costretto a sceglierne una a caso e a tornare indietro per verificare l'altra se la regola scelta non è soddisfatta.

Il parser produce una versione più compatta del sorgente, dove a ciascuna parola chiave del linguaggio (le parole come Print in Basic oppure WriteLn in Pascal) viene sostituito un numero caratteristico. Le parole che appartengono al linguaggio sono infatti numerate, e il parser riconoscendole le rimpiazza con questo numerodistintivo.

Prima che possa iniziare la produzione del codice, è necessario che il compilatore abbia a disposizione un elenco delle variabili usate nel programma. Se venisse trovata una istruzione del tipo 'stampa sul video il contenuto della variabile x', infatti, il compilatore deve innanzitutto sapere se una simile variabile esiste, ma deve anche sapere di che tipo sia (un numero intero, un numero reale, un carattere?) e soprattutto deve conoscere l'indirizzo di memoria dove quella variabile è stata memorizzata, in modo da potervi accedere.
Il compilatore produce pertanto una tabella delle variabili, dove queste informazioni vengono salvate: tipicamente la tabella contiene le tre informazioni che abbiamo detto: il nome, il tipo e l'indirizzo della variabile. Le cose si complicano un poco se pensiamo che alcune variabili non sono visibili da ovunque nel programma, e si complicano ancora di più di fronte alla considerazione che l'indirizzo di memoria spesso non è conosciuto dal compilatore, e viene stabilito soltanto al momento dell'esecuzione del programma a seconda delle disponibilità di memoria della macchina che lo deve eseguire. Appianare tutti questi dettagli richiederebbe troppo tempo, e noi li tralasceremo: potremo forse ritornarvi sopra in futuro.

La necessità di avere a disposizione la tabella delle variabili durante la compilazione ci porta difilati a una angosciosa domanda: quante volte viene letto il sorgente dal compilatore? La questione non è peregrina, perchè non sempre il compilatore può permettersi di leggere tutto il sorgente dal disco sulla memoria centrale della macchina, dove l'accesso è rapido. Spesso il sorgente risiede su disco, e viene letto pezzo per pezzo: in questo caso la necessità di rileggere più volte il sorgente allunga notevolmente i tempi di compilazione.
Il numero di letture del sorgente cambia ampiamente di linguaggio in linguaggio. In Assembler, per esempio, tipicamente vi sono due letture del sorgente: durante la prima di produce la tabella delle variabili, mentre durante la seconda viene generato il codice eseguibile. Questo è necessario perchè in Assembler è possibile scrivere:
LDA Variab1
(...omissis...)
Variab1 Equ *


Per dirla in altri termini, in Assembler è possibile usare una etichetta, come variab1 nel nostro esempio, prima di averla definita.
Il linguaggio Pascal, invece, è stato creato in modo che sia necessaria una sola lettura del sorgente. Quei nostri lettori che conoscono il Pascal ricorderanno certamente che gli elementi del programma devono rispettare una certa sequenza rigida di apparizione:
  1. Label
  2. Const
  3. Type
  4. Var
  5. Procedure e Function
  6. Corpo del programma


Questa sequenza è accuratamente calibrata in modo che l'apparizione di una variabile sia sempre preceduta dalla sua dichiarazione: in questo modo il parser, il creatore della tabella di variabili e il modulo generatore di codice eseguibile possono lavorare contemporaneamente, e il sorgente viene letto una volta sola.
É per questo che i compilatori Pascal sono tanto veloci: se prendiamo per esempio i linguaggi Turbo della Borland, tutti specializzati nella estrema velocità di compilazione, vediamo che mentre Turbo Pascal 5.0 divora 34.000 righe di codice al minuto su un Ps/2 modello 60, il Turbo C 2.0 è limitato a sole 16.000 righe, meno della metà.

Il linguaggio C, in effetti, spicca per la sua lentezza: in genere richiede tre passate prima di finire il suo lavoro: alle due letture effettuate dall'Assembler, il C fa precedere una lettura dedicata alla risoluzione delle istruzioni al preprocessore.

I compilatori migliori, poi, effettuano la ottimizzazione del codice prodotto.
Ovviamente, la capacità di produrre un programma eseguibile di dimensioni modeste è un pregio notevolissimo. Un programma più piccolo viene caricato più in fretta dal disco, ed è eseguibile anche su computer con poca memoria centrale a disposizione.

Per capire come funziona l'ottimizzazione del codice, dobbiamo ritornare alla nostra prima definizione di compilatore: il compilatore, abbiamo detto, sostituisce a una istruzione del linguaggio evoluto una serie di operazioni di linguaggio macchina. E abbiamo fatto l'esempio dell'istruzione C = A + B.
Proviamo a considerare cosa succede quando il compilatore compila un programma che contiene queste due righe:
C = A + B
Stampa (C)


Una istruzione di stampa viene normalmente eseguita facendo una richiesta al sistema operativo (questo aspetto della vicenda è esaminato in dettaglio del mio libro "Conoscere i sistemi operativi", numero 554 del catalogo Jackson).
Pertanto il codice prodotto dal compilatore risulta essere pressappoco così (il registro è una variabile costruita fisicamente all'interno del processore che esegue il programma, ed è al suo interno che vengono eseguiti i calcoli):

(C = A + B)

  • Leggi A nel registro 1
  • Somma B al registro 1
  • Salva il registro 1 in C

( Stampa C )

  • Leggi C nel registro 1
  • Chiedi al SO di stampare


Che cosa notate immediatamente? La prima istruzione creata compilando Stampa C è inutile, perchè il valore di C si trova già nel registro 1. D'altra parte il compilatore deve generare quella istruzione, perché se tra le due righe del programma sorgente che abbiamo visto venisse inserita un'altra riga, il contenuto del registro 1 sarebbe quasi certamente distrutto.

È proprio per questo motivo che alcuni compilatori forniscono la capacità di ottimizzazione. L'ottimizzatore del compilatore riconosce una situazione come quella che abbiamo visto, e rimuove dal codice oggetto l'istruzione in surplus.

Le operazioni di ottimizzazione in genere sono molto complesse e richiedono una discreta quantità di tempo per essere svolte. É per questo motivo che normalmente l'ottimizzazione del proramma viene richiesta dai programmatori solamente quando lo sviluppo del programma è stato completato, e bisogna produrre la versione definitiva che sarà commercializzata.

E con questo abbiamo davvero finito il nostro viaggio nello strano mondo dei linguaggi di programmazione. Ci auguriamo di aver interessato i nostri lettori senza annoiarli eccessivamente: Feedback, la nostra rubrica della posta, resta a disposizione per chiarire tutti quegli aspetti che fossero rimasti oscuri.


Questo articolo fa parte di uno dei miei percorsi. Se vuoi saperne di più su questo argomento, visita il resto del percorso cliccando qui.