ZIG - Ennuple (tuple)


Abbiamo già accennato al discorso delle tuple, scusatemi ma io preferisco il termine italico "ennuple" e quello userò nel prosieguo, come elemento che troviamo ad esempio in fase di stampa.
In Zig le ennuple non sono un tipo separato come in Python o Rust. Sono un caso speciale di struct anonime, con campi indicizzati numericamente invece che nominati. I valori e questo è importante, possono essere di tipo differente l'uno dall'altro.

La sintassi base è:
.{elementi separati da virgola}
esempio:
const t01 = .{0, "ciao", 0.03}

si noti il punto prima della parentesi graffa. Ma soprattutto va sottolineato che i campi, diversamente da quanto vedremo per gli array e come abbiamo anticipato, sono (possono essere) eterogenei.

Una ennupla vuota è ovviamente:
.{}

Tenete presente che il numero di elementi una ennupla è immutabile.
A fronte di una dichiarazione come la costante t01 precedente, internamente Zig provvede a costruire una struct simile:

struct {
  0: i32,
  1: []const u8,
  2: f32,
}

i campi sono posizionali quindi, iniziano come di consueto da 0 e il tipo è determinato a comptime sulla base della natura degli elementi.
per accedere ai singoli campi si può usare la sintassi @"0" (gli indici sono stringhe e si utilizza il nome generato automaticamente) ecc... o anche quella classica [0] ecc... di tipo puramente posizionale. I due metodi sono equivalenti.

Esempio 16.1

const std = @import("std");

pub fn main() void {
    const t = .{ 0, "ciao", 0.03 };
    std.debug.print("{}\n", .{t.@"0"});
    std.debug.print("{s}\n", .{t[1]});
    std.debug.print("{}\n", .{t.@"2"});
}

da cui otteniamo

0
ciao
0.03

Indicare un campo fuori range dà origine ad errore in compilazione.

Quindi, per riassumere, qual è la differenza tra le struct e le ennuple come le abbiamo definite in questo linguaggio:

Questo è il concetto base da ricordare, internamente il compilatore mi pare di ricordare che le tratta  come delle struct. Tuttavia per noi umani è interessante chiarire un po' il rapporto esiste tra struct anonime ed ennuple. Per questo possiamo dire che:
---  La risposta breve è: le ennuple sono struct anonime con campi non nominati esplicitamente.
---  In Zig esiste un solo concetto sottostante — la struct anonima — che si manifesta in due forme sintattiche:
---- ---- Struct anonima con campi nominati
---- ---- Struct anonima senza nomi (= "tupla")
Questa è la base per capire come vengono trattate le ennuple in questo linguaggio.
Naturalmente è possibile dichiarare una ennupla come var invece che const e allora dobbiamo fare attenzione. Il seguente codice per esempio:

var a = .{ 1, 2 };
var b = .{ 1, 2 };
a[0] = 5;
b[0] = 6;


non compila e il motivo è semplice, come ci mostra il compilatore che si ferma al primo errore:
error: value stored in comptime field does not match the default value of the field a[0] = 5;
questo perchè quando scriviamo .{ 1, 2 } senza un tipo esplicito, i valori 1 e 2 sono comptime_int ovvero valori noti e fissi a compile-time. Il compilatore li tratta come costanti intrinseche della struct, non come campi mutabili a runtime. Quindi dichiarare la ennupla come var non è sufficiente. Cosa bisogna quindi fare? Bisogna assegnare un tipo alle variabili interne. Propongo un paio di soluzioni al volo anche se poi tutto sommato sono due facce della stessa medaglia, ovvero si fa uso della struct anonima che definisce una ennupla:
soluzione 1)  con struct esplicita:

const Coppia = struct { i32, i32 };
var a: Coppia = .{ 1, 2 };
var b: Coppia = .{ 1, 2 };
a[0] = 5; //
b[0] = 6;


soluzione 2) con struct inline

var a: struct { i32, i32 } = .{ 1, 2 };
a[0] = 5;
var b: struct { i32, i32 } = .{ 1, 2 };
b[0] = 6;


Per vedere quanti sono sono gli elementi interni ad una ennupla possiamo usare la proprietà len:

std.debug.print("{}\n", .{t.len});

ci restituirà 3 nel caso precedente.

L'operatore ++ che ha valenza, come vedremo, anche per gli array, permette di concatenare due ennuple:

Esempio 16.2

const std = @import("std");

pub fn main() void {
    const t1 = .{ 0, "ciao", 0.03 };
    const t2 = .{ "mondo", 99 } ++ t1;
    std.debug.print("{}\n", .{t2.len});
}

Mentre l'operatore ** permette la ripetizione di un ennupla:
const r = .{ "x" } ** 3; // .{ "x", "x", "x" }

Un operazione importante è l'attraversamento di un ennupla e per risolvere questo caso  possiamo ricorrere al ciclo for ma ricorreremo al for inline, a cui avevamo accennato nel capitolo relativo al for stesso. Ad obbligare questa scelta è la possibile diversa natura degli elementi che vengono presi in esame col dipanarsi del ciclo. Non è infatti possibile creare un blocco unico valido per tutte le iterazioni a runtime come fa il for normale proprio per la possibile difformità degli elementi presi in esame per ciascuna iterazione. Ora è il momento di vedere in azione anche inline for:

Esempio 16.3

const std = @import("std");
pub fn main() void {
    const t1 = .{ 0, "ciao", 0.03 };
    const t2 = .{ "mondo", 99 } ++ t1;
    std.debug.print("{}\n", .{t2.len});
    inline for (0..4) |x| {
        std.debug.print("{any}\n", .{t2[x]});
    }
}

Quando vedremo la formattazione dell'output potremo far meglio di quanto comparirà con questo programma.

Anche in Zig è possibile destrutturare una ennupla. La cosa non è (ancora?) elegante e comoda come ad esempio in Rust. In quel linguaggio infatti possiamo scrivere  semplicemente (vedere la sezione Rust):
let (var-1, var-2, var-3... var-n) = (valore-1, valore-2, valore-3,... valore-n)
in Zig dobbiamo lavorare un po' di più di codice:
const t = .{ 1, 2 };
const a = t.@"0"; // o t[0]
const b = t.@"1"; // o t[1]

certamente meno comodo ed elegante.

Quando usare le ennuple?

1) Innanzitutto le abbiamo già viste in azione quando usiamo la funzione di stampa:

std.debug.print("Ciao, {s}!\n", .{"amico"});

Vuole necessariamente una ennupla che può contenere 0 o più elementi. In generale quando servono argomenti variadici..

2) Un altro caso è il raggruppamento di valori senza dover creare un tipo..

3) In congiunzione con le funzioni per restituire valori multipli.

Ci sono altri casi che però appartengono ad un ambito più avanzato di quello che è il nostro attuale livello di conoscenza del linguaggio.

std.meta.Tuple

Si tratta di un costruttore di tipi, una funzione comptime che restituisce un tipo e serve a creare un tipo struct-tupla a partire da un array di tipi, in pratica quando una ennupla la costruiamo programmaticamente a comptime.
Con la classica sintassi possiamo creare una ennupla solo se conosciamo già i valori e questi sono hardcoded nella memoria statica del programma. Attraverso il costruttore oggetto di questa sezione possiamo definire programmaticamente le ennuple che vogliamo utilizzare. In pratica le ennuple "normali" sono costruite tramite input di valori mentre in questo caso come input abbiamo degli array di tipi. Propongo solo un esempio di base:

Esempio 16.4

const std = @import("std");

pub fn main() void {
    // 1. Definisco un tipo tuple con due campi: i32 e bool
    const T = std.meta.Tuple(&.{ i32, bool });
    // 2. Creo un valore di quel tipo
    const x: T = .{ 123, true };
    // 3. Accedo ai campi posizionali
    std.debug.print("Valori: {}, {}\n", .{ x.@"0", x.@"1" });
}

Vi sono casi più complessi che vedremo più avanti, questo esempio è valido solo per illustrare il concetto. Questa possibilità del linguaggio è molto utilizzata ad esempio nell'ambito della metaprogrammazione

COPIA DI ENNUPLE

Avrete già intuito che questo è un argomento spinoso. Se Rust ad esempio si affida al suo borrow checker ed alle regole di Copy e Clone, quindi vi mette al riparo da problemi, Zig vi lascia più libertà... anche di sbagliare. Questo perchè in una ennupla abbiamo dati di diversa natura e questo fa tutta la differenza del mondo da caso a caso.

1: valori scalari

const a = .{ 1, 2, 3 };
const b = a;


Qui abbiamo una copia completa e indipendente, tutti i campi sono comptime_int o tipi numerici concreti — valori interi memorizzati direttamente nella struct. La copia è totalmente indipendente, non c'è nulla che punti altrove.

2: stringhe letterali

const a = .{ "ciao", "mondo" };
const b = a;

Qui i tipi coinvolti sono *const [4:0]u8 e *const [5:0]u8 — ovvero puntatori a dati che vivono nel segmento statico del binario (read-only). La copia copia i puntatori, non i caratteri.
Ma in questo caso non è un problema: le stringhe letterali, come abiamo visto, sono immutabili e vivono per tutta la durata del programma, quindi condividere il puntatore è sicuro e corretto.

3: slice const []u8
Qui la cosa è un po' più complicata:

var buf = [_]u8{ 'c', 'i', 'a', 'o' };
const a = .{ buf[0..] };
const b = a;

Qui il campo è una slice e abbiamo pertanto una coppia puntatore + lunghezza. Avviene la copia del puntatore ma la zona di memoria puntata è la stessa. Quindi c'è dipendenza tra le due slice. L'esempio seguente lo dimostra:

Esempio 16.5

const std = @import("std");
pub fn main() void {
  var buf = [_]u8{ 'c', 'i', 'a', 'o' };
  const a = .{buf[0..]};
  const b = a; // copia il fat pointer (ptr + len), NON i byte
  buf[0] = 'X';
  std.debug.print("{s}\n", .{a.@"0"}); // "Xiao"
  std.debug.print("{s}\n", .{b.@"0"}); // "Xiao"
}

Ovvero ma variazione sul buffer originario ha avuto effetto su entrambe le ennuple.

4: puntatori a heap
Per questo caso ci servono ulteriori conoscenze ma intanto guardiamo il seguente codice:
const allocator = std.heap.page_allocator;

const ptr = try allocator.create(i32);
ptr.* = 42;
const a = .{ ptr };
const b = a; // copia il puntatore, non il valore heap!
// a.@"0" e b.@"0" puntano alla stessa locazione heap
// chi libera la memoria? doppio free = undefined behavior

Questo è il caso più pericoloso: la copia crea due "proprietari" dello stesso dato heap, senza che Zig ce lo impedisca (non ha un borrow checker come Rust). Qui è il programmatore che deve fare attenzione.

In generale bisogna prestare attenzione ai dati interni alle ennuple perchè un conto sono quelli realmente interni, come gli scalari, un altro quelli puntati per riferimento.

Per questo esempio ci manca qualche pezzo che dovremo vedere più avanti.

Con le ennuple per ora è tutto ma come vedremo le ritroveremo in altri ambiti durante il nostro percorso.