Articoli di programmazione

Programmazione funzionale in C# - terza parte

Introduzione alle Monadi in C#, gestione delle Eccezioni e dei Valori Null

circuiti.jpg
Le #monadi sono un concetto astratto proveniente dalla teoria delle categorie matematiche, che ha trovato applicazioni pratiche nella programmazione #funzionale. Nella #programmazione C#, le monadi possono essere utilizzate per gestire operazioni asincrone, manipolare flussi di dati e gestire eccezioni e valori nulli in modo sicuro ed efficiente, come avviene in linguaggi principalmente funzionali come ad esempio F# ed Haskell.

Concetto di Monade
In termini semplici, una monade è un contenitore che incapsula un valore e fornisce un modo per applicare operazioni su quel valore. Le monadi forniscono un'astrazione potente per lavorare con valori che potrebbero avere effetti collaterali o risultati indesiderati.

In C#, le monadi vengono implementate utilizzando pattern come i metodi di estensione, i tipi generici e il concetto di fluent syntax. Vediamo un paio di esempi.

Utilizzo delle Monadi per la Gestione delle Eccezioni
Le monadi possono essere utilizzate per gestire le eccezioni in modo più robusto rispetto all'approccio tradizionale basato su try-catch. Creando una monade personalizzata per gestire le eccezioni, è possibile trasmettere l'informazione sull'errore lungo una catena di operazioni in modo più chiaro e senza interrompere il flusso del programma.

// Definizione di una monade per gestire eccezioni
public class ExceptionMonad< T >
{
private readonly Exception _exception;

private ExceptionMonad(Exception exception)
{
_exception = exception;
}

public static ExceptionMonad< T > Return(T value)
{
return new ExceptionMonad< T >(null) { Value = value };
}

public static ExceptionMonad< T > Throw(Exception exception)
{
return new ExceptionMonad< T >(exception);
}

public T Value { get; }

public bool IsFaulted => _exception != null;

public static implicit operator ExceptionMonad< T >(T value)
{
return Return(value);
}

public static ExceptionMonad< T > operator |(ExceptionMonad< T > left, Func< T, ExceptionMonad< T > > func)
{
return left.IsFaulted ? left : func(left.Value);
}
}

Nell'esempio sopra, abbiamo definito una monade ExceptionMonad che può contenere un valore di tipo T o un'eccezione. Possiamo utilizzare l'operatore | per concatenare operazioni e gestire le eccezioni lungo la catena di operazioni.

// Utilizzo della monade per gestire le eccezioni
var result = ExceptionMonad.Return(10)
| (value => ExceptionMonad.Return(value * 2))
| (value => ExceptionMonad.Throw(new InvalidOperationException("Invalid operation")));

if (result.IsFaulted)
{
Console.WriteLine($"Error: {result.Exception.Message}");
}
else
{
Console.WriteLine($"Result: {result.Value}");
}

In questo esempio, se una delle operazioni genera un'eccezione, il flusso viene interrotto e viene restituito un ExceptionMonad contenente l'eccezione. In caso contrario, viene restituito il risultato corretto.

Un approccio alternativo è quello invece di inglobare la logica Try/Catch all'interno della monade. La monade ResultMonad consente l'accodamento di più chiamate Try in modo fluent. Inoltre, se una delle chiamate Try va in errore, le successive non vengono eseguite:

using System;

public class ResultMonad< T >
{
public T Value { get; private set; }
public bool Invalid { get; private set; }

private ResultMonad() { }

public static ResultMonad< T > Try(Func< T > func)
{
var resultMonad = new ResultMonad< T >();

try
{
resultMonad.Value = func();
}
catch (Exception)
{
resultMonad.Invalid = true;
}

if (resultMonad.Value == null)
resultMonad.Invalid = true;

return resultMonad;
}

public ResultMonad< T > Then(Func< T > func)
{
if (!Invalid)
{
try
{
Value = func();
}
catch (Exception)
{
Invalid = true;
}

if (Value == null)
Invalid = true;
}

return this;
}
}

class Program
{
static void Main(string[] args)
{
var resultChain = ResultMonad< string >
.Try(() => "First")
.Then(() => "Second")
.Then(() => throw new Exception("Third error"))
.Then(() => "Fourth");

Console.WriteLine($"Result: Value = {resultChain.Value}, Invalid = {resultChain.Invalid}");
}
}

In questo esempio, abbiamo aggiunto un metodo Then alla monade ResultMonad, che esegue una nuova funzione solo se il risultato precedente non è stato invalidato. Abbiamo anche fatto in modo che se una qualsiasi delle chiamate Try o Then restituisce null o genera un'eccezione, il flag Invalid venga impostato su true, e le chiamate successive non vengano eseguite. Infine, abbiamo creato un'esempio con una catena di almeno 4 chiamate Try accodate, dove il terzo metodo va in errore e il quarto non viene eseguito.

Utilizzo delle Monadi per la Gestione dei Valori Null
Le monadi possono anche essere utilizzate per gestire i valori nulli in modo più sicuro ed elegante. Creando una monade per gestire i valori nulli, è possibile evitare i problemi di null reference durante la manipolazione dei dati.

// Definizione di una monade per gestire i valori nulli
public class Maybe< T >
{
private readonly T _value;

private Maybe(T value)
{
_value = value;
}

public static Maybe< T > Return(T value)
{
return new Maybe< T >(value);
}

public T Value => _value;

public bool HasValue => _value != null;

public static implicit operator Maybe< T >(T value)
{
return Return(value);
}

public static Maybe< T > operator |(Maybe< T > left, Func< T, Maybe < T > > func)
{
return left.HasValue ? func(left.Value) : Maybe< T >.Return(default);
}
}

Nell'esempio sopra, abbiamo definito una monade Maybe che può contenere un valore di tipo T o essere vuota. Utilizziamo l'operatore | per concatenare operazioni e gestire i valori nulli.

// Utilizzo della monade per gestire i valori nulli
var result = Maybe.Return("hello")
| (value => Maybe.Return(value.ToUpper()))
| (value => Maybe.Return(value + " world"));

Console.WriteLine($"Result: {(result.HasValue ? result.Value : "No value")}");

// Grazie agli operatori che abbiamo introdotto, l'uso può essere semplificato così:
var result2 = Maybe.Return("hello")
| (value => value.ToUpper())
| (value => value + " world");

Console.WriteLine($"Result2: {(result.HasValue ? result.Value : "No value")}");

In questo esempio, se una delle operazioni genera un valore null, il flusso viene interrotto e viene restituito un Maybe vuoto. In caso contrario, viene restituito il risultato corretto. Da notare l'uso degli operatori che semplificano il codice da scrivere in fase di chiamata alla monade.

In conclusione utilizzando monadi personalizzate, è possibile scrivere codice più robusto e chiaro. Possiamo creare un piccolo framework di monadi da utilizzare nei nostri programmi: questo uniforma il nostro codice, lo rende chiaro e meno prono ai bug. Le monadi sono i piccoli aiutanti di cui non sapevi di aver bisogno.

#csharp #programmazione #codice #net #netcore #funzionale