Maurizio Tomasi
Università degli Studi di Milano
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 (6 kB) | ![]() |
![]() |
RAM (8 GB) | ![]() |
![]() |
HD SSD da 1 TB | ![]() |
goto
)for
2 * x + y / z
)new
e
delete
Un 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!
(Byte magazine, Febbraio 1989)
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
-S
for
Per 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++ offriva la parola chiave
register
(oggi deprecata):
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?
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_command
La 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 defined
I 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.3
Esso 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.
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 implementa i costrutti object-oriented del C++: non ci sono classi né metodi virtuali.
L’approccio OOP 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
Measurement
struct 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…
Measurement
Supponiamo 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 m
In 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
:
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 codice e variabili hanno la stessa rappresentazione.
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 sono apparentemente simili alle funzioni del C++, ma hanno una importante differenza.
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 variabilix
if
, 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 argv
Un’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.33333
Il codice all’inizio del main
era il seguente:
main
Julia non è adatto per scrivere programmi da linea di comando, così mi baserò su Nim, un altro linguaggio «omoiconico».
La libreria cligen di Nim implementa una macro che, se fosse scritta per il C++, si userebbe così:
int run_program(double a, double b, int nsteps_max, double prec) {
// Here comes my program
}
// Macro call… but C++ has not them, so let's mimick Julia's syntax
@define_main(run_program);
La macro @define_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
$
Una volta che si ha a disposizione un linguaggio omoiconico, le possibilità sono illimitate
Un campo in cui Julia sta prendendo sempre più piede è quello dell’intelligenza artificiale
Anche la fisica teorica e computazionale sono due campi in cui Julia si sta affermando sempre di più