Posted in

Genéricos fluentes em c# • oleksi holub

Genéricos fluentes em c# • oleksi holub

A programação genérica é um recurso poderoso disponível em muitos idiomas estaticamente digitados. Ele oferece uma maneira de escrever código que opera perfeitamente contra muitos tipos diferentes, direcionando os recursos que eles compartilham e não os tipos. Isso fornece os meios para criar componentes flexíveis e reutilizáveis ​​sem precisar sacrificar a segurança do tipo ou introduzir duplicação desnecessária.

Embora os genéricos existam em C# há um tempo, às vezes ainda consigo encontrar maneiras novas e interessantes de usá -las. Por exemplo, em um dos meus Artigos anteriores Escrevi sobre um truque que criei que ajude a obter inferência do tipo-alvo, fornecendo uma maneira mais fácil de trabalhar com certos tipos de contêineres.

Recentemente, eu também estava trabalhando em algum código envolvendo genéricos e tinha um desafio incomum: eu precisava definir uma assinatura em que todos os argumentos de tipo eram opcionais, mas utilizáveis ​​em combinações arbitrárias entre si. Inicialmente, tentei fazer isso introduzindo sobrecargas de tipo, mas isso levou a um design impraticável que eu não gostava muito.

Depois de um pouco de experimentação, encontrei uma maneira de resolver esse problema elegantemente usando uma abordagem semelhante ao interface fluente Padrão de design, exceto aplicado em relação aos tipos em vez de objetos. O design que eu cheguei apresenta uma linguagem específica de domínio que permite aos consumidores resolver o tipo de que precisam, “configurando-o” em uma sequência de etapas lógicas.

Neste artigo, explicarei o que é essa abordagem e como você pode usá -la para organizar tipos genéricos complexos de uma maneira mais acessível.

Interfaces fluentes

Na programação orientada a objetos, o interface fluente O design é um padrão popular para a construção de interfaces flexíveis e convenientes. Sua idéia principal gira em torno do uso do encadeamento do método para expressar interações através de um fluxo contínuo de instruções legíveis por humanos.

Entre outras coisas, esse padrão é comumente usado para simplificar as operações que dependem de grandes conjuntos de parâmetros de entrada (potencialmente opcionais). Em vez de esperar todas as entradas antecipadamente, as interfaces projetadas de maneira fluente fornecem uma maneira de configurar cada um dos aspectos relevantes separadamente um do outro.

Como exemplo, vamos considerar o seguinte 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]"
    }
);

Neste trecho, estamos chamando o RunCommand(...) Método para gerar um processo infantil e bloquear a execução até concluir. Configurações relevantes, como argumentos da linha de comando, diretório de trabalho e variáveis ​​de ambiente, são especificadas através dos parâmetros de entrada.

Embora completamente funcional, a expressão de invocação do método acima não é muito legível por humanos. À primeira vista, é difícil dizer o que cada um dos parâmetros faz sem depender dos comentários.

Além disso, como a maioria dos parâmetros é opcional, a definição de método também deve explicar isso. Existem diferentes maneiras de conseguir isso, incluindo sobrecargas, nomeados parâmetros com valores padrão etc., mas todos são bastante desajeitados e oferecem experiência abaixo do ideal.

Podemos melhorar esse design, no entanto, reformulando o método em uma interface fluente:

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

Com essa abordagem, o consumidor pode criar um estado de Estado Command Objeto especificando o nome executável necessário, após o que eles podem usar os métodos disponíveis para configurar livremente quaisquer opções adicionais necessárias. A expressão resultante não é apenas significativamente mais legível, mas também é muito mais flexível, pois não é restringida pelas limitações inerentes aos parâmetros do método.

Definições de tipo fluente

Neste ponto, você pode estar curioso para saber como se está relacionado aos genéricos. Afinal, essas são apenas funções, e devemos estar falando sobre o sistema de tipos.

Bem, a conexão está no fato de que genéricos também são apenas funções, exceto os tipos. De fato, você pode considerar um tipo genérico como um construto especial de ordem superior que resolve um tipo regular depois de fornecer os argumentos genéricos necessários. Isso é análogo à relação entre funções e valores, onde uma função precisa ser fornecida com os argumentos correspondentes para resolver um valor concreto.

Devido à sua semelhança, os tipos genéricos também podem às vezes sofrer com os mesmos problemas de design. Para ilustrar isso, vamos imaginar que estamos construindo uma estrutura na web e queremos definir um Endpoint Interface responsável pelo mapeamento de solicitações desserializadas nos objetos de resposta correspondentes.

Esse tipo pode ser modelado usando a seguinte assinatura:

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
    );
}

Aqui, temos uma classe genérica básica que leva um argumento de tipo correspondente à solicitação que ele deve receber e outro argumento de tipo que especifica o formato de resposta que ele deve fornecer. Esta classe também define o ExecuteAsync(...) Método que o usuário precisará substituir para implementar a lógica relevante para um terminal específico.

Podemos usar isso como base para construir nossos manipuladores de rota como assim:

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()
        });
    }
}

Herdando de Endpoint<SignInRequest, SignInResponse>o compilador aplica automaticamente a assinatura correta no método do ponto de entrada. Isso é muito conveniente, pois ajuda a evitar erros em potencial e também torna a estrutura do aplicativo mais consistente.

No entanto, mesmo que o SignInEndpoint Se encaixa perfeitamente neste design, nem todos os pontos de extremidade terão necessariamente os modelos de solicitação e resposta. Por exemplo, um análogo SignUpEndpoint provavelmente apenas retornará um código de status sem qualquer corpo de resposta, enquanto SignOutEndpoint pode nem precisar de um modelo de solicitação.

Para acomodar corretamente os terminais como esse, poderíamos tentar estender nosso modelo adicionando algumas sobrecargas adicionais do tipo genérico:

// 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
    );
}

À primeira vista, isso pode parecer ter resolvido esse problema, no entanto, o código acima não é realmente compilado. A razão para isso é o fato de que o Endpoint<TReq> e Endpoint<TRes> são ambíguos, pois não há como determinar se um único argumento de tipo não restrito deve especificar uma solicitação ou uma resposta.

Assim como no RunCommand(...) Método no início do artigo, existem algumas maneiras diretas de contornar isso, mas elas não são particularmente elegantes. Por exemplo, a solução mais simples seria renomear os tipos para que seus recursos sejam refletidos em seus nomes, evitando colisões no processo:

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
    );
}

Isso aborda o problema, mas resulta em um design bastante feio. Como metade dos tipos é nomeado de maneira diferente, o usuário da biblioteca pode ter mais dificuldade em encontrá -los ou até mesmo saber sobre sua existência em primeiro lugar. Além disso, se considerarmos que podemos querer adicionar mais variantes no futuro (por exemplo, manipuladores não asincronizados, além do assíncrono), também fica claro que essa abordagem não escala muito bem.

Obviamente, todos os problemas acima podem parecer um pouco artificiais e pode não haver razão para tentar resolvê -los. No entanto, eu pessoalmente acredito que otimizar a experiência do desenvolvedor é um aspecto extremamente importante da redação do código da biblioteca.

Felizmente, há uma solução melhor que podemos usar. Com base nos paralelos entre funções e tipos genéricos, podemos nos livrar de nossas sobrecargas de tipo e substituí -las por um esquema fluente:

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
            );
        }
    }
}

O design acima mantém os quatro tipos originais de anteriores, mas os organiza em uma estrutura hierárquica, em vez de uma plana. Isso é possível alcançar, porque C# permite que as definições de tipo sejam aninhadas uma na outra, mesmo que sejam genéricas.

Na verdade, Os tipos contidos nos genéricos são especiais porque também têm acesso aos argumentos de tipo especificados em seus pais. Isso nos permite colocar WithResponse<TRes> dentro WithRequest<TReq> e use os dois TReq e TRes Para definir o interior ExecuteAsync(...) método.

Funcionalmente, a abordagem mostrada acima e a de anterior é idêntica. No entanto, a estrutura não convencional empregada aqui elimina completamente todos os problemas de descoberta, enquanto ainda oferece o mesmo nível de flexibilidade.

Agora, se o usuário quisesse implementar um terminal, seria capaz de fazê -lo assim:

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 { /* ... */ }

E aqui está como o atualizado SignInEndpoint pareceria:

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

Como você pode ver, essa abordagem leva a uma assinatura do tipo muito expressiva e limpa. Independentemente de que tipo de endpoint que o usuário queria implementar, eles sempre começariam a partir do Endpoint classe e compor os recursos de que precisam de maneira fluente e legível pelo homem.

Além disso, como nossa estrutura de tipos representa essencialmente uma máquina de estado finita, é seguro contra o uso indevido acidental. Por exemplo, as seguintes tentativas incorretas de criar um terminal resultam em erros de compilação no tempo:

// 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> { /* ... */ }

Resumo

Embora os tipos genéricos sejam incrivelmente úteis, sua natureza rígida pode dificultar a consumo de alguns cenários. Em particular, quando precisamos definir uma assinatura que encapsula várias combinações diferentes de argumentos de tipo, podemos recorrer à sobrecarga, mas que impõe certas limitações.

Como uma solução alternativa, podemos aninhar tipos genéricos um no outro, criando uma estrutura hierárquica que permite que os usuários os componham de maneira fluente. Isso fornece os meios para obter uma personalização muito maior, mantendo a usabilidade ideal.

Luis es un experto en Inteligência Empresarial, Redes de Computadores, Gestão de Dados e Desenvolvimento de Software. Con amplia experiencia en tecnología, su objetivo es compartir conocimientos prácticos para ayudar a los lectores a entender y aprovechar estas áreas digitales clave.

Leave a Reply

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

8k8 com casino