ZIG - Error union
NB: questo capitolo è solo una parte preliminare della gestione degli errori. Più avanti ne sarà presentato uno più completo che riprenderà anche argomenti trattati in questa sede.
Gli error union sono probabilmente il meccanismo principale di Zig per la gestione degli errori. Detto in maniera diretta, sono un tipo speciale che rappresenta "o un errore, oppure un valore valido". Formalmente si usa il punto esclamativo prima della definizione di tipo, ovvero:
E!T
che appresenta quindi un error union contenente o un errore o valore di
tipo T.
Partiamo subito con un po' di codice poi potremo fare le nostre
considerazioni:
Esempio 21.1
const std = @import("std");
fn divide(a: i32, b: i32) !i32 {
if (b == 0) {
return error.DivisionByZero;
}
return @divTrunc(a, b);
}
pub fn main() !void {
const result = divide(10, 0);
std.debug.print("risultato: {any}\n", .{result});
}
Questo programma richiama una funzione (si, lo non le abbiamo ancora viste a questo punto) che restituisce, tramite la keyword return o un errore, se il secondo argomento passato è uno 0, oppure il risultato intero della divisione di due numeri passati appunti come argomento. Con i valori che passiamo nell'esempio, ovvero 10 come dividendo e 0 come divisore, come output:
| risultato: error.DivisionByZero |
quel !32 che troviamo nella riga di definizione della funzione ci avvisa proprio che il risultato può essere un intero o un errore. Avrete notato la mancanza di "qualcosa" a sinistra del punto esclamativo. Questo perchè la E rappresenta un error set che viene inferito dal compilatore sulla base dei possibili errori presenti nella funzione. Se nulla viene specificato il tipo inferito è un error set specifico e minimale, contenente solo gli errori effettivamente possibili in quella funzione. Volendo si potrebbe specificare ad esempio anyerror che contiene tutti i possibili errori in tale caso la definizione della funzione risulterebbe:
fn divide(a: i32, b: i32) anyerror!i32
Normalmente è pratica comune lasciar fare al compilatore tramite inferenza per evitare di sprecare risorse con un enorme contenitore di errori la grande maggioranza dei quali non utilizzabili. Invece la è T (nell'esempio 21.1 abbaimo definito un i32) è il tipo su cui il compilatore in pratica lavorerà nel caso in cui non ci siano errori.
In realtà il codice dell'esempio 21.1 non è particolarmente valido dal punto di vista pratico. L'errore se c'è va gestito, qui c'è la funzione di stampa semplice e va bene ma se avessimo dovuto lavorare con il risultato restituito dalla funzione le cose non potevano funzionare (l'errore nell'esempio 21.1 non viene nè gestito nè propagato) e il compilatore ci avrebbe imposto di impegnarci un po' di più.
Una soluzione, da usare in casi eccezionali in genere quando la presenza
di un errore induce a non proseguire l'esecuzione è usare
try:
modifichiamo la definizione della
const result come segue:
const result = try divide(10, 0);
ed otterremo:
| error: DivisionByZero C:\Users\rexle\zigprogs\z558.zig:4:9: 0x7ff7d3ccd3e2 in divide (z558_zcu.obj) return error.DivisionByZero; ^ C:\Users\rexle\zigprogs\z558.zig:9:20: 0x7ff7d3ccd4cc in main (z558_zcu.obj) const result = try divide(10, 0); |
cosa succede, in pratica nel caso si usi try l'errore viene propagato, il main termina immediatamente e ci viene mostrato uno stack trace. Nell'esempio 21.1 invece l'errore resta dentro union, in quanto appunto non gestito e in quel caso viene stampato grazie alla funzione apposita. In buona sostanza si può dire che try conduce ad una situazione equivalente a:
const tmp = divide(10, 0);
if (tmp) |value| {
result = value;
} else |err| {
return err; // propagazione
}
L'istruzione try viene usata come detto in praticolare se si incorre in una situazione del tutto anomala e può essere utile in caso di test delle applicazioni dal momento che restituisce proprio il tipo di errore che si è verificato. Tuttavia la strategia, a parer mio, migliore in particolar modo quando si va in produzione è quella che fa uso di catch che utilizziamo quando vogliamo / possiamo recuperare l'errore
Esempio 21.2
const std = @import("std");
fn divide(a: i32, b: i32) !i32 {
if (b == 0) {
return error.DivisionByZero;
}
return @divTrunc(a, b);
}
pub fn main() !void {
const result = divide(10, 0) catch |err| {
std.debug.print("Impossibile dividere: {}\n", .{err});
return; // o un valore di default, o un retry, ecc.
};
std.debug.print("risultato: {any}\n", .{result});
}
L'istruzione critica è:const result = divide(10, 0) catch |err|
In cui fa la comparsa appunto catch. La
sintassi |err| è il payload capture tipico di Zig, non è peculiare di catch.
Il compilatore sa che se siamo entrati nel branch
catch, per forza c'è un errore lì dentro. Ma
quell'errore è un valore vero e proprio (di tipo error set), e dobbiamo poterlo nominare per usarlo. La sintassi
|err| è esattamente questo: dichiara un nome locale che cattura quel
valore, visibile solo dentro il blocco che segue. In conclusione qui err è
una variabile normale, di tipo error{DivisionByZero} che vive solo
dentro le graffe che delimitano il blocco entro cui è definito. In generale
il fatto di usare le barre laterali invece, ad esempio, delle parentesi tonde
mi pare di aver capito che è una scelta sintattica per creare
differenziazione
rispetto alle chiamate a funzione.
Come detto, la scelta di usare
catch in
produzione è molto sovente quella preferibile.
Il punto chiave, a questo punto dovrebbe essere chiaro, è che in Zig gli errori sono valori (non eccezioni come in altri linguaggi) e come tali possono essere trattati. Questo ci permette altri sistemi di gestione piuttosto comodi. Il classico duo if + else è senz'altro una ottima strada:
Esempio 21.3
const std = @import("std");
fn divide(a: i32, b: i32) !i32 {
if (b == 0) {
return error.DivisionByZero;
}
return @divTrunc(a, b);
}
pub fn main() !void {
if (divide(10, 0)) |value| {
std.debug.print("Risultato: {d}\n", .{value});
} else |err| {
std.debug.print("Errore: {}\n", .{err});
}
}
potete provare passando ad esempio 10, 2 come parametri invece di 10, 0 per ottenere il risultato corretto. Il codice mi sembra abbastanza chiaro e sinceramente mi sembra la forma più pulita e semplice, la sequenza if-else è uno statement e non obbliga e esprimere un risultato, un tale obbligo potrebbe creare incompatibilità tra tipi e in breve ad un inutile surplus di codifica. Quanto meno appunto per casi come questo.
Potete usare catch anche per inserire un risultato di default:
Esempio 21.4
const std = @import("std");
fn divide(a: i32, b: i32) !i32 {
if (b == 0) {
return error.DivisionByZero;
}
return @divTrunc(a, b);
}
pub fn main() !void {
const result1 = divide(10, 0) catch 0;
std.debug.print("Risultato: {d}\n", .{result1});
const result2 = divide(10, 5) catch 0;
std.debug.print("Risultato: {d}\n", .{result2});
}
da cui otteniamo:
| Risultato: 0 Risultato: 2 |
Come uso un po' più avanzato, Zig permette di definire l'unione di più
error set per crearne uno che li possa comprendere sotto un unico "ombrello".
Si usa l'operatore ||. A grandi linee ecco
l'esempio proposto sul sito ufficiale:
const A = error{
NotDir,
/// A doc comment
PathNotFound,
};
const B = error{
OutOfMemory,
/// B doc comment
PathNotFound,
};
const C = A || B;
fn foo() C!void {
return error.NotDir;
}
In pratica C comprende sia A che B.
Dovremmo parlare anche di errdefer (che libera le risorse se si verifica un errore) ma lo faremo quando parleremo dell'analogo defer (che libera sempre le risorse quando si esca da un blocco).
Come detto useremo molto spesso gli error union per gestire errori nei nostri programmi. Questo non è però l'unico modo per trattare situazioni analoghe e quindi dovremo tornare sull'argomento a tempo debito.