ZIG - Le struct
Altro costrutto fondamentale in questo linguaggio sono l'oggetto di questo
capitolo, le struct o strutture.
Si tratta di un tipo composto
nell'ambito del quale possiamo definire campi aventi diversa natura. La
keyword che le definisce è, ovviamente, struct e formalmente possiamo
definirle così:
const Nomestruct = struct {
campo-1,
campo-2,
.....
campo-n
}
I campi, come detto, possono essere di tipo diverso e devono essere separati da un virgola. Il nome della struttura, secondo le linee guida, va scritto con la maiuscola. Un esempio tipico:
const Persona = struct {
nome: []const u8,
eta: u8,
};
Qui abbiamo una struttura che contiene un array e un u8. In pratica possiamo
costruire dei tipi custom. Inoltre e struct possono contenere anche
dichiarazioni comptime e tipi generici, rendendole strumenti potenti
per la metaprogrammazione. Ma di questo parleremo altrove. Le struct sono anonime internamente
e questo è importante da ricordare. A livello sintattico essere vengono poi
associate a delle costanti, come nel codice appena visto il che
nella pratica equivale ad assegnare un identificativo ad un type.lteral come
detto anonimo. Il nome di una struct peraltro può essere anche attribuito di
ritorno da una funzione oppure, come da documentazione ufficiale, essa assume un
nome tipo filename.funcname__struct_ID.
La fase successiva è istanziare questo nuovo tipo
che abbiamo creato, ovvero creare un elemento basato sul modello (la struct)
che abbiamo precedentemente definito. Vediamo quindi un primo esempio completo:
Esempio 14.1
const std = @import("std");
pub fn main() void {
const Persona = struct {
nome: []const u8,
eta: u8,
};
const p1 = Persona{ .nome = "Maria", .eta = 30 };
const p2 = Persona{ .nome = "Mario", .eta = 25 };
std.debug.print("Persona 1: {s} ({} anni)\n", .{ p1.nome, p1.eta });
std.debug.print("Persona 2: {s} ({} anni)\n", .{ p2.nome, p2.eta });
}
Abbiamo creato un "tipo" persona e abbiamo creato due istanze, p1 e p2.
L'accesso ai campi segue e relativa inizializzazione la sintassi
.nomecampo = valore-iniziale
mentre per recuperare i campi
ed utilizzarli, come evidente si usa:nomevariabile.nomecampo.
Naturalmente una istanza marcata const vedrà i suoi campi immutabili mentre potranno essere modificati quelli di una istanza marcata var.
Dal momento che Zig vuole essere un C migliore bisogna avere un occhio anche per l'aspetto prestazionale. In questo senso occorre sapere che le struct, così come definite fin qui, non hanno un layout predefinito in memoria. Quindi possono esserci variazioni tra una compilazione e un'altra ed anche tra piattaforme diverse, parlando empre ovviamente di occupazione di risorse, non certo di comportamento. I campi possono essere riordinati, è possibile aggiungere un padding per l'allineamento, possono essere applicate delle ottimizzazioni. Quindi c'è un margine di imprevedibilità e questo può essere un parametro da tenere ben presente in caso di applicazioni critiche sotto questi aspetti. Vedremo più avanti un tipo particolare di strutture che sono ottimizzate in questo senso.
E' importante sapere che in Zig è anche possibile definire delle funzioni all'interno della struct di modo che queste possano avere un proprio, chiamiamolo, comportamento, peculiare. Potreste pensare alle classi di altri linguaggi ma ci sono differenze sostanziali che chiariremo più avanti. Comunque vediamo all'opera questo potente ed importante meccanismo, tenete però presente che anticipiamo qualcosa che riguarda le funzioni per cui alcune cose potrebbero non essere chiare:
Esempio 14.2
const std = @import("std");
const Persona = struct {
nome: []const u8,
eta: u8,
pub fn chisono(self: Persona) void {
std.debug.print("Mi chiamo {s} e ho {d} anni.\n", .{ self.nome, self.eta });
}
};
pub fn main() void {
const p1 = Persona{ .nome = "Maria", .eta = 30 };
p1.chisono();
}
In questo caso abbiamo portato la struct fuori dal main in modo che essa, quindi il suo nome, Persona, risulti globalmente disponibile. Questa, a mio avviso, è la soluzione migliore. Se voleste gestire la struct all'interno del main non si potrebbe più fare direttamente riferimento al nome ma dovremmo usare la funzione built-in @This(), come segue:
Esempio 14.3
const std = @import("std");
pub fn main() void {
const Persona = struct {
nome: []const u8,
eta: u8,
pub fn chisono(self: @This()) void {
std.debug.print("Mi chiamo {s} e ho {d} anni.\n", .{ self.nome, self.eta });
}
};
const p1 = Persona{ .nome = "Maria", .eta = 30 };
p1.chisono();
}
detto questo concentriamoci sulla riga seguente, considerando l'esempio
14.2
pub fn chisono(self: Persona) void
{
questa è una funzione che permette alla struct, o
meglio ad una sua istanza, di presentare un comportamento, un'azione. Qui
abbiamo l'esposizione di una stringa video ma potrebbero essere, come sono
in genere, sequenze anche molto complesse.
Possiamo anche fare in modo
che una funzione modifichi un parametro preimpostato all'interno della
struct:
Esempio 14.4
const std = @import("std");
const Persona = struct {
nome: []const u8,
eta: u8,
pub fn chisono(self: Persona) void {
std.debug.print("Mi chiamo {s} e ho {d} anni.\n", .{ self.nome, self.eta });
}
pub fn cambioeta(self: *Persona) void {
self.eta += 1;
}
};
pub fn main() void {
// 2. Usiamo 'var' invece di 'const', altrimenti p1 non pu├▓ essere modificata
var p1 = Persona{ .nome = "Maria", .eta = 30 };
p1.chisono();
p1.cambioeta(); // passa l'indirizzo di p1
p1.chisono();
}
Come vedete, all'interno della struct Persona abbiamo indicato due
funzioni, una è "chisono" e l'abbiamo già vista all'opera nell'esempio 14.3,
l'altra è "cambioeta". All'interno avviene una dereferenziazione automatica
quando si richiede l'accesso ai campi. E' interessante notare il parametro passato, nel
secondo caso infatti abbiamo un puntatore alla struct. Qual è la differenza
tra i due parametri:
- self: Persona
è in pratica un passaggio per valore. Ovvero passiamo, come dire, una
fotocopia dell'elemento indicato
- self
* Persona è un passaggio per riferimento, in questo caso
passiamo l'indirizzo di memoria al quale è possibile accedere in lettura e
scrittura
- self: *const Persona
è una forma intermedia che permette comunque solo la lettura. Lavora bene
con struct di grandi dimensioni
Di seguito un piccolo schema che
chiarisce la cosa per il primo e il secondo caso:
| Caratteristica | self: Persona | self: *Persona |
| Memoria | Viene creato un nuovo spazio (copia). | Viene usato l'indirizzo dell'originale. |
| Modificabilità |
No (i parametri sono
const). |
Sì (tramite il puntatore). |
Effetto su
p1 |
Nessuno. |
p1 viene modificata realmente. |
| Utilizzo tipico |
Metodi di sola lettura (es.
chisono). |
Metodi che cambiano lo stato (es.
cambioeta) |
La domanda che potreste fare, se conoscete linguaggi OO (Object Oriented), è: ma allora siamo davanti ad una classe? La risposta è no ma la motivazione è un po' sottile. Indubbiamente richiamare una funzione come abbiamo fatto nell'esempio 14.4 è molto simile, formalmente identico, al richiamo dei metodi nei linguaggi a oggetti. In realtà una struct può essere vista semplicemente come un namespace in cui appoggiamo campi e funzioni. Non è possibile usare questi namespace per ereditare o applicare su di essi il polimorfismo. Mancano quindi i pilastri, classici, della programmazione a oggetti. Insomma, in parole semplici: le struct non sono classi. E le funzioni interne non sono metodi: restano funzioni. E' invece vero che ad esempio la scrittura:
p1.chisono() è semplicemente " sintactic sugar" per
Persona.chisono(p1).
Abbiamo detto all'inizio che era possibile fornire dei valori di default ai campi di una struct. Ecco l'esempio:
Esempio 14.5
const std = @import("std");
const Punto = struct {
x: u8 = 0,
y: u8 = 0,
z: u8 = 0,
};
pub fn main() void {
const p1 = Punto{ .x = 1, .y = 2, .z = 3 };
const p2 = Punto{};
std.debug.print("Punto p1: x={}, y={}, z={}\n", .{ p1.x, p1.y, p1.z });
std.debug.print("Punto p2: x={}, y={}, z={}\n", .{ p2.x, p2.y, p2.z });
}
p1 cambia i valori di default laddove p2 tiene quelli forniti dalla definizione originale della struct.
Può essere interessante in termini pratici la possibilità di poter creare una struct tramite una funzione. Vediamo l'esempio e poi lo commentiamo:
Esempio 14.6
const std = @import("std");
const Person = struct {
name: []const u8,
age: u8,
pub fn print(self: Person) void {
std.debug.print("{s} ha {} anni\n", .{ self.name, self.age });
}
};
pub fn create(name: []const u8, age: u8) Person {
return Person{
.name = name,
.age = age,
};
}
pub fn main() void {
// Creazione tramite la funzione create
const p1 = create("Mario", 30);
// Creazione diretta (senza create)
const p2 = Person{
.name = "Maria",
.age = 25,
};
p1.print();
p2.print();
}
Come detto questo sarà più chiaro quando avremo visto le funzioni. Ad ogni modo la funzione create simula il funzionamento di un costruttore di strutture (attenzione: Zig non ha costruttori veri e propri nel senso di alcuni linguaggi Object Oriented), basta passare i parametri giusti nell'ordine giusto, come si vede nell'istruzione di creazione della istanza p1. Il che non toglie, come si vede creando p2, che si possa utilizzare anche il sistema classico. La funzione create si potrebbe anche innestare nella definizione della struct esattamente così come è nel qual caso per creare ad esempio p1 potremmo scrivere:
const p1 = Person.create("Mario", 30);
Una possibilità interessante è la cosiddetta composizione dei dati. Possiamo, in pratica, annidare le strutture in modo da creare elementi complessi. Un esempio è il seguente, lo si trova simile sul Web ma non ricordo dove:
Esempio 14.7
const std = @import("std");
const Citta = struct {
localita: []const u8,
};
const Persona = struct {
nome: []const u8,
dove: Citta,
};
pub fn main() void {
const p1 = Persona{
.nome = "Mario",
.dove = Citta{ .localita = "Roma" },
};
const p2 = Persona{
.nome = "Maria",
.dove = Citta{ .localita = "Milano" },
};
std.debug.print("{s} vive a {s}\n", .{ p1.nome, p1.dove.localita });
std.debug.print("{s} vive a {s}\n", .{ p2.nome, p2.dove.localita });
}
Quindi creiamo una struct che si chiama Citta e la innestiamo nella
struct Persona. La riga.dove = Citta{ .localita = "Milano" },
determina il campo di Persona come valore il campo localita di Citta
La copia di una struct in un'altra avviene per valore tramite l'operatore = :
var p1 = P{.x = 1, .y = 2};
var p2 = p1;
p2 avrà gli stessi valori
di p1 (copia bitwise) ed ogni variazione effettuata sui valori dei campi x e y dell'uno
non
avrà alcun effetto sui rispettivi campi sull'altro. Se si vogliono
evitare copie fisiche bisogna ricorrere ai puntatori come nell'esempio
seguente:
Esempio 14.8
const std = @import("std");
const Punto = struct {
x1: u8,
y1: u8,
};
pub fn main() void {
var p1 = Punto{ .x1 = 5, .y1 = 10 };
var p2 = &p1;
p2.x1 = 15;
p1.y1 = 20;
std.debug.print("Punto 1: ({}, {})\n", .{ p1.x1, p1.y1 });
std.debug.print("Punto 2: ({}, {})\n", .{ p2.x1, p2.y1 });
}
Il questo caso abbiamo usato la copia per riferimento tramite l'operatore & e quindi l'output mostrerà valore per i campi identici per le due istanze:
| Punto 1: (15, 20) Punto 2: (15, 20) |
Per quanto forse sia argomento più avanzato rispetto alle nostre pretese attuali di apprendimento, segnalo che esistono le packed struct. Queste permettono una ottimizzazione massima della memoria e non solo. Ad esempio:
const PackedData = packed struct {
a: u4,
b: u4,
};
In questa struttura viene applicata una compressione bit a bit, viene eliminato l'allineamento standard (le CPU preferiscono leggere dati che si trovano a indirizzi di memoria che sono multipli della loro dimensione) e si possono usare tipi con multipli diversi dal byte (es u3). Attenzione però: le packed struct pagano dazio in termini di accesso e nel complesso hanno una peggior compatibilità con C e ABI. In questa fase le packed struct non ci serviranno.
Ancora più complicate concettualmente per un neofita sono le
extern struct. Queste sono caratterizzate dal fatto che il loro
layout in memoria è definito secondo
le regole del C ABI (Application Binary Interface). Dal punto di
vista della definizione formale non ci sono particolarità critiche:const
ExStruct = extern struct {
a: i32,
b: f64,
};
In realtà ha caratteristiche ben precise utili per la compatibilità con il
linguaggio C ma anche per chiamate FFI (Foreign Function Interface):
- ha campi tipizzati
- ha un ordine preciso
- può includere padding automatico
- segue le regole del compilatore C della piattaforma
Per ora anche questo argomento è off-limits per i nostri scopi.
Le struct sono elemento assolutamente primario in questo linguaggio ed è bene approfondirne quanto più possibile il funzionamento.