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:

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.