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:

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:

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)

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.

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.