A monád tervezési minta egy olyan funkcionális programozási minta, amely lehetővé teszi a mellékhatások kezelését és a számítások szerkezetének kontrollált módon történő végrehajtását.
A mellékhatások a programok olyan viselkedéseit jelentik, amelyek túlmutatnak a számítási eredményen, vagyis változtatják a program állapotát vagy hatással vannak a környezetére.
A monádokat általában egymásba ágyazott típusokként definiálják. Minden monádtípus két alapvető műveletet tartalmaz: egyet a monádba való csomagoláshoz és egyet a monádból történő kicsomagoláshoz és a számítás végrehajtásához.
A bevezető után felmerülhet a kérdés, hogy C# esetén miért is kellene nekünk a monádokkal foglalkozni, mikor a C# alapvetően nem egy funkcionális nyelv? Ez teljes mértékben igaz, de ez nem jelenti azt, hogy a .NET ne tartalmazna monád megvalósításokat. A LINQ kapcsán a SelectMany egy monád, illetve az aszinkron programozás során létrehozott Task, amit aztán async és await segítségével használunk, az is egy monád.
Azon felül, hogy megismerjük, hogy ezek monádok, számos más előny is szól az alkalmazásuk mellett: lehetővé teszik a kifejezőbb kód írását, segítik a kód könnyebb tesztelhetőségét.
Ennyi bevezető után nézzük meg, milyen fajta monádok léteznek és milyen problémák megoldásában tudnak nekünk segíteni.
Maybe
A maybe (talán) monád az opcionális értékek kezelésére szolgál, ahol az eredmény lehet érték vagy null. Egy lehetséges implementációja:
public class Maybe<T>
{
private readonly T? _value;
private Maybe(T? value)
{
_value = value;
}
public static Maybe<T> Some(T value)
{
return new Maybe<T>(value);
}
public static Maybe<T> None()
{
return new Maybe<T>(default);
}
[MemberNotNullWhen(true, nameof(_value))]
public bool HasValue => !EqualityComparer<T>.Default.Equals(_value, default);
public T Value => HasValue ? _value : throw new InvalidOperationException("Maybe does not have a value.");
public Maybe<TResult> Bind<TResult>(Func<T, Maybe<TResult>> func)
{
if (HasValue)
return func(_value);
return Maybe<TResult>.None();
}
}
Használati példa:
Maybe<int> Divide(int numerator, int denominator)
{
if (denominator == 0)
return Maybe<int>.None();
return Maybe<int>.Some(numerator / denominator);
}
var invalidResult = Maybe<int>.Some(10)
.Bind(num => Divide(num, 0))
.Bind(num => Divide(num, 5));
//Nem lesz eredménye,
//mert a második osztás már null-ra viszi az eredményt
if (!invalidResult.HasValue)
Console.WriteLine("Invalid division");
A maybe használatában hasonlóságot mutat a C# beépített nullable típusához, mivel egy az egyben ezt a koncepciót valósítja meg, azonban a maybe nem csak érték típusokkal alkalmazható.
Either
Az either (akár) monád olyan értékek reprezentálásra szolgál, amelyek két különböző típusúak lehetnek és általában olyan esetekben érdemes használni, mikor egy számítás eredménye lehet mondjuk egy eredmény vagy egy hibaüzenet. Egy lehetséges implementációja:
public class Either<TLeft, TRight>
{
private readonly TLeft? left;
private readonly TRight? right;
private readonly bool isLeft;
private Either(TLeft? left, TRight? right, bool isLeft)
{
this.left = left;
this.right = right;
this.isLeft = isLeft;
}
public static Either<TLeft, TRight> Left(TLeft left)
{
return new Either<TLeft, TRight>(left, default, true);
}
public static Either<TLeft, TRight> Right(TRight right)
{
return new Either<TLeft, TRight>(default, right, false);
}
public bool IsLeft => isLeft;
public bool IsRight => !isLeft;
public TLeft? LeftValue => IsLeft ? left : throw new InvalidOperationException("Either is not Left.");
public TRight? RightValue => IsRight ? right : throw new InvalidOperationException("Either is not Right.");
public Either<TLeft?, TResult?> Bind<TResult>(Func<TRight?, Either<TLeft?, TResult?>> func)
{
if (IsRight)
return func(right);
return Either<TLeft?, TResult?>.Left(left);
}
public Either<TLeft?, TResult?> Select<TResult>(Func<TRight?, TResult?> selector)
{
return Bind(value => Either<TLeft?, TResult?>.Right(selector(value)));
}
}
Használati példa:
Either<string, int> EitherDivide(int numerator, int denominator)
{
if (denominator == 0)
return Either<string, int>.Left("Division by zero");
return Either<string, int>.Right(numerator / denominator);
}
var result = EitherDivide(10, 2)
.Bind(num => EitherDivide(num, 5))
.Select(num => num * 10);
if (result.IsRight)
{
//ez hajtódik végre, mivel a jobb oldali típus az int
Console.WriteLine($"Result: {result.RightValue}");
}
else
{
//ez akkor hajtódna végre, ha 0-val való osztás lenne
Console.WriteLine($"Error: {result.LeftValue}");
}
A fenti példában a helyes szám értéket kapjuk, de ha 0-val osztanánk, akkor a Division by zero üzenet kapnánk.
State
A state (állapot) monádot olyan számítások modellezésére használhatjuk, amelyek az állapot kezelését és mutációját foglalják magukban. Egy lehetséges implementációja:
public delegate (TState, TResult) State<TState, TResult>(TState state);
public static class StateMonadExtensions
{
public static State<TState, TResult> ToState<TState, TResult>(this TResult value)
{
return state => (state, value);
}
public static State<TState, TResult> Bind<TState, TIntermediate, TResult>(
this State<TState, TIntermediate> state,
Func<TIntermediate, State<TState, TResult>> func)
{
return initialState =>
{
var (intermediateState, intermediateResult) = state(initialState);
return func(intermediateResult)(intermediateState);
};
}
public static State<TState, TResult> Select<TState, TSource, TResult>(
this State<TState, TSource> state,
Func<TSource, TResult> selector)
{
return state.Bind(value => selector(value).ToState<TState, TResult>());
}
public static State<TState, TResult> SelectMany<TState, TSource, TIntermediate, TResult>(
this State<TState, TSource> state,
Func<TSource, State<TState, TIntermediate>> intermediateSelector,
Func<TSource, TIntermediate, TResult> resultSelector)
{
return state.Bind(value => intermediateSelector(value).Bind(intermediateValue =>
resultSelector(value, intermediateValue).ToState<TState, TResult>()));
}
}
Használati példa:
State<int, int> Increment()
{
return state => (state += 10, state);
}
State<int, int> GetCounter()
{
return state => (state, state);
}
var incrementAndGetCounter = Increment()
.Bind(_ => Increment())
.Bind(_ => GetCounter());
var initialState = 0;
var (finalState, state) = incrementAndGetCounter.Invoke(initialState);
Console.WriteLine($"Initial State: {initialState}"); //0
Console.WriteLine($"Final State: {finalState}"); //20
Console.WriteLine($"State: {state}"); //20
A state monádban a számítást olyan függvényként ábrázoljuk, amely egy kezdeti állapotot vesz fel bemenetként, kimeneként pedig egy eredményt és egy frissített állapotot ad vissza. Az állapot implicit módon átadásra kerül a számítások között, így azok zökkenőmentesen összeláncolhatóak.
A fenti példában két metódusunk van: Az Increment és a GetCounter. Az Increment 10-zel növlei az állapot változót, a GetCounter pedig visszaadja az állapot értékét eredményként és állapotként is. A példából látható, hogy a state monád lehetővé teszi számunkra, hogy elválasszuk az állapotmanipulációs logikát a tényleges érték visszakeresésétől.
List
A list (lista) monád olyan számítások modellezésére szolgál, amelyek értékek gyűjteményeit vagy sorozatait foglalják magukban. Lehetővé teszi a számítások elvégzését a lista minden elemén, és ennek eredményeként új lista létrehozását. Egy lehetséges implementációja:
public static class ListMonadExtensions
{
public static List<TResult> Bind<TSource, TResult>(this IEnumerable<TSource> source,
Func<TSource, IEnumerable<TResult>> func)
{
var result = new List<TResult>();
foreach (var item in source)
{
result.AddRange(func(item));
}
return result;
}
}
Használati példa:
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
List<int> multipliedNumbers = numbers.Bind(num => new List<int> { num * 2 });
Console.WriteLine(string.Join(", ", multipliedNumbers)); //2, 4, 6, 8, 10
A fenti példa egy számokból álló lista minden elemét megdupláz (transzformál) és egy új listát ad vissza az átalakított értékekkel.
Writer
A writer (író/naplózó) monád akkor hasznos, amikor egy számítás során információkat szeretnénk naplózni vagy nyomon követni. Egy lehetséges implementációja:
public class Writer<TLog, TValue>
{
public TLog Log { get; }
public TValue Value { get; }
public Writer(TLog log, TValue value)
{
Log = log;
Value = value;
}
public Writer<TLog, TResult> Bind<TResult>(Func<TValue, Writer<TLog, TResult>> func)
{
var result = func(Value);
return new Writer<TLog, TResult>(CombineLogs(Log, result.Log), result.Value);
}
public Writer<TLog, TResult> Select<TResult>(Func<TValue, TResult> selector)
{
return new Writer<TLog, TResult>(Log, selector(Value));
}
private static TLog CombineLogs(TLog log1, TLog log2)
{
if (log1 == null)
return log2;
if (log2 == null)
return log1;
if (log1 is ICollection<string> collection1 && log2 is ICollection<string> collection2)
{
var combinedLogs = new List<string>(collection1);
combinedLogs.AddRange(collection2);
return (TLog)(object)combinedLogs;
}
throw new InvalidOperationException("Combining logs is not supported for the given type.");
}
}
Használati példa:
Writer<List<string>, double> LogDivide(double dividend, double divisor)
{
if (divisor == 0)
return new Writer<List<string>, double>(new List<string> { "Error: Division by zero" }, 0);
var result = dividend / divisor;
return new Writer<List<string>, double>(new List<string>(), result);
}
Writer<List<string>, double> Multiply(double a, double b)
{
var result = a * b;
return new Writer<List<string>, double>(new List<string>(), result);
}
Writer<List<string>, T> Log<T>(string message, T value)
{
return new Writer<List<string>, T>(new List<string> { message }, value);
}
var computation = LogDivide(10, 2)
.Bind(result => Log($"Division Result: {result}", result))
.Bind(result => Multiply(result, 5))
.Bind(result => Log($"Multiplication Result: {result}", result));
Console.WriteLine($"Final Result: {computation.Value}");
Console.WriteLine($"Log Messages:");
Console.WriteLine(string.Join("\n", computation.Log));
A program kimenete:
Log Messages:
Division Result: 5
Multiplication Result: 25
A fenti példában TLog típusa némileg szabadon befolyásolható egészen addig, amíg egy ICollection implementáció.
Összegzés
A monádok igen hasznosak tudnak lenni, ha funkcionális stílusban szeretnénk C#-ban programozni. Az itt bemutatottakon kívül számos monád létezik még különböző célokra. Ezekről a https://en.wikipedia.org/wiki/Monad_(functional_programming) cikkben lehet részletesen olvasni, illetve ha C#-ban szeretnénk használni őket és nem feltétlen magunk szeretnénk őket leimplementálni, akkor a Language-ext projekt NuGet csomagjai segítségével funkcionális stílusban is programozhatunk C# és .NET segítségével A Language-ext projektről több információ a hivatalos Github oldalukon található: https://github.com/louthy/language-ext