ZIG - I puntatori


Croce e delizia dei programmatori C/C++ con i puntatori prima o poi ci dobbiamo scontrare in qualche modo in quasi tutti i linguaggi moderno (o forse tutti? Non lo so).
Detto in maniera un po' grezza ma efficace, un puntatore è un valore che rappresenta un indirizzo memoria di un altro dato. Una variabile contiene un valore esplicito, ad esempio
const x = 42;
ci è chiara. Un puntatore ci indica dove quel valore si trova in memoria. Possiamo ottenere proprio il valore di cui abbiamo l'indirizzo? Si, attraverso una operazione che si chiama dereferenziazione.
In generale, quindi non soltanto in relazione a Zig, perchè esistono i puntatori e perchè praticamente tutti i linguaggi, in un modo o nell'altro li prevedono? Ci sono tanti validi motivi, certamente i puntatori permettono di:

In pratica sono uno dei concetti fondamentali della programmazione di sistema, quindi per linguaggi, come questo che si rivolgano al quell'ambito sono un argomento centrale. Chi viene da C/C++ ne sa qualcosa. Si tratta di un strumento molto potente e, purtroppo, anche piuttosto pericoloso e la cui manipolazione richiedere un po' di prudenza e di pratica.

In Zig i puntatori sono molto controllati, non esiste, come da filosofia generale del linguaggio, nulla di "sotterraneo".
Abbiamo sostanzialmente questa situazione:

E' ora di vedere un po' di codice:

var x: i32 = 5;
const p: *i32 = &x;

che nel dettaglio significa:

In un certo senso la caratterizzazione, la tipizzazione, di un puntatore è determinata dal dato puntato.

Esempio 19.1

const std = @import("std");

pub fn main() !void {
    var x: i32 = 10;
    const p: *i32 = &x; // p punta a x
    p.* = 20; // dereferenziazione con .*
    std.debug.print("{}\n", .{x}); // stampa 20
}

Quindi:

Evidentemente è anche la strada per assegnare il valore dell'elemento puntato ad un'altra variabile o costante:

const y = p.*;

Questo è un esempio tipico, facilmente comprensibile, molto comune e non dissimile da quanto si trova in altri linguaggi.
Se x fosse stata dichiarata const invece che var allora la definizione di p avremmo dovuto indicarla diversamente:

const x: i32 = 5;
const p: *const i32 = &x;

In quanto definirlo solo come i32 avrebbe significato la possibilità di modificare nel contenuto di quanto puntato, cosa che non poteva dal momento che x è dichiara const. Ricordiamoci che const è transitivo, ovvero se lo è l'elemento puntato lo è anche il puntatore.

Quello he abbiamo visto è il caso più semplice, ovvero il puntamento ad un singolo valore. Riassumendo:

*T
*const T

sono le due forme previste pr questo caso.

Se vi interessasse conosce l'indirizzo di memoria, ovvero il valore del puntatore potete scrivere:

std.debug.print("{*}\n", .{p});

Facciamo un passo avanti e incontriamo

[*]T

che indica il puntatore ad una sequenza di lunghezza ignota. Il fatto che la lunghezza della sequenza non sia conosciuta impone che la si debba gestire manualmente, come in C. Vediamo quindi un esempio completo:

Esempio 19.2

const std = @import("std");

pub fn main() void {
    var arr = [_]i32{ 10, 20, 30, 40, 50 };
    const p: [*]i32 = &arr;

    // accesso per indice
    std.debug.print("p[0] = {}\n", .{p[0]});
    std.debug.print("p[2] = {}\n", .{p[2]});

    // modifica tramite puntatore
    p[1] = 99;
    std.debug.print("arr[1] = {}\n", .{arr[1]}); // 99

    // per iterare dobbiamo conoscere la lunghezza
    const len = arr.len;
    var i: usize = 0;
    while (i < len) : (i += 1) {
        std.debug.print("p[{}] = {}\n", .{ i, p[i] });
    }
}

Come si vede per l'iterazione ricaviamo la lunghezza con i consueti metodi dell'array. Bisogna fare attenzione in quanto la gestione delle sequenze ha quale insidia. Infatti se nell'esempio precedente inserissimo questa istruzione:

std.debug.print("{}\n", .{p[10]});

non avremmo alcun errore di compilazione ma il risultato sarà poco o nulla significativo. Purtroppo la lunghezza non è nota, come abbiamo detto e Zig non può gestire errori di range per un costrutto di cui non conosce le dimensioni.

Un caso ancora diverso è quello dei puntatori nullable. in Zig nessun puntatore può essere null implicitamente. Se un puntatore può essere null (non obbligatoriamente, si capisce), dobbiamo dichiararlo esplicitamente con ?*T. Il prezzo da pagare è che Zig obbliga a gestire il caso null il che appesantisce il codice. Partiamo con uno dei soliti esempi di base, molto semplice direi anche autoesplicativo:

Esempio 19.3

const std = @import("std");

pub fn main() void {
    var x: i32 = 5;
    var p: ?*i32 = null; // null esplicito
    std.debug.print("p - null: {}\n", .{p == null}); // true
    p = &x; // ora punta a x
    std.debug.print("p - null: {}\n", .{p == null}); // false
}

Diverso è il caso in cui dovessimo dereferenziare il nostro ?*T. Questa operazione infatti non può essere fatta direttamente. Il motivo lo capisce, mentre *T è sempre valido ?*T è un Optional è può contenere un valore valido oppure no. Conseguentemente un  valore di tipo ?*T che deve essere convertito a *T prima della dereferenziazione, ad esempio usando if:

Esempio 19.4

const std = @import("std");

pub fn main() void {
    var x: i32 = 10;
    const p: ?*i32 = &x;
    if (p) |ptr| {
        std.debug.print("Valore: {}\n", .{ptr.*});
    } else {
        std.debug.print("Null pointer\n", .{});
    }
}

Un ultimo punto, che non trattiamo a fondo in questa e vale in particolare con l'interoperabilità con il linguaggio C, sono sentinel-terminated pointer. Come avrete intuito si tratta di puntatori per i quali è specificato un preciso valore di terminazione della sequenza. Formalmente vengono indicati con:

[*:s]T

Ad esempio:

[*:0]u8 // sequenza di byte terminata da 0 (stringa C)
[*:0]i32 // sequenza di i32 terminata da 0

Per adesso con i puntatori è tutto, giusto una solida base per iniziare ad usarli.