Menu
Menu
Contattaci
Il nostro settore richiede uno studio continuo e una forte attenzione all’innovazione. Incentiviamo quindi dei programmi formativi annuali per tutti i componenti del team, con ore dedicate (durante l’orario di lavoro) e serate formative sia online che in presenza. Sponsorizziamo eventi, sia come partner che semplicemente come partecipanti, e scriviamo articoli su quello che abbiamo imparato per essere, a nostra volta, dei divulgatori.
Vai alla sezione TeamLa programmazione funzionale sta diventando sempre più popolare, uscendo al di fuori degli ambiti accademici, tanto che un vasto numero di linguaggi e framework nati nell’ultimo decennio sposano questo paradigma.
Inoltre, anche linguaggi già consolidati, come C# o Java, stanno vedendo una pesante introduzione di features di stampo funzionale, consentendo uno stile di programmazione multi-paradigma.
In questo articolo mostreremo l’implementazione di concetti di programmazione funzionale tramite un linguaggio orientato ad oggetti come il C#.
Che cos’è esattamente la programmazione funzionale?
Per rispondere ad alto livello:
è uno stile di programmazione che predilige l’utilizzo di funzioni e tende ad evitare le mutazioni di stato
Questa definizione fornisce già due concetti fondamentali:
L’esempio che segue, seppur in breve, mostra come queste tre caratteristiche vengano messe in pratica nella programmazione funzionale, ma prima dobbiamo spendere qualche altra parola per dei concetti teorici fondamentali per padroneggiare ciò che vedremo.
Cos’è una funzione?
In matematica, una funzione è una mappa tra due insiemi, chiamati rispettivamente dominio e codominio.
Dato un elemento dal suo dominio, una funzione produce un elemento dal suo codominio.
Capita, a volte, nello sviluppo software, che le funzioni scritte (e/o utilizzate) siano sovrapponibili alla definizione di funzione matematica sopra data. Purtroppo, però, spesso non è così.
Generalmente vogliamo mostrare qualcosa sullo schermo, scrivere un record su un database o mandare una e-mail, elaborazioni che hanno niente a che vedere con le funzioni matematiche.
In breve, spesso si desidera che una funzione faccia qualcosa, che abbia un effetto collaterale: un side effect.
C’è una seconda importante differenza tra le funzioni matematiche e le nostre: i risultati delle funzioni sono determinati esclusivamente dai loro argomenti.
Nello sviluppo software, infatti, siamo abituati a scrivere funzioni che hanno accesso ad un “contesto”: un metodo di istanza ha accesso a membri e proprietà di istanza, a variabili globali e molte funzioni accedono a risorse esterne al programma, come il clock di sistema (ad esempio per interrogare la data ed ora), un database, o un servizio remoto.
L’esistenza di questo tipo di interazioni rende sostanzialmente più complesso prevedere (o testare) un programma rispetto ad una funzione matematica, e questo ha portato a una distinzione tra funzioni pure e impure.
L’output dipende interamente dagli argomenti di input; non causano side effects.
Esse posseggono la proprietà della referential transparency: possono essere sostituite con il valore corrispondente (e viceversa) senza modificare il comportamento del programma.
Fattori diversi dagli argomenti di input possono influire sull’output e causare effetti collaterali.
Non posseggono la proprietà della referential transparency, anzi si dicono essere referential opaque.
Definiamo velocemente cosa sia un side effect:
Si dice che una funzione provochi side effect se esegue una delle seguenti operazioni:
La natura deterministica delle funzioni pure (il fatto che restituiscano sempre lo stesso output per lo stesso input) ha alcune conseguenze interessanti.
Limitare l’utilizzo di tipi primitivi
Potreste aver già sentito parlare di funzioni oneste e disoneste; nel caso voleste approfondire ulteriormente, ne abbiamo già parlato precedentemente qui.
In questa sede ci limiteremo a riproporre velocemente un classico esempio come building block per la riflessione successiva.
Prendiamo un metodo così strutturato:
public Category CalculateCategory(int age)
{
if (age < 0 || 120 < age)
throw new ArgumentException($"{age} is not a valid age");
return age < 25 ? Category.Junior :
age >= 25 && age < 65 ? Category.Senior :
Category.Retired;
}
Prevedendo come parametro di input un tipo int, al metodo CalculateCategory è possibile passare valori che difficilmente rappresentino il concetto di età in un mondo reale. Il problema è che l’insieme dei numeri interi è troppo poco specifico per rappresentare una età anagrafica.
Oltretutto, il controllo effettuato nelle prime righe del metodo non risulta essere di grande aiuto in quanto produce un side effect che un client non può prevedere.
Una delle tecniche più utilizzate in questi casi è creare un nuovo tipo (chiamato Value Object in ambito Domain Driven Design) per rappresentare nel nostro dominio il concetto di Age:
public class Age
{
public int Value { get; private set; }
public Age(int value)
{
if (value < 0 || 120 < value)
throw new ArgumentException($"{value} is not a valid age");
Value = value;
}
}
In questo modo possiamo modificare il metodo precedente così:
public Category CalculateCategory(Age age)
=> age < 25 ? Category.Junior :
age >= 25 && age < 65 ? Category.Senior :
Category.Retired;
Una funzione onesta è semplicemente una funzione che onora la sua firma, sempre.
La firma Age → Category dichiara “Dammi un’età e ti restituirò una categoria”.
Non ci sono altri risultati possibili.
Questa funzione si comporta come una funzione matematica, mappa ogni elemento dal dominio a un elemento del codominio.
La firma int → Category dichiara “Dammi un int (qualsiasi dei 2^32 possibili valori per int) e restituirò una Category”.
L’implementazione, tuttavia, non rispetta la signature, generando un’ArgumentException quando rileva un input non valido.
Ciò significa che questa funzione è “disonesta”: in realtà dovrebbe dire “Dammi un int, e potrei restituire una Category, o potrei invece lanciare un’eccezione”.
In sintesi, una funzione è onesta se il suo comportamento può essere previsto dalla sua firma: restituisce un valore del tipo dichiarato, non genera nessuna eccezione e nessun valore di ritorno nullo.
In C#, come in molti altri linguaggi di programmazione, l’assenza di valore viene generalmente rappresentata con NULL.
Nei linguaggi funzionali viene invece utilizzato un tipo chiamato Option (anche se potreste imbattervi in altri nomi come Maybe, il principio sottostante non cambia).
Option è essenzialmente un contenitore che racchiude un valore… o nessun valore. È una scatola che può contenere qualcosa, o potrebbe essere vuota.
La definizione di Option è la seguente:
Option<T> = None | Some (T)
Questa è l’implementazione del tipo Option che potete trovare nella libreria open source LaYumba, disponibile a questo indirizzo su github https://github.com/la-yumba/functional-csharp-code :
public struct Option<T>
{
readonly T value;
readonly bool isSome;
bool isNone => !isSome;
private Option(T value)
{
if (value == null)
throw new ArgumentNullException();
this.isSome = true;
this.value = value;
}
public static implicit operator Option<T>(Option.None _) => new Option<T>();
public static implicit operator Option<T>(Option.Some<T> some) => new Option<T>(some.Value);
public static implicit operator Option<T>(T value)
=> value == null ? None : Some(value);
public R Match<R>(Func<R> None, Func<T, R> Some)
=> isSome ? Some(value) : None();
}
Oltre alle caratteristiche già precedentemente descritte e agli operatori impliciti che per comodità sono stati implementati, la parte più importante è il metodo Match, che consente di eseguire codice a seconda dello stato dell’Option.
Match è infatti un metodo che, prevedendo due funzioni come parametri di ingresso, dice “Dimmi cosa vuoi fare quando non c’è valore, e cosa vuoi che sia fatto quando c’è un valore; io eseguirò la funzione corretta in base al mio stato”.
Purtroppo, un approfondimento sul metodo Match e sui suoi innumerevoli utilizzi è fuori dal perimetro di questo articolo, in quanto richiederebbe una lunga e complessa trattazione.
Per adesso basti sapere che, qualora decideste di continuare lo studio di approcci funzionali, questo metodo sarà un building block fondamentale per comporre efficacemente funzioni tra di loro.
Abbiamo discusso di come le funzioni mappino gli elementi da un insieme all’altro e, come è intuibile dagli esempi sopra proposti, di come nei linguaggi di programmazione object oriented i tipi descrivano tali insiemi.
Esistono però funzioni che per loro stessa natura non possono essere sovrapponibili a funzioni matematiche.
Aggiungiamo quindi un’importante distinzione tra funzioni totali e parziali:
Pensiamo ad esempio ad una funzione parser che, data una stringa in input debba restituire il valore numerico che la stringa rappresenta.
Tale funzione, con firma string → int, è ovviamente parziale e non è chiaro dalla firma cosa accadrà se si fornisce una stringa che non può essere convertita in un numero intero (potrebbe ritornare il valore di default per il tipo int, lanciare una eccezione…).
Il tipo Option offre una soluzione perfetta per modellare questi casi: se la funzione riesce a convertire l’input dato, restituisce un Some che contiene il risultato; in caso contrario, restituisce None.
public static Option<int> Parse(string s)
{
int result;
return int.TryParse(s, out result) ? Some(result) : None;
}
Così facendo la funzione parser con la firma string → Option<int> è totale, perché per qualsiasi stringa in input sarà sempre restituito un tipo Option<int> valido.
Precedentemente abbiamo definito il tipo Age, più restrittivo di int, per rappresentare solamente valori validi come età anagrafica. Questo ci ha consentito di avere funzioni con una signature onesta quando si deve operare sulle età.
Un problema è rimasto però: il costruttore del tipo Age è una funzione parziale e disonesta.
Il costruttore del nuovo tipo dichiara “dammi un int ed io ti restituirò la Age corrispettiva”, senza però esplicitare ai client che potrebbe lanciare una eccezione qualora l’intero sia < 0 o > 120.
Anche in questo caso possiamo modellare questa funzione parziale con il tipo Option.
public class Age
{
private int Value {get;}
public static Option<Age> Of(int age)
=> IsValid(age) ? Some(new Age(age)) : None;
private Age(int value)
{
if(!IsValid(value))
throw new ArgumentException(nameof(value));
Value = value;
}
private static bool IsValid(int age)
=> 0 <= age && age < 121;
}
La modifica prevede:
Con questo breve articolo, nel quale abbiamo toccato solo la parte più superficiale della programmazione funzionale in C#, speriamo di avervi incuriositi e di avervi fornito almeno una vaga idea di quanto questo approccio possa portare benefici alla chiarezza e predicibilità del nostro codice.
Tutte le tecniche più avanzate come il currying, il railways programming, le monads, i functors, il monadic binding ecc. hanno come condizione necessaria il capire i concetti di base qui espressi.
Per concludere, segnaliamo un po’ di references che sono state d’aiuto negli approfondimenti, nella speranza che possano risultare interessanti anche per voi:
https://en.wikipedia.org/wiki/Functional_programming
https://enterprisecraftsmanship.com/posts/c-and-f-approaches-to-illegal-state/
https://www.c-sharpcorner.com/UploadFile/akkiraju/functional-programming-explained-in-detail/
https://www.codeproject.com/Articles/375166/Functional-Programming-in-Csharp
https://hamidmosalla.com/2019/04/25/functional-programming-in-c-sharp-a-brief-guide/
Segui Giuneco sui social