Genéricos fluidos en C# • Oleksi Holub

Genéricos fluidos en C# • Oleksi Holub

 

 

La programación genérica es una característica poderosa disponible en muchos lenguajes escrita estáticamente. Ofrece una forma de escribir un código que opera sin problemas contra muchos tipos diferentes, al dirigir las características que comparten en lugar de los tipos en sí. Esto proporciona los medios para construir componentes flexibles y reutilizables sin tener que sacrificar la seguridad del tipo o introducir una duplicación innecesaria.

A pesar de que los genéricos han existido en C# por un tiempo, a veces todavía puedo encontrar formas nuevas e interesantes de usarlos. Por ejemplo, en uno de mis artículos anteriores Escribí sobre un truco que se me ocurrió que ayuda a lograr una inferencia de tipo objetivo, proporcionando una manera más fácil de trabajar con ciertos tipos de contenedores.

Recientemente, también estaba trabajando en algún código que involucraba genéricos y tenía un desafío inusual: necesitaba definir una firma donde todos los argumentos de tipo eran opcionales, pero utilizables en combinaciones arbitrarias entre sí. Inicialmente intenté hacerlo introduciendo sobrecargas de tipo, pero eso condujo a un diseño poco práctico que no me gustaba mucho.

Después de un poco de experimentación, encontré una manera de resolver este problema elegantemente utilizando un enfoque similar al interfaz fluida Patrón de diseño, excepto aplicado en relación con los tipos en lugar de objetos. El diseño al que llegué presenta un lenguaje específico de dominio que permite a los consumidores resolver el tipo que necesitan, al “configurarlo” en una secuencia de pasos lógicos.

En este artículo explicaré de qué se trata este enfoque y cómo puede usarlo para organizar tipos genéricos complejos de una manera más accesible.

Interfaces fluidas

En la programación orientada a objetos, el interfaz fluida El diseño es un patrón popular para construir interfaces flexibles y convenientes. Su idea central gira en torno al uso del encadenamiento del método para expresar interacciones a través de un flujo continuo de instrucciones legibles por humanos.

Entre otras cosas, este patrón se usa comúnmente para simplificar las operaciones que dependen de grandes conjuntos de parámetros de entrada (potencialmente opcionales). En lugar de esperar todas las entradas por adelantado, las interfaces diseñadas de manera fluida proporcionan una forma de configurar cada uno de los aspectos relevantes por separado entre sí.

Como ejemplo, consideremos el siguiente código:

var result = RunCommand(
    "git", // executable (required)
    "pull", // args (optional)
    "/my/repository", // working dir (optional)
    new Dictionary<string, string> // env vars (optional)
    {
        ("GIT_AUTHOR_NAME") = "John",
        ("GIT_AUTHOR_EMAIL") = "[email protected]"
    }
);

En este fragmento, estamos llamando al RunCommand(...) Método para generar un proceso infantil y bloquear la ejecución hasta que se complete. La configuración relevante, como los argumentos de línea de comandos, el directorio de trabajo y las variables de entorno se especifican a través de los parámetros de entrada.

Aunque completamente funcional, la expresión de invocación del método anterior no es muy legible por humanos. De un vistazo, es difícil incluso saber qué hace cada uno de los parámetros sin confiar en los comentarios.

Además, dado que la mayoría de los parámetros son opcionales, la definición del método también debe explicarlo. Hay diferentes formas de lograr eso, incluidas las sobrecargas, los parámetros nombrados con valores predeterminados, etc., pero todos son bastante torpes y ofrecen experiencia subóptima.

Sin embargo, podemos mejorar este diseño reelaborando el método en una interfaz fluida:

var result = new Command("git")
    .WithArguments("pull")
    .WithWorkingDirectory("/my/repository")
    .WithEnvironmentVariable("GIT_AUTHOR_NAME", "John")
    .WithEnvironmentVariable("GIT_AUTHOR_EMAIL", "[email protected]")
    .Run();

Con este enfoque, el consumidor puede crear un estado de estado Command Objeto especificando el nombre ejecutable requerido, después de lo cual pueden usar los métodos disponibles para configurar libremente cualquier opción adicional que necesiten. La expresión resultante no solo es significativamente más legible, sino que también es mucho más flexible, ya que no está limitada por las limitaciones inherentes de los parámetros del método.

Definiciones de tipo fluido

En este punto, puede tener curiosidad por saber cómo se relaciona algo de los genéricos. Después de todo, estas son solo funciones, y se supone que debemos hablar sobre el sistema de tipos.

Bueno, la conexión radica en el hecho de que Los genéricos también son solo funciones, excepto los tipos. De hecho, puede considerar un tipo genérico como una construcción especial de orden superior que se resuelve a un tipo regular después de suministrarlo los argumentos genéricos requeridos. Esto es análogo a la relación entre funciones y valores, donde se debe proporcionar una función con los argumentos correspondientes para resolver un valor concreto.

Debido a su similitud, los tipos genéricos también a veces pueden sufrir los mismos problemas de diseño. Para ilustrar esto, imaginemos que estamos construyendo un marco web y queremos definir un Endpoint interfaz responsable de mapear las solicitudes deserializadas en los objetos de respuesta correspondientes.

Tal tipo se puede modelar utilizando la siguiente firma:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    // This method gets called by the framework
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

Aquí tenemos una clase genérica básica que toma un argumento de tipo correspondiente a la solicitud que está destinada a recibir y otro argumento de tipo que especifica el formato de respuesta que se espera que proporcione. Esta clase también define el ExecuteAsync(...) Método que el usuario deberá anular para implementar la lógica relevante para un punto final particular.

Podemos usar esto como base para construir nuestros manejadores de ruta como así:

public class SignInRequest
{
    public string Username { get; init; }
    public string Password { get; init; }
}

public class SignInResponse
{
    public string Token { get; init; }
}

public class SignInEndpoint : Endpoint<SignInRequest, SignInResponse>
{
    (HttpPost("auth/signin"))
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken cancellationToken = default)
    {
        var user = await Database.GetUserAsync(request.Username);

        if (!user.CheckPassword(request.Password))
        {
            return Unauthorized();
        }

        return Ok(new SignInResponse
        {
            Token = user.GenerateToken()
        });
    }
}

Heredando de Endpoint<SignInRequest, SignInResponse>el compilador aplica automáticamente la firma correcta en el método de punto de entrada. Esto es muy conveniente, ya que ayuda a evitar posibles errores y también hace que la estructura de la aplicación sea más consistente.

Sin embargo, aunque el SignInEndpoint Se adapta perfectamente a este diseño, no todos los puntos finales necesariamente tendrán los modelos de solicitud y respuesta. Por ejemplo, un análogo SignUpEndpoint es probable que solo devuelva un código de estado sin ningún cuerpo de respuesta, mientras que SignOutEndpoint Es posible que ni siquiera necesite un modelo de solicitud.

Para acomodar adecuadamente los puntos finales como ese, podríamos intentar extender nuestro modelo agregando algunas sobrecargas de tipo genérico adicional:

// Endpoint that expects a typed request and provides a typed response
public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

// Endpoint that expects a typed request but does not provide a typed response (*)
public abstract class Endpoint<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

// Endpoint that does not expect a typed request but provides a typed response (*)
public abstract class Endpoint<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

// Endpoint that neither expects a typed request nor provides a typed response
public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

De un vistazo, esto puede parecer haber resuelto este problema, sin embargo, el código anterior en realidad no se compila. La razón de eso es el hecho de que el Endpoint<TReq> y Endpoint<TRes> son ambiguos, ya que no hay forma de determinar si un solo argumento de tipo no restringido está destinado a especificar una solicitud o una respuesta.

Al igual que con el RunCommand(...) Método anteriormente en el artículo, hay un par de formas directas de trabajar en torno a esto, pero no son particularmente elegantes. Por ejemplo, la solución más simple sería cambiar el nombre de los tipos para que sus capacidades se reflejen en sus nombres, evitando colisiones en el proceso:

public abstract class Endpoint<TReq, TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutResponse<TReq> : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        TReq request,
        CancellationToken cancellationToken = default
    );
}

public abstract class EndpointWithoutRequest<TRes> : EndpointBase
{
    public abstract Task<ActionResult<TRes>> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

public abstract class Endpoint : EndpointBase
{
    public abstract Task<ActionResult> ExecuteAsync(
        CancellationToken cancellationToken = default
    );
}

Esto aborda el problema, pero da como resultado un diseño bastante feo. Debido a que la mitad de los tipos se nombran de manera diferente, el usuario de la biblioteca podría tener más dificultades para encontrarlos o incluso saber sobre su existencia en primer lugar. Además, si consideramos que es posible que deseemos agregar más variantes en el futuro (por ejemplo, manejadores no asíntecos además de asíncrono), también queda claro que este enfoque no se escala muy bien.

Por supuesto, todos los problemas anteriores pueden parecer un poco artificiales y puede que no haya razón para intentar resolverlos. Sin embargo, personalmente creo que optimizar la experiencia del desarrollador es un aspecto extremadamente importante de escribir código de biblioteca.

Afortunadamente, hay una mejor solución que podemos usar. Basándose en los paralelos entre funciones y tipos genéricos, podemos deshacernos de nuestras sobrecargas de tipo y reemplazarlas con un esquema con fluidez: en su lugar:

public static class Endpoint
{
    public static class WithRequest<TReq>
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                TReq request,
                CancellationToken cancellationToken = default
            );
        }
    }

    public static class WithoutRequest
    {
        public abstract class WithResponse<TRes>
        {
            public abstract Task<ActionResult<TRes>> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }

        public abstract class WithoutResponse
        {
            public abstract Task<ActionResult> ExecuteAsync(
                CancellationToken cancellationToken = default
            );
        }
    }
}

El diseño anterior conserva los cuatro tipos originales de antes, pero los organiza en una estructura jerárquica en lugar de una plana. Esto es posible lograrlo porque C# permite que las definiciones de tipo se aniden entre sí, incluso si son genéricas.

De hecho, Los tipos contenidos dentro de los genéricos son especiales porque también obtienen acceso a los argumentos de tipo especificados en sus padres. Nos permite ponernos WithResponse<TRes> adentro WithRequest<TReq> y usa ambos TReq y TRes para definir el interior ExecuteAsync(...) método.

Funcionalmente, el enfoque que se muestra arriba y el de antes es idéntico. Sin embargo, la estructura no convencional empleada aquí elimina por completo todos los problemas de descubrimiento, al tiempo que ofrece el mismo nivel de flexibilidad.

Ahora, si el usuario quisiera implementar un punto final, podría hacerlo así:

public class MyEndpoint
    : Endpoint.WithRequest<SomeRequest>.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutResponse
    : Endpoint.WithRequest<SomeRequest>.WithoutResponse { /* ... */ }

public class MyEndpointWithoutRequest
    : Endpoint.WithoutRequest.WithResponse<SomeResponse> { /* ... */ }

public class MyEndpointWithoutNeither
    : Endpoint.WithoutRequest.WithoutResponse { /* ... */ }

Y así es como el actualizado SignInEndpoint se vería como:

public class SignInEndpoint : Endpoint
    .WithRequest<SignInRequest>
    .WithResponse<SignInResponse>
{
    (HttpPost("auth/signin"))
    public override async Task<ActionResult<SignInResponse>> ExecuteAsync(
        SignInRequest request,
        CancellationToken cancellationToken = default)
    {
        // ...
    }
}

Como puede ver, este enfoque conduce a una firma de tipo muy expresiva y limpia. Independientemente del tipo de punto final que el usuario quería implementar, siempre comenzaría desde el Endpoint Clase y componga las capacidades que necesitan de manera fluida y legible.

Además de eso, dado que nuestra estructura tipo esencialmente representa una máquina de estado finita, es seguro contra el mal uso accidental. Por ejemplo, los siguientes intentos incorrectos de crear un punto final resultan en errores de tiempo de compilación:

// Incomplete signature
// Error: Class Endpoint is sealed
public class MyEndpoint : Endpoint { /* ... */ }

// Incomplete signature
// Error: Class Endpoint.WithRequest<TReq> is sealed
public class MyEndpoint : Endpoint.WithRequest<MyRequest> { /* ... */ }

// Invalid signature
// Error: Class Endpoint.WithoutRequest.WithRequest<T> does not exist
public class MyEndpoint : Endpoint.WithoutRequest.WithRequest<MyRequest> { /* ... */ }

Resumen

Aunque los tipos genéricos son increíblemente útiles, su naturaleza rígida puede dificultarlos de consumir en algunos escenarios. En particular, cuando necesitamos definir una firma que encapsule múltiples combinaciones diferentes de argumentos de tipo, podemos recurrir a sobrecarga, pero que impone ciertas limitaciones.

Como solución alternativa, podemos anidar los tipos genéricos entre sí, creando una estructura jerárquica que permite a los usuarios componerlos de manera fluida. Esto proporciona los medios para lograr una personalización mucho mayor, al tiempo que conserva la usabilidad óptima. Solana Token Creator

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *