Quando si comincia a parlare di linguaggi di programmazione è facile che la discussione degeneri in confronti e “misure” della relativa “potenza”, intendendo con ciò la possibilità di essere applicati a diversi (numerosi) domini. Ovviamente anche su queste pagine non sono mancate discussioni in merito, in particolare nel recente articolo riguardo alla causa intentata da Oracle nei confronti di Google per Java.
Dal punto di vista strettamente formale il discorso si potrebbe tranquillamente chiudere qui, perché sappiamo bene dalla teoria della calcolabilità che tutti i linguaggi Turing-completi sono equipotenti, quindi sono in grado di elaborare e risolvere esattamente la stessa classe di problemi.
Tutto ciò rimanendo in ambito puramente accademico, ma nella realtà di tutti i giorni non abbiamo a che fare con modelli teorici, macchine dotate di registri o nastri infiniti, ecc., quanto piuttosto di macchine finite con caratteristiche ben precise che limitano ciò che siamo in grado di fare o, matematicamente parlando, di risolvere, e per le quali esiste una moltitudine di linguaggi che devono misurarsi con le risorse a disposizione.
La proliferazione dei linguaggi di programmazione è intimamente legata all’evoluzione dei computer e dei progressi che sono stati fatti a livello tecnologico per permetterci di avere hardware sempre più a buon mercato, e con caratteristiche via via migliori, spalancando le porte del computer domestico (o PC) alla massa, e al contempo a una maggiore astrazione.
I primi computer si programmavano in linguaggio macchina perché non c’era altro mezzo: avevano pochissima memoria e capacità di calcolo, per cui gli sviluppatori dell’epoca erano costretti a conoscere dettagli di bassissimo livello quali la struttura degli opcode del processore, avendo a che fare con sfilze di numeri binari che rappresentavano codice o dati a seconda del contesto.
Con la disponibilità di hardware più capace e l’affinamento delle conoscenze, si è passati a un maggior livello di astrazione, grazie all’introduzione degli assemblatori prima, e di linguaggi di più alto livello come Fortran, Cobol, Lisp, Algol, e così via, fino ad arrivare ai nostri giorni dove assistiamo a un’esplosione di nuovi linguaggi che saltano fuori come funghi, grazie al fatto che realizzare compilatori, interpreti, o “ibridi” (compilatori con runtime che fanno uso di virtual machine) è diventato decisamente più semplice.
L’astrazione ha avuto un duplice, ma importantissimo, scopo nonché implicazione: slegarsi dai dettagli di più basso livello dell’hardware, e fornire agli sviluppatori strumenti più comodi per lavorare, migliorandone la produttività.
Applicare questo concetto (il primo) a un assemblatore sembra, però, un’eresia, in quanto tale strumento è sinonimo di basso livello, appena un pelo sopra il linguaggio macchina, e di cui viene considerato sostanzialmente un “mascheramento”, visto che a uno mnemonico corrisponde un opcode codificato in linguaggio macchina.
In realtà questa visione abbastanza comune non tiene conto delle evoluzioni che ci sono state anche in questo campo. L’introduzione di macro, ad esempio, ha aggiunto notevole flessibilità e capacità di astrazione al linguaggio assembly, come pure l’aggiunta di strutture dati (o record, per gli affezionati del Pascal) con tanto di “ereditarietà” che richiama alla mente la programmazione a oggetti, e altri utili costrutti.
Inoltre non si tiene conto del fattore più importante: l’astrazione offerta dall’assembly permette di slegarsi dall’architettura a cui normalmente si riferisce, e in particolare dal linguaggio macchina che ci sta sotto.
Anche questo può sembrare un attentato alla fede costituita (vista la citata relazione fra mnemonico e opcode), ma un esempio servirà a chiarire meglio il concetto, e riguarda l’8086 di Intel, che al momento dell’introduzione offriva compatibilità a livello sorgente coi precedenti processori della casa, 8008, 8080 e 8085.
Questo era possibile grazie alla presenza di strumenti che convertivano i sorgenti dall’assembly di quei microprocessori a quello 8086. L’architettura di quest’ultimo era diversa, soprattutto a livello di ISA, per cui la compatibilità binaria era fuori discussione, ma grazie a questi programmi di traduzione il parco software dei predecessori poteva essere facilmente portato sul nuovo arrivato.
Può sembrare un caso isolato, ma il concetto è applicabile ad altre architetture, anche moderne. Chiaramente non sempre una traduzione del genere è funzionante, perché dipende da due fattori molto importanti: l’hardware e/o il s.o. su cui gira il processore, che possono inchiodare il codice a quello specifico sistema.
In ultima analisi, bisogna considerare anche le “porcate” che un programmatore assembly può tirare fuori dal cappello; roba perfettamente legittima per quella precisa architettura, ma la cui traduzione risulterebbe poi inutilizzabile. Ad esempio giocare coi limiti dei segmenti a 16 bit degli 8086, o con quelli dello spazio d’indirizzamento a 20 bit e relativo wrapping alla parte bassa della memoria. Oppure, ancora peggio, infilare codice macchina in mezzo a quello assembly, sotto forma di “dati”.
Il passaggio successivo, a linguaggi di più alto livello, ha permesso un’astrazione maggiore e dovrebbe aver rimosso il legame con la particolare architettura, o comunque con dettagli di livello estremamente basso con cui in genere si ha a che fare lavorando in linguaggio macchina o assembly.
Sulla carta, e formalmente, è più o meno così, e a memoria non ricordo linguaggi di alto livello le cui specifiche consentono di accedere a determinati registri di una CPU, a precise locazioni di memoria, o a porte di I/O per le architetture che le mettono a disposizione, giusto per citarne qualcuno.
Infatti spulciando il draft dello standard ISO C99 non si evince nulla del genere. Anzi, il documento è pieno di parti non definite e lasciate liberamente allo sviluppatore. Se consideriamo che il C è il linguaggio che da anni ha ormai preso il posto dell’assembly per l’interfacciamento con l’hardware, c’è di che rimanere a bocca aperta.
Non bisogna, però, meravigliarsi, perché rientra perfettamente nello scopo: astrarre dall’hardware e dai dettagli di basso livello, semplificando la vita ai programmatori e permettendo di scrivere software molto più portabile. Ma come si può pensare di realizzare sistemi operativi per le architetture più disparate senza poter manipolare lo specifico hardware?
Eppure sappiamo che il C come linguaggio è nato proprio con lo scopo di scrivere (velocemente) Unix, s.o. che è stato portato sulle piattaforme più disparate, e non è servito soltanto a questo, ma a realizzare codice che spazia dal basso livello (driver, librerie), ad applicazioni anche molto complesse e addirittura dotate di interfaccia grafica.
Un linguaggio “potente”, quindi, nell’accezione utilizzata all’inizio dell’articolo, ma che non sarebbe tale attenendosi rigorosamente alle specifiche dello standard.
La chiave di volta, infatti, sta nell’andare oltre. Quindi nell’introduzione di costrutti non presenti nel linguaggio (ad esempio funzioni per gestire le porte di I/O) e/o forzando i dettagli che non sono specificati (la dimensione del char, che non è detto coincida col singolo byte), lasciando all’implementazione il compito di definirli e sfruttarli in base agli obiettivi che ci si è posti. O ancora limitando il linguaggio ed eliminando alcune funzionalità intrinsecamente non portabili (ad esempio i campi di bit).
Lo stesso concetto è, però, applicabile anche ad altri linguaggi. Infatti prima del C non è che non esistessero s.o. scritti in linguaggi di più alto livello; e anche dopo l’introduzione di questo linguaggio e l’ampio uso per la programmazione di sistema, ne sono stati scritti in altri linguaggi (ce ne sono anche in Java, e di recente ha fatto notizia Singularity, scritto in C# et similia).
In fine dei conti è l’implementazione che fa la differenza, anche se certamente si può affermare che un determinato linguaggio si presta meglio di altri per determinati scopi. D’altra parte se un linguaggio nasce, è proprio per portare un contributo, un miglioramento a un determinato settore dell’informatica, e come tale dovrebbe essere primariamente utilizzato (il che non significa che non si possa fare altro, sia chiaro).
Non bisogna, però, lasciarsi influenzare dalle implementazioni per fare assunzioni sul linguaggio in generale. Se è vero che un compilatore ottimizzato per il C ne può esaltare le qualità, non possono sprofondare di fronte a una pessima versione. Giusto per essere chiari, non sarebbe certo un interprete C a togliere meriti a questo linguaggio e alla sua “potenza”, come per qualunque altro.
Questo perché un linguaggio rimane rappresentato dalla sintassi e semantica che i suoi creatori gli hanno conferito. Con buona pace di qualunque implementazione…