ZIG - Le stringhe


NB: stante la complessità dell'argomento e la sua stretta vicinanza agli array e alle slice non è escluso che in futuro vengano apportate corpose modifiche a questa sezione e anche a quelle degli array e delle slice

Le stringhe. Elemento fondamentale di tutti i linguaggi di programmazione e pure non così semplici come si direbbe. Iniziamo subito col dire che Zig non prevede un tipo stringa indipendente. Le 3 parole magiche sono array, slice e sentinelle. Tutto già visto. Le stringhe quindi, non hanno un trattamento speciale, sono solo dati come altri. Nel prosieguo continueremo a chiamarle comunque "stringhe" intendendo appunto quella che comunemente vediamo come una sequenza di caratteri.
Fondamentalmente quando intendiamo il concetto stringhe vicino a quanto esiste in altri linguaggi in Zig, come da definizione ufficiale, parliamo di un puntatore singolo, costante, a un array di byte (u8) terminato da sentinella 0.  Quindi parliamo sempre di sequenze indicizzate 0-based. Queste sono le definizioni da cui partire. Meglio ancora però, vediamo subito il seguente codice che ci darà molte indicazioni che confermano quanto detto:

Esempio 13.1

const std = @import("std");
pub fn main() void {
  const saluto = "Ciao, mondo!";
  const info = @typeInfo(@TypeOf(saluto));
  std.debug.print("Il tipo di saluto -> {}\n", .{info});
}

il cui interessante output è:

Il tipo di saluto -> .{ .pointer = .{ .size = .one, .is_const = true, .is_volatile = false, .alignment = 1, .address_space = .generic, .child = [12:0]u8, .is_allowzero = false, .sentinel_ptr = null } }

Andiamo ad analizzare ogni elemento:

Campo Significato
.pointer Il tipo è un pointer type
.size = .one È un pointer singolo, non slice, non many-pointer
.is_const = true La stringa è immutabile
.is_volatile = false Non sono previste ottimizzazioni da parte del compilatore
.alignment = 1 u8 richiede allineamento 1 che è normale
.address_space = .generic memoria ram standard, è un caso base
.child = [12:0]u8 Il tipo puntato è un array di 12 u8 con sentinel 0. In effetti "Ciao, mondo!" è composto da 12 caratteri.
.is_allowzero = false Il pointer non può essere null
.sentinel_ptr = null Il pointer stesso non è sentinel-terminated (lo è l’array)

In questo senso possono essere oggetto di coercizione sia verso slice sia verso puntatori null-terminated. Cosa vuol dire questo passaggio?
la scrittura:
const s = "ciao";
origina un puntatore, come abbiamo visto. In Zig, la coercizione è un cast implicito e sicuro che il compilatore esegue quando il tipo di destinazione è "più generico" di quello di origine. Una slice, lo abbiamo visto è identificato da un puntatore e da una lunghezza. Il compilatore conosce la lunghezza di "ciao" e quindi il passaggio verso slice è immediato. In pratica convivono varie nature e questo permette interoperabilità verso il C, ad esempio, in maniera molto efficiente. La natura legata ad un puntatore si può anche esprime definendo una stringa come segue:

const cstr: [*:0]const u8 = "ciao";

In stile C piuttosto che altro. In pratica definiamo un puntatore ad una sequenza terminata da \0. Non serve contare basta arrivare fin dove, appunto si incontra \0. Molto utile come detto per interagire con il linguaggio C.
La deferenziazione verso array è ancora molto semplice e lo vediamo nel seguente esempio:

Esempio 13.2

const std = @import("std");
pub fn main() void {
  const literal = "Zig"; // Tipo: *const [3:0]u8
  const array = literal.*; // Tipo: [3:0]u8 (L'array fisico in memoria)
  const t = @typeInfo(@TypeOf(array));
  std.debug.print("{}\n", .{t});
}

che ci restituisce il seguente output, interessante  in particolare nell'elemento evidenziato in verde:

.{ .array = .{ .len = 3, .child = u8, .sentinel_ptr = anyopaque@7ff7d71537ea } }

avrete senz'altro notato la riga:
const array = literal.*;
ne parleremo tra breve
Bisogna fissare bene in mente che la sequenza di caratteri puntata è costante e non modificabile. Ad esempio è un errore banale pensare che, essendo le nostrre stringhe pur sempre sequenze indicizzate, si possa modificarne un elemento come in altri linguaggi:

var saluto = "Ciao, mondo!";
saluto[0] = 'H';

questo codice non funziona e il compilatore ce lo dice chiaramente:

error: cannot assign to constant

Infatti la variabile è saluto e, caso mai, è questo che può cambiare target:

var saluto = "Ciao, mondo!";
saluto = "Hello world!";
std.debug.print("Saluto modificato -> {s}\n", .{saluto});

questo frammento di codice è corretto. Quindi se vogliamo cambiare un elemento all'interno di un sequenza di caratteri come possiamo fare? Una soluzione è quella del seguente programma:

Esempio 13.3

const std = @import("std");
pub fn main() void {
  var saluto = "Ciao, mondo!".*;
  saluto[0] = 'H';
  std.debug.print("Saluto modificato -> {s}\n", .{saluto});
}

In questo caso l'output è questo:

Saluto modificato -> Hiao, mondo!

vediamo cosa significa quella istruzione:
var saluto = "Ciao, mondo!".*;
il punto centrale è ovviamente .* che abbiamo già visto anche nell'esempio 13; ebbene quello è l'operatore di dereferenziazione. In pratica esso dice al compilatore, prendi quello a cui punto e mettilo qua dove ti dico. In particolare esso lo mette sullo stack. Quindi  il compilatore prende l'array e lo mette nella variabile saluto che a quel punto altro non è che un array modificabile. Quindi, come avrete intuito, anche qui usiamo l'operatore [ ] per individuare il singolo elemento.

Lavorare con queste sequenze non è quindi molto più complicato di quanto abbiamo visto finora.
per trovare la lunghezza di una stringa si usa quindi len:

const str = "Zig";
std.debug.print("{}\n", .{str.len});

proprio grazie a len possiamo notare un fatto interessante:

Esempio 13.4

const std = @import("std");
pub fn main() void {
  const str01 = "èèè";
  const str02 = "aaa";
  std.debug.print("{}\n", .{str01.len});
  std.debug.print("{}\n", .{str02.len});
}

questo semplice programma ci restituisce il seguente output:

6
3

Ma come? Ci sono 3 lettere in entrambe le stringhe... D'accordo però come mai la lunghezza restituita è diversa? Torniamo alle origini, abbiamo detto che le stringhe sono "fisicamente" degli array di byte e sono codificate via UTF-8. La lettera 'a' in questo ambito è codificata come U+0061 ed è definita su un singolo byte. Quindi 3 'a' occupano 3 byte. Al contrario 'è' viene codificata in UTF-8 risulta U+00E8 che occupa 2 byte. Di qui la doppia occupazione. E ovviamente esistono grafemi con una occupazione ancora maggiore, fino a 4. Insomma una cosa è il carattere percepito dall'occhio umano, un'altra è la sua rappresentazione interna.
Di seguito ricordo la tabella valida per UTF-8:

 

Range Unicode Byte UTF‑8 Pattern binario
U+0000–U+007F 1 byte 0xxxxxxx
U+0080–U+07FF 2 byte 110xxxxx 10xxxxxx
U+0800–U+FFFF 3 byte 1110xxxx 10xxxxxx 10xxxxxx
U+10000–U+10FFFF 4 byte 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Quindi bisogna fare attenzione con l'uso di len. Ma se voglio il conteggio proprio diciamo degli elementi che compongono la mia stringa, quale che essi siano a prescindere da come UTF-8 li vede? Il seguente programma risponde al quesito:

Esempio 13.5

const std = @import("std");
pub fn countCodepoints(s: []const u8) !usize {
  var view = try std.unicode.Utf8View.init(s);
  var it = view.iterator();
  var count: usize = 0;
  while (it.nextCodepoint()) |_| {
    count += 1;
  }
  return count;
}

pub fn main() !void {
  std.debug.print("{d}\n", .{try countCodepoints("ééé")}); // 3
  std.debug.print("{d}\n", .{try countCodepoints("aaé")}); // 3
}

Questo programma indica il numero di elementi presenti nelle due stringhe dando 3 come output per entrambe mentre usando len avremmo 6 e 4 in quanto la 'è' occupa due byte. Prendete il codice dell'esempio 13.5 come utility volante perchè non tutto il suo funzionamento può essere chiaro in questa fase. Non voglio addentrarmi più di tanto ne mondo Unicode che è abbastanza complesso e non utile per i nostri scopi. La cosa che va comunque sottolineata è che Zig si basa in questi casi sempre sul concetto di Codepoint (un valore numerico, in soldoni)

La copia di una stringa in un'altra è, anche questo, un discorso interessante. Inziamo col dire che questa:   

const s1 = "abc";    
const s2 = s1;

è' una copia ma è in particolare una shallow copy, ovvero è semplicemente un puntatore ulteriore alla stessa area di memoria. In pratica copiamo il puntatore ma i dati sono sempre quelli della medesima area. Un metodo veloce è quello di usare l'operatore di dereferenziazione ma c'è un elemento a cui prestare attenzione:

Esempio 13.6

const std = @import("std");
pub fn main() !void {
  const s1 = "aaaa";
  var s2 = s1.*; // Deep copy sullo stack
  s2[0] = 'b'; // OK: s2 è "baaa", s1 rimane "aaaa"
  std.debug.print("s1: {s}\n", .{s1}); // s1: "aaaa"
  std.debug.print("s2: {s}\n", .{s2}); // s2: "baaa"
  const t1 = @typeInfo(@TypeOf(s1));
  const t2 = @typeInfo(@TypeOf(s2));
  std.debug.print("t1: {any}\n", .{t1}); // t1: Slice
  std.debug.print("t2: {any}\n", .{t2}); // t
}

e abbiamo questo output:

s1: aaaa
s2: baaa
t1: .{ .pointer = .{ .size = .one, .is_const = true, .is_volatile = false, .alignment = 1, .address_space = .generic, .child = [4:0]u8, .is_allowzero = false, .sentinel_ptr = null } }
t2: .{ .array = .{ .len = 4, .child = u8, .sentinel_ptr = anyopaque@7ff60f1258da } }

Il punto è che il primo elemento s1 è una vera e propria stringa nel senso che abbiamo visto in questo paragrafo il secondo è un array. Array che si presenta indipendente ma, tutto sommato ha una natura diversa anche se può essere normalmente utilizzato quindi, di fatto, la copia del contenuto del puntatore c'è. Se vogliamo avere un altro puntatore e quindi una deep copy dobbiamo ricorrere ad una allocazione sullo heap anche se pure in questo caso ci sarà qualche leggera differenza. I concetti espressi nel prossimo esempio sono avanzati e andranno ripresi quando avremo più nozioni:

Esempio 13.7

const std = @import("std");
pub fn main() !void {
  var gpa = std.heap.GeneralPurposeAllocator(.{}){};
  const allocator = gpa.allocator();
  defer _ = gpa.deinit();
  const s1 = "aaaa";
  // Creiamo una Deep Copy sull'Heap
  const s2 = try allocator.dupe(u8, s1);
  defer allocator.free(s2);
  std.debug.print("S1: {s}, S2: {s}\n", .{ s1, s2 });
  const t1 = @typeInfo(@TypeOf(s1));
  const t2 = @typeInfo(@TypeOf(s2));
  std.debug.print("t1: {any}\n", .{t1}); // t1: Slice
  std.debug.print("t2: {any}\n", .{t2}); // t
}

Come detto non tutto può essere chiaro a questo livello. Notiamo solo che
allocator.dupe, diversamente dall'operatore di dereferenziazione, mette il contenuto sullo heap.

Le stringhe possono essere convertite in altre tipologie di dati ad esempio, caso molto comune, in interi o numeri con virgola. Questo è possibile grazie a funzioni specifiche ovvero:
std.ftm.parseInt(tipo-destinazione, stringa, base numerica)
std.ftm.parseFloat(tipo-destinazione, stringa)

Esempio 13.8

const std = @import("std");
pub fn main() !void {
  const st1 = "10";
  const st2 = "3.45";
  var num1 = try std.fmt.parseInt(i32, st1, 10);
  var num2 = try std.fmt.parseFloat(f64, st2);
  num1 += 1;
  num2 += 1;
  std.debug.print("num1 = {d} \n", .{num1});
  std.debug.print("num2 = {d} \n", .{num2});
}

Come sempre try serve per la gestione dell'errore eventuale. Un metodo alternativo, lo accenno qui ma degli errori parleremo in maniera approfondita, ovviamente, può essere il seguente:

if (std.fmt.parseInt(i32, st1, 10)) |num1| {
} else |err| {
// Qui gestiamo l'errore (es. stampa "Input non valido")
}

Al termine di tutte queste considerazioni mi pare opportuno presentare la seguente tabella riassuntiva, comprensiva anche di quanto indicato nei paragrafi precedenti

Tipo Significato Mutabilità Lunghezza nota Caso d’uso
[]const u8 slice di byte no stringhe generiche
[:0]const u8 slice sentinel-terminated no interop C
[*:0]const u8 many-pointer sentinel-terminated no no API C
*const [N:0]u8 pointer a array con sentinella no stringhe letterali
[N]u8 array mutabile buffer

Come specificato nella documentazione ufficiale è possibile incorporare byte non UTF-8 in una stringa letterale utilizzando la notazione specifica \xNN. Al momento questo va oltre i miei scopi di apprendimento.

Anche in Zig valgono le consuete sequenze di escape:

Sequenza escape Esito
\n A capo
\r Ritorno della carrello
\t Tabulazione orizzontale
\\ Barra rovesciata
\' Apice
\" Doppio apice
\xNN Valore in byte esadecimale a 8 bit (2 cifre)
\u{NNNNNN} Valore scalare Unicode esadecimale codificato in UTF-8 (1 o più cifre)
 

Prima di chiudere vediamo qualche funzione utile nella pratica:

1) trovare una sottostringa in una stringa
si usa indexOf che troviamo nel namespace std.mem. Formalmente indexOf è definita come segue
fn index_of (comptime T: type, slice: []const T, value: T) ?usize
dove il primo parametro indica il tipo all'interno della sequenza, il secondo la stringa, il terzo la sottostringa da cercare. Viene restituito un usize oppure null se la corrspondenza non viene trovata. 
const s1 = "Juventus";
const ss1 = "en";
if (mem.indexOf(u8, s1, ss1)) |index| {
std.debug.print("indice = {} \n", .{index});
e il risultato sarà il  numero 3, indice al quale si incontra la 'e' con cui inizia la sottostringa "en"

2) verificare l'esistenza di una sottostringa, nel caso in cui ci basti sapere se una sottostringa esiste oppure no, senza averne la posizione. Si usa std.mem.containsAtLeast definita come segue:
pub fn containsAtLeast(comptime T: type, haystack: []const T, expected_count: usize, needle: []const T) bool
ovvero il tipo della sequenza, la sequenza, il nimero di sequenze atteso, la sottostringa cercata. Il risultato è un booleano.
const s1 = "Juventus";
const ss1 = "en";
const esiste = mem.containsAtLeast(u8, s1, 1, ss1);
std.debug.print("esiste: {}\n", .{esiste});

che ovviamente ci restituisce "true". Se al posto di 1 avessimo messo un niumero maggiore il risultato sarebbe stato false, perchè "en" compare una volta sola.