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:
- struct normali → campi nominati
- ennuple → campi posizionali anonimi
- una struct genera un tipo
- una ennupla produce un valore (a meno che non si parli dei tuple type che vedremo più avanti nel paragrafo)
--- 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 scalariconst 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 letteraliconst 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.