En mi artículo anterior describí cómo lograr el comportamiento de mónada "Quizás" usando operadores asíncronos / en espera . Esta vez voy a mostrar cómo implementar otro patrón de diseño popular "Reader Monad" usando las mismas técnicas.
Ese patrón permite pasar implícitamente algún contexto a alguna función sin usar parámetros de función u objetos globales compartidos y puede considerarse como otra forma más de implementar la inyección de dependencia. Por ejemplo:
class Config { public string Template; } public static async Task Main() { Console.WriteLine(await GreetGuys().Apply(new Config {Template = "Hi, {0}!"}));
"Lector" clásico
Primero, echemos un vistazo a cómo se puede implementar la mónada sin operadores asíncronos / en espera :
public class Config { public string Template; } public static class ClassicReader { public static void Main() { var greeter = GreetGuys(); Console.WriteLine(greeter.Apply(new Config{Template = "Hello, {0}"}));
(Lector)
public class Reader<T, TCtx> { private readonly Func<TCtx, T> _exec; public Reader(Func<TCtx, T> exec) => this._exec = exec; public T Apply(TCtx ctx) => this._exec(ctx); } public static class Reader { public static Reader<TJoin, TCtx> SelectMany<TIn, TOut, TCtx, TJoin>( this Reader<TIn, TCtx> source, Func<TIn, Reader<TOut, TCtx>> bind, Func<TIn, TOut, TJoin> join) => new Reader<TJoin, TCtx>(ctx => { var inValue = source.Apply(ctx); var outValue = bind(inValue).Apply(ctx); return join(inValue, outValue); }); }
El código funciona pero no parece natural para los desarrolladores de C #. No es de extrañar, porque las mónadas provienen de lenguajes funcionales donde se puede escribir un código similar de una manera más concisa. Sin embargo, la implementación clásica ayuda a comprender la esencia del patrón: en lugar de la ejecución inmediata de algún código, se pone en una función que se llamará cuando su contexto esté listo.
public static Reader<string, Config> Greet(string name) => new Reader<string, Config>(cfg => string.Format(cfg.Template, name));
SelectMany puede combinar varias funciones de este tipo en una sola, por lo que puede crear una subrutina cuya ejecución se diferirá hasta que se aplique su contexto. Por otro lado, ese enfoque se asemeja a escribir código asincrónico donde la ejecución del programa se detiene si se está ejecutando alguna operación asincrónica. Cuando un resultado de la operación está listo, la ejecución del programa continuará. Se asume que la infraestructura de C # diseñada para trabajar con operaciones asíncronas ( asíncrono / espera ) podría utilizarse de alguna manera en la implementación de la mónada "Reader" y ... ¡la suposición es correcta!
"Lector" asíncrono
En mi artículo anterior , demostré cómo controlar los operadores asíncronos / en espera utilizando los tipos de retorno asíncrono generalizado . La misma técnica se utilizará esta vez. Comencemos con la clase "Lector" que se usará como un tipo de resultado de operaciones asincrónicas:
[AsyncMethodBuilder(typeof(ReaderTaskMethodBuilder<>))] public class Reader<T> : INotifyCompletion, IReader { ...
La clase tiene dos responsabilidades diferentes (en teoría podríamos crear 2 clases):
- Extraer algún valor de un contexto cuando se aplica el contexto
- Creación de una lista vinculada de instancias de Reader que se utilizará para distribuir un contexto en una jerarquía de llamadas.
Para cada responsabilidad usaremos un constructor separado:
private readonly Func<object, T> _extractor;
Cuando se usa una instancia de la clase Reader como argumento del operador wait , la instancia recibirá un enlace a un delegado de continuación que debe llamarse solo cuando se resuelva un contexto de ejecución y podamos extraer (del contexto) algunos datos que serán utilizado en la continuación.

Para crear conexiones entre "lectores" primarios y secundarios, creemos el método:
private IReader _child; internal void SetChild(IReader reader) { this._child = reader; if (this._ctx != null) { this._child.SetCtx(this._ctx); } }
que se llamará dentro de ReaderTaskMethodBuilder :
public class ReaderTaskMethodBuilder<T> { ... public void GenericAwaitOnCompleted<TAwaiter, TStateMachine>( ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : INotifyCompletion where TStateMachine : IAsyncStateMachine { if (awaiter is IReader reader) { this.Task.SetChild(reader); } awaiter.OnCompleted(stateMachine.MoveNext); } public Reader<T> Task { get; } }
Dentro del método SetChild llamamos SetCtx, que propaga un contexto a una jerarquía y llama a un extractor si está definido:
public void SetCtx(object ctx) { this._ctx = ctx; if (this._ctx != null) { this._child?.SetCtx(this._ctx); if (this._extractor != null) { this.SetResult(this._extractor(this._ctx)); } } }
SetResult almacena un valor extraído de un contexto y llama a una continuación:
internal void SetResult(T result) { this._result = result; this.IsCompleted = true; this._continuation?.Invoke(); }
En el caso de que una instancia de Reader no tenga un "extractor" inicializado, ReaderTaskMethodBuilder debe llamar a SetResult cuando una máquina de estado generada pasa a su estado final.
El método Apply solo llama a SetCtx
public Reader<T> Apply(object ctx) { this.SetCtx(ctx); return this; }
Puede encontrar todo el código en github ( si aún no está bloqueado )
Ahora, quiero mostrar un ejemplo más realista de cómo se puede usar el lector asíncrono:
Haga clic para ampliar el ejemplo. public static class ReaderTest { public class Configuration { public readonly int DataBaseId; public readonly string GreetingTemplate; public readonly string NameFormat; public Configuration(int dataBaseId, string greetingTemplate, string nameFormat) { this.DataBaseId = dataBaseId; this.GreetingTemplate = greetingTemplate; this.NameFormat = nameFormat; } } public static async Task Main() { int[] ids = { 1, 2, 3 }; Configuration[] configurations = { new Configuration(100, "Congratulations, {0}! You won {1}$!", "{0} {1}"), new Configuration(100, "¡Felicidades, {0}! Ganaste {1} $", "{0}"), }; foreach (var configuration in configurations) { foreach (var userId in ids) {
El programa muestra saludos para algunos usuarios, pero no sabemos sus nombres de antemano ya que solo tenemos sus identificadores, por lo que debemos leer esa información de una "base de datos". Para conectarnos a la base de datos necesitamos conocer algún identificador de conexión y para crear un saludo necesitamos su plantilla y ... toda la información se pasa implícitamente a través del lector asíncrono.
Inyección de dependencia con "Reader" asíncrono
En comparación con la implementación clásica, el lector asíncrono tiene una falla: no podemos especificar un tipo de contexto pasado. Esta limitación proviene del hecho de que el compilador de C # espera solo un único parámetro de tipo genérico en una clase de generador de métodos asíncronos (tal vez se solucionará en el futuro).
Por otro lado, no creo que sea crítico ya que en la vida real lo más probable es que se pase algún contenedor de inyección de dependencia como contexto:
public static class Reader { public static Reader<TService> GetService<TService>() => Reader<TService>.Read<IServiceProvider>(serviceProvider => (TService)serviceProvider .GetService(typeof(TService))); }
... private static async Reader<string> Greet(string userName) { var service = await Reader.GetService<IGreater>(); return service.GreetUser(userName); } ...
( aquí puedes encontrar un ejemplo completo ... )
A diferencia del "tal vez" asíncrono , que no recomendé usar en ningún código de producción (debido al problema con los bloques finalmente ), consideraría usar el lector asíncrono en proyectos reales como reemplazo de (o "además de") el enfoque de inyección de dependencia tradicional (cuando todas las dependencias se pasan a un constructor de clase) ya que el lector tiene algunas ventajas:
- No hay necesidad de propiedades de clase que almacenen enlaces a recursos inyectados. En realidad no hay ninguna necesidad en las clases, toda la lógica se puede implementar en métodos estáticos.
- El uso del Reader fomentará la creación de código sin bloqueo, ya que todos los métodos serán asíncronos y nada impedirá que los desarrolladores usen versiones sin bloqueo de las operaciones de entrada / salida.
- El código será un poco más "legible": cada vez que veamos al lector como un tipo de resultado de algún método, sabremos que requiere acceso a algún contexto implícito.
- El lector asíncrono no utiliza la reflexión.
Por supuesto, puede haber algunos argumentos en contra del uso del Reader, pero de todos modos, el objetivo principal de estos artículos es mostrar cómo los patrones, que inicialmente fueron diseñados para lenguajes funcionales, pueden adoptarse para un estilo imperativo de codificación que se cree que es más simple de usar. entender por la mayoría de la gente.