ZIG - il tipo union


nb: capitolo in aggiornamento - non è tutto chiaro

In Zig, union è un tipo che permette di memorizzare uno solo tra più tipi possibili nello stesso spazio di memoria. E' una sorta di contenitore dall'interno del quale possiamo utilizzare un oggetto solo per volta. Ricorda il tipo union del C, ma con alcune differenze importanti. Distingueremo tra union senza e con tag. La cosa è abbastanza differente in particolare gli union senza tag possono esporre a pericoli che il linguaggio invece di solito evita.
Come potrete intuire la keyword che identifica questo tipo è union.
Partiamo quindi dal classico esempio che trovate ovunque si parli di questo argomento:

const Valore = union {
  intero: i64,
  float: f64,
  booleano: bool,
};

 Qui abbiamo una unione che fa contenitore per 3 elementi di diversa natura. I vari campi interni ha un identificativo un tipo sono separati e chiusi con una virgola. Iniziamo a sottolineare, che rispetto ad una struct abbiamo un vantaggio in termini di occupazione di memoria. Lo vediamo qui di seguito

Definiamo una struct:  occupa sempre 16 byte (somma di tutti i campi:

const SenzaUnion = struct {
  intero: i64, // 8 byte
  float: f64, // 8 byte
};

 una analoga union: occupa solo 8 byte (il campo più grande)

const ConUnion = union {
  intero: i64, // 8 byte
  float: f64, // 8 byte <- stessa memoria!
};

Detto questo andiamo a sviluppare il codice precedente:

Esempio 17.1

const std = @import("std");

pub fn main() void {
    const Valore = union {
        intero: i64,
        float: f64,
        booleano: bool,
    };
    var v1 = Valore{ .intero = 42 };
    v1.intero = 3;
    std.debug.print("Valore v1: {}\n", .{v1.intero});
}

questo codice compila e gira tranquillamente. Ma cosa succederebbe aggiungessimo la seguente riga, prima della stampa:

v1.float = 1.5;

a runtime otterreste questo:

thread 13224 panic: access of union field 'float' while field 'intero' is active

Il numero del thread può cambiare, anzi sicuramente cambierà, ma l'errore no. Come detto un variabile di tipo unione può "accendere" solo uno dei suoi campi. Quindi il compilatore non sa chi è l'elemento attivo. Possiamo anche scrivere questo codice:

var v1 = Valore{ .intero = 42 };
v1.intero = 3;
std.debug.print("Valore v1: {}\n", .{v1.intero});
v1 = Valore{ .float = 3.14 };
std.debug.print("Valore v1: {}\n", .{v1.float});

e allora tutto funziona ma se l'ultima istruzione, quella di stampa del float la riscriviamo ancora come come:

std.debug.print("Valore v1: {}\n", .{v1.intero});

otteniamo di nuovo un errore simile a quello precedente a runtime. Uno per volta, è la regola.

thread 2820 panic: access of union field 'intero' while field 'float' is active

Questo tipo di unione viene usato molto raramente. Ovviamente se definiamo noi le variabili che di volta in volta usiamo non ci sarebbero problemi perchè lì siamo noi a tenere traccia delle variabili in uso. Le criticità sorgono quando siamo costretti a lavorare con una sola variabile che può essere manipolata in varie parti del codice ad esempio può essere riassegnata o manipolata da varie funzioni.
Per questo motivo il tipo union è quasi sempre usato congiunzione con un enumeratore che tiene traccia del campo attivo. Si parla allora di tagged union e sarà quello che useremo nella grande maggioranza dei casi
Darò solo un esempio ripromettendomi di approfondire successivamente il discorso.

Esempio 17.2

const std = @import("std");

// Tagged union: il tipo viaggia insieme al valore
const Valore = union(enum) {
    intero: i64,
    float: f64,
    booleano: bool,
};

fn stampa(v: Valore) void {
    // Il compilatore sa esattamente cosa sta dentro
    // e mi OBBLIGA a gestire tutti i casi
    switch (v) {
        .intero => |n| std.debug.print("(tagged) intero:   {}\n", .{n}),
        .float => |f| std.debug.print("(tagged) float:    {}\n", .{f}),
        .booleano => |b| std.debug.print("(tagged) booleano: {}\n", .{b}),
    }
}

pub fn main() void {
    std.debug.print("---\n", .{});
    // --- Tagged union: il tipo viaggia col valore ---
    var x = Valore{ .intero = 42 };
    stampa(x); // stampa correttamente "intero: 42"
    x = Valore{ .float = 3.14 };
    stampa(x); //  stampa correttamente "float: 3.14"
}

La collaborazione con switch è evidente ed essenziale.

Come ultima osservazione, è importante evidenziare che anche il tipo union può includere delle funzioni. Il concetto è sempre quello dei namespace che inglobano un funzione nel loro spazio niente di diverso anche concettualmente da quanto abbiamo già per struct ed enumerativi. Anche qui darò un esempio basico:

Esempio 17.3

const std = @import("std");

const U = union(enum) {
    a: i32,
    b: f32,

    pub fn print(self: U) void {
        switch (self) {
            .a => |x| std.debug.print("a = {}\n", .{x}),
            .b => |x| std.debug.print("b = {d}\n", .{x}),
        }
    }
};

pub fn main() void {
    var xu1: U = .{ .a = 10 };
    var xu2: U = .{ .b = 3.14 };
    xu1.print();
    xu2.print();
}