ZIG - Costrutti e funzioni
In questa sezione esaminiamo come lavorano le funzioni
all'interno di alcuni costrutti che abbiamo già incontrato.
Il
primo e a mio avviso più interessante di questi costrutti sono le:
STRUCT.
La cosa principale da capire è che anche se le funzioni sono
circoscritte nel perimetro di una struct questo non significa che siamo di
fronte ad un oggetto. e quindi, tanto meno, siamo nell'ambito della
programmazione a oggetti. In pratica la struct (o anche gli altri
costrutti come vedreno)funziona come un
namespace all'interno del quale vive la funzione che diventa
accessibile tramite la consueta dot notation, quindi nel formato:
nomecostrutto.nomefunzione.
in questo modo ci è permesso
usarli esattamente come fossero metodi (che non sono).
Vediamo un
esempio completo, anche qui andiamo sul classico:
Esempio 23.1
const std = @import("std");
pub const Vettore = struct {
x: f32,
y: f32,
// Convenzione per l'uso di @This()
const Self = @This();
// 1. Funzione "statica" / Costruttore
pub fn init(x: f32, y: f32) Self {
return Self{ .x = x, .y = y };
}
// 2. Metodo di sola lettura (passaggio per valore)
pub fn magnitudine(self: Self) f32 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
// 3. Metodo di modifica (passaggio per puntatore mutabile)
pub fn ridim(self: *Self, factor: f32) void {
self.x *= factor;
self.y *= factor;
}
};
pub fn main() void {
// Chiamata alla funzione statica
var v = Vettore.init(3.0, 4.0);
// Sintassi dot-notation per il metodo di lettura
std.debug.print("Magnitudine: {d}\n", .{v.magnitudine()}); // 5.0
// Zig converte automaticamente `v.ridim(2.0)` in `Vector2.Ridim(&v, 2.0)`
v.Ridim(2.0);
std.debug.print("Nuova dimensione: ({d}, {d})\n", .{ v.x, v.y }); // (6.0, 8.0)
}
Come si vede nella nostra struct che si chiama Vettore, abbiamo varie funzioni, iniziamo a considerare init. Ma prima andiamo ad analizzare:
const Self = @This();
@This() è una funzione builtin che restituisce il tipo che la racchiude —> in questo caso Vettore. Si usa per due motivi:
- Evitare ripetizioni: invece di scrivere Vettore ovunque nel corpo dello struct, scrivi Self.
- Riutilizzo del codice: se domani rinominassimo Vettore in Vec2, non devi toccare il corpo interno — Self si aggiorna automaticamente.
- Come da documentazione ufficiale, è anche molto utile per strutture
anonime che devono richiamare se stesse.
Molto comodo insomma anche se non strettamente necessario. Vediamo ora un'alaisi del codice:
pub fn init(x: f32, y: f32) Self {
return Self{ .x = x, .y = y };
}
Init è una funzione statica il cui scopo è creare e restituire una
nuova istanza di Vettore. Ovviamente, quando la usiamo per creare v
è equivalente a:var v = Vettore{.x = 3.0, .y = 4.0}
pub fn magnitudine(self: Self) f32 {
return std.math.sqrt(self.x * self.x + self.y * self.y);
}
magnitudine è funzione specificata di sola lettura in quanto riceve come parametro una struct che passa i suoi dati ma non viene modificata. Quindi è più logico (e sicuro) passare una copia e non un puntatore.
pub fn ridim(self: *Self, factor: f32) void {
self.x *=
factor;
self.y *= factor;
}
ridim invece presenta un puntatore mutabile alla struttura v che gli viene passata in chiamata. Viene anche inviato alla funzione un parametro che viene usato come valore di scala. In questo caso c'è scrittura sulla struttura originale. Senza puntatore (*) sarebbe stata modificata la copia locale creata all'interno della funzione.
Lo stesso discorso è possibile farlo anche con gli
ENUMERATORI.
Anche ad essi si possono abbinare delle funzioni che non sono metodi ma
vivono nel namespace definito dall'enumeratore. Anche qui ricorro ad un
esempio classico (sempre in onore dei mitici 883):
Esempio 23.2
const std = @import("std");
const Direzione = enum {
nord,
sud,
est,
ovest,
pub fn opposto(self: Direzione) Direzione {
return switch (self) {
.nord => .sud,
.sud => .nord,
.est => .ovest,
.ovest => .est,
};
}
};
pub fn main() !void {
const d1 = Direzione.nord;
const d2 = d1.opposto();
std.debug.print("Contrario di {any} = {any}\n", .{ d1, d2 });
}
Non c'è nulla di nuovo rispetto all'esempio 22.1, abbiamo una funzione che viene richiamata tramite dot.notation dalla quale viene restituito un enumeratore. Noterete lo switch esaustivo ma questa non è una novità. Normalmente le funzioni interne agli enumeratori sono utili per gestire in modo custom la logica delle varianti.
UNION
In questo contesto, la collaborazione con le funzioni è particolarmente funzionale con le tagged union, come abbiamo visto nella sezione dedicata. In questa vediamo un altro esempio, tanto per fissare il concetto.
Esempio 23.3
const std = @import("std");
const Figura = union(enum) {
cerchio: f64, // raggio
rettangolo: struct { larghezza: f64, altezza: f64 },
triangolo: struct { base: f64, altezza: f64 },
pub fn area(self: Figura) f64 {
return switch (self) {
.cerchio => |raggio| std.math.pi * raggio * raggio,
.rettangolo => |r| r.larghezza * r.altezza,
.triangolo => |t| 0.5 * t.base * t.altezza,
};
}
};
pub fn main() !void {
const s = Figura{ .cerchio = 2.0 };
const a = s.area(); // usa il payload legato al tag attivo
std.debug.print("Area del cerchio: {d}\n", .{a});
const r = Figura{ .rettangolo = .{ .larghezza = 3.0, .altezza = 4.0 } };
const areaRettangolo = r.area();
std.debug.print("Area del rettangolo: {d}\n", .{areaRettangolo});
const t = Figura{ .triangolo = .{ .base = 5.0, .altezza = 6.0 } };
const areaTriangolo = t.area();
std.debug.print("Area del triangolo: {d}\n", .{areaTriangolo});
}
E' importante notare quel commento, usa il payload legato al tag attivo, che è appunto il punto di forza, se vogliamo chiamarlo così del tipo Union.