|
In matematica un funzione è una relazione tra due insiemi di valori detti dominio e codominio. In pratica scrivere f(x) = y equivale a mettere in relazione tutti i valori possibili di x ricavandone, per effetto di f, l'insieme dei valori y. 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 per ottimizzare la loro gestione e tutti i linguaggi di uso comune supportano questo tipo di costrutti. Tra l'altro nel nostro percorso abbiamo già visto in azione la funzione principale di Rust ovvero main. Da un punto di vista tecnico, il codice vero e proprio della funzione è allocato nel text-segment o code-segment mentre i dati, parametri e variabili, sono memorizzati nello stack (possono andare sullo heap in caso di uso del trait Box, di cui parleremo) Da un punto di vista formale invece la definizione delle funzioni è molto semplice e prende le mosse dalla già ben nota keyword fn: fn nomefunzione (eventuali parametri) -> eventuale valore di ritorno { implementazione }
Come potrete facilmente notare, la funzione è definita tra la riga 1 e la 3 e viene richiamata alla 5. Apro una breve parentesi per sottolineare un differenza comportamento di Rust rispetto ad altri linguaggi. Se infatti alla riga 5 togliete le parentesi il programma compila ugualmente ma non fa nulla. In pratica l'istruzione che diventerebbe: saluto; è un semplice riferimento alla funzione ma non un richiamo. In altri linguaggi questo fatto generebbe un errore In C# ad esempio, lo stesso programma, richiamando la funzione senza parentesi
class Test
{
public static void saluto() {
System.Console.WriteLine("ciao!");
}
public static void Main() {
saluto;
}
}
origina in effetti un errore error CS0201: È possibile usare come istruzione solo le espressioni di assegnazione, chiamata, incremento, decremento, attesa e nuovo oggetto in Rust invece anche un codice come: fn main() { 5; } è accettabile, quel 5 è una espressione che viene valutata e poi scartata in quanto non ha alcun effetto. La pur banale morale, per quanto riguarda le funzioni è: attenzione, in programmi complessi, a richiamarle nel modo giusto, altrimenti potreste pensare di mandare in esecuzione un processo che invece non parte. Ma il compilatore non ve lo dice. Tornando alle nostre funzioni, già così possono essere utili in molti contesti ma molto più interessanti sono quelle che possono ricevere dei parametri in fase di chiamata. Quando si parla di parametri è necessario tenere a mente che devono essere rispettate le seguenti 3 regole:
1) se nella firma, ovvero la definizione di una funzione, sono richiesti n parametri, n dovremo fornirne in sede di chiamata. 2) deve esserci corrispondenza tra i tipi in firma e quelli passati 3) vi deve essere corrispondenza anche posizionale, cioè se la funzione richiede un intero e una stringa devo passarli in quell'ordine preciso. Formalmente una funzione con parametri ha questo aspetto: fn nomefunzione (parametro-1: tipo-1, parametro-2: tipo-2, ... , parametro-n: tipo-n) con i parametri separati da virgola e con il tipo esplicitato. Niente inferenza, in questo caso. Condivido questa scelta che rende meno error-prone tutto il sistema di definizione delle funzioni. Un primo esempio, giusto per rendere l'idea, è il seguente:
la firma della funzione richiede una stringa e quindi due interi e alla riga 7, come da commento, richiamiamo la funzione fornendo i paragrametri richiesti seguendo le 3 regole. Invertire ad esempio il primo ed il secondo risulterebbe in un chiaro errore: error[E0308]: arguments to this function are incorrect --> r408.rs:7:5 | 7 | somma(3, "risultato".to_string(), 4); | ^^^^^ - ----------------------- expected `i32`, found `String` | | | expected `String`, found `{integer}` | note: function defined here --> r408.rs:1:4 | 1 | fn somma (s1: String, x1: i32, x2: i32) { | ^^^^^ ---------- ------- ------- help: swap these arguments | 7 | somma("risultato".to_string(), 3, 4); e come vedete il compilatore ci dice anche cosa fare. Una osservazione che sorge dalla definizione formale precedente è che ogni parametro deve essere seguito singolarmente dal suo tipo. Non è possibile quindi riunire gruppi di parametro che condividono un'unica tipologia. Quindi non si può in Rust scrivere firme per funzioni come la seguente: fn funzione (x1, x2: i32) E' possibile mediare questa cosa, se ne sentite la necessità, ricorrendo a dei costrutti come ad esempio una ennupla:
oppure si può usare una slice. Ma ci torneremo sopra nel prosieguo del capitolo. L'esempio 25.2 è chiaramente un po' bruttino, diciamo molto poco elegante, è certamente meglio ricorrere, in casi come questi, a funzioni che restituiscano un valore. Di default, ogni funzione Rust in cui esplicitato un valore di ritorno restituisce () ovvero il "tipo vuoto", diversamente da quello che avviene in C Tale valore può anche essere attribuito ad una variabile, come abbiamo detto nel capitolo relativo alle variabili stesse. Il formato della firma una funzione con un valore di ritorno è: fn nomefunzione (eventuali parametri) -> tipo-di-ritorno
In questo caso la firma della funzione indica che verrà restituito un valore di tipo i32 il quale, alle righe 5 e 7 viene attribuito ad una variabile. Anche se banale, va detto che la variabile in cui restituirete il valore deve essere del tipo che la funzione vi manda indietro. Nel programma precedente ha fatto la sua comparsa la parola return. Questa serve, come intuibile, a terminare l'esecuzione della funzione restituendo il controllo al chiamante insieme al valore se questo viene specificato. In realtà, in situazioni come quella esposta si potrebbe fare uso anche del return implicito che si realizza semplicemente togliendo il ; alla fine dell'istruzione comunicando al compilatore che la funzione finisce lì. In pratica, la riga 2 potrebbe essere riscritta: x1 + x2 e il programma funzionerebbe allo stesso identico modo. Va detto che l'uso di return, più chiaro da punto di vista espressivo, potrebbe però esporre a qualche rischio, anche se il compilatore vi aiuta. Riscriviamo la funzione somma come segue: fn somma(x1: i32, x2: i32) -> i32 { return x1 + x2; println!("Non mi vedrai mai..."); } In questo caso il compilatore ci avvisa con un warning: warning: unreachable statement --> r415.rs:3:5 | 2 | return x1 + x2; | -------------- any code following this expression is unreachable 3 | println!("Non mi vedrai mai..."); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unreachable statement | = note: `#[warn(unreachable_code)]` on by default = note: this warning originates in the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info) che ci fa notare come l'istruzione di stampa non sarà mai eseguita in quanto il return restituirà al chiamante il controllo del programma prima della sua esecuzione. Questo è il significato delle righe evidenziate. In altri casi invece return può essere necessario per forzare l'uscita al verificarsi di precise circostanze. Ad esempio, nel codice che segue vogliamo che la funzione termini quando raggiungiamo il valore 3:
e così avviene. Da notare che se invece di return mettete il classico break viene comunque eseguita ancora una volta l'istruzione alla riga 15, cosa che invece non avviene con return che termina completamente l'esecuzione della funzione. Il passo successivo è il seguente: rendere i parametri modificabili. Questo perchè è possibile che ci venga comodo operare delle variazioni sui valori che vengono passati dal chiamante tramite, parametri che quindi devono essere modificabili. La soluzione ritengo possiate immaginarla da soli. L'esempio precedente si presta allo scopo con le opportune variazioni:
In questo caso abbiamo accettato i valori che ci vengono passati in fase di chiamata della funzioni per poi successivamente cambiarli. Il risultato dopo le modifiche ovviamente non sarà più 7 ma 165. Come prevedibile quindi si fa uso di mut. Questo esempio, piuttosto sempolice, ci fa capire come modificare i parametri all'interno della funzione senza alcuna conseguenza su quelli originali. v1 e v2 rimarrebbero con i loro valori anche li definissimo mutabili. I parametri sono passati per valore ed essendo mutabili nella firma della funzione possono essere cambiati all'interno del corpo della funzione stessa. Questa è la tecnica. E se volessimo modificare presso il chiamante il valore del parametro passato? Qui entrano di scena i puntatori con gli operatori di riferimento e di dereferenziazione che abbiamo visto nell'apposito breve capitolo:
quindi, la funzione accetta come parametro un riferimento ad un intero, che gli viene fornito alla riga 9, tale riferimento deve essere mutabile al fine di consentirne la modifica. Alla riga 2 interviene la dereferenziazione con accesso diretto al valore in memoria puntato dal riferimento, valore che viene incrementato di una unità. La variabile num, ovviamente mutabile, viene quindi modificata nel suo valore originale. Si capisce che nulla vieta di mischiare variabili modificabili e altre no nella definizione di una funzione. Un problema comune è quello di restituire al chiamante più di un valore. Rust mette a disposizione le ennuple come semplice risoluzione a questa necessità, ricordiamoci che le ennuple possono contenere qualsiasi valore il che le rende perfette come strumento di ritorno da una funzione:
che ci dà:
Oppure con una impostazione leggermente differente:
Finora abbiamo visto esempi che coinvolgevano tipi come gli interi o le stringhe fino ad arrivare alle ennuple, ma nulla osta che passiamo e riceviamo tipi definiti dall'utente, come ad esempio le struct: che tra l'altro possono essere anch'esse utili per restituire più dati al chiamante
Anche in questo caso il discorso è abbastanza semplice: - definiamo la struttura - la funzione la accetta come parametro insieme a due f64 - richiamiamo la funzione alla riga 12 con gli opportuni parametri - la funzione ci restituisce un struct che memorizziamo nella variabile punto_spos. Un altro esempio con una struct restituita verso il chiamante è il seguente:
Se volete è anche possibile usare un enumerativo sia come parametro di input sia come valore restituito al chiamante. Ecco un classico esempio per questo tipo di possibilità:
Anche qui lo schema è il solito, l'output sarà:
Proseguendo nell'analizzare input e output per le funzioni, è il momento degli array, delle slice parleremo in fondo al paragrafo per affrontare un altra problematica. L'esempio è piuttosto semplice:
la funzione accetta in ingresso un array di 3 elementi e ne restituisce un altro, sempre di 3 elementi con gli elementi ricevuti aumentati di una unità. La chiamata alla riga 10 associa direttamente l'array alla variabile nuovo_array. Concludiamo questa serie di esempi prendendo in esame il caso dei vettori usati sia come parametri passati alla funzione sia come valore di ritorno sostituito. Il seguente programma accetta appunto un vettore in input e ne restituisce un altro con un elemento in più somma dei precedenti.
con output:
Anche con le funzioni bisogna fare un po' di attenzione alle solite regole di barrowing e ownership. Il seguente programma ad esempio non compila:
il perchè ce lo dice come al solito il compilatore: error[E0382]: borrow of moved value: `s0` --> r426.rs:4:20 | 2 | let s0 = String::from("ciao"); | -- move occurs because `s0` has type `String`, which does not implement the `Copy` trait 3 | fn01(s0); | -- value moved here 4 | println!("{}", s0); | ^^ value borrowed here after move | note: consider changing this parameter type in function `fn01` to borrow instead if owning the value isn't necessary --> r426.rs:7:13 | 7 | fn fn01(s1: String) { | ---- ^^^^^^ this parameter takes ownership of the value | | | in this function = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider cloning the value if the performance cost is acceptable | 3 | fn01(s0.clone()); suggerendo anche una soluzione, infatti modificando la riga 3 come segue: fn01(s0.clone()); tutto funziona. Cosa succede, in pratica quando alla riga 3 passiamo la stringa alla funzione, questa ne prende il possesso (ricordiamoci che le stringhe non implementano il trait Copy), quindi nel momento in cui la funzione termina (il solito drop) si porta dietro anche la stringa che in pratica non esiste più e quindi la riga 4 non la trova. Una nota la dobbiamo spendere sulle funzioni definite all'interno di altre funzioni ad esempio del main stesso. Finora le abbiamo definite all'esterno, prima o dopo il main stesso, ma in realtà possiamo definirle anche altrove ed in questo caso valgono le regole di shadowing e scope come per le variabili. Con qualche differenza. Partiamo subito con una considerazione: possiamo, come noto, certamente scrivere: let x = 1; let x = 2; mentre non possiamo invece definire due funzioni con lo stesso nome nello stesso scope fn f1() {}; fn f1() {}; come ci dice il compilatore: error[E0428]: the name `f1` is defined multiple times se proprio avete questa necessità dovrete stabilire ambiti diversi, ad esempio come segue: { fn f1() {}; } { fn f1() {}; } Possiamo usare funzioni innestate:
In questo esempio, la funzione f1, chiamata alla riga 9, chiama a sua volta, alla 6, la funzione f2 definita alla riga 3. Ovviamente f2 non è visibile al di fuori di f1. Troppo lavoro per stampare un semplice "Ciao, mondo".... Le funzioni possono accettare anche altre funzioni come parametro. Il discorso è solo un po' più complicato per via della necessità di gestire correttamente gli argomenti ma non è niente che non si possa gestire:
Alla riga 1 definiamo una funzione che accetta a sua volta come parametro una funzione che richiede due interi. Entrambe restituiscono un intero. Alla riga 4 definiamo una semplice funzione che accetta due interi e ne restituisce uno. Tale funzione è passata alla riga 8 come parametro insime dai due interi richiesti alla riga 1, ovvero a e b. La riga 2 richiama la funzione parametro con i suoi due argomenti Un livello ulteriore di complicazione lo si può avere considerando una funzione che accetta come parametro un funzione e ne restituisce un'altra verso il chiamante. Mostrerò solo un semplice esempio assolutamente basilare, in rete ne troverete altri simili, ma non vado più a fondo in quanto richiede concetti un po' più avanzati del nostro livello attuale. Tenete presente anche questa possibilità che sarà più chiara quando avremo appreso altri concetti:
Un argomento interessante è quello delle funzioni variadiche, ossia quelle funzioni che supportano un numero variabile di parametri in ingresso. Rust è un po' più rigido su questo aspetto rispetto ad altri linguaggi e ne abbiamo già accennato in precedenza. In effetti le macro risolvono questo problema, la più nota è forse la coppia print! - println! macro che, lo appiamo, accetano argomenti multipli. Tuttavia qualcosa si può fare anche con le funzioni normali. Una possibile soluzione è ricorrere ad una slice (che, avrete notato, avevamo lasciato fuori in precedenza), a patto ovviamente che gli elementi siano tutti dello stesso tipo, il che rende questa soluzione un po' rigida ma funzionale in casi simili.
Questo esempio mostra chiaramente come sia possibile fornire, ad ogni chiamata, un numero diverso di elementi. Impararemo comunque a gestire le macro, meccanismo davvero potente in Rust. Di passaggio, notate un altro utilizzo di iter. Il materiale relativo alle funzioni, come vedete è davvero tanto e certamente non l'ho presentato tutto. Vi invito a consultare l'argomento anche in rete e su altre fonti perchè qualche cosa l'ho tralasciata certamente. Altro argomento di grande importanza legato alle funzioni è quello relativo alle funzioni generiche, che affronteremo nel capitolo seguente. |