ZIG - I numeri
Il trattamento dei numeri in Zig non è molto diverso da quello che trovate in altri linguaggi di programmazione. E questo mi pare ovvio.
Relativamente ai numeri interi abbiamo la consueta suddivisione in numeri con segno e senza segno. La suddivisione è quella classica:
-
con segno: 8 bit - 16 bit - 32 bit - 64 bit - 128 bit
con valori che vanno da -2^n-1 a (2^n-1) - 1 -
senza segno: 8 bit - 16 bit - 32 bit - 64 bit - 128 bit
con valori che vanno da 0 a (2^n)-1
dove n indica il numero di bit su cui definiamo ogni singola categoria di interi. Abbiamo poi gli
-
isize e usize: rispettivamente con segno e senza segno, usati generalmente per indicizzazione di elementi sequenziali e dimensioni di memoria, ha le stesse dimensioni del puntatore della piattaforma sottostante.
Dal punto di vista della rappresentazione ovvero della definizione
formale, come abbiamo già visto nel paragrafo precedente, si usa la
lettera 'i' seguita dal numero di bit su cui è definito il numero, ovvero:
i8, i16, i32 ecc... oppure direttamente
isize e usize
per gli ultimi 2 indicati in precedenza.
E' interessante notare che in
Zig in realtà potete definire interi con un numero di bit rappresentativi
di che va a 1 a 65535. Ad esempio nessuno vi vieta di definire valori
quali:
const x: i4 = 6;
var y: i5 = 9;
var x: i200 = 1234567890;
const:
i111 = 23456;
Abbiamo definito una costante che è un intero su 4 bit, una variabile intera su 5, una variabile su 200 bit e una costante su 111. Attenzione ai range sui quali il compilatore è molto attento, diciamo che negli ultimi due casi avrete spazio praticamente illimitato nei primi due un po' meno.
Per quanto riguarda invece i numeri con virgola, i cosiddetti float, abbiamo la seguente suddivisione:
-
16 bit - 32 bit - 64 bit - 80 bit -128 bit
Indicati dalla lettera 'f' seguita dal numero di bit relativi alla
rappresentazione interna quindi:f16, f32 ecc...
Da notare la non scontata presenza degli f80, che non si trovano frequentemente in altri linguaggi e che, per quanto ne so, sono un retaggio del passato legato ai vecchi coprocessori matematici 8087 di Intel. Tra l'altro mi mpare che LLVM esponga un tipo specifico x86_fp80. Relativamente alla rappresentazione base, mantissa e range ecco la seguente tabella:
| Tipo | Bit | Mantissa | Base | Esponente |
|---|---|---|---|---|
| f16 | 16 | 10 | 2 | 5 |
| f32 | 32 | 23 | 2 | 8 |
| f64 | 64 | 52 | 2 | 11 |
| f80 | 80 | 64 | 2 | 15 |
| f128 | 128 | 112 | 2 | 15 |
Probabilmente siete ansiosi (ok, si fa per dire) di conoscere esplicitamente i limiti massimi e minimi di ciascun tipo. Vi lascio il piacwre di ricavarveli attraverso gli strumenti che il linguaggio offre. In particolare esiste un namespace (che in senso generale può essere visto come un contenitore di funzioni e proprietà) e che si chiama, guarda un po', math che vi offre questa possibilità, oltre a molte altre che alle quali accenneremo più avanti in questo paragrafo. Vediamo un po' di codice:
Esempio 4.1
const std = @import("std");
pub fn main() void {
const min_i32 = std.math.minInt(i32);
const max_i32 = std.math.maxInt(i32);
std.debug.print("i32: min={}, max={}\n", .{ min_i32, max_i32 });
const min_f32 = std.math.floatMin(f32);
const max_f32 = std.math.floatMax(f32);
std.debug.print("f32: min={}, max={}\n", .{ min_f32, max_f32 });
const min_i11 = std.math.minInt(i111);
const max_i11 = std.math.maxInt(i111);
std.debug.print("i111: min={}, max={}\n", .{ min_i111, max_i111 });
}
Abbiamo evidenziato le funzioni utili ad ottenere quanto cercavamo e, come vediamo, tali funzioni sono applicabili anche a interi customizzati (il tipo i111 non è tra gli interi standard e lo abbiamo inserito come esempio in tal senso).
Stabilito l'ambito vediamo qualche cosa di semplice, per prendere confidenza e iniziamo il con le consuete operazioni di base:
Esempio 4.2
const std = @import("std");
pub fn main() void {
const x1: i32 = 7;
const x2: i32 = 3;
std.debug.print("somma: {} \n", .{x1 + x2});
std.debug.print("sottrazione: {} \n", .{x1 - x2});
std.debug.print("moltiplicazione: {} \n", .{x1 * x2});
std.debug.print("divisione: {} \n", .{x1 / x2});
std.debug.print("modulo: {} \n ", .{x1 % x2});
}
Noterete una cosa interessante: in Zig la funzione di stampa è una sola, print, non esiste il classico "scrivi e vai a capo" tipico di altri linguaggi, ad esempio un "println". Per andare nella riga sotto dovrete inserire esplicitamente la sequenza specifica \n (che, come saprà chi già programma, non è peculiare di Zig). Il risultato dell'esecuzione di questo codice è il seguente:
| somma: 10 sottrazione: 4 moltiplicazione: 21 divisione: 2 modulo: 1 |
La divisione lavora sugli interi, quindi 7/3 fa 2 invece di 2,333333 come avviene per quasi tutti i linguaggi di sistema o orientati verso il low level. Questo perchè prevedibilità del risultato, consistenza dei tipi, assenza di conversioni implicite e anche ottimizzazioni fanno si che questa sia di gran lunga la strada preferibile. Altri linguaggi, diciamo, più lontani dal "ferro", tipo Python, Ruby, Julia e Javascript (e anche il defunto Cobra, uno dei miei preferiti) propongono subito il risultato con i decimali.
Per i numeri con virgola valgono le stesse considerazioni operativamente parlando ma, come immaginerete, c'è qualcosa di diverso:
Esempio 4.3
const std = @import("std");
pub fn main() void {
const x1: f64 = 7.1;
const x2: f64 = 3.3;
std.debug.print("somma: {} \n", .{x1 + x2});
std.debug.print("sottrazione: {} \n", .{x1 - x2});
std.debug.print("moltiplicazione: {} \n", .{x1 * x2});
std.debug.print("divisione: {} \n", .{x1 / x2});
}
ed ecco l'output:
| somma: 10.399999999999999 sottrazione: 3.8 moltiplicazione: 23.429999999999996 divisione: 2.1515151515151514 |
Notiamo subito che non è previsto, ovviamente il resto della divisione ma ne parleremo oltre, prima c'è da osservare l'approssimazione dei risultati ottenuti, cosa non peculiare di Zig ma determinata dalle necessità di approssimazione insite nella rappresentazione dei decimali in codifica binaria nel formato IEEE-754. E no, anche i computer quantistici, che peraltro non fanno aritmetica nel modo classico, potranno sistemare questa cosa. Tornando a noi, come rimediare a questa situazione con gli strumenti che Zig ci mette a disposizione? I metodi sono i soliti che troviamo in altri linguaggi, quindi arrotondare, troncare, oppure avvicinare al floor o al ceiling, verso il basso o verso l'alto andando però verso l'intero più vicino.... ovvero 10.3399999 diventa 10.0 non 10.34.... quindi, se si vuole il risultato preciso non resta che usare le solite tecniche empiriche, almeno io non trovato di meglio, ecco un esempio banale che ci mostra Zig in azione:
Esempio 4.4
const std = @import("std");
pub fn main() void {
const x: f64 = 3.3399999;
const r = std.math.round(x * 100.0) / 100.0;
std.debug.print("Arrotondato: {}\n", .{r});
}
Insomma vi dovete organizzare con le funzioni apposite oppure usare la
classica tecnica degli interi scalati.
E vediamo quindi qualcuna di
queste funzioni, quelle diciamo più conosciute, che si trovano sempre nel
namespace math e come queste lavorano:
Esempio 4.5
const std = @import("std");
pub fn main() void {
const x: f64 = -3.7;
const y: f64 = 4.7631;
std.debug.print("floor: {}\n", .{std.math.floor(x)});
std.debug.print("ceil : {}\n", .{std.math.ceil(x)});
std.debug.print("round: {}\n", .{std.math.round(x)});
std.debug.print("trunc: {}\n", .{std.math.trunc(x)});
std.debug.print("floor: {}\n", .{std.math.floor(y)});
std.debug.print("ceil : {}\n", .{std.math.ceil(y)});
std.debug.print("round: {}\n", .{std.math.round(y)});
std.debug.print("trunc: {}\n", .{std.math.trunc(y)});
}
-
floor arrotonda per difetto
-
ceil arrotonda per eccesso
-
round arrotonda all'intero più vicino
-
trunc tronca la parte decimale.
Stando così le cose, l'output è il seguente:
| floor: -4 ceil : -3 round: -4 trunc: -3 floor: 4 ceil : 5 round: 5 trunc: 4 |
Vediamo ora un altro importante argomento, ovvero la convivenza tra numeri di tipo diverso. Iniziamo da una cosa facile:
const x: i8 = 44;
const y: i32 = 127;
const z = x + y;
Questo codice funziona e dimostra che non ci sono problemi di convivenza tra interi anche se di natura diversa, qui abbiamo un i8 e un i32. Anche il codice successivo compila e gira tranquillamente, mostro un esempio completo:
Esempio 4.6
const std = @import("std");
pub fn main() void {
const x1: i8 = 10;
const x2: i64 = 20;
const x3 = x1 + x2;
const info = @typeInfo(@TypeOf(x3));
std.debug.print("Type info: {any}\n", .{info});
std.debug.print("Risultato: {d}\n", .{x3});
}
che fornisce il seguente output:
|
Type info: .{ .int = .{ .signedness = .signed, .bits = 64 } } Risultato: 30 |
il quale, oltre a fornire il risultato del facile calcolo, ci informa che siamo di fronte ad un numero intero, dotato di segno e definito su 64 bit. Ovvero è stato attribuito il tipo con magnitudine più ampia tra i due interessati dall'operazione, i64.
const x: f64 = 44.237;
const y: i32 = 127;
const z = x + y;
Anche in questo caso andremo verso il tipo più capiente ovvero f64 ma il criterio in questo caso non è quello della maggior capienza ma quello della conservazione della precisione, quindi le cifre decimali. Se anche y fosse stato definito come i2000, intero su 2000 bit, quindi immenso rispetto ad un f64, il risultato sarebbe stato riversato in un f64. Bisogna però notare una differemza comportamentale piuttosto importante:
Esempio 4.7
const std = @import("std");
pub fn main() !void {
const x1 = 5 + 3.2;
std.debug.print("Valore di x1: {d}\n", .{x1});
std.debug.print("Tipo di x1: {any}\n", .{@typeInfo(@TypeOf(x1))});
const x2: i32 = 5;
const x3: f32 = 3.2;
const x4 = x2 + x3;
std.debug.print("Valore di x4: {d}\n", .{x4});
std.debug.print("Tipo di x4: {any}\n", .{@typeInfo(@TypeOf(x4))});
}
Il risultato può lasciar spiazzati:
| Valore di x1: 8.2 Tipo di x1: .{ .comptime_float = void } Valore di x4: 8.2 Tipo di x4: .{ .float = .{ .bits = 32 } } |
In fondo si tratta di due operazioni nella pratica identiche. La
differenza è che nel primo caso abbiamo due valori che sono rispettivamente
un comptime_int e un comptime_float nel secondo caso esplicitiamo i tipi e
quindi il compilatore ha tutto per decidere cosa fare. Se avessimo definito
x2 e x3 senza definire esplicitamente i tipi allora anche x4 sarebbe un
comptime_float.
La questione delle conversioni e degli
overflow in particolare va affrontata a parte. In generale
Zig si rivolge verso il formato più capiente e in presenza di numeri interi
e con virgola predilige, giustamente, questi ultimi. In particolare se ad
esempio provaste a forzare il numero xx.yy con virgola dento un i32
otterreste un errore siffatto:
error: fractional component prevents float value 'xx.yy' from
coercion to type 'i32'
Ma tutte queste cose le vedremo meglio più
avanti, meritano un approfondimento per evitare sorprese.
Come ci comportiamo con le divisioni? Come otteniamo il risultato corretto? La soluzione è avere almeno uno dei due operandi come float altrimenti il risultato viene comunque intero. Ad esempio:
const x: i32 = 7;
const y: i32 = 3;
const z = @as(f64, x) / y;
oppure:
const x: i32 = 7;
const y: f32 = 3.0;
Insomma ci deve essere un elemento che appartenga al mondo con virgola mobile. Un piccolo semplice sempio riassuntivo è il seguente:
Esempio 4.8
const std = @import("std");
pub fn main() void {
const x1: i128 = 30;
const x2: f32 = 20.044;
const x3 = x1 / x2;
const x4 = x1 * x2;
const info1 = @typeInfo(@TypeOf(x3));
const info2 = @typeInfo(@TypeOf(x4));
std.debug.print("Type info: {any}\n", .{info1});
std.debug.print("Type info: {any}\n", .{info2});
std.debug.print("Result: {d}\n", .{x3});
std.debug.print("Result: {d}\n", .{x4});
}
dal quale otteniamo, a conferma di quanto detto:
| Type info: .{ .float = .{ .bits = 32 } } Type info: .{ .float = .{ .bits = 32 } } Result: 1.4967072 Result: 601.32 |
Una operazione tipica è quella dell'elevamento a potenza che presenza 4 casi:
1) base intera - esponente intero
2) base float - esponente intero
3) base intera - esponente float
4) base float - esponente float
Al momento non ho trovato soluzioni eleganti e uso sempre pow. Il codice seguente funziona per tutti i casi elencati, a quanto pare, quindi mi tengo questa soluzione per ora:
Esempio 4.9
const std = @import("std");
pub fn main() void {
const b1: f64 = 2.51;
const e1: f64 = 4.03;
const r1 = std.math.pow(f64, b1, e1);
std.debug.print("ris = {}\n", .{r1});
const b2: i64 = 2;
const e2: i64 = 4;
const r2 = std.math.pow(i64, b2, e2);
std.debug.print("ris = {}\n", .{r2});
const b3: f64 = 2.51;
const e3: i64 = 4;
const r3 = std.math.pow(f64, b3, @floatFromInt(e3));
std.debug.print("ris = {}\n", .{r3});
const b4: i64 = 2;
const e4: f64 = 4.03;
const r4 = std.math.pow(f64, @floatFromInt(b4), e4);
std.debug.print("ris = {}\n", .{r4});
}
A questo punto ci potremmo chiedere come passare da intero a float e viceversa.
In linea di massima, Zig permette quelle conversioni automatiche dove non
vi è pericolo di crash o che risultino comunque matematicamente possibili
sia pure con perdita di precisione. Anche qui, dedicheremo una sezione
apposita. Per adesso mi limito a qualche considerazione in modo da
facilitare il lavoro di chi volesse cimentarsi:
1) da intero a
float:const x: i32 = 42;
const y = @as(f64, @floatFromInt(x));
2) da float a intero:const x: f32 = 42.7;
const y = @as(i32,
@intFromFloat(x));
Comunque riporto quanto scritto pari pari sul sito
ufficiale: @floatFromInt is always safe, whereas @intFromFloat is
detectable illegal behaviour if the float value cannot fit in the integer
destination type. Vediamo un esempio completo:
Esempio 4.10
const std = @import("std");
pub fn main() void {
const x: f32 = 42.7;
const y = @as(i32, @intFromFloat(x)); // y = 42.0
std.debug.print("Conversione ok: {}\n", .{y});
const info1 = @typeInfo(@TypeOf(y));
std.debug.print("Tipo: {any}\n", .{info1});
const z: i32 = 42;
const t = @as(f64, @floatFromInt(z)); // y = 42.0
std.debug.print("Conversione ok: {}\n", .{y});
const info2 = @typeInfo(@TypeOf(t));
std.debug.print("Tipo: {any}\n", .{info2});
}
Ripeto, il discorso conversione cc... non finisce qui.
Brevemente, Zig, come tutti i linguaggi che si rispettino, gestisce anche i formati binario, ottale ed esadecimale. Per la precisione si usano i prefissi consueti (che formano la parola "box" se volete iun trucchetto mnemonico:
b per i numeri in formato binario
o
per i numeri in formato ottale
x per i numeri in
formato esadecimale
Di seguito un esempio completo che presenta i vari casi di conversione.
Esempio 4.11
const std = @import("std");
pub fn main() !void {
const b1 = 0b101101;
const o1 = 0o755;
const h1 = 0x1A3F;
std.debug.print("Binario: {b}\nOttale: {o}\nEsadecimale: {x}\n", .{ b1, o1, h1 });
const n1 = try std.fmt.parseInt(u32, "101101", 2);
const n2 = try std.fmt.parseInt(u32, "755", 8);
const n = try std.fmt.parseInt(u32, "FF", 16);
std.debug.print("Da binario: {d}\nDa ottale: {d}\nDa esadecimale: {d}\n", .{ n1, n2, n });
const numero: u8 = 28;
var buf: [8]u8 = undefined; // Buffer da otto byte per un u8
const bin_str = try std.fmt.bufPrint(&buf, "{b:0>8}", .{numero});
std.debug.print("Stringa binaria memorizzata: {s}\n", .{bin_str});
const oct_str = try std.fmt.bufPrint(&buf, "{o:0>8}", .{numero});
std.debug.print("Stringa ottale memorizzata: {s}\n", .{oct_str});
const hxm_str = try std.fmt.bufPrint(&buf, "{x:0>8}", .{numero});
std.debug.print("Stringa esadecimale minuscola memorizzata: {s}\n", .{hxm_str});
const hxM_str = try std.fmt.bufPrint(&buf, "{X:0>8}", .{numero});
std.debug.print("Stringa esadecimale maiuscola memorizzata: {s}\n", .{hxM_str});
}
Anche se l'ultima riga potrebbe far pensare il contrario, quando inserite b, o, x, dovete farlo usando le minuscole.