Contattaci

Functional C#

  • Data: 1 Settembre 2022
  • Autore: Gabriele Seroni
  • Categorie

  • Giuneco Tech

    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 Team
  • Functional C#

    La 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#.

    Cos’è il Functional Programming

    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:

    • First-class functions: le funzioni sono cittadini di primo ordine, possono essere usate come qualsiasi altri tipo di oggetto. Possono essere usate come input o output di altre funzioni, possono essere assegnate a variabili o essere salvate in raccolte (es. dizionari).
    • Evitare la mutazione di stato: Una volta creato, un oggetto non deve cambiare e le variabili non devono mai essere riassegnate. Tali mutazioni sono anche chiamate “aggiornamenti distruttivi”, in quanto dopo l’aggiornamento non è più possibile riottenere il valore precedente.

    In cosa è utile?

    • Potenza: La programmazione funzionale alza il livello di astrazione, così da permettere la scrittura di codice di più alto livello, più dichiarativo, e di non avere a che fare con tecnicismi di basso livello come, ad esempio, gli statement (a favore di espressioni). Questo consente l’implementazione di più funzionalità con meno codice.
    • Sicurezza: questo è particolarmente vero quando si ha a che fare con la concorrenza. La caratteristica dell’immutabilità è fondamentale per semplificare e rendere più sicuri gli scenari con esecuzioni parallele.
    • Chiarezza: sposando un approccio più dichiarativo, il codice risulta più facile da leggere e da comprendere per un nuovo sviluppare. Questa caratteristica è fondamentale dal momento che dedichiamo più tempo alla manutenzione e al debug di codice esistente che alla scrittura di nuovo codice.

    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.

    Funzioni pure vs funzioni impure

    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.

    Funzioni pure

    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.

    Funzioni impure

    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:

    • Muta lo stato globale: “Globale” indica qualsiasi stato visibile al di fuori della funzione (campo di istanza, cache condivisa, ecc.).
    • Muta i suoi argomenti di input
    • Esegue qualsiasi operazione di I/O: qualsiasi interazione tra il programma e il mondo esterno, inclusa la lettura o la scrittura sulla console, filesystem, database o interazione con qualsiasi processo al di fuori dell’applicazione.
    • Genera eccezioni: questa è forse la parte più controversa e dibattuta. Proprio per questo ci baseremo su questo punto nell’esempio che seguirà.

    La natura deterministica delle funzioni pure (il fatto che restituiscano sempre lo stesso output per lo stesso input) ha alcune conseguenze interessanti.

    1. Le funzioni pure sono facili da testare: Creare Unit test per funzioni pure è estremamente più facile ed efficace rispetto alle funzioni impure. La parte di Arrange (di setup delle dipendenze) è generalmente sostanzialmente ridotta, data la mancanza di accesso a qualsiasi contesto globale/esterno. Avendo un valore di ritorno, i test effettuati sulle funzioni pure (se opportunamente realizzati), possono essere maggiormente resistenti al refactoring poiché possiamo basarci su di esso nella fase di Assert e non siamo obbligati a controllare lo stato, o l’invocazione, di qualche
    2. L’ordine di esecuzione non è importante: Grazie al fatto che gli output dipendono solo dagli input, possiamo valutare il risultato di una funzione in qualsiasi momento. Ciò implica che le parti del programma costituite da funzioni pure possono essere ottimizzate in diversi modi:
    • Parallelizzazione: thread diversi eseguono attività in parallelo
    • Lazy evaluation: posticipare l’esecuzione a fino al momento strettamente necessario (eventualmente non eseguirla se non vengono soddisfatti alcuni criteri specifici).
    • Caching: memorizzare nella cache il risultato di una funzione in modo che venga calcolato una sola volta

    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:

    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:

    In questo modo possiamo modificare il metodo precedente così:

    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.

    Un modo alternativo per rappresentare l’assenza di valore: Option

    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)

    • T rappresenta il tipo del valore interno, quindi un Option<int> può contenere un int oppure None.
    • None è un valore speciale che indica l’assenza di un valore. Se la Option non ha valore interno, diciamo che “Option è None”
    • Some(T) è il contenitore che racchiude un valore di tipo T. Se l’opzione ha un valore interno, diciamo che “Option è Some”.

    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 :

    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.

    Option come valore di ritorno delle funzioni parziali

    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:

    • Le funzioni totali sono mappature definite per ogni elemento del dominio.
    • Le funzioni parziali sono mappature definite per alcuni, ma non per tutti, gli elementi di dominio.

    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.

    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.

    Modellare il nuovo tipo Age con Option

    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.

    La modifica prevede:

    • Rendere privato il costruttore: il costruttore chiaramente non può prevedere un valore di ritorno diverso dal tipo stesso. I client non dovranno passare da qui per creare una Age valida.
    • Creare un metodo statico con firma int → Option<Age>: generalmente chiamato “smart constructor”, questo metodo rende la creazione di un oggetto Age un’operazione onesta. Il metodo statico Of (nome standard in questi casi) è una funzione totale in quanto non prevede side effects e mappa correttamente ogni elemento del dominio dei numeri interi.

    Conclusioni

    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://www.amazon.it/Functional-Programming-C-Enrico-Buonanno-dp-1617299820/dp/1617299820/ref=dp_ob_image_bk

    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/