FYI.

This story is over 5 years old.

Tecnologia

Qual è il codice più piccolo possibile?

Una singola cifra numerica? Un linea di codice assembly? Scopriamolo.

Questa domanda mi è stata fatto dall'Editor-in-Chief di Motherboard Derek Mead e non riesco a smettere di pensarci: Qual è la più piccola unità di codice del mondo dell'informatica?

È una domanda interessante perché fa riferimento, prima di tutto, al vero significato di 'codice'. Un piccolo codice corrisponde a ciò che noi sviluppatori e programmatori vediamo effettivamente sullo schermo? O dovremmo misurare il codice in base a come viene tradotto e come effettivamente agisce su una vera macchina? Personalmente, credo sia una combinazione delle due cose: il codice più piccolo possibile corrisponde alla minore quantità di sintassi di un linguaggio di programmazione che possiamo scrivere per produrre il più grande effetto a livello macchina.

Pubblicità

Quindi, diamo un'occhiata alle tre prospettive menzionate sopra, cominciando con la più semplice.

LA SINTASSI PIÙ PICCOLA

Parlando di numero di caratteri, qual è il più piccolo pezzo di sintassi di programmazione funzionante che posso scrivere

Per essere chiari: non conosco tutti i linguaggi di programmazione, ma ho dimestichezza con tutti i principali e anche qualcosa in più. I cosiddetti linguaggi interpretati sono ciò che rendono questo interrogativo semplice. Si tratta di linguaggi la cui sintassi (ciò che il programmatore, di fatto, scrive) viene data in pasto a un software intermediario che funge da traduttore (o interprete) tra il nostro codice di alto livello e unità pre-costruite di istruzioni macchina. È come eseguire dei programmi dentro altri programmi.

L'alternativa a un linguaggio interpretato è un linguaggio compilato, per cui scriviamo un tot di codice in un file e poi inviamo quel file ad essere convertito in una nuova serie di istruzioni macchina che rappresentano solamente (e solamente) il file di input. La differenza è un po' come quella che c'è tra il costruire un castello con dei Lego (linguaggio interpretato) e costruirlo con un grosso pezzo di plastica fusa (linguaggio compilato). Entrambi gli approcci hanno i loro vantaggi e i loro svantaggi. Generalmente, se stai lavorando a un grosso pezzo di software che dovrà essere utilizzato su un computer vero e proprio, lo programmerai con un linguaggio compilato.

Pubblicità

Python e il JavaScript sono entrambi linguaggi interpretati. Siamo liberi di scrivere enormi programmi vecchia scuola in ognuno dei due linguaggi, ma possiamo anche dare in pasto piccoli frammenti di sintassi direttamente all'interprete di ognuno dei linguaggi, che esistono sotto forma di linea di comando identica a quella del tuo sistema operativo (anch'essa è un'interprete, ma per un set diverso di comandi). Quest'è, Python è un linguaggio di programmazione ma è anche un software installato nei nostri sistemi come qualsiasi altro software.

Per rispondere alla domanda: una singola cifra numerica. Questo, probabilmente, è il più piccolo frammento di sintassi codice che posso scrivere in qualsiasi linguaggio di programmazione.

Posso inserire una singola cifra numerica sia nell'interprete Phyton che in quello Node.js (una shell che interpreta il JavaScript) e entrambi me lo restituiranno senza errori o avvisi di sorta. Posso anche non scrivere nulla e non ricevere alcun errore, ma in questo caso forse non avrebbe granché senso.

In un linguaggio compilato, c'è bisogno di molto di più, relativamente parlando. Abbiamo almeno bisogno della shell di una funzione che fornisca al sistema operativo il punto di partenza nel programma, quindi almeno una mezza dozzina di caratteri. Lo scheletro base di un programma in C++ assomiglia a questo:

int main() { return 0; }

Non è molto, ma è sicuramente più di:

Pubblicità

L'IMPRONTA PIU' PICCOLA

Non credo che la misura della sintassi più breve indicata sopra sia una maniera onesta di rispondere a questa domanda. Per eseguire quello "0" ci sono bisogno, in realtà, di un bel po' di risorse di sistema, relativamente parlando. Secondo l'activity monitor del mio MacBook, lo shell Node che ho usato per interpretare la singola cifra sta occupando circa 11 MB di memoria di sistema. Un singolo carattere, però, può essere rappresentato da un singolo byte di memoria. Quindi, stiamo sfruttando 11 milioni di byte per visualizzare un byte di dati.

include

int main() { cout << 0; return 0; } Il codice C++ sopra è modificato per visualizzare la singola cifra "0" occupa circa 28.000 byte di memoria al massimo (secondo il tool per code profiling Valgrind). Si tratta di un ingombro decisamente minore. Ciononostante, 28.000 byte sono 28.000 byte. Potrei migliorare la situazione liberandomi di "iostream," una libreria C++ standard usata per operazioni input/output. Includerla significa che sto includendo del codice extra da altri file, e poi altro codice da altri file che dipendono dal codice iostream. La libreria iostream non è di per sé enorme, ma deve chiamare in causa qualche altra componente per riuscire a lavorare a dovere. Tutto ciò finisce impiantato nella memoria di sistema quando il codice viene effettivamente eseguito. Nel programma sopra, iostream ci restituisce solamente cout (pronunciato "sii-aut", ma sarà per sempre "kout" nella mia testa). Si tratta solamente di un frammento di sintassi utile a restituire dati allo schermo. Possiamo fare la stessa cosa in maniera leggermente diversa, come in:

include int main() { printf(0); return 0; }

Abbiamo scambiato le librerie per una libreria standard usata nella programmazione in C. L'utilizzo di memoria è più o meno lo stesso, ma abbiamo reso il programma molto più piccolo. La versione iostream (C++) è da circa 9KB, mentre la più leggera libreria stdio, pensata per il linguaggio C, permette di limare il programma fino a raggiungere una grandezza di 1KB. Possiamo anche effettuare queste misurazioni grazie a una rappresentazione in assembly. Il linguaggio assembly è la tappa finale del viaggio di ogni programma, dal programmatore, passando per il linguaggio di programmazione fino alle effettive istruzioni macchina. Possiamo dire che si tratti dell'ultimo step leggibile dagli esseri umani nel processo. Il codice è compilato in linguaggio assembly da un compilatore ed è poi assemblato da un assembler. Il compito dell'assembler è di fare una conversione uno-a-uno dal linguaggio assembly alle istruzioni binarie leggibili dalla macchina. Paragonando i due frammenti di codice sopra, la differenza è da trovarsi solamente nella grandezza del file—dei due file assembly—che è di circa 50 byte, anche se una rapida ispezione rivela che i codici assembly prodotti dai compilatori è decisamente simile. Il linguaggio assembly è come una grossa piallatrice. Non gliene frega nulla del tuo linguaggio di programmazione preferito, vuole solo rendere il programma quanto più efficiente possibile per una data architettura di sistema. Potrei anche scrivere del codice assembly da zero per tirare fuori lo stesso numero visualizzato dai codici di prima, anche se sarà tutto sommato uguale a ciò che viene restituito dal compilatore. C'è un problema con questo esempio, però. Restituire una singola cifra alla finestra della linea di comando non è così semplice quando si parla di codice a questi livelli. Per riuscirci, il linguaggio assembly deve fare riferimento al sistema operativo—non ci sono istruzione pre-installato per "print." Tralasciando print, possiamo chiederci qual è il più piccolo frammento di codice che fa qualunque cosa. Il meglio che sono riuscito a fare è stato: xor eax, 0x01 Qui l'assembly sta dicendo al processore di cambiare un singolo bit nel registro chiamato eax. In pratica, non è così leggero e diretto quanto sembra, ma in teoria stiamo dicendo alla macchina di cambiare un singolo bit non in un registro di memoria, ma in un registro procedurale. In teoria, non sta facendo nulla alla memoria di sistema, agisce solamente nei confronti dei piccoli pacchetti di memoria che il processore si riserva per i suoi stessi calcoli.

POCO CODICE, TANTA MEMORIA

Senza alcuna buona ragione, mi sono messo a pensare a un esempio di piccolo frammento di codice (a livello di sintassi) che ha grandi effetti (a livello macchina). Questo aspetto, in particolare, è la nemesi di qualunque ingegnere software, e di solito assume l'aspetto di un leak di memoria. Quando vengono eseguiti, i programmi (in particolare quelli in C e in C++) dovrebbero deallocare tutta la memoria che usano mentre sono in funzione. Succede questo: un programma viene lanciato e richiede una certa quantità di memoria e, poi, mentre sta funzionando, ha bisogno di altra memoria per una ragione qualsiasi. È compito del programmatore assicurarsi che quella memoria in più venga liberata una volta cessato il programma. I leak di memoria sono pericolosi per il modo in cui si accumulano. Anche se ogni volta che viene chiamata una funzione munita di perdita si rischiano di perdere appena una manciata di byte, quella stessa funzione potrebbe essere chiamata un milione di volte. Improvvisamente, questo malfunzionamento diventa un problema gigantesco. Dal punto di vista della memoria o del processore, tutto ciò che serve per distruggere un sistema è un loop. La più classica forma di loop ha questo aspetto, la quale va in ciclo all'infinito fintanto che la funzione a cui fa riferimento la funzione "while" diventa non vera. while(1){} Nel nostro caso, questa funzione (il numero 1) non diventerà mai non vera, quindi andrà in ciclo all'infinito. Se per esempio a questo loop aggiungessimo un codice che include una parola in memoria (64 o 32 bit, normalmente) per ogni iterazione, ecco che salta fuori un bel problema. La scorsa notte ho fatto saltare il mio sistema, un MacBook Pro circa 2015 di medio livello, in maniera piuttosto spettacolare. Stavo risolvendo una sfida su HackerRank che raggruppa e mappa dei numeri in maniera particolare. C'è un modo semplice per farlo, per cui praticamente mappi ogni numero più piccolo fino al numero che dovremmo effettivamente mappare. L'idea è che stai contando di uno in uno, ma prima di poter andare al prossimo numero più alto, devi tornare allo zero e poi risalire. Per capirci, per andare da 9.999.999.999 a 10.000.000.000, devo contare alla rovescia di 9.999.999.999 prima di poter salire di uno. È un po' difficile da spiegare, ma una volta che siamo arrivati a 10 miliardi, abbiamo mosso una quantità assurda di potenza procedurale per fare queste semplici addizioni e sottrazioni. Quindi mi sono ritrovato con un computer in crash e insolitamente caldo, bloccato in pochi secondi da mezza dozzina di linee di codice. Quindi, in questo caso, sto parlando di percentuali di utilizzo CPU invece che di memoria, ma credo si sia capito come un frammento di codice possa far colare a picco la CPU come se fosse nulla. Infine, non so se ho davvero una risposta alla domanda. L'idea di un piccolo-codice-dal-grande-impatto è buona, ma nelle sue forme più estreme non è altro che un esempio di cattiva programmazione. Provare a conservare risorse hardware, siano esse memoria o cicli procedurali, è una buona idea, ma i computer oggi non hanno bisogno di programmatori attenti a questi aspetti. Per quanto riguarda la sintassi, il primo esempio non è particolarmente ideale, il codice super compatto di solito corrisponde a bassa leggibilità e mantenibilità. Ci sono molti modi per rendere piccolo un codice, ma come in tante cose, ciò che vogliamo è moderazione.