ZIG - Input - output(1)


Interrompiamo l'analisi di strutture dati e altri concetti più profondi per affrontarne uno apparentemente banale ma che in Zig non solo non lo è, almeno in prima battuta, ma esprime perfettamente lo spirito del linguaggio, il quale non prevede "magie" nascoste e dove tutto deve essere sotto controllo. Questo paragrafo può inoltre fornire la possibilità di lavorare in maniera un po' più interattiva.
La lettura dello standard input è molto semplice in tanti linguaggi. Ad esempio leggere un numero da tastiera è triviale in Python e Ruby rispettivamente:

x = int(input("Inserisci un numero: "))
print("Hai inserito:", x)


print "Inserisci un numero: "
numero = gets.chomp.to_i
puts "Hai inserito: #{numero}"

semplice in C:

#include <stdio.h>
int main() {
  int x;
  printf("Inserisci un numero: ");
  scanf("%d", &x);
  printf("Hai inserito: %d\n", x);
  return 0;
}

e anche in C#

using System;
class Program {
  static void Main() {
  Console.Write("Inserisci un numero: ");
  int x = int.Parse(Console.ReadLine());
  Console.WriteLine("Hai inserito: " + x);
  }
}

e tornando al mio primo amore anche in Object Pascal (Delphi), infatti il Pascal (puro) è stato il primo linguaggio che ho studiato, come molti altri colleghi che ho conosciuto nei vari anni.

program LeggiNumero;
{$APPTYPE CONSOLE}
uses
SysUtils;
var
x: Integer;
begin
  Write('Inserisci un numero: ');
  ReadLn(x);
  WriteLn('Hai inserito: ', x);
end.

Ma se provate anche in  D, Go, ecc... in genere non c'è nulla di veramente complicato (con Rust, altro linguaggio moderno, guarda caso invece le cose sono un po' più complicate anche se non come vedremo per Zig). Qual è il punto... tutti questi metodi che trovate nei vari linguaggi hanno però un qualche cosa che sfugge al controllo del programmatore, ovvero ci pensa il linguaggio, le sue librerie, ad operare determinati passaggi nascosti... insomma la semplicità ha il prezzo costituito da qualche cosa che non di cui non abbiamo controllo e che viene eseguito in nostra vece. Zig è molto pragmatico e non vuole che ci sia nulla che non sia alla luce del Sole.
Ci proponiamo quindi in questa prima parte, come negli esempi precedenti relativi ad altri linguaggi, di leggere un input da tastiera, un numero e di operare la relativa conversione. Questo semplice task ci permetterà di conoscere meglio il grande controllo che Zig impone anche in queste situazioni apparentemente semplici.
Presento pertanto il programma (che non prevede la gestione degli errori, quello è ancora un altro discorso che affronteremo a parte) che poi commenteremo necessariamente riga per riga, altrimenti risulta difficile da capirne il funzionamento. Come vedrete inoltre ce ne sono due varianti perchè a seconda della lunghezza dell'input le cose possono cambiare... tra l'altro, a complicare un po' le cose, fino alla versione 0.15 del linguaggio, definita dopo un decennio dalla creazione del linguaggio,  il tutto funzionava in un certo modo ma dalla 0.16, se ricordo bene, c'è stato un certo stravolgimento... quindi ho dovuto cambiare un po' quello che avevo preparato in passato per far si che il codice tornasse funzionante.... c'è una logica condivisibile ovviamente dietro a questi cambiamenti imposti in vista della versione 1.0 del linguaggio, ma come direbbero a Roma, "Kelley... 'tacci tuoi".

Esempio 24.1

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io; // "juicy main": l'Io arriva giá pronto

    var stdin_buffer: [1024]u8 = undefined;
    var stdin_file: std.Io.File = .stdin();
    var stdin_reader = stdin_file.reader(io, &stdin_buffer);
    const reader = &stdin_reader.interface; // NB: per riferimento

    var stdout_buffer: [1024]u8 = undefined;
    var stdout_file: std.Io.File = .stdout();
    var stdout_writer = stdout_file.writer(io, &stdout_buffer);
    const stdout = &stdout_writer.interface;

    try stdout.print("Inserisci un numero: ", .{});
    try stdout.flush();

    const line_raw = try reader.takeDelimiterExclusive('\n');
    const line = std.mem.trim(u8, line_raw, "\r");
    const number = try std.fmt.parseInt(i32, line, 10);

    try stdout.print("Hai inserito: {d}\n", .{number});
    try stdout.flush();
}

Bello eh? Uguale uguale alla semplcità di Python e degli altri linguaggi visti (e anche molti di quelli non visti).
Bene iniziamo la nostra analisi.

1) pub fn main(init: std.process.Init) !void {
questo è il cosiddetto "juicy main", termine informale usato nella comunità Zig per evidenziare una nuova versione "rinforzata" del main; dalla release 0.16 del compilatore si può iniettare automaticamente un oggetto "io" (ovviamente input-output)  già pronto all'uso tramite il parametro `init`, invece di doverlo costruire a mano (es. con std.Io.Threaded). Insomma, una facilitazione sintattica da cui possiamo far seguire

2) const io = init.io;
che ci fornisce una interfaccia con una serie completa di funzioni da applicare alle operazioni di input-output. Il discorso, molto interessante, potrà essere approfondito ma non in questo paragrafo.

3) var stdin_buffer: [1024]u8 = undefined;
eccoci alla definizione del buffer di input. Questo è il magazzino dei dati in input con una disponibilità di 1024  u8. So già la domanda: ma potrebbero esserci input più lunghi e soprattutto non prevedibili. Ci arriveremo, per adesso, per accogliere un numero inserito da tastiera, possiamo legittimamente considerare che 1024 sia una ampiezza sufficiente. Nulla vi vieterebbe di porre 2048 o 10.000, ma bisogna anche essere realisti e non sprecare risorse inutili. Ovviamente il buffer di input non ha dei valori definiti al suo interno.

4) var stdin_file: std.Io.File = .stdin();
qui otteniamo un handle al file speciale "stdin" (ovvero lo standard input), cioè il canale da cui arrivano i byte digitati da tastiera (o reindirizzati da file/pipe).

5) var stdin_reader = stdin_file.reader(io, &stdin_buffer);
qui dobbiamo fermarci un attimo perchè siamo nel cuore vero e proprio della fase di lettura. Definiamo elemento di lettura, chiaramente una variabile, ovvero un oggetto reader che sa da quale file leggere (lo stdin, visto all'istruzione precedente la 4) usando il contesto io definito in fase di costante con la istruzione 2 e che utilizza il buffer di memorizzazione definito alla 3 che viene passato per riferimento, non passiamo nessun valore e nessuna copia di tale buffer. La funzione reader è definita pubblica all'interno dell'oggetto file (insieme a molte altre) e restituisce una struct con numerosi campi atti a gestire le fasi di lettura. In breve, abbiamo creato un oggetto che sa usare la memoria che gli passiamo per leggere.

6) const reader = &stdin_reader.interface;
con questa istruzione creiamo un puntatore all'interfaccia pubblica del reader attraverso la quale possiamo operare in lettura. Il puntatore non deve essere riassegnato quindi per forza è const. Si usa un puntatore perchè (ma non ho verificato, l'ho letto sulla documentazione del linguaggio se ricordo bene) internamente usa  @fieldParentPtr per "risalire" al proprio genitore (stdin_reader) usare una copia annullerebbe il collegamento.

7) var stdout_buffer: [1024]u8 = undefined;
Inizia la parte in cui definiamo l'output la logica seguita ocme vedremo è identica a quella appena vista nei passi precedenti. Qui definiamo il buffer in cui sarà contenuto quello he verrà visualizzato. Valgono le stesse considerazione fatte al punto 3).

8) var stdout_file: std.Io.File = .stdout();
Come al punto 4) ci procuriamo un handle ma questa volta verso stdout, lo standard output, ovvero lo schermo.

9) var stdout_writer = stdout_file.writer(io, &stdout_buffer);
Anche qui nulla di nuovo, siamo in sintonia con il punto 5) ma stavolta siamo direzionati verso l'output. Creiamo un oggetto writer che dove scrivere e cosa usare, il solito Io per farlo.

10) const stdout = &stdout_writer.interface;
Simmetricamente, estrae l'interfaccia generica std.Io.Writer, sempre per riferimento // per lo stesso motivo spiegato sopra per il reader.

 11)
try stdout.print("Inserisci un numero: ", .{});
try stdout.flush();
Qui usiamo un metodo diverso e probabilmente migliore per stampare una stringa a video rispetto al solito std.debug.print. Usiamo lo standard output comunicando nel contempo che l'operazione può fallire, da qui il try, sia nella prima che nella seconda istruzione. Interessante è proprio l'uso della funzione flush() che ha lo scopo di svuotare il buffer e presentare la stringa in output, diversamente bisognerebbe aspettare un <enter> da parte dell'utente come è facile verificare.

12) const line_raw = try reader.takeDelimiterExclusive('\n');
qui siamo nella fase di lettura vera e propria. Il reader legge in pratica la riga fino al delimitatore indicato che nel caso è \n, ovvero il carattere di newline nel caso. Quella parte "Exclusive" indica che tale carattere sarà escluso dalla memorizzazione nel buffer. Anche qui abbiamo il nostro try che dice: se la lettura ha successo, dammi il risultato; se si verifica un errore, propagalo alla funzione chiamante.

In pratica in questo momento siamo arrivati qui:

stdin

│ dati digitati dall'utente

stdin_file


stdin_reader

│ usa stdin_buffer quando necessario

reader.interface


takeDelimiterExclusive('\n')


line_raw

Non è un caso che la vriabile al punto 12 sia stata chiamata line_raw. Diciamo che non è ancora il risultato finito, almeno non sempre. Ovvero se lavoraste su Linux avreste finito. Ma noi vogliamo essere "universali" e ci ricordiamo che per Windows il fine riga non è solo \n ma prevede anche il carattere di ritorno del carrello \r. quindi:

13) const line = std.mem.trim(u8, line_raw, "\r");
ecco fatto usiamo la funzione trim che rimuove eventuali '\r' (o altri caratteri indicati) da entrambi gli estremi della stringa.

a questo punto abbiamo la costante line che è il nostro input pulito. Non ci resta che effettuare la conversione verso il corrispondente numero intero, non dimentichiamoci che, al momento, abbiamo una stringa non un numero.

14) const number = try std.fmt.parseInt(i32, line, 10);
La funzione parseInt accetta 3 parametri, il target numerico, in questo caso i32, la stringa da parsare e la base numerica in cui tradurre il risultato.

Insomma, questa operazione di lettura di un input e relativa conversione dimostra che non siamo proprio davanti ad una passeggiata di salute, specialmente se confrontiamo con altri linguaggi come abbiamo visto all'inizio del paragrafo. In compenso abbiamo il pieno e totale controllo di tutte le fasi,,, non c'è nulla che venga fatto diciamo alle nostre spalle. Però la questione non finisce qui.
Abbiamo visto che il buffer di input ha una dimensione fissa. Ampia abbastanza, a logica, per casi come questi, ben difficilmente un utente inserirà un numero composto da più di 1000 cifre. Ma se, generalizzando il problema, dovessimo ricevere una stringa di cui non sappiamo la dimensione? O immettiamo un buffer enorme (ma sarà comunque abbastanza?) o percorriamo strade meno dispendiose in termini di risorse.
Una strada possibile è quella di gestire l'errore che si verifica se immettiamo una stringa troppo lunga. Se nel codice dell'esempio 24.1 ponessimo il buffer di input lungo solo 2 caratteri e ne inserissimo 3 in fase di digitazione del numero otterremmo:

error: StreamTooLong

sintetico e chiaro.

Un primo modo per uscirne è gestire l'errore ma questo lo vedremo in un prossimo paragrafo.

Il secondo modo, che è quello che forse è preferibile in tanti casi critici, è quello di affidarci ad un allocatore. Vediamo un programma che accetta in input una stringa di qualsiasi lunghezza e ne restituisce il numero di caratteri, la eventuale conversione in numero è uguale all'esempio precedente, quando si giunge ad avere la stringa definitiva.

Esempio 24.2

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;

    var gpa = std.heap.DebugAllocator(.{}){};
    defer _ = gpa.deinit(); // segnala eventuali leak a fine programma
    const allocator = gpa.allocator();

    var stdin_buffer: [1024]u8 = undefined;
    var stdin_file: std.Io.File = .stdin();
    var stdin_reader = stdin_file.reader(io, &stdin_buffer);
    const reader = &stdin_reader.interface;

    var stdout_buffer: [1024]u8 = undefined;
    var stdout_file: std.Io.File = .stdout();
    var stdout_writer = stdout_file.writer(io, &stdout_buffer);
    const stdout = &stdout_writer.interface;

    try stdout.print("Inserisci una stringa (lunga quanto vuoi): ", .{});
    try stdout.flush();

    var line_buffer = std.Io.Writer.Allocating.init(allocator);
    defer line_buffer.deinit(); // libera la memoria a fine funzione

   _ = reader.streamDelimiter(&line_buffer.writer, '\n') catch |err| switch (err) {
        error.EndOfStream => {}, // ok: l'utente ha premuto Ctrl+D/Ctrl+Z senza newline
        else => return err,
    };
    reader.toss(1); // scarta il carattere '\n' rimasto "in coda" nel reader
    const line = std.mem.trim(u8, line_buffer.written(), "\r");

    try stdout.print("Hai scritto: {s}\n", .{line});
    try stdout.print("Lunghezza: {d} caratteri\n", .{line.len});
    try stdout.flush();
}

Il programma è un po' più complicato del precedente e d'altra parte la situazione è diversa. Vediamo di commentare le righe critiche altre le trovate uguali a quelle viste nell'esempio precedente :

a) var gpa = std.heap.DebugAllocator(.{}){};
Questa istruzione ha un duplice effetto:

b) defer _ = gpa.deinit()
Questa riga definisce la pulizia finale dell’allocatore, eseguita automaticamente all’uscita dallo scope. In particolare:
-
defer significa: esegui questa istruzione quando esci dallo scope corrente, qualunque sia il percorso (return, errore, ecc.).
- gpa.deinit()
-- chiude l'allocatore
-- controlla i leak, ovvero allocazioni non liberate
-- può restituire uno stato (non in questo caso)
Abbiamo poi la variabile di scarto perhè non ci interessa l'eventale valore di ritorno ma solo l'esecuzione.

c) const allocator = gpa.allocator();
eccoci arrivati alla fine di questo primo blocco. gpa.allocator() è un metodo che restituisce un valore di tipo std.mem.allocator che non è "la memoria", ma una struttura che descrive come allocare memoria: contiene un puntatore a dei dati (ptr) e una vtable (tabella di funzioni) con alloc, free, resize, ecc. Il codice che riceve un Allocator non sa (né gli importa) se dietro c'è il page_allocator, un arena, o un allocatore custom (perchè esiste anche la possibilità di costruirseli appoggiandosi su questa struttura, detto che non mai provato). Questa è una cosa molto interessante e positiva perchè rende più prevedibile il comportamento di un allocatore. Abbiamo quindi l'interfaccia necessaria per i nostri scopi.

Come nel caso precedente, definiamo poi i buffer, che nel caso di input di lunghezza indefinita servono in pratica da area di carico e scarico, e i relativi reader e writer, in queesto senso non cambia nulla e dopo aver richiesto l'input all'utente arriviamo a:

 d) var line_buffer = std.Io.Writer.Allocating.init(allocator);
possiamo "smontare" questa assegnazione. La parte sinistra è solo una variabile a destra abbiamo

e)    _ = reader.streamDelimiter(&line_buffer.writer, '\n') catch |err| switch (err) {
        error.EndOfStream => {}, // ok: l'utente ha premuto Ctrl+D/Ctrl+Z senza newline
        else => return err,
altro passaggio da chiarire perchè ricco di informazioni.

Certamente non è banale tutto quanto visto rispetto ad altri linguaggi. Ma Zig non vuole lasciare nulla che non sia chiaramente gestito dal programmatore. Alla fine ritengo che verranno create delle funzioni ad hoc per effettuare queste operazioni nell'ambito di programmi di grandi dimensioni.

Come ultima annotazione, se volessimo imporre comunque un tetto massimo ragionevole per proteggerti da input malevoli enormi (es. qualcuno che incolla 2GB di testo), possiamo controllare line_buffer.written().len durante o dopo lo streaming e interrompere manualmente se supera una soglia da noi scelta.