Lezione 4 di 7 · 20 min di lettura

Variabili e tipi

var, l'inferenza dei tipi, le costanti comptime e i tipi fondamentali di Mojo. Con un primo sguardo a SIMD, il segreto dei suoi numeri.

Un nome per un valore

In Mojo una variabile si dichiara con la parola chiave var:

profile.mojo
def main():
    var name = "Ada"
    var year = 1843
    var height = 1.65
    var curious = True
    print(name, year, height, curious)
output
Ada 1843 1.65 True

Non abbiamo scritto nessun tipo, eppure ognuna di queste variabili ne ha uno, deciso una volta per tutte a tempo di compilazione: name è una String, year un Int, height un Float64, curious un Bool. Il compilatore li ha dedotti dai valori iniziali: si chiama inferenza di tipo.

Se preferisci essere esplicito, o se vuoi un tipo diverso da quello che il compilatore sceglierebbe, puoi annotarlo dopo il nome, con i due punti:

mojo
var year: Int = 1843
var height: Float32 = 1.65

Qui height è un decimale a 32 bit invece che a 64: con l’annotazione hai scelto tu.

Il tipo non cambia, mai

Una variabile Mojo può cambiare valore quante volte vuoi, ma il suo tipo resta quello della dichiarazione:

retype.mojo
def main():
    var count = 8
    count = "nine"
    print(count)
output
/home/ada/learn-mojo/retype.mojo:3:13: error: cannot implicitly convert 'StringLiteral["nine"]' value to 'Int'
    count = "nine"
            ^~~~~~
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

Leggi il messaggio con attenzione: count è un Int, e il compilatore ha cercato un modo per trasformare quel testo in un intero, senza trovarlo. Nota anche il tipo del testo: non String ma StringLiteral["nine"], il tipo speciale dei letterali scritti nel codice, che diventa una String quando finisce in una variabile.

Dichiarare prima, assegnare dopo

Puoi dichiarare una variabile senza darle subito un valore, purché indichi il tipo:

later.mojo
def main():
    var total: Int
    total = 40 + 2
    print(total)
output
42

Il compilatore, però, controlla che tu non la usi prima di averle dato un valore:

later.mojo
def main():
    var total: Int
    print(total)
output
/home/ada/learn-mojo/later.mojo:3:10: error: use of uninitialized value 'total'
    print(total)
         ^
/home/ada/learn-mojo/later.mojo:2:9: note: 'total' declared here
    var total: Int
        ^
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to run the pass manager

In C leggere una variabile non inizializzata è lecito e restituisce quello che c’era in memoria: una fonte classica di bug imprevedibili. Mojo lo rende impossibile. La riga note: ti indica anche dove la variabile è stata dichiarata.

var è obbligatorio

Un’altra differenza con Python: una variabile nuova si introduce sempre con var.

implicit.mojo
def main():
    x = 5
    print(x)
output
/home/ada/learn-mojo/implicit.mojo:2:5: error: implicit declaration of 'x' is not allowed; add 'var' to declare a new name
    x = 5
    ^
    var
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

Il compilatore ti mostra perfino cosa aggiungere e dove. Il motivo della regola è pratico: con var obbligatorio, un errore di battitura come totl = 3 (invece di total = 3) non crea in silenzio una variabile nuova, ma diventa un errore. Per la stessa ragione non puoi dichiarare due volte lo stesso nome nello stesso blocco:

twice.mojo
def main():
    var level = 1
    var level = 2
    print(level)
output
/home/ada/learn-mojo/twice.mojo:3:9: error: invalid redefinition of 'level'
    var level = 2
        ^
/home/ada/learn-mojo/twice.mojo:2:9: note: previous definition here
    var level = 1
        ^
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

Per cambiare il valore di una variabile esistente, si assegna e basta, senza var:

levels.mojo
def main():
    var level = 1
    print("Level", level)
    level = 2
    print("Level", level)
    level = level * 10
    print("Level", level)
output
Level 1
Level 2
Level 20

level = level * 10 si legge da destra a sinistra: calcola level * 10 con il valore attuale, poi mettilo in level. In Mojo tutte le variabili sono modificabili: non esiste un let o un const per le variabili di una funzione.

comptime: le costanti

E se ti serve un valore che non deve cambiare, come la velocità della luce o il numero di secondi in un minuto? Si usa comptime:

light.mojo
comptime SPEED_OF_LIGHT = 299_792_458  # metres per second
comptime SECONDS_PER_MINUTE = 60


def main():
    var light_minute = SPEED_OF_LIGHT * SECONDS_PER_MINUTE
    print("In one minute light travels", light_minute, "metres")
output
In one minute light travels 17987547480 metres

Un valore comptime è calcolato durante la compilazione e diventa una costante nel programma finale: a runtime non occupa una variabile e non costa niente. Nota anche i trattini bassi in 299_792_458: servono solo agli occhi umani, il compilatore li ignora.

comptime è l’unico modo di avere un valore a livello di file, fuori dalle funzioni. Le variabili globali, semplicemente, non esistono:

global.mojo
var counter = 0


def main():
    print(counter)
output
/home/ada/learn-mojo/global.mojo:1:1: error: global variables are not supported; move this into a function body or use 'comptime' to declare a constant
var counter = 0
^
/home/ada/learn-mojo/global.mojo:5:11: error: use of unknown declaration 'counter'
    print(counter)
          ^~~~~~~
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

E naturalmente una costante non si può riassegnare: SPEED_OF_LIGHT = 3 dentro main produce error: expression must be mutable in assignment.

Quiz

Quale di questi programmi compila?

I tipi numerici: interi

Int è l’intero di default: un numero con segno grande quanto una parola macchina, cioè 64 bit su qualsiasi computer moderno. Va da circa −9,2 miliardi di miliardi a +9,2 miliardi di miliardi, abbastanza per quasi tutto.

Quando serve controllare esattamente quanta memoria occupa ogni numero (e nel calcolo numerico serve spesso: un array di un miliardo di valori a 8 bit occupa 1 GB, a 64 bit ne occupa 8), Mojo offre gli interi a larghezza fissa: Int8, Int16, Int32, Int64, Int128, Int256 con segno e UInt8 … UInt256 senza segno. Ognuno ha un intervallo limitato, e cosa succede quando lo superi è importante:

overflow.mojo
def main():
    var small: Int8 = 127
    small += 1
    print(small)
    var byte: UInt8 = 255
    byte += 1
    print(byte)
    var big = 9_223_372_036_854_775_807
    big += 1
    print(big)
output
-128
0
-9223372036854775808

(+= 1 significa “aumenta di 1”: ne parliamo nella prossima lezione.) Nessun errore, nessun avviso: il valore trabocca e ricomincia dall’altra parte dell’intervallo. Succede anche con Int, solo molto più in là. È il comportamento del processore stesso, ed è la ragione per cui gli interi di Mojo sono veloci quanto quelli del C: nessun controllo a ogni operazione.

Dettagli nerd Perché 127 + 1 fa −128? (il complemento a due)

Un Int8 è fatto di 8 bit, quindi può assumere 2⁸ = 256 configurazioni. I processori rappresentano gli interi con segno in complemento a due: le configurazioni da 00000000 a 01111111 sono i numeri da 0 a 127, quelle da 10000000 a 11111111 sono i numeri da −128 a −1. Il primo bit, in pratica, “vale” −128 invece di +128.

127 è 01111111. Aggiungendo 1 ottieni 10000000, che in complemento a due è −128. Il processore non ha fatto niente di strano: ha sommato in binario come sempre, ed è l’interpretazione dei bit a produrre il salto. Il vantaggio del complemento a due è che somma e sottrazione funzionano con lo stesso circuito per numeri positivi e negativi.

I tipi numerici: decimali

Float64 è il numero decimale di default: 64 bit in formato IEEE 754, circa 15-16 cifre significative. È il float di Python e il double del C. Esistono anche Float32 (meno preciso, metà della memoria), Float16 e BFloat16, e formati a 8 e 4 bit usati sulle GPU per l’intelligenza artificiale:

floats.mojo
def main():
    var third64 = 1.0 / 3.0
    var third32: Float32 = 1.0 / 3.0
    print(third64)
    print(third32)
output
0.3333333333333333
0.33333334

Con 32 bit le cifre affidabili sono circa 7, e si vede. Nell’intelligenza artificiale, però, i formati piccoli sono preziosi: un modello con miliardi di parametri a 16 o 8 bit occupa da due a otto volte meno memoria che a 64 bit, e trasferire meno byte (dalla memoria al processore, o alla GPU) è spesso il collo di bottiglia. Il B di BFloat16, per curiosità, sta per Brain, da Google Brain, che lo ha inventato per i suoi acceleratori.

Dettagli nerd Come si scrive 1/3 con solo 0 e 1? (IEEE 754)

Un numero IEEE 754 è una specie di notazione scientifica in base 2: un bit di segno, un esponente e una mantissa (le cifre significative). In un Float64 sono 1 + 11 + 52 bit; in un Float32 1 + 8 + 23; in un BFloat16 1 + 8 + 7, lo stesso esponente di Float32 (quindi la stessa ampiezza di valori) con molta meno precisione.

1/3 in base 2 è 0.010101... all’infinito, come in base 10 è 0.333...: con un numero finito di bit bisogna tagliare, e il risultato è il valore rappresentabile più vicino. Quando print mostra 0.3333333333333333, sta scegliendo la più breve sequenza di cifre decimali che identifica esattamente quel valore binario. Lo standard prevede anche valori speciali: inf, -inf e nan (not a number), che incontreremo presto.

Bool e String

Bool ha due soli valori, True e False, con la maiuscola come in Python. String è il testo, codificato in UTF-8: ha molte sorprese interessanti, e le vedremo per bene nella lezione sulla stampa.

Il segreto dei numeri di Mojo: SIMD

C’è un dettaglio della libreria standard che spiega molto del carattere di Mojo. Float32, Int8, UInt64 e quasi tutti gli altri tipi numerici non sono tipi “primitivi”: sono tutti casi particolari di un unico tipo, SIMD, un vettore di numeri che il processore sa elaborare in blocco con una sola istruzione. Uno Float32 è un SIMD di tipo float32 con un elemento. Ma nessuno ti vieta di chiederne quattro:

simd.mojo
def main():
    var prices = SIMD[DType.float32, 4](1.5, 2.0, 3.25, 4.0)
    var quantities = SIMD[DType.float32, 4](2, 1, 4, 3)
    var totals = prices * quantities
    print(totals)
    print(totals.reduce_add())
output
[3.0, 2.0, 13.0, 12.0]
30.0

prices * quantities moltiplica le quattro coppie di numeri insieme, elemento per elemento, e reduce_add() somma gli elementi del vettore. Tra le parentesi quadre ci sono due parametri del tipo, noti a tempo di compilazione: il tipo degli elementi (DType.float32) e quanti sono (4, sempre una potenza di due). Per ora prendila come un’anteprima: ai vettori SIMD dedicheremo un modulo intero, perché sono uno dei motivi per cui il calcolo numerico in Mojo è così veloce.

Dettagli nerd Cos'è SIMD, esattamente?

SIMD sta per Single Instruction, Multiple Data: una sola istruzione che opera su più dati contemporaneamente. I processori moderni hanno registri vettoriali larghi 128, 256 o 512 bit: in un registro da 256 bit (le istruzioni AVX2, che Mojo richiede sui processori x86) stanno 8 Float32 o 32 Int8. Un’istruzione come vmulps li moltiplica tutti in un colpo solo, nello stesso tempo che servirebbe per una moltiplicazione singola.

Nella maggior parte dei linguaggi usare queste istruzioni richiede funzioni speciali del compilatore (gli intrinsics) o la speranza che il compilatore le usi da solo. In Mojo sono il fondamento stesso dei tipi numerici.

Conversioni: sempre esplicite

Mojo non converte mai da solo un tipo numerico in un altro. Nemmeno da Int a Float64, che in molti linguaggi è automatico:

mix.mojo
def main():
    var items = 3
    var price = 1.5
    print(items * price)
output
/home/ada/learn-mojo/mix.mojo:4:19: error: invalid call to '__mul__': value passed to 'rhs' cannot be converted from 'Float64' to 'Int'
    print(items * price)
          ~~~~~~~~^~~~~
Imported from /home/ada/learn-mojo/mix.mojo:1:
Mojo/stdlib/std/simd.mojo:1102:1: note: function declared here
def __mul__(self, rhs: Self) -> Self    # note - synthetic signature
^
/home/ada/learn-mojo/.venv/bin/mojo: error: failed to parse the provided Mojo source module

Il messaggio è più tecnico dei precedenti, ma dice una cosa semplice: * tra un Int e qualcosa vuole un altro Int (è la funzione __mul__ di Int, e le note ti mostrano dove è definita nella libreria standard). Non esistendo un modo sicuro di trasformare 1.5 in un intero, il compilatore si ferma. La conversione si scrive chiamando il tipo di destinazione come una funzione:

convert.mojo
def main() raises:
    var items = 7
    var price = 2.5
    var total = Float64(items) * price
    print(total)
    print(Int(total))
    print("Items: " + String(items))
    var parsed = Int("42")
    print(parsed + 1)
output
17.5
17
Items: 7
43
  • Float64(items) trasforma l’intero in decimale.
  • Int(total) fa il contrario, troncando la parte decimale (verso lo zero: con var x = -7.9, Int(x) fa -7).
  • String(items) trasforma un numero in testo, per poterlo concatenare con +.
  • Int("42") fa il contrario: legge un numero da un testo.

Quest’ultima può fallire: cosa dovrebbe restituire Int("12a")? In Mojo una funzione che può fallire si dice che solleva un errore (raises), e il compilatore pretende che tu te ne occupi. La soluzione più semplice, per ora, è dichiarare che anche main può sollevare errori, scrivendo raises prima dei due punti: def main() raises:. Se togli raises, il compilatore protesta con cannot call function that may raise in a context that cannot raise. E se il testo non è un numero, il programma si ferma con un messaggio:

parse.mojo
def main() raises:
    var n = Int("12a")
    print(n)
output
stack trace was not collected. Enable stack trace collection with environment variable `MODULAR_DEBUG=stack-trace-on-error`
Unhandled exception caught during execution: String is not convertible to integer with base 10: '12a'
/home/ada/learn-mojo/.venv/bin/mojo: error: execution exited with a non-zero result: 1

Nota la differenza con gli errori visti finora: questo non è un errore di compilazione, il programma è partito ed è l’esecuzione a essersi fermata. Come gestire gli errori senza far terminare il programma lo vedremo in un modulo dedicato.

Quiz

Hai var n = 10 e var rate = 0.25. Quale espressione calcola correttamente 2.5?

Mettiamo tutto insieme

Esercizio · sul tuo computer

La scheda del pianeta

Crea planet.mojo con:

  • una costante comptime SECONDS_PER_HOUR = 3600;
  • dentro main, le variabili name ("Mars"), moons (2), gravity (3.71), habitable (False);
  • una variabile day_seconds calcolata a partire da SECONDS_PER_HOUR: un giorno marziano dura 24 ore, 39 minuti e 35 secondi.

Il programma deve stampare esattamente:

output
Planet: Mars
Moons: 2
Gravity: 3.71 m/s2
Day: 88775 s
Habitable: False

Suggerimento: print separa gli argomenti con uno spazio, quindi print("Moons:", moons) basta per la seconda riga.

Mostra una soluzione (prima prova da solo!)
planet.mojo
comptime SECONDS_PER_HOUR = 3600


def main():
    var name = "Mars"
    var moons = 2
    var gravity = 3.71
    var habitable = False
    var day_seconds = 24 * SECONDS_PER_HOUR + 39 * 60 + 35
    print("Planet:", name)
    print("Moons:", moons)
    print("Gravity:", gravity, "m/s2")
    print("Day:", day_seconds, "s")
    print("Habitable:", habitable)

Ricapitolando

  • var nome = valore dichiara una variabile; il tipo è dedotto dal valore o annotato con var nome: Tipo = valore.
  • Il tipo di una variabile non cambia mai; il valore sì. var è obbligatorio e un nome non si ridichiara nello stesso blocco.
  • Si può dichiarare senza valore (var total: Int) ma non usare prima di assegnare.
  • comptime NOME = valore definisce una costante calcolata in compilazione; le variabili globali non esistono.
  • Int (64 bit) e gli interi a larghezza fissa Int8…UInt256 traboccano senza avvisare.
  • Float64 è il decimale di default; Float32, Float16, BFloat16 risparmiano memoria a scapito della precisione.
  • Tutti i numeri sono vettori SIMD: SIMD[DType.float32, 4] ne tiene quattro e li elabora insieme.
  • Le conversioni sono sempre esplicite: Float64(n), Int(x), String(n), Int("42") (che può sollevare errori: def main() raises:).

Nella prossima lezione facciamo i conti: operatori, divisioni che non si comportano come in Python e numeri calcolati con precisione infinita.