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