ZIG - Gli enumeratori
Presenti in tutti i linguaggi di programmazione alla moda, diciamo così,
gli enumeratori sono, in Zig, strumenti molto più evoluti rispetto alle
controparti in linguaggio C. E possono essere tenuti sotto controllo,
diciamo così, come tutti i costrutti di questo linguaggio.
Si tratta
sostanzialmente di un gruppo di valori nominati, cioè
valori a cui è abbinato un nome. A questi valori, se non diversamente
specificati, come è possibile fare, il linguaggio attribuisce di default
il tipo più piccolo che li può contenere. In più in Zig possiamo avere un
controllo sui valori, inserire metodi ed è
supportata l'integrazione con i tagged union (che ancora
non sappiamo cosa sono, lo so).
Dichiarare
un enumeratore è molto semplice, la keyword che li identifica è
enum:
const En01 = enum {
elemento-1,
elemento-2,
.......
elemento-n,
};
gli elementi all'interno devono essere separati da una virgola, come si vede e sono detti varianti. Il nome dell'enumeratore va messo in maiuscolo, secondo le solite linee guida. Per utilizzare un enumeratore è necessario crearne una istanza, proprio come abbiamo visto per le struct. In pratica ad esempio:
const Numeri = enum {
zero,
uno,
due,
}
const
mio_numero = Numeri.uno
quindi l'accesso alla singola variante avviene attraverso il consueto
operatore . (punto). Un
variabile, o una costante creata a partire da un enumeratore può contenere
una sola variante.
Di default, Zig assegna valore 0 alla
prima variante, 1, alla seconda e così via basandosi sul tipo ideale per
l'architettura sottostante e laddove non sia specificato un tipo (lo
vedremo subito dopo) ma è possibile dare dei valori
personalizzati:
const Errori = enum {
ok = 200,
not_found = 404,
bad_gateway = 502,
}
Ecco comunque un esempio di base:
Esempio 15.1
const std = @import("std");
pub fn main() void {
const E01 = enum {
uno,
due,
tre,
};
const ie01 = E01.due;
std.debug.print("e01: {d}\n", .{ie01});
}
che ci dà:
| e01: 1 |
Fornire valori personalizzati può essere pericoloso se non state attenti alla sequenza:
const e01 = enum(i32) {
uno = 1,
due = 0,
tre,
};
Come prima cosa noterete che è necessario esplicitare il tipo (nel caso specifico i32) ma in particolare questo codice darebbe errore perchè il programma cerca di attribuire a tre il valore successivo a quello precedente, ovvero 1, ma questo valore è già stato usato per la variante "uno". Zig fa continua il conteggio dal valore immediatamente precedente. Da questo si evince anche che se immettete dei valori personalizzati questi devono essere unici ed è bene che siano anche ordinati in maniera crescente.
Un aspetto del controllo che Zig ci fornisce può essere ben illustrato dal seguente esempio, tratto dalla documentazione ufficiale, e riguarda, come anticipato in precedenza, la specifica di un tipo per i valori da attribuire alle varie voci:
const Value = enum(u2)
questa impostazione dice, in aggiunta ad una dichiarazione classica, anche lo spazio e il range che dovrà essere occupato dai valori connessi alle varianti. Il tipo, in questo caso u2 si chiama tag type. L'importanza del tag type è notevole, in particolare per compatibilità binaria ma questo è argomento al momento troppo avanzato. Quindi sulla base di quel codice potremmo scrivere:
const Value = enum(u2) {
alpha, // Valore 0
beta, //
Valore 1
gamma, // Valore 2
delta, // Valore 3
//
omega, // ERRORE: Se aggiungessi un quinto elemento, il compilatore darebbe
errore
// perché
u2 non può contenere il valore 4.
};
L'errore risulta dal fatto che un u2 può contenere solo i valori 0,1,2,3. Questo è utile nella programmazione di basso livello perchè ci dà un controllo preciso sulla memoria che andremo ad utilizzare evitando range troppo ampi e non utilizzati appieno.
Una interessante caratteristica è quella di supportare la non-esaustività ovvero quando è necessario prevedere una espandibilità dei valori delle possibili varianti. Qual è la differenza tra un enumeratore non esaustiviìo ed una esaustivo? Si può definire così:
- enum esaustivo → il compilatore conosce tutti i tag
- enum non
esaustivo → il compilatore non può garantire la copertura
La costruzione di un enumeratore non esaustivo prevede la presenza del tipo esplicito e di un unknown tag che specifichiamo col simbolo _ (underscore) . E' proprio la presenza di questa variabile speciale che permette di distinguere un enumeratore non esaustivo. Vediamo un esempio:
Esempio 15.2
const std = @import("std");
const Numero = enum(u4) {
zero = 0,
uno = 1,
due = 2,
tre = 3,
_,
};
pub fn main() void {
const boh: u4 = 6;
const nm: Numero = @enumFromInt(boh);
switch (nm) {
.zero => std.debug.print("ZERO\n", .{}),
.uno => std.debug.print("UNO\n", .{}),
.due => std.debug.print("DUE\n", .{}),
.tre => std.debug.print("TRE\n", .{}),
else => std.debug.print("Numero sconosciuto: {d}\n", .{boh}),
}
}
In pratica cosa vuol dire quel unknowin tag: "ogni valore del
tipo indicato va bene". Questo è il significato. L'istruzione chiave è:
const op: Numero = @enumFromInt(raw);
che presenta la funzione built-in
enumFromInt(valore) la quale a sua volta significa: "prendi
questo valore e trattalo come appartenente all'enumeratore". Ovviamente la
cosa ha senso in presenza di una selezione come quella effettuata nel codice
dell'esempio 15.2 con l'uso dello switch.
Se non prevedete la clausola else nello
switch il compilatore segnala un
prevedibile errore di tipo:
error: switch on non-exhaustive enum must
include 'else' or '_' prong or both switch (nm) {
che
sostanzialmente ci invita a completare lo switch
che, come noto, deve essere esaustivo. Al posto della clausola
else si poteva anche scrivere:
_ =>
ecc....
Quindi bisogna ricordare sempre che in caso di enumeratori non
esaustivi bisogna prevedere delle clausole di controllo della completezza,
come appujnto può essere l'uso di else in
uno switch.
Il rapporto tra enumeratori
e switch è interessante anche perchè quest'ultimo lavora particolarmente
bene con il costrutto oggetto di questo paragrafo, permettendo di cogliere
al volo la non esaustività dell'analisi dei vari casi. Esempio:
Esempio 15.3
const std = @import("std");
const Otto83 = enum {
nord,
sud,
ovest,
est,
};
pub fn main() void {
const dir = Otto83.nord;
switch (dir) {
.nord => std.debug.print("Si va a Nord\n", .{}),
.sud => std.debug.print("Si va a Sud\n", .{}),
.ovest => std.debug.print("Si va a Ovest\n", .{}),
.est => std.debug.print("Si va a Est\n", .{}),
}
}
Questo esempio compila e funziona ma provate a togliere o ridurre a
commento una delle righe dello switch ad esempio la seconda fatela
diventare://.sud => std.debug.print("Si va a Sud\n", .{}),
e
vedrete che il compilatore si farà senitre. Mica per nulla si parla di
exhaustive switch.
La cosa però che rende speciali gli enumeratori in Zig è la possibilità di includere in essi delle funzioni. Proprio così. Anche in questo caso, così come per le struct, non si tratta di metodi ma di funzioni che appartengono al namespace sotto il controllo dell'enumeratore. Vediamo un esempio che ci permetterà di incontrare un'altra funzione built-in:
Esempio 15.4
const std = @import("std");
const Numero = enum(u4) {
Uno = 1,
Due = 2,
pub fn isEven(self: Numero) bool {
return (@intFromEnum(self) % 2) == 0;
}
};
pub fn main() void {
const ie01 = Numero.Due;
std.debug.print("pari? {}\n", .{ie01.isEven()});
const ie02 = Numero.Uno;
std.debug.print("pari? {}\n", .{ie02.isEven()});
}
Come si vede, all'interno del nostro enumeratore di nome Numero abbiamo
definito una funzione che si chiama isEven e determina se la variante che
abbiamo deciso di analizzare sia un valore pari o dispari. Al suo interno
abbiamo:
@intFromEnum
che converte un valore da un enumeratore in valore numerico.
Sintatticamente abbiamo:
@intFromEnum(enum_value:
anytype)
anyint
la funzione viene richiamata dalle istanze ie01 e ie02 definite
all'interno del main() ed espongono il risultato della loro elaborazione.
Come detto per le struct si tratta di funzioni che appartengono al namespace
definito dall'enumeratore, non di metodi in quanto non siamo di fronte ad
una classe.
Il richiamo di una funzione come si vede segue le regole
consuete che fanno uso del .(punto) come
operatore. Ovviamente il parametro self fa
riferimento allo stesso enumeratore in cui si trova la funzione.
Come detto, non siamo di fronte ad una classe e anche siamo lontani dal
concetto di struct + funzione. In pratica, riassumendo un discorso che
merita comunque delle riflessioni:
Un enum con funzioni è un tipo
discreto con comportamento associato.
Una struct con
funzioni è un oggetto (ma non una classe!) con dati e comportamento.
Un'altra funzone built-in interessante svolge un lavoro si può dire opposto a enumFromInt che converte un intero in un enumerativo e qui c'è qualche rischio. La vediamo all'opera nel seguente esempio:
Esempio 15.5
const std = @import("std");
const Direzione = enum { Nord, Sud, Est, Ovest };
pub fn main() void {
const d: Direzione = @enumFromInt(1);
std.debug.print("La direzione = {s}\n", .{@tagName(d)});
}
La cosa dovrebbe essere abbastanza chiara. Definiamo un valore ed
estraiamo l'elemento corrispondente nell'enumeratore. Per stamparlo usiamo
un'altra funzione built-in ovvero tagName
che restituisce una stringa ([]const
u8) corrispondente al nome del campo. Come al solito attenzione al
parametro di formattazione, di questi parametro parleremo in sezione
apposita, comunque in questo caso:
- {d} forza una espressione numerica e
il risultato sarebbe 1
- {s} mostra la stringa Sud, corripondente
all'elemento legato al numero 1.
- {any} vi mostra Sud nella sua forma
vettore di u8.
Cosa succede se però forniamo un numero che non è compreso nel range previsto dall'enumeratore? Qui le cose si fanno un po' più problematiche. Se ad esempio nell'esempio 15.4 anzichè il numero 1 passassimo come elemento, che so, 99, avremmo un errore in compilazione se il valore è noto a compile time:
error: enum 'z422.Direzione' has no tag with value '99'
mentre se, per esempio, è il risultato di un input utente, quindi che il compilatore non può prevedere, ecco, allora avremmo un undefined behaviour molto pericoloso. Ci sono varie strategie per prevenire questi errori, in genere, caso più semplice se i valori sono in qualche modo prevedibili, ad esempio numeri, si può ricorrere,se quindi c'è il dubbio, ad un enumeratore esaustivo, usando l'unknown tag.
Similarmente alle funzioni, il namespace definito da un enumeratore può contenere anche delle variabili. Queste non sono varianti ma vere e proprie variabili semplicemente contenute nell'ambito dell'enumeratore. Esse non appartengono alle istanze eventuali ma al tipo. Possono essere utili ad esempio, come contatori interni, ad esempio:
Esempio 15.6
const std = @import("std");
const Punto = enum {
x,
y,
z,
pub var cont: u32 = 0;
pub fn conta() void {
cont += 1;
}
};
pub fn main() void {
Punto.conta();
Punto.conta();
Punto.conta();
std.debug.print("Contatore = {}", .{Punto.cont});
}
La variabile cont (che deve essere pubblica, quindi marcata pub, viene
incrementata all'interno della funzione conta() che si trova allinterno del
namespace delimitato dall'enumeratore. Ogni chiamata incrementa detta
variabile. Da notare che la funzione conta() non può essere chiamata
da una istanza di Punto. Ad esempio se definissimo const p1 =
Punto.x;
non
potremmo poi effetturare una chiamata tipo:p1.conta()
perchè la
funzione non contiene il parametro self e quindi è una pura funzione che
vive dentro l'enumeratore.
Possiamo concludere questo paragrafo con una tabella riassuntiva che riprende anche alcune della annotazioni inserite nel testo precedente.:
| Caratteristica | Supporto in Zig | Note |
|---|---|---|
| Tag numerico automatico | ✔️ | Parte da 0 |
| Tag numerico personalizzato | ✔️ | Con controllo di overflow |
| Enum non esaustivi | ✔️ | Richiedono else negli switch |
| Metodi nell’enum | ✔️ | Funzioni namespaced |
| Variabili nell’enum | ✔️ | Interne namespace |
| Conversione int → enum | ✔️ @enumFromInt |
Sicura solo se esaustivo |
| Enum + dati | ✔️ union(enum) |
Tagged union |
Avrete notato la presenza di enum + dati in congiunzione con union. Dovremo vedere in dettaglio questo argomento che è importante e necessita di spiegazione ed analisi specifica, poi, in quello stesso paragrafo, integreremo il discorso relativo agli enumeratori.