Rust - Numeri con virgola
In Rust abbiamo due tipi di numeri con virgola, che differiscono per la precisione a cui possono arrivare:
- f32 - definiti su 32 bit quindi singola precisione
- f64 - definiti su 64 bit quindi doppia precisione
a distinguerli la precisione, il range e il "peso" in memoria mentre dal punto di vista comportamentale i due tipi non hanno alcuna altra differenza sostanziale. Vediamo subito la dichiarazione che non presenta ovviamente novità rispetto agli interi:
let f1 = 2.0;
let f2: f32 = 3.1;
let f3 = 2f64;
let f4 = 2.0_f32;
quindi lasciando all'inferenza (che di default sceglie giustamente f64),
oppure forzando noi il tipo o anche ponendo dei suffissi nelle due forme note.
Un'altra rappresentazione abbastanza nota è quella esponenziale,
attraverso la lettera "e" seguita dal numero dell'esponente che può essere
positivo o negativo
Esempio 7.1
fn main() {
let numero_grande = 2.5e6;// equivale a 2.5 * 10^6
println!("2.5e6 = {}", numero_grande);
let numero_piccolo = 3.2e-6; // equivale a 3.2 * 10^-6
println!("3.2e-6 = {}", numero_piccolo);
let esp = 1e3; // equivale a 1 * 10^3
println!("1e3 = {}", esp);
}
Per la magnitudine dei due tipi abbiamo anche in questo caso MIN e MAX. Nel seguente programma vedremo la differenza di precisione tra i due tipi ed eseguendolo potremo apprezzare la enorme capacità del tipo f64.
Esempio 7.2
fn main() {
let f1: f32 = 7.0;
let f2: f32 = 3.0;
let f3: f64 = 7.0;
let f4: f64 = 3.0;
println!("{}", f1 / f2);
println!("{}", f3 / f4);
println!("{}", f32::MIN);
println!("{}", f32::MAX);
println!("{}", f64::MIN);
println!("{}", f64::MAX);
}
Con questo output parziale (il resto vi lascio il piacere di vederlo da soli)
| 2.3333333 2.3333333333333335 |
per le altre operazioni di base non ci sono differenze d'uso sostanziali
ovviamente rispetto a quanto visto nel capitolo precedente, è intuitivo che
non esista il resto della divisione.
Per quanto
riguarda l'occupazione in memoria dei due tipi vediamo il seguente semplice
programma che vi potrà essere utile anche in altri casi:
use std::mem;
fn main() {
let size_of_f64 =
mem::size_of::<f64>();
let size_of_f32 = mem::size_of::<f32>();
println!("Dimensione di f64: {} byte", size_of_f64);
println!("Dimensione di f32: {} byte", size_of_f32);
}
da cui risulterà che f64 occupa 8 byte
ed f32 solo 4.
Veniamo adesso ad
un capitolo interessante, ovvero la convivenza tra numeri interi e
numeri con virgola, delle cui peculiarità parleremo comunque più
avanti in questo paragrafo. Le (piccole) difficoltà di questa convivenza
sono prevedibili sulla base di quanto visto nel precedente paragrafo, dove
addirittura non c'è convivenza pacifica tra interi di natura diversa, ma
d'altronde la "safety" pretesa da Rust vuole anche qualche piccolo
sacrificio, lo ribadiamo. Infatti, il seguente codice:
let x = 7.0;
let y = 3;
let z = x + y;
dà origine ad un errore:
no implementation for `{float} +
{integer}`
e lo stesso vale se provate con altre operazioni,
ovvero non possiamo lavorare con due tipi diversi. Quindi,
armiamoci di pazienza e prepariamoci ad adottare qualche escamotage per
risolvere la situazione. Una prima strada è quella di ricorrere
all'operatore as, che già conosciamo:
let x = 7.0;
let y = 3;
let z = x + y as f64;
questo funziona e, in particolare, risulta utile in caso di divisioni quando volete trovare il risultato corretto con i decimali. Si può anche fare il contrario, convertendo un float in intero, perdendo la parte decimale ovviamente:
let x = 7.0123;
let y = 5;
let z = x as i32 + y;
Su questo bisogna fare quindi attenzione almeno finchè usate as. Altri linguaggi, come Zig, su questo aspetto sono più rigidi.
Come potrete facilmente intuire non è nemmeno possibile mischiare facilmente gli f32 con gli f64. Quindi:
let x = 65f64;
let y = 2_f32;
println!("{}", x/y);
Questo codice origina un errore nell'ambito del quale compare questa segnalazione:
^ no implementation for `f64 / f32`
che è abbastanza chiaro e ribadisce quanto detto prima. Come detto possiamo ricorrere ad as ma con una possibile perdita di precisione se scegliamo di passare da f64 a 32 mentre passare fa f32 a f64 ha pure le sue peculiarità. Vedremo un paragrafo apposta devoluto alle conversioni numeriche. Per ora ci accontentiamo di as.
Di nuovo con riferimento alla convivenza tra numeri interi e con
virgola, apro una parentesi relativa agli elevamenti a
potenza, un tipo di computazione che può essere molto utile e
comune. Riassumendo:
1) base intera, esponente intero
visto nel capitolo precedente, la funzione ad hoc è pow:let x = 7;
let y = 3;
let z = i32::pow(x, y);
2) base con virgola esponente intero:
si usa powilet x = 7.17; // si può usare anche f32::powi che
ha un range meno ampio di valori ammissibili
let y = 3;
let z = f64::powi(x, y);
3) base con virgola,
esponente con virgola:
si usa powflet x = 7.17;
let y = 3.29;
let z = f32::powf(x, y);
4) base intera esponente con virgola:
si usa
ancora powf con as per convertire il numero intero:let x = 7;
let
y = 3.29;
let z = f32::powf(x as f32, y);
Come gli interi,
anche i numeri con virgola possono essere manipolati tramite numerose
funzioni che trovate sul sito ufficiale. In questo paragrafo vedremo qualche
esempio per impostarne l'utilizzo. Ne approfittiamo anche per vedere un
aspetto interessante dell'inferenza e di come essa lavora.
fn main()
{
let x = 2.34;
println!("{}", x.trunc());
}
questo codice non compila e il messaggio può essere abbastanza
sorprendente:
error[E0689]:
can't call method `trunc` on ambiguous numeric type `{float}`
--> r0098.rs:4:22
|
4 | println!("{}", x.trunc());
| ^^^^^
|
help: you must specify a type for this binding, like `f32`
|
3 | let x: f32 = 2.34;
| ~~~~~~
Certo può
sembrare e forse è, curioso che l'inferenza sia in grado di attribuire il
tipo i32 alla variabile x e poi il metodo
trunc (che come dice il suo nome tronca
alla sola parte intera il numero con virgola) mi dica che il tipo è ambiguo
come nella riga evidenziata nell'errore. La spiegazione non è semplice,
tanto che questo fatto, comune a tutte le funzioni, è
oggetto di discussioni sui vari forum da parte di utenti un po' confusi (mi
ci metto anche io) specie se abituati con altri linguaggi. Detto in breve,
se è vero che il compilatore inferisce il tipo in fase di compilazione a
garanzia della safety sui dati, tale meccanismo non interviene quando viene
chiamato un metodo che si può in realtà applicare su più tipi e che può
restituire a sua volta più tipi. Per il metodo insomma la variabile è
sconosciuta pertanto deve essere specificata. L'errore si presenta anche in
altri casi, vediamo ad esempio per gli interi:
let x = -2;println!("{}",
x.abs());
Qui è il metodo abs che va in crisi in quanto non sa esattamente con quale tipo di variabile numerica abbia a che fare. In pratica il compilatore garantisce l'integrità dei dati in termini di tipologia ad essa assegnata così che l'uso che ne viene fatto sia coerente ma le singole funzioni necessitano diciamo di chiarimenti qualora possano lavorare su più tipi. Non ho approfondito il perchè di questa scelta ma esiste un capitolo molto interessante sul sito ufficiale legato a come lavora l'inferenza "under the hood". Una possibile soluzione per il codice precedente è riscrivere la riga incriminata come segue:
println!("{}", (x as f64).trunc());
Ad ogni modo trunc() è solo delle tantissime funzioni che avete a disposizione. Nel seguente esempio ne vediamo altre, anche qui ovviamente si tratta di un campione al fine di mostrarne le modalità d'uso non esaustivo, ma sul sito ufficiale le trovate tutte.
Esempio 7.3
fn main()
{
let x: f32 = 2.30;
println!("{}", x.round());
let y: f32 = 2.40;
println!("{}", y.round());
let z: f32 = 2.4999;
println!("{}", z.round());
let t: f32 = 2.50;
println!("{}", t.round());
println!("{}", t.floor());
println!("{}", t.ceil());
println!("{}", z.trunc());
println!("{}", z.fract());
println!("{}", z.sqrt());
}
Eseguendolo potrete capire come funziona l'arrotondamento (round),
che tende al numero più basso fino al valore .49999 e poi attonda verso
l'alto.
- floor porta all'intero basso
più vicino
- ceil porta all'intero alto
più vicino
- trunc, tronca, come detto
- fract espone la parte frazionale del
numero
- sqrt, la classica radice
quadrata. Questa agisce sui numeri con virgola, non lavora sugli interi.
Per i numeri con virgola sono disponibili anche i metodi
is_finite(),
is_infinite(), is_nan() che
troveremo utili nel paragrafo dedicato agli overflow.
Il programma
successivo invece mostra l'uso di alcune funzioni built-in, qualcuna
l'abbiamo già vista
Esempio 7.4
fn main() {
let x = 45.0_f64;
// Calcolo trigonometrico
let rad = x.to_radians(); // Converte gradi in radianti
let y_sin = rad.sin(); // Calcola il seno di x (x in radianti)
println!("Il seno di {} gradi è {}", x, y_sin);
// Calcolo del logaritmo
let val = 10.0_f64;
let log_base_10 = val.log(10.0); // Calcola il logaritmo in base 10
println!("Il logaritmo in base 10 di {} è {}", val, log_base_10);
// Esponenziale
let e_pow_x = val.exp(); // Calcola e^x
println!("e^{} = {}", val, e_pow_x);
// Radice quadrata
let sqrt_val = val.sqrt(); // Calcola la radice quadrata
println!("La radice quadrata di {} è {}", val, sqrt_val);
// Potenza
let pow_val = val.powf(2.0);
// Eleva a potenza (in questo caso, al quadrato)
println!("{} al quadrato è {}", val, pow_val);
}
Infine passiamo in rassegna un gruppo di interessanti costanti legate ai tipi dei numeri con virgola e che trovate già pronte per l'uso per vostra comodità:
| DIGITS Numero delle cifre significative in base 10. EPSILON Valore di confronto minimo INFINITY Infinito (∞). MANTISSA_DIGITS Numero di cifre significative in base 2. MAX Valore float32 (o float64) massimo. MIN Valore float32 (o float64) minimo MIN_POSITIVE Più piccolo valore positivo. NAN Not a Number (NaN). NEG_INFINITY Infinito negativo (−∞). |
In pratica:
println!("{}", f32::DIGITS);
println!("{}", f64::DIGITS);
println!("{}", f32::EPSILON);
Sono inoltre predefinite altre costanti numeriche che rappresentano invece valori di un certo uso comune nel campo della matematica:
| E Numero di Eulero e FRAC_1_PI 1/π FRAC_1_SQRT_2 1/sqrt(2) FRAC_2_PI 2/π FRAC_2_SQRT_PI 2/sqrt(π) FRAC_PI_2 π/2 FRAC_PI_3 π/3 FRAC_PI_4 π/4 FRAC_PI_6 π/6 FRAC_PI_8 π/8 LN_2 ln(2) LN_10 ln(10) LOG2_10 log2(10) LOG2_E log2(e) LOG10_2 log10(2) LOG10_E log10(e) PI il famoso pigreco SQRT_2 sqrt(2) TAU costante equivalente a 2 volte pigreco (τ) |
per richiamare queste ultime occorre appunto riferirsi al modulo consts:
println!("{}", std::f32::consts::PI);
Si tratta evidentemente di una raccolta molto utile in tante situazioni.
Una questione spinosa quando si parla dei numeri con virgola, riguarda il loro confronto. La matematica in virgola mobile è un gran grutta bestia a volte, come dimostra il classico esempio:
Esempio 7.5
fn main() {
let x = 0.1;
let y = 0.2;
let z = x + y;
println!("{}", z == 0.3);
}
Questo programma resituisce un laconico "false" mentre noi sappiamo che l'uguglianza è vera. Perchè ci esce "false"? Questo dipende dalla rappresentazione interna dei float (è una cosa comune a tutti i linguaggi di programmazione proprio per come i computer rappresentano i numeri float) ed è potenziale causa, come facilmente comprensibile, di molti problemi. Per rimediare, il primo classico sistema valido, in tutti i linguaggi è quello di confrontare i termini attraverso un valore piccolo a piacere. Quindi, usando ad esempio il nostro EPSILON appena visto qui sopra si potrebbe scrivere:
Esempio 7.6
fn main() {
let x = 0.1;
let y = 0.2;
let z = x + y;
let c = (z - 0.3_f64).abs() > std::f64::EPSILON;
println!("{}", c);
}
che restituisce false perchè la distanza tra i due elementi di confronto è minore del nostro EPSILON. Quindi possiamo considerarli uguali (quali sono). E' possibile anche definire un epsilon personalizzato. Questa soluzione, concettualmente molto semplice, di norma funziona in maniera soddisfacente. Tuttavia Rust ci fornisce anche altre strade. Ricorrendo a Cargo per creare un nuovo progetto inseriamo nella sezione delle dipendenze, cargo.toml, la riga seguente che fa riferimento ad un crate:
1) creaimo un progetto con cargo, ad esempio:
cargo
float
che crea
una directory di nome float.
2) nella directory float entriamo in cargo.toml e nella
sezione dependecies aggiungiamo la riga:
float-cmp = "0.9"
che è il nostro crate
3) scendiamo nella directory src ed editiamo main .rs facendolo diventare come segue:
Esempio 7.7
use float_cmp::approx_eq;
fn main() {
let a = 0.1 + 0.2;
let b = 0.3;
if approx_eq!(f64, a, b, ulps = 2) {
println!("a == b");
} else {
println!("a != b");
}
}
cargo run - ci confermerà l'uguaglianza. Avrete notato senza dubbio quella istruzione ulps = 2. In realtà ULPS è una sigla che sta per Unit in the Last Place ed è l’unità minima di differenza tra due numeri floating‑point rappresentabili in un certo formato che nel nostro caso è f64. Si può anche aggiungere che Un ULP è la distanza tra due numeri consecutivi nella rappresentazione IEEE‑754. Non voglio scendere in dettagli che esulano da questa trattazione, dettagli che troverete senza problemi in rete con numerosi esempi anche con codice di supporto. Vi do solo la seguente piccola tabella:
-
0 ULP → sono esattamente lo stesso valore binario
-
1 ULP → differiscono di un solo gradino
-
2 ULP → sono due gradini di differenza
A voi stabilire entro quale limite due numeri sono uguali. Questo codice e il ragionamento sottostante possono non essere immediati, tenete però presente questa possibilità alternativa.
Un altro carte utile, forse anche di più, è float_eq. I passi per usarlo
sono uguali ai precedenti 3, il crate da inserire nella sezione dependencies
è:
float_eq = "1"
e il codice
potrebbe ad esempio essere:
Esempio 7.8
use float_eq::float_eq;
fn main() {
let x = (2.0f64).sqrt();
let y = 1.41421356237;
if float_eq!(x, y, abs <= 1e-10) {
println!("sono uguali entro tolleranza");
}
}
Non ho approfondito molto in discorso ma a quanto pare float_eq è preferibile.
Come per gli interi dovremo cimentarci con il discorso della gestione degli overflow, pur se con gli f64 non è che abbia molto senso forse stante il loro range davvero enorme.