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). (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)
avrete anche notato che p1 è var e non const come nell'esempio 14.3 altrimenti non avremmo potuto modificare nulla.
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):

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.