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.