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:
- la prima parte, ovvero
std.heap.DebugAllocator(.{})
è una funzione comptime che accetta eventuali parametri di configurazione e restituisce una struct. - { } subito dopo è un literal di struct: istanzia quel tipo appena
generato.
Il risultato è una variabile gpa che contiene lo stato interno dell’allocatore di debug. Ovvero gpa è l’oggetto allocatore di debug, con:
-- campi interni (tabella delle allocazioni, puntatore al backing allocator, ecc.)
-- metodi come deinit() e allocator().
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
- std.Io - la libreria generica di inout/output
- Writer - il writer generico, ovvero l'ibterfaccia generica
- Allocating - con questo definiamo una sotto-struttura di memorizzazione dinamica all'interno del Writer stesso
- .init(allocator) - init è per convenzione (non per regola del linguaggio, ma per abitudine che troverete diffusissima nella libreria standard e nel codice Zig in generale) il nome della funzione "costruttore": prende dei parametri di configurazione e restituisce un valore (non un puntatore) del tipo Allocating, già pronto all'uso. Qui l'unico parametro richiesto è allocator: dato che Allocating deve poter far crescere il proprio buffer interno quando necessario, ha bisogno di sapere con quale allocatore farlo — coerente con la filosofia Zig "niente è nascosto, tutto è esplicito nei parametri", la stessa che regola l'Io di cui parlavamo. E allocator lo abbiamo definito al punto c)
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.
reader- è il nostro collegamento con stdinstreamDelimiter(&line_buffer.writer, '\n')- in pratica dice: immetti i byte da stdin verso line_buffer.writer finchè non trovi il carattere di delimitazione che in questo caso è \n. In particolare streamDelimiter(...) è una funzione la cui descrizione nella documentazione ufficiale recita:
Appends contents by reading from the stream until delimiter is found. Does not write the delimiter itself.
restituisce un usize che in questo caso scartiamo perchè non ci interessa, infatti abbiamo usato la variabile di scarto _.catch |err| switch (err)- la funzione di cui al punto precedente può fallire e a questo punto bisogna gestire l'errore, anche se, come detto sarà argomento avanzato che vedremo più avanti. Qui consideraino due casi di fallimento
- l'utente preme una combinazione di fine input, es CTRL+D in Linux o CTRL+Z in Windows. in quel caso non fare nulla, quello che hai letto finora va bene
- altrimenti propaga l'errore interrompendo la funzionereader.toss(1)- dopo streamDelimiter, il carattere delimitatore ('\n') è stato individuato ma non ancora "consumato" dal reader; toss(1) lo scarta esplicitamente, altrimenti resterebbe lì per la prossima lettura.const line = std.mem.trim(u8, line_buffer.written(), "\r");- rimuoviamo il caratter \r (caso Windows) e finalmente abbiamo la nostra stringa memorizzata dentro la costante line che useremo per i nostri scopi.
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.