Maurizio Tomasi (maurizio.tomasi@unimi.it)
Università degli Studi di Milano
https://ziotom78.github.io/tnds-tomasi-notebooks/tomasi-c++-python-julia.html
g++ e clang++
convertono il C++ in linguaggio macchina.
Attraverso identificativi come ebp, rsp,
eax… (interi), xmm0, xmm1, …
(floating point)
Esclusiva pertinenza della CPU!
RAM: la CPU richiede il dato al bus della memoria specificando l’indirizzo numerico
![]() |
Registri (<1 kB) | ![]() |
![]() |
RAM (8 GB) | ![]() |
![]() |
HD SSD da 1 TB | ![]() |
goto)for2 * x + y / z)new e
deleteUn programma in linguaggio macchina è una sequenza di bit:
0110101110…
Può essere «traslitterato» partendo dal linguaggio assembler (usando compilatori come NASM e YASM):
Compilare da assembler a linguaggio macchina (e viceversa) assomiglia a una «traslitterazione», come «πάντα ῥεῖ» ↔︎ «pánta rheî», più che a una «traduzione»
In passato, per molti computer era necessario programmare direttamente in Assembler (ossia in linguaggio macchina). Solo poche macchine offrivano nativamente linguaggi ad alto livello, come il Commodore 64:

Ma già dagli anni ’50 si erano sviluppati linguaggi ad alto livello, come Lisp e Fortran, i cui compilatori traducono (questa volta sì!) il codice in linguaggio macchina
for in cicli più semplici che la CPU
può eseguireFino agli anni ’90 i compilatori non producevano codice macchina efficiente
Spesso un programmatore con una minima infarinatura di assembler poteva scrivere codice più efficiente!
Alcuni versioni dei compilatori C/C++/Pascal offrivano la possibilità di inserire codice assembler direttamente all’interno di programmi scritti in altri linguaggi!
Esempio di codice Assembler (in verde) all’interno di un programma Pascal (Borland Pascal 7.0).
(Byte magazine, Febbraio 1989)
Nel Capitolo 8 del bel libro di Bolboacă & Deák “Debunking C++ myths”, gli autori raccontano che negli anni ’90 riuscirono tramite l’assembler a rendere più veloce un loro programma C++
Il programma doveva effettuare in modo efficiente una moltiplicazione di un intero per il numero 320. Il compilatore generava un banale prodotto, ma gli autori si accorsero che 320 = 256 + 64 = 2^8 + 2^6 (vedi i dettagli nel testo), e sfruttarono operazioni sui bit per rendere il prodotto più veloce
Ricompilando il loro vecchio codice ai giorni nostri (il libro è del 2024), gli autori hanno constatato che i compilatori moderni riescono a generare codice ancora più furbo e veloce del loro!
Come mostra l’esempio di Bolboacă & Deák, oggi siamo in una situazione completamente rovesciata!
Da un lato, le CPU più recenti usano ottimizzazioni molto complesse, ed è quindi difficile per un programmatore umano scrivere codice assembler che sfrutti efficientemente la macchina…
…e d’altra parte i compilatori moderni sono così sofisticati da produrre codice macchina imbattibile!
Scrivere codice assembler è quindi una cosa che oggi non è quasi mai necessaria, anche perché rende il codice poco portabile. (Ma ci sono eccezioni, come FFmpeg…)
gcc e clang, esiste il flag
-SforPer ogni dato, il compilatore deve decidere se usare un registro
o la RAM: nell’esempio, n era nella RAM mentre
i in un registro (eax)
Trovare la scelta ottimale è molto difficile (vedi Wikipedia)
In passato il C/C++ supportava la keyword
register (rimossa col C++17):
g++ si basa su GCC, che implementa una serie di
algoritmi per capire quale sia il modo più performante di usare i
registri e ordinare le istruzioniclang si basa sulla libreria LLVM, che prende in input una descrizione
«ad alto livello» della sequenza di operazioni da eseguire e le traduce
in codice assembler ottimizzatogcc), D (gdc), Go
(gccgo), Fortran
(gfortran), Ada
(gnat).| C++ | Python |
In C++, una istruzione come x = a + b, se
a e b sono interi, può essere convertita in
Assembler così:
Ma se a e b sono double,
diventa così:
Consideriamo ora questo programma Python:
Come può Python compilare in un linguaggio assembler la funzione
add, visto che la somma può assumere significati
diversi?
La risposta è che Python non produce un vero assembler, ma un “assembler virtuale”, più astratto perché non legato a dell’hardware specifico!
In Python, l’istruzione x = a + b viene
sempre compilata così:
Questi comandi assumono che ci sia un vettore di elementi
(chiamato stack) che venga mantenuto durante l’esecuzione, e
che load_fast e store_fast aggiungano e
tolgano elementi in coda al vettore.
Istruzioni come binary_add tolgono uno o più
elementi in coda al vettore, fanno un’operazione su di essi, e mettono
il risultato in coda al vettore
Per eseguire il file test.py, occorre sempre chiamare
python3:
Il programma python3 è scritto in C, ed è più o meno
fatto così:
run_commandLa funzione run_command esegue una
istruzione, e ogni volta che viene invocata deve capire come operare in
base al tipo di dato.
Verosimilmente, a seconda del comando che deve eseguire,
run_command chiama una funzione C che gestisce l’esecuzione
di quel particolare comando (load_fast,
store_fast, binary_add, …)
Questa è una possibile implementazione per
binary_add:
void binary_add(PyObject * val1,
PyObject * val2,
PyObject * result) {
if (isinteger(val1) && isinteger(val2)) {
/* Sum two integers */
int v1 = get_integer(val1);
int v2 = get_integer(val2);
result.set_type(PY_INTEGER)
result.set_integer(v1 + v2);
} else if (isreal(val1) && isreal(val2)) {
/* Sum two floating-point numbers */
} else {
/* ... */
}
}.h) → meno file da
gestireSe le variabili non hanno tipo, sono possibili molti errori
Quasi tutti gli errori capitano durante l’esecuzione: è quindi più facile che vada in crash un programma Python piuttosto che un programma C++. Esempio:
$ python3 test.py
Computing results… Please wait!
Traceback (most recent call last):
File "/home/tomasi/test.py", line 1429, in <module>
print_results(results)
NameError: name 'print_results' is not definedI programmi sono molto più lenti del C++!
Supponiamo di avere un file, test.txt, contenente
questi dati:
# This is a comment
#
# sensor temperature
upper_flange 301.76
lower_flange 270.1
horn 290.81
detector 85.3Esso contiene delle temperature registrate da termometri installati in uno strumento
Vogliamo scrivere un programma che stampi a video i nomi dei sensori, ordinati secondo la temperatura dal più freddo al più caldo. Il codice deve ignorare spazi, commenti e linee vuote
with open("test.txt", "rt") as inpf:
lines = [x.strip() for x in inpf.readlines()] # lines = { x.strip | x ∈ inpf.readlines }
# Remove from "lines" empty lines and comments
lines = [x for x in lines if x != "" and x[0] != "#"]
# Split each line in two
pairs = [x.split() for x in lines]
for sensor, temp in sorted(pairs, key=lambda x: float(x[1])):
print(f"{sensor:20} (T = {temp} K)")detector (T = 85.3 K)
lower_flange (T = 270.1 K)
horn (T = 290.81 K)
upper_flange (T = 301.76 K)
C, C++, FreePascal, gfortran, Rust, GNAT Ada, Nim, …
CPython, R, Matlab, IDL, …
Java, Kotlin, C#, LuaJIT, 👉Julia👈, etc.
| Python | Julia |
Julia ha le medesime performance del C++, ma com’è possibile se come per Python in Julia non si specificano i tipi?
Julia, a differenza di Python, compila il codice in linguaggio macchina. Ma la compilazione viene effettuata la prima volta che si chiede di eseguire una funzione, e solo per i tipi specifici usati in quella chiamata
Per esempio, nel momento in cui si scrive
mysum(1, 2), Julia esegue la compilazione assumendo che
a e b siano due interi.
A differenza del C++, la compilazione non viene fatta su un intero file, ma sulle singole funzioni: se una funzione non viene mai chiamata, non viene mai compilata in linguaggio macchina.
Julia non supporta le classi.
L’approccio OOP “alla C++” si è infatti dimostrato negli anni
poco adatto per il calcolo scientifico. Consideriamo ad esempio
FunzioneBase, che ci è servita molte volte:
e vediamone i limiti nell’ipotesi di voler rendere il codice più versatile.
Abbiamo visto che, per studiare come gli errori si propagano nel codice, un buon metodo è quello di eseguire una simulazione Monte Carlo
Ma queste simulazioni possono essere molto lente da eseguire, soprattutto se il modello è complesso!
Per certi calcoli sarebbe sufficiente la propagazione degli errori
Measurementstruct Measurement {
double value;
double error;
Measurement(double v, double e) : value{v}, error{e} {}
};
Measurement operator+(Measurement a, Measurement b) {
return Measurement{a.value + b.value, sqrt(pow(a.error, 2) + pow(b.error, 2))};
}
// Do the same for the other operators: -, *, /, sin, cos…MeasurementSupponiamo ora che io voglia calcolare lo zero o l’integrale di
una funzione derivata da FunzioneBase.
Mi è impossibile usare Measurement nella nostra
FunzioneBase, perché essa lavora solo con il tipo
double:
Anche qualsiasi classe derivata deve quindi usare i
double.
Se FunzioneBase fosse una classe di ROOT (quindi
immodificabile), sarei spacciato: non potrei usare
Measurement con essa!
Se invece fossi io l’autore di
FunzioneBase (ed è così!), potrei allora modificare il
codice. Ma così non potrei più compilare i miei vecchi programmi che
usavano la versione con i double.
Potrei fare una copia della classe e modificare quella, ma se in
futuro correggessi bug o apportassi miglioramenti a
FunzioneBase, dovrei ricordarmi di aggiornare
entrambe.
Supponiamo ora di aver implementato una classe
UnitValue che combini valori e unità di misura, e ne
verifichi la consistenza:
Mi piacerebbe usarla insieme alla mia classe
Measurement che propaga gli errori, ma non posso: sia
value che error sono variabili
double!
Se però modifico Measurement, rischio che la mia
nuova versione di FunzioneBase non funzioni più!
In Julia non si definisce il tipo dei parametri: si può quindi passare anche tipi «nuovi» a funzioni «vecchie».
In effetti, questo si può fare con due librerie già esistenti: Measurements.jl e Unitful.jl
using Measurements, Unitful
speed = (2.0 ± 0.1)u"m/s" # Use 'u' followed by a string to define the unit
start_pos = (3.5 ± 0.1)u"m"
time = (6.0 ± 0.5)u"s"
final_pos = start_pos + speed / time
# ERROR: DimensionError: 3.5 ± 0.1 m and 0.333 ± 0.032 m s^-2
# are not dimensionally compatible.
final_pos = start_pos + speed * time # Ok, the result is 15.5 ± 1.2 mIn realtà, anche in C++ è possibile ottenere la versatilità di Julia, ma bisogna abbandonare l’approccio OOP.
Se si definisse UnitValue come una classe template,
si potrebbe combinare con la classe Measurement (a patto
però di ridefinire anche gli operatori!):
Di fatto, le librerie scientifiche moderne in C++ non usano più approcci OOP come ROOT, ma sono basate sui template (Armadillo…)
Julia è un linguaggio omoiconico (“medesima rappresentazione”), che significa che il codice è rappresentato come una struttura dati accessibile dal linguaggio stesso.
Questa è una caratteristica mutuata dal linguaggio Scheme, da cui gli sviluppatori di Julia hanno preso spesso ispirazione. (Il cuore del compilatore di Julia è scritto in un dialetto di Scheme!)
Le macro di Julia sono apparentemente simili alle funzioni del
C++, ma hanno una importante differenza (no, non hanno nulla a che
vedere con #define!).
Consideriamo una funzione che accetta come argomento un intero
x, e stampa "A" se x è maggiore
di 2, "B" altrimenti.
| C++ | Julia |
Supponiamo ora di affrontare un problema apparentemente simile.
Vogliamo scrivere una funzione che accetta come argomento un
parametro x, e stampa "A" solo se
x è stato calcolato usando una somma, altrimenti
"B".
if, ma questa in C++ può
essere usata solo per confrontare il valore di variabilixif, while, for, …)
funzionano solo sul contenuto dei dati (variabili), e non sulle
istruzioni di codiceJulia è invece omoiconico, e quindi si può ispezionare il codice usando gli stessi costrutti del linguaggio che si usano con i dati:
Le macro vengono eseguite prima che il codice Julia venga tradotto in linguaggio macchina
Possono quindi essere usate per modificare del codice presente nel file sorgente, o addirittura per generarlo automaticamente
Ma una caratteristica simile non è troppo esotica e “accademica”? No, anzi, è straordinariamente pratica! Ma bisogna avere mente aperta per immaginarne le applicazioni…
La libreria Latexify traduce la
definizione di una funzione Julia in un’espressione LaTeX, che può
essere visualizzata con la funzione render:
julia> latex_str = @latexrun f(x; y=2) = (x + 2) / y - 1
julia> println(latex_str)
L"$f\left( x; y = 2 \right) = \frac{x + 2}{y} - 1$"
julia> render(latex_str)
Il modo in cui @latexrun opera è quello di esaminare
pezzo per pezzo l’espressione, e tradurre le sue operazioni in simboli
LaTeX.
È utilissima per verificare una formula matematica complessa.
argc e argvUn’altra bella applicazione dell’omoiconicità è la generazione di interfacce da linea di comando. Ricordate l’esercizio 6.2 (ricerca degli zeri)?
$ ./esercizio6.2 0 3 100 1e-5
Zero: 0.33333Il codice all’inizio del main era il seguente:
mainLa libreria Comonicon.jl (bel
nome!) implementa una macro, @main, che, se fosse scritta
per il C++, si userebbe così:
La macro @main analizza i parametri di
run_program e genera automaticamente il main,
usando stod e stoi in modo
appropriato.
$ ./esercizio6.2 --help
Usage:
fun [REQUIRED,optional-params]
An API call doc comment
Options:
-h, --help print this cligen-erated help
--help-syntax advanced: prepend,plurals,..
-a=, --a= float REQUIRED set a
-b=, --b= float REQUIRED set b
-n=, --nsteps-max= int REQUIRED set nsteps_max
-p=, --prec= float REQUIRED set prec
$ ./esercizio6.2 -a=0 -b=3 --nsteps-max=100 --prec=1e-5
Zero: 0.33333
$ ./esercizio6.2 0 3 100 1e-5
Zero: 0.33333
$
Julia rende semplice l’implementazione della differentiable programming, in cui si possono calcolare automaticamente derivate esatte di funzioni o addirittura interi programmi, usando le macro e l’omoiconicità
Un campo in cui Julia sta prendendo sempre più piede è quello dell’intelligenza artificiale: la differenziabilità è molto utile per ottimizzare reti neurali complesse
Anche se è un concetto vecchio (è nato col LISP, che risale agli anni ’50!), l’enorme potenziale dell’omoiconicità non è stato probabilmente ancora ben sfruttato
Al momento è ancora molto complicato produrre degli eseguibili stand-alone (come il C++), il che rende complesso far girare programmi Julia su cluster HPC.
Le librerie Julia tendono a “rompersi” facilmente quando escono a nuove versioni del linguaggio. (Ad esempio, Comonicon.jl non funziona con la versione più recente di Julia!)
Il fatto che il compilatore debba sempre compilare in tempo reale le funzioni lo rende a volte lento nel “rispondere”, soprattutto se confrontato con Python.
Alcuni paradigmi (es., il multiple dispatch, l’abbandono delle classi…) può richiedere uno sforzo significativo per chi viene da una esperienza di programmazione tradizionale ad oggetti.
Vi fornisco alcune regole generali per scegliere il linguaggio da usare nel vostro prossimo progetto:
C++: Programmi che devono girare molto velocemente e devono essere estremamente robusti; potenzialmente potreste volerli far girare anche tra anni.
Python: script che risolvono i piccoli problemi quotidiani, produzione di grafici e tabelle, uso di pacchetti Python standard (Intelligenza Artificiale…)
Julia: ricerca scientifica, nuovi modelli fisici, prototipi che richiedono elevata velocità di calcolo