ZIG - Le funzioni
Eccoci arrivati ad un argomento fondamentale per questo e per molti altri
linguaggi. In matematica una funzione è una relazione tra due insiemi
(dominio e codominio) che associa a ogni elemento del dominio uno e un solo
elemento del codominio. Quando scriviamo: f(x) = y
mettiamo in relazione ogni valore possibile di x ricavandone per ciascuno
un valore y, l'insieme dei valori y costituisce l'immagine.
Nell'ambito informatico il concetto può essere descritto in maniera un po'
più ampia. Esiste un aspetto vicino a quello matematico, nel senso che
passiamo dei parametri alla funzione ricavandone un valore in uscita, ma una
funzione può essere anche vista come un insieme di istruzioni, che
realizzano task specifici di varia complessità, raggruppate sotto un nome
che ne permette anche il riutilizzo ripetuto. Il concetto è così
importante in ambito informatico che esiste persino un supporto hardware nei
processori (es. CALL /RET) per ottimizzare la loro gestione e tutti i
linguaggi di uso comune supportano questo tipo di costrutti.
Zig tratta le funzioni in modo abbastanza esplicito e "vicino al metallo", coerentemente con la filosofia del linguaggio: niente magie nascoste, tutto ciò che il compilatore fa deve essere leggibile nel codice.
FUNZIONE BASE
Detto che la keyword che identifica le funzioni è
fn, come in Rust, vediamo il caso più
semplice, la funzione che non ha parametri in ingresso ne in uscita e che si
limita a riproporre il proprio codice quando richiamata. La sintassi di base
in questo caso è:
fn nomeFunzione () void {
istruzioni
}
il nome della funzione come evidenziato segue le regole camelCase, per convenzione, non per obbligo e la prima riga ne è la firma, contenente la specifica dei parametri in ingresso e in uscita. Dalla firma della funzione possiamo dedurne il tipo. Vediamo un banale esempio:
Esempio 22.1
const std = @import("std");
fn saluto() void {
std.debug.print("ciao\n", .{});
}
pub fn main() !void {
saluto();
saluto();
}
qui otteniamo:
| ciao ciao |
L'analisi è semplice, definiamo una funzione di nome "saluto" e nel corpo del main la richiamiamo due volte. La funzione non esporta all'esterno alcun valore (e non può fallire), da cui risulta essere void e contestualmente non ha alcun parametro in ingresso. Serve unicamente per eseguire il suo codice interno, la semplice stampa del saluto, ogni volta che viene richiamata. Notere che invece il main, che pur sempre una funzione a tutti gli effetti, nella sua firma ha !void ovvero, ricorderete, un union type. In questo senso può propagare verso un eventuale errore al suo interno ovvero verso il runtime di Zig che sa gestire gli eventuali error code generati. La funzione saluto invece se fosse marcata !void e quindi ne fosse previsto il fallimento, dovrebbe prevedere al suo interno try o catch per la propagazione o gestione dell'errore come abbiamo visto nel capitolo precedente.
FUNZIONE CON PARAMETRI IN INGRESSO
Passiamo ad uno step successivo, ovvero avvicinandoci alla controparte
"matematica" forniamo alla funzione uno o più parametri in ingresso. Ovvero, da un
punto di vista formale:
fn nomeFunzione (param1: tipo1, param2: tipo 2....) void {
istruzioni
}
e vediamo l'esempio:
Esempio 22.2
const std = @import("std");
fn somma(x1: i32, x2: i32) void {
std.debug.print("{d} + {d} = {d}\n", .{ x1, x2, x1 + x2 });
}
pub fn main() !void {
somma(2, 3);
somma(5, 7);
}
Qui vediamo che la firma della funzione richiede due parametri, x1 e x2 di tipo i32. E sarnno proprio quelli che passiamo nel richiamare la funzione somma. Se un tipo di parametro passato non fosse compatibile il compilatore ci avviserebbe, ad esempio se chiamassimo la funzione somma come segue:
somma(3, 5.5)
ricaveremmo:
error: fractional component prevents float value '5.5' from coercion to type 'i32'
da notare che se invece di 5.5 scrivessimo 5.0 la coercizione verso i32 funzionerebbe. Se invece mettessimo una stringa come "aa" al posto di un inytero otterremmo un errore molto chiaro:
error: expected type 'i32', found '*const [2:0]u8'
Da tenere a mente che non solo il tipo ma anche l'ordine delle variabili deve essere rispettato. Quindi se nella firma della funzione avete per esempio un i32 e un f32 in successione dovrete passarli in quell'ordine al momento della chiamata, il che è intuitivo peraltro.
E' molto interessante a questo punto notare che aver introdotto le funzioni ci dà la possibilità di aprire
una piccola parentesi su una apparente contraddizione presente nel mondo Zig che ci presenta invece i due universi paralleli, di cui abbiamo già
accennato, che coesistono in questo
linguaggio.
Partiamo dal seguente codice:
Esempio 22.3
const std = @import("std");
pub fn main() !void {
const x2: i32 = 5;
const x3: f32 = 3.2;
const x4 = x2 + x3;
std.debug.print("x4: {d}\n", .{x4});
}
questo programma, riduzione dell'esempio 4.7 compila, gira e ci restituisce il risultato corretto:
| x4: 8.2 |
vediamo l'equivalente tramite una funzione parametrizzata:
Esempio 22.4
const std = @import("std");
fn somma(x1: i32, x2: f32) void {
std.debug.print("{d} + {d} = {d}\n", .{ x1, x2, x1 + x2 });
}
pub fn main() !void {
somma(2, 3.2);
somma(5, 5.5);
}
E questo manco compila:
error: incompatible types: 'i32'
and 'f32'
std.debug.print("somma = {d}\n", .{ x1, x2, x1 + x2 });
Strano... nell'esempio 22.3 sommo un intero e un float e tutto va bene, nella procedura somma nell'esempio 22.4 faccio lo stesso e il compilatore mi blocca. Come avrete già intuito, sulla base di quanto detto in fase di presentazione di variabili e costanti, nel caso delle costanti, presenti quindi nell'esempio 22.3 il calcolo avviene a comptime, il tipo di x2 e x3 è infatti comptime_int e comptime_float. Quindi nel 22.3 abbiamo a che fare con dei letterali (ovvero valori scritti direttamente nel sorgente) mentre nel 22.4 con delle variabili tipate, calcolate a runtime (in generale vale per tutti i parametri di funzione) e Zig non applica promozioni implicite a runtime, ma le applica a comptime perché i letterali non hanno un tipo fisso. Se al posto delle costanti nell'esempio 22.3 usaste delle variabili (modificandone quindi il valore) la compilazione non andrebbe a buon fine. E' una scelta, questa di avere due comportamenti diversi per la stessa operazione che può piacere o no, diciamo che vi risparmiate qualche cast (Rust ad esempio non permette mai di mischiare i tipi dovete usare cast espliciti) in cambio c'è questa filosofia davvero peculiare. Per rimediare e rendere fruibile il codice dell'esempio 22.4 la soluzione a mio avviso più semplice e intuitiva è la conersione esplicita all'interno della funzione:
fn somma(x1: i32, x2: f32) void {
const x1_float: f32 =
@floatFromInt(x1);
std.debug.print("{d} + {d} = {d}\n", .{ x1,
x2, x1_float + x2 });
}
Ricordate che è sempre meglio convertire da intero verso float che viceversa. Qui di seguito scriveremo una funzione hgenrica che accetta qualsiasi tipo di numero in ingresso senza troppi problemi
FUNZIONE CON VALORI RESTITUITI
Altro importante aspetto delle funzioni è quello di poter restituire valori verso il chiamante. Valori che possono essere attribuiti a variabili o costanti, ad esempio, o far parte di una operazione più complessa. La sintassi base, considerando la presenza di parametri in ingresso, è la seguente:
fn nomeFunzione (param1: tipo1, param2: tipo 2....)
tipo-restituito {
istruzioni
}
Un esempio di base è il seguente:
Esempio 22.5
const std = @import("std");
fn somma(x1: i32, x2: i32) i32 {
const r1 = x1 + x2;
return r1;
}
pub fn main() !void {
const x1 = somma(4, 3);
std.debug.print("x1: {d}\n", .{x1});
}
In pratica nella firma della funzione si prevede l'uscita verso il chiamante di un intero i32 che viene in effetti restituito tramite l'istruzione return. Tale valore viene riversato nella variabile x1. Già che ci siamo proviamo a risolvere il problema precedente ovvero scrivere un programma che accetti tutti i tipi di valori numerici in ingresso e ce ne restituisca la somma. La soluzione più sintetica è probabilmente la seguente:
Esempio 22.6
const std = @import("std");
fn somma(x1: anytype, x2: anytype) @TypeOf(x1 + x2) {
return x1 + x2;
}
pub fn main() !void {
std.debug.print("Somma {any}\n", .{somma(3, 4)});
std.debug.print("Somma {any}\n", .{somma(3.7, 4)});
std.debug.print("Somma {any}\n", .{somma(3, 4.33)});
std.debug.print("Somma {any}\n", .{somma(3.23, 452)});
}
che, come potete provare, funziona sempre con ogni tipo di numero in
input.
Nel caso si specifichi un tipo il valore restituito deve essere
compatibile con il corrispondente nel chiamante a cui debba eventualmente
essere abbinato. Se ad esempio la firma della funzione prevede la
restituzione di un intero:
fn somma(x1: i32, x2: i32) i32
e proviamo ad abbinarlo ad un f32:
const f1: f32 = somma(2, 3);
il compilatore non gradisce
error: expected type 'f32', found 'i32'
come è normale che sia.
PARAMETRI PER RIFERIMENTO
I parametri passati nelle modalità viste finora sono indicati come "per valore" ovvero passiamo il loro valore nudo e crudo. I parametri eventualmente passati non vengono intaccati direttamente da operazioni effettuate all'interno della funzione, possono esserlo solo a seguito di operazioni nel chiamante. Per chiarire:
Esempio 22.7
const std = @import("std");
fn incrementa(x: i32) i32 {
return x + 1;
}
pub fn main() !void {
var x1: i32 = 5;
const x2 = incrementa(x1);
std.debug.print("x2: {d}\n", .{x2});
std.debug.print("x1: {d}\n", .{x1});
x1 = x1 - 1;
std.debug.print("x1: {d}\n", .{x1});
}
da questo codice otteniamo
| x2: 6 x1: 5 x1: 4 |
Da cui si ricava che x1 è passato come parametro ma la modifica all'interno della funzione "incrementa" non la intacca, viene modificata dall'istruzione di decremento successiva. I parametri passati in questo modo sono inattaccabili dalla funzione.
In questa sezione vediamo i parametri per riferimento quindi dei quali è passato l'indirizzo di memoria tramite puntatore. Questo cambia le cose in maniera radicale. Si fa uso delle classiche funzionalità tipiche dei puntatori, che abbiamo visto e che ribadiamo:
-
&aottiene il puntatore -
x.*dereferenzia il puntatore
Esempio 22.8
const std = @import("std");
fn incrementa(x: *i32) void {
x.* += 1;
}
pub fn main() !void {
var x: i32 = 5;
incrementa(&x);
std.debug.print("x: {d}\n", .{x});
}
e a questo punto
| x: 6 |
perchè nell'ambito della procedura abbiamo avuto accesso all'area di memoria in cui si trovava il valore 5 e lo abbiamo aumentato di una unità. Nella firma della funzione abbiamo *i32 che indica che il parametro è un puntatore ad un intero, niente di nuovo per questo argomento rispetto a quanto già sappiamo dei puntatori.
SLICE - ARRAY COME ARGOMENTO
Ricordiamo che uno slice, come definito nell'apposito paragrafo, è definito come:
[]T
Uno slice è argomento che collabora molto bene con le funzioni ed è usato assai spesso per la sua duttilità. Vediamo quindi un esempio:
Esempio 22.9
const std = @import("std");
fn somma(v: []const i32) i32 {
var s: i32 = 0;
for (v) |n| {
s += n;
}
return s;
}
pub fn main() !void {
const numeri = [_]i32{ 1, 2, 3, 4, 5 };
const risultato = somma(numeri[0..]);
std.debug.print("Somma: {d}\n", .{risultato});
}
Il codice è abbastanza semplice, definiamo uno slice di 5 numeri e lo passiamo integralmente come argomento alla funzione somma, la quale elabora la somma dei singoli elementi e quindi restituisce il risultato. Avendo passato l'intero array il risultato è identico a quello che avremmo ottenuto se avessimo scritto la chiamata alla funzione:
const risultato = somma(&numeri);
d'altronde se provate a ricavare i tipi dei due modi per passare il parametro:
std.debug.print("numeri[0..] --> {}\n", .{@TypeOf(numeri[0..])});
std.debug.print("&numeri --> {}\n", .{@TypeOf(&numeri)});
ricavate lo stesso identico risultato:
| numeri[0..] --> *const [5]i32 &numeri --> *const [5]i32 |
in pratica si tratta di array fatti e finiti. Diverso è il caso in cui le dimensioni o i limiti non siano noti se non a runtime allora verrà prodotta dal compilatore una slice. Bisogna fare attenzione a questo caso, vediamo l'esempio:
Esempio 22.10
const std = @import("std");
fn func() []i32 {
var sl1 = [_]i32{ 10, 20, 30, 40, 50 };
return sl1[0..];
}
pub fn main() void {
const sl2 = func();
std.debug.print("slice: {any}, len={d}\n", .{ sl2, sl2.len });
}
In questo caso creiamo una slice all'interno della funzione func. Il programma compila ma il risultato della elaborazione svolta non è quello atteso, io ad esempio ottengo:
| slice: { 0, 0, 0, 0, 0 }, len=5 |
e va bene la lunghezza ma il contenuto no. Ma la cosa più soprendente è passiamo il parametro con:
return &sl1;
In qesto caso il compilatore ci avvisa:
error: returning address of expired local variable 'sl1'
e ci fa capire qual è il problema: li slice creato all'interno della
funzione vive finchè è viva la funzione, ma nel momento in cui il valore
viene restituito la funzione termina e con essa il suo contenuto non è più
valido. In pratica
la variabile sl1 non esiste più ed eccoci davanti a un qualcosa molto vicino
ad un dangling pointer. Questo è pericoloso (in Rust ad esempio ciò non può avvenire) e
obbliga il programmatore a prestare attenzione.
Il modo sicuro per
gestire uno slice è passare lo stesso come parametro. Allora non ci sono
problemi di esistenza in vita. Darò un esempio ma attenzione che l'argomento
non è così semplice e potrebbero esserci novità nel linguaggio in un
prossimo futuro.
Esempio 22.11
const std = @import("std");
fn riempiPrimi(buffer: []i32, n: usize) []i32 {
var i: usize = 0;
while (i < n) : (i += 1) {
buffer[i] = @intCast(i * i);
}
return buffer[0..n];
}
pub fn main() void {
var buffer: [10]i32 = undefined; // buffer vive nello stack
const risultato = riempiPrimi(&buffer, 5);
std.debug.print("{any}\n", .{risultato});
}
In quest'ultimo esempio abbiamo creato un buffer nel main che quindi continuerà a vivere finche vive il main stesso. Questo viene passato alla funzione che calcolerà i numeri primi (è un esempio banale tanto per mostrare un minimo di operatività) e quindi con il return restituiamo uno slice. Se andate ad analizzare il tipo di result risulterà proprio [ ]i32.
Bisognerà comunque tornare sull'argomento.
STRUCT COME PARAMETRI
Anche usare le struct, che ricordiamo essere gestita conme tipo valore), per via parametrica in ingresso e/o uscita da una funzione è pratica molto comune. Vediamo un esempio di base, molto semplice:
Esempio 22.12
const std = @import("std");
const Punto = struct {
x: i32,
y: i32,
};
fn trasla(p: Punto, dx: i32, dy: i32) Punto {
return Punto{ .x = p.x + dx, .y = p.y + dy };
}
pub fn main() void {
const p1 = Punto{ .x = 1, .y = 2 };
const p2 = trasla(p1, 10, 10);
std.debug.print("p1 = {any}\n", .{p1});
std.debug.print("p2 = {any}\n", .{p2});
}
L'esempio direi che è abbastanza auto-esplicativo. Definita la struct ne
creiamo una istanza (p1) che passiamo come parametro alla funzione
trasla insieme a due interi che useremo per modificare le
modificare le coordinate. Quindi restituiamo la struct che diviene istanza
di p2 ricalcando, come evidente, esattamente la definizione di
p1 che di suo resta immutata.
Un altra possibilità è quella di
passare la struct per riferimento. In questo modo è
possibile anche effettuare modifiche direttamente dall'interno della
funzione a cui la passiamo, in place, come suol dirsi. Anche qui fornisco un esempio di base:
Esempio 22.13
const std = @import("std");
const Punto = struct {
x: i32,
y: i32,
};
fn traslaInPlace(p: *Punto, dx: i32, dy: i32) void {
p.x += dx;
p.y += dy;
}
pub fn main() void {
var p1 = Punto{ .x = 1, .y = 2 };
std.debug.print("p1 = {any}\n", .{p1});
traslaInPlace(&p1, 10, 10);
std.debug.print("p1 = {any}\n", .{p1});
}
da cui ricaviamo:
| p1 = .{ .x = 1, .y = 2 } p1 = .{ .x = 11, .y = 12 } |
che dimostra la variazione avvenuta all'interno di traslaInPlace.
Certamente se il passaggio è per valore, se la struct è grande (molti campi,
array interni, ecc.), passarla e restituirla comporta una copia dei dati ma
non abbiamo bisogno di preoccuparci di lifetime (cioè se il dato esiste o
no) e dal momento che il compilatore spesso ottimizza il processo di copia,
tutto sommato forse e potendolo usare è il metodo preferibile.
ENUMERATORI COME PARAMETRI
Niente di nuovo anche con l'uso degli enumeratori, anche essi tipi per valore. Possono essere tranquillamente usati sia in ingresso che in uscita rispetto ad una funzione. Solito esempio di base:
Esempio 22.14
const std = @import("std");
const Colore = enum {
Rosso,
Verde,
Blu,
};
fn coloreOpposto(c: Colore) Colore {
return switch (c) {
.Rosso => .Verde,
.Verde => .Rosso,
.Blu => .Blu,
};
}
pub fn main() void {
const c1 = Colore.Rosso;
const c2 = coloreOpposto(c1);
std.debug.print("c1 = {any}\n", .{c1});
std.debug.print("c2 = {any}\n", .{c2});
}
output:
| c1 = .Rosso c2 = .Verde |
quindi, definiamo una istanza dell'enumeratore (c1) passiamo detta istanza come prametro per la funzione coloreOpposto all'interno della quale eseguiamo un switch, ovviamente completo, ed eseguiamo una riassegnazione dei colori. La corrispondenza da rosso a verde, così come è definito nell'ambito della istanza definita nel main, ci fornisce il risultato di output. dentro il corpo della funzione, siccome il tipo atteso è già noto (Colore), possiamo scrivere .Rosso, .Verde, ecc. invece di Colore.Rosso, Zig deduce il tipo dal contesto (l'espressione switch (c) su un valore Colore, e il tipo di ritorno della funzione).
FUNZIONI VARIADICHE
Interessante argomento che, stranamente, in Zig e in uno dei linguaggi
più in crescita, Rust non è così banale trattare, in altri linguaggi è molto
più semplice. E' strano perchè le funzioni variadiche, ovvero quelle
che possono accettare un numero variabile di parametri, sono
veramente utili in tanti contesti. Una funzione che può stampare una o n
stringhe, una di calcolo che possa sommare due o n numeri... ma ci sono
tanti casi possibili.
Relativamente a Zig propongo una soluzione, la più
semplice che ho trovato in giro per la rete, magari poi ci sarà di meglio
quindi sto alla finestra nel caso trovassi delle novità. Per il
momento di accontento di usare il tipo "jolly"
anytype, come nell'esempio seguente:
Esempio 22.15
const std = @import("std");
fn somma(args: anytype) i64 {
var totale: i64 = 0;
inline for (args) |v| {
totale += v;
}
return totale;
}
pub fn main() void {
const r1 = somma(.{ 1, 2, 3 }); // 6
const r2 = somma(.{ 10, 20 }); // 30
const r3 = somma(.{5}); // 5
std.debug.print("r1 = {any}\n", .{r1});
std.debug.print("r2 = {any}\n", .{r2});
std.debug.print("r3 = {any}\n", .{r3});
}
In pratica il parametro è un tupla anonima il cui tipo è
dedotto a compile-time e può contenere un numero e tipi di elementi diversi
ogni volta. Da qui scende anche la necessità di usare
inline for, come avevamo visto nel
paragrafo dedicato a for. In questo caso
abbiamo un numero variabile di elementi che useremo per calcolarne la somma.
Ancora diverso è il caso in cui ci dovessimo interfacciare con il
linguaggio C, ma ne parleremo un'altra volta anche perchè per ora è
argomento un po' avanzato.
FUNZIONI ANNIDATE
Non sono permesse in Zig. In pratica:
fn main() void {
fn interno() void {}
}
questo non si può. Quindi le funzioni sono sempre globali. O meglio
valgono le seguenti definizioni:
- Le funzioni sono sempre uniche e fisse a compile-time, senza istanziazione a runtime - sempre vero
- Le funzioni sono sempre visibili globalmente - non sempre, perché puoi restringerne la visibilità del nome dentro un blocco locale tramite una struct anonima
FUNZIONI GENERICHE
Questo è un altro argomento decisamente importante ed interessante. Il problema che risolvono è, in linea di ragionamento, semplice: supponiamo di avere una funzione che somma due numeri interi. La scriviamo funziona e va bene. Ci potrebbe però servire anche una funzione che faccia la stessa cosa, ovvero sommare due numeri, ma lavori con i float. Allora devo scrivere un'altra funzione che accetti come parametri dei float invece che interi. Generalizzando insomma potrei trovarmi davanti al problema di scrivere più volte una funzione identica nella logica per accogliere tipi diversi. Si può fare ma certamente è una perdita di tempo e rende più complessa la manutenzione del programma, pensate ad esempio se la logica della funzione dovesse essere modificata. Le funzioni generiche risolvono questa situazione potendo accogliere tipi diversi, quindi potremo usare la stessa funzione per una più ampia gamma di tipi come parametri. La soluzione principale che abbiamo in Zig si basa sull'utilizzo di comptime nella firma della funzione, come nel seguente, classico, esempio
Esempio 22.16
const std = @import("std");
fn massimo(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
pub fn main() void {
const m1 = massimo(i32, 10, 20);
const m2 = massimo(f64, 3.14, 2.71);
std.debug.print("m1 = {any}\n", .{m1});
std.debug.print("m2 = {any}\n", .{m2});
}
La cosa che è importante notare, come è scritto anche in alcuni documenti sul linguaggio e che chiarisce il funzionamento è che type in Zig è un tipo esso stesso. Zig ha un sistema di tipi dove i tipi sono valori di prima classe, manipolabili a compile-time. Dichiarando comptime T: type dici al compilatore: "T deve essere noto in anticipo, prima ancora di generare il codice macchina". Ecco perchè viene specificato il tipo in fase di chiamata della funzione. Il compilatore allora genera, dietro le quinte, una versione specializzata e distinta della funzione per ogni tipo concreto con cui viene chiamata (massimo(i32, ...) e massimo(f64, ...) sono, a livello di codice generato, due funzioni completamente diverse). Concettualmente questo è molto vicino ai template C++, ma senza un linguaggio di metaprogrammazione separato. Da questo punto di vista il tutto è molto più integrato nel linguaggio. La soluzione in Zig è anche più sempice ed intuitiva di quanto implementato in Rust (non per limiti del linguaggio ma per le diverse filosofie che stanno alla base, sia chiaro). La cosa interessante è che possiamo avere più comptime dichiarati contemporamente nella stessa firma, anche qui presento due righe di codice simile a quanto si trova "in giro":
fn coppia(comptime A: type, comptime B: type, a: A, b: B) void { ... }
coppia(i32, []const u8, 42, "ciao");
Una soluzione alternativa prevede l'uso di anytype. Il questo caso il tipo non è esplicitato, come nel caso precedente, ma è dedotto dal contesto. E' una versione più semplice.
Esempio 22.17
const std = @import("std");
fn somma(a: anytype, b: anytype) @TypeOf(a + b) {
return a + b;
}
pub fn main() void {
const r1 = somma(3, 5); // i32
const r2 = somma(3.2, 5); // comptime_float
const r3 = somma(3.2, 5.1); // f64
std.debug.print("r1 = {any}\n", .{r1});
std.debug.print("r2 = {any}\n", .{r2});
std.debug.print("r3 = {any}\n", .{r3});
}
Interessante è la firma della funzione:
fn somma(a: anytype, b: anytype) @TypeOf(a + b)
- anytype significa: “accetta qualunque tipo, deducilo a comptime”.
- @TypeOf(a + b) significa: “il tipo di ritorno è esattamente il tipo risultante dall’espressione a + b”.
Attenzione che queste valutazioni devono poter essere effettuate a comptime. Se avessimo:
var t1: i32 = 3;
var t2: f32 = 5.1;
const r4 = somma(t1, t2);
il compilatore segnalerebbe un errore perchè t1 e t2 sono valutate a
runtime.
Quale dei due metodi è migliore? Dipende dal caso ma, dal mio
punto di vista, il metodo che usa comptime invece di anytype è migliore
perchè più controllato. Ad esempio:
P1
const std = @import("std");
fn somma(a: anytype, b: anytype) @TypeOf(a
+ b) {
return a + b;
}
pub fn main() void {
var t1: i32 = 3;
var t2: f32 = 5.1;
_ = &t1;
_ = &t2;
const r4 = somma(t1, t2);
std.debug.print("r4 = {any}\n", .{r4});
}
P2
const std =
@import("std");
fn somma(comptime T: type, a: T, b: T) T {
return a + b;
}
pub fn
main() void {
var t1: i32 = 3;
var t2: f32 = 5.1;
_ = &t1;
_ =
&t2;
const r4 = somma(f64, t1, t2);
std.debug.print("r4 = {any}\n",
.{r4});
}
Questi due programmi sono quasi identici ma, come evidenziato, cambia la
firma della funzione. il programma P1 non compila mentre P2 si. Questo
perchè la valutazione nel primo caso avviene a runtime e si
ha una incompatibilità dei tipi. L'errore lo evidenzia.
error: incompatible types: 'i32' and
'f32'
nel secondo caso siamo noi a stabilire
il tipo di ritorno, f64 e questo permette la compilazione
considerando che siamo nelle regole di coercizione, se avessimo voluto un
i32 come valore di ritorno il compilatore ci avrebbe bloccati per la
possibile perdita di precisione.
Proseguiamo con alcune annotazioni di carattere generale.
- In Zig i parametri sono sempre posizionali, lo abbiamo già accennato
ma adesso fissiamo questo concetto. Non esistono (per adesso?) i
parametri con nome, pertanto:
funzione(x = 5, y = 3)
non è una chiamata accettabile - i parametri con valori predefiniti non sono ammessi.
fn funzione(eta: u8 = 18)
non è accettato. Per simulare questa funzionalità si può ricorrere ad una struct. Ed è una funzionalità molto potente in questo senso. Nel seguente esempio vediamo il meccanismo in azione, con una struct che presenta dei valori di deafult che possono essere richiamati come sono oppure modificati in fase di richiamo.Esempio 22.18 const std = @import("std"); const Opzioni = struct { porta: u16 = 8080, timeout: u32 = 30, usa_tls: bool = false, }; pub fn main() void { const default: Opzioni = .{}; const custom: Opzioni = .{ .porta = 443, .usa_tls = true, }; std.debug.print("Config 1: {}\n", .{default}); std.debug.print("Config 2: {}\n\n", .{custom}); } - parametri opzionali - possono essere definiti
tramite il ? e il loro valore può essere noto, ovvero può essere
specificato in sede di chiamata della funzione, oppure
null e questo valore va specificato,
cioè non potete lasciare la parentesi () vuota, la funzione si aspetta
comunque un parametro e qualcosa bisogna per forza specificare
Esempio 22.19 const std = @import("std"); fn saluta(nome: ?[]const u8) void { if (nome) |n| { std.debug.print("Ciao {s}\n", .{n}); } else { std.debug.print("Ciao sconosciuto\n", .{}); } } pub fn main() void { saluta("Alice"); saluta(null); } - Inferenza del tipo di ritorno: Zig non la prevede, in linea perfetta con la filosofia del linguaggio tutto deve essere esplicitato in maniera chiara.
- Per quanto banale avrete capito che il tipo del valore di ritorno va sempre indicato, quando non viene restituito nulla va indicato void. Questa scelta permette anche un miglior controllo del codice da parte del compilatore.
- Overloading di funzioni - non consentito. In linguaggi come C++ o Java, possiamo avere più funzioni con lo stesso nome ma firme diverse (numero e/o tipo dei parametri), e il compilatore sceglie quella giusta in base agli argomenti passati alla chiamata. In Zig questo non è possibile: ogni nome, in un dato namespace (file, struct, enum, union), deve essere unico. Zig tratta un nome duplicato esattamente come tratterebbe due variabili con lo stesso nome nello stesso scope: un errore di compilazione, non una scelta di overload risolta in base ai tipi. Quindi dobbiamo differenzare i nomi, non c'è altra via.
- Funzioni inline - introdotte da
inline fn invece del solo fn dice al
compilatore: "al posto della chiamata, inserisci sempre il corpo della
funzione direttamente nel punto di chiamata" quindi non abbiamo nessuna
vera istruzione call a runtime, il codice della funzione viene
letteralmente copiato/espanso lì dove viene invocata.
inline fn quadrato(x: i32) i32 {
return x * x;
}
utile ad esempio per eliminare l'overhead di una vera chiamata, anche se l'utilità può essere più ampia. Attenzione al solito dualismo comptime - runtime:Esempio 22.20 const std = @import("std"); inline fn fattoriale(n: u64) u64 { if (n <= 1) return 1; return n * fattoriale(n - 1); } pub fn main() void { std.debug.print("Fattoriale di 5: {}\n", .{fattoriale(5)}); }così funziona ma se il main fosse:pub fn main() void {
var x: u64 = 5;
_ = &x;
std.debug.print("Fattoriale di 5: {}\n", .{fattoriale(x)});
il compilatore si farebbe sentire:
error: inline call is recursive
perché un vero inlining ricorsivo a runtime richiederebbe un'espansione infinita di codice (non sa quante volte "srotolare" la ricorsione, dato che n non è noto in anticipo) cosa impossibile da generare staticamente dal momento che il dato è una variabile.
Per quanto ho letto le funzioni inline non sono da utilizzarsi alla leggera in quanto possono appesantire il binario generato e in generale gravare sulle prestazioni. Non ho fatto test in merito e nemmeno ne ho trovati on line.
Per questo corposo paragrafo è tutto nel prossimo vedremo ancora l'utilizzo delle funzioni in congiunzione con altre strutture dati, struct, enumeratori e unioni. In particolare la "collaborazione con le struct rende possibile usare delle funzioni senza nome. Invece per parlare di chiusure e funzioni anonime vere e proprie il discorso è un po' diverso e dovremo affrontarlo a parte.