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:
- evitare copie inutili di dati grandi;
- modificare dati definiti altrove;
- costruire strutture dinamiche (liste, alberi, grafi);
- gestire la memoria;
- interfacciarsi direttamente con l'hardware e il sistema operativo.
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:
-
*nel tipo indica che una variabile è un puntatore. -
&è l'operatore che prende l'indirizzo di una variabile. -
.*è l'operatore di "dereferenziazione.
E' ora di vedere un po' di codice:
var x: i32 = 5;
const p: *i32 = &x;
che nel dettaglio significa:
-
xcontiene evidentemente il valore 5. -
&xsignifica: "dammi l'indirizzo dix". -
*i32significa: "puntatore a uni32". -
pcontiene quindi l'indirizzo dix.
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:
- abbiamo allocato il valore 10,
- ne abbiamo ricavato l'indirizzo in memoria tramite puntatore
- abbiamo avuto accesso tramite dereferenziazione al suo contenuto che abbiamo modificato
- infine lo abbiamo stampato.
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.