Acima de tudo no desenvolvimento de software, eu realmente gosto de construir estruturas que permitam que outros desenvolvedores criem algo legal. Às vezes, ao perseguir o design perfeito que tenho em mente, me vejo com hacks estranhos que empurram o idioma C# para o limite.
Um desses casos aconteceu há pouco tempo, quando eu estava tentando descobrir como fazer o compilador determinar o tipo genérico de um método com base no tipo de retorno esperado. Como C# só pode inferir genéricos dos argumentos do método, isso inicialmente parecia impossível, no entanto, eu consegui encontrar uma maneira de fazê -lo funcionar.
Neste artigo, mostrarei um pequeno truque que criei para simular a inferência do tipo alvo, bem como alguns exemplos de onde isso pode ser útil.
Digite inferência
O tipo de inferência, em geral, é a capacidade do compilador de detectar automaticamente o tipo de expressão específica, sem que o programador especifique explicitamente. Esse recurso funciona analisando o contexto em que a expressão é avaliada, bem como as restrições impostas pelo fluxo de dados no programa.
Ao ser capaz de detectar o tipo automaticamente, os idiomas que suportam a inferência do tipo permitem a gravação de um código mais sucinto, mantendo os benefícios completos de um sistema de tipo estático. É por isso que a maioria dos idiomas mainstream estaticamente tipados tem inferência de tipo, de uma forma ou de outra.
C#, sendo um desses idiomas, também tem inferência de tipo. O exemplo mais simples possível é o var
Palavra -chave:
var x = 5; // int
var y = "foo"; // string
var z = 2 + 1.0; // double
var g = Guid.NewGuid(); // Guid
Ao fazer uma declaração combinada e operação de atribuição com o var
palavra -chave, você não precisa especificar o tipo de variável. O compilador é capaz de descobrir com base na expressão no lado direito.
Na mesma linha, o C# também permite a inicialização de uma matriz sem precisar especificar manualmente seu tipo:
var array = new() {"Hello", "world"}; // string()
Aqui, o compilador pode ver que estamos inicializando a matriz com dois elementos de string, para que possa concluir com segurança que a matriz resultante é do tipo string()
. Em alguns casos (muito raros), pode até inferir o tipo de matriz com base no tipo comum mais específico entre os elementos individuais:
var array = new() {1, 2, 3.0}; // double()
No entanto, o aspecto mais interessante da inferência de tipo em C# é, obviamente, métodos genéricos. Ao chamar um método com uma assinatura genérica, podemos omitir argumentos do tipo se eles puderem ser deduzidos dos valores passados para os parâmetros do método.
Por exemplo, podemos definir um método genérico List.Create<T>(...)
Isso cria uma lista de uma sequência de elementos:
public static class List
{
public static List<T> Create<T>(params T() items) => new List<T>(items);
}
Que por sua vez podem ser usados assim:
var list = List.Create(1, 3, 5); // List<int>
No cenário acima, poderíamos ter especificado o tipo explicitamente escrevendo List.Create<int>(...)
mas não precisamos. O compilador conseguiu detectá -lo automaticamente com base nos argumentos passados para o método, que são restringidos pelo mesmo tipo que a própria lista retornada.
Curiosamente, todos os exemplos mostrados acima são de fato baseados na mesma forma de inferência de tipo, que funciona analisando as restrições impostas por outras expressões, cujo tipo já é conhecido. Em outras palavras, examina o fluxo de dados que entra e tira conclusões sobre os dados que sai.
Existem cenários, no entanto, onde podemos querer que o tipo de inferência para trabalhar na direção oposta. Vamos ver onde isso pode ser útil.
Digite inferência para contêineres de opções
Se você está escrevendo código em um estilo funcional antes, é muito provável que esteja intimamente familiarizado com o Option<T>
tipo. É um contêiner que encapsula um único valor (ou ausência do mesmo) e nos permite executar várias operações no conteúdo sem realmente observar seu estado.
Em C#, um tipo de opção geralmente é definido encapsulando dois campos – um valor genérico e um sinalizador que indica se esse valor está realmente definido. Pode parecer algo assim:
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue;
private Option(T value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public Option(T value)
: this(value, true)
{
}
public TOut Match<TOut>(Func<T, TOut> some, Func<TOut> none) =>
_hasValue ? some(_value) : none();
public void Match(Action<T> some, Action none)
{
if (_hasValue)
some(_value);
else
none();
}
public Option<TOut> Select<TOut>(Func<T, TOut> map) =>
_hasValue ? new Option<TOut>(map(_value)) : new Option<TOut>();
public Option<TOut> Bind<TOut>(Func<T, Option<TOut>> bind) =>
_hasValue ? bind(_value) : new Option<TOut>();
}
Este design da API é bastante básico. A implementação acima esconde o valor subjacente dos consumidores, aparecendo apenas através do Match(...)
Método, que desenrola o contêiner, lidando com os dois estados em potencial. Além disso, existem Select(...)
e Bind(...)
Métodos que podem ser usados para transformar com segurança o valor, independentemente de ter sido definido ou não.
Além disso, neste exemplo, Option<T>
é definido como um readonly struct
. Visto que retornou principalmente dos métodos e mencionados nos escopos locais, essa decisão faz sentido do ponto de vista de desempenho.
Apenas para tornar as coisas convenientes, também podemos querer fornecer métodos de fábrica que ajudem os usuários a construir novas instâncias de Option<T>
mais naturalmente:
public static class Option
{
public static Option<T> Some<T>(T value) => new Option<T>(value);
public static Option<T> None<T>() => new Option<T>();
}
Que pode ser usado assim:
public static Option<int> Parse(string number)
{
return int.TryParse(number, out var value)
? Option.Some(value)
: Option.None<int>();
}
Como você pode ver, no caso de Option.Some<T>(...)
fomos capazes de abandonar o argumento genérico porque o compilador poderia inferir do tipo de value
que é int
. Por outro lado, o mesmo não funcionaria com Option.None<T>(...)
Porque não possui parâmetros, por isso o tipo precisava ser especificado manualmente.
Mesmo que o tipo de argumento para Option.None<T>(...)
Parece ser inerentemente óbvio do contexto, o compilador não é capaz de deduzi -lo. Isso ocorre porque, como mencionado anteriormente, o tipo de inferência em C# só funciona analisando os dados que fluem e não o contrário.
Obviamente, idealmente, queremos que o compilador descobrisse o tipo de T
em Option.None<T>(...)
com base no Tipo de retorno esta expressão é esperado ter, conforme ditado pela assinatura do método contendo. Caso contrário, queremos que pelo menos obtenha o T
desde o primeiro ramo da expressão condicional, onde já foi deduzido de value
.
Infelizmente, nenhum deles é possível com o sistema de tipo C#, porque precisaria descobrir os tipos ao contrário, o que é algo que ele não pode fazer. Dito isto, podemos ajudar um pouco.
Podemos simular Inferência do tipo alvo por ter Option.None()
devolver um tipo não genérico especial, representando uma opção com inicialização diferida que pode ser coagida a Option<T>
. Veja como isso seria:
public readonly struct Option<T>
{
private readonly T _value;
private readonly bool _hasValue;
private Option(T value, bool hasValue)
{
_value = value;
_hasValue = hasValue;
}
public Option(T value)
: this(value, true)
{
}
// ...
public static implicit operator Option<T>(NoneOption none) => new Option<T>();
}
public readonly struct NoneOption
{
}
public static class Option
{
public static Option<T> Some<T>(T value) => new Option<T>(value);
public static NoneOption None { get; } = new NoneOption();
}
Com essas mudanças, Option.None
agora retorna um manequim NoneOption
Objeto, que é essencialmente uma opção vazia cujo tipo ainda não foi decidido. Porque NoneOption
Não é genérico, também conseguimos abandonar os genéricos do método de fábrica correspondente e transformá -lo em uma propriedade.
Além disso, fizemos isso Option<T>
implementa uma conversão implícita de NoneOption
. Embora os próprios operadores não possam ser genéricos em C#, eles ainda mantêm argumentos do tipo todo possível variante de Option<T>
.
Tudo isso nos permite escrever Option.None
e peça ao compilador que o coage automaticamente para o tipo de destino. Do ponto de vista do consumidor, parece que implementamos com sucesso a inferência do tipo Target:
public static Option<int> Parse(string number)
{
return int.TryParse(number, out var value)
? Option.Some(value)
: Option.None;
}
Digite inferência para contêineres de resultado
Assim como fizemos com Option<T>
podemos querer aplicar o mesmo tratamento a Result<TOk, TError>
. Esse tipo atende a um propósito semelhante, exceto que também possui um valor de pleno direito que representa o caso negativo, fornecendo informações adicionais sobre o erro.
Veja como poderíamos implementá -lo:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
// ...
}
public static class Result
{
public static Result<TOk, TError> Ok<TOk, TError>(TOk ok) =>
new Result<TOk, TError>(ok);
public static Result<TOk, TError> Error<TOk, TError>(TError error) =>
new Result<TOk, TError>(error);
}
E aqui está como seria usado:
public static Result<int, string> Parse(string input)
{
return int.TryParse(input, out var value)
? Result.Ok<int, string>(value)
: Result.Error<int, string>("Invalid value");
}
Como você pode ver, a situação em relação à inferência de tipo é ainda mais terrível aqui. Nenhum Result.Ok<TOk, TError>(...)
nem Result.Error<TOk, TError>(...)
têm parâmetros suficientes para inferir os dois argumentos genéricos, por isso somos forçados a especificá -los manualmente nos dois casos.
Ter que escrever esses tipos sempre leva a ruído visual, duplicação de código e má experiência de desenvolvedor em geral. Vamos tentar corrigir isso usando a mesma técnica de anterior:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
public static implicit operator Result<TOk, TError>(DelayedResult<TOk> ok) =>
new Result<TOk, TError>(ok.Value);
public static implicit operator Result<TOk, TError>(DelayedResult<TError> error) =>
new Result<TOk, TError>(error.Value);
}
public readonly struct DelayedResult<T>
{
public T Value { get; }
public DelayedResult(T value)
{
Value = value;
}
}
public static class Result
{
public static DelayedResult<TOk> Ok<TOk>(TOk ok) =>
new DelayedResult<TOk>(ok);
public static DelayedResult<TError> Error<TError>(TError error) =>
new DelayedResult<TError>(error);
}
Aqui nós também definimos DelayedResult<T>
que representa a parte inicializada de Result<TOk, TError>
. Novamente, estamos usando operadores de conversão implícitos para coagir a instância atrasada no tipo de destino.
Fazer tudo o que nos permite reescrever nosso código como este:
public static Result<int, string> Parse(string input)
{
return int.TryParse(input, out var value)
? (Result<int, string>) Result.Ok(value)
: Result.Error("Invalid value");
}
Isso é um pouco melhor, mas não é o ideal. O problema aqui é que a expressão condicional em C# não coagia seus ramos diretamente para o tipo esperado, mas, em vez disso, tenta converter primeiro o tipo de ramificação negativa no tipo de ramificação positiva. Por causa disso, precisamos lançar explicitamente o ramo positivo em Result<int, string>
para especificar o denominador comum.
No entanto, esse problema pode ser completamente evitado se usarmos uma declaração condicional:
public static Result<int, string> Parse(string input)
{
if (int.TryParse(input, out var value))
return Result.Ok(value);
return Result.Error("Invalid value");
}
Estou muito mais satisfeito com esta configuração. Conseguimos abandonar completamente os argumentos genéricos, mantendo a mesma assinatura e segurança de segurança de antes. Novamente, de uma perspectiva de alto nível, isso pode parecer que os argumentos genéricos foram de alguma forma inferidos do tipo de retorno esperado.
No entanto, você deve ter notado que há um bug na implementação. Se os tipos de TOk
e TError
são iguais, há uma ambiguidade sobre qual estado DelayedResult<T>
realmente representa.
Por exemplo, imagine que estávamos usando nosso tipo de resultado no seguinte cenário:
public interface ITranslationService
{
Task<bool> IsLanguageSupportedAsync(string language);
Task<string> TranslateAsync(string text, string targetLanguage);
}
public class Translator
{
private readonly ITranslationService _translationService;
public Translator(ITranslationService translationService)
{
_translationService = translationService;
}
public async Task<Result<string, string>> TranslateAsync(string text, string language)
{
if (!await _translationService.IsLanguageSupportedAsync(language))
return Result.Error($"Language {language} is not supported");
var translated = await _translationService.TranslateAsync(text, language);
return Result.Ok(translated);
}
}
Aqui Result.Error<TError>(...)
e Result.Ok<TOk>(...)
ambos retornam DelayedResult<string>
então o compilador luta para descobrir o que fazer com isso:
Cannot convert expression type 'DelayedResult<string>' to return type 'Result<string,string>'
Felizmente, a correção é simples – só precisamos representar cada um dos estados individuais separadamente:
public readonly struct Result<TOk, TError>
{
private readonly TOk _ok;
private readonly TError _error;
private readonly bool _isError;
private Result(TOk ok, TError error, bool isError)
{
_ok = ok;
_error = error;
_isError = isError;
}
public Result(TOk ok)
: this(ok, default, false)
{
}
public Result(TError error)
: this(default, error, true)
{
}
public static implicit operator Result<TOk, TError>(DelayedOk<TOk> ok) =>
new Result<TOk, TError>(ok.Value);
public static implicit operator Result<TOk, TError>(DelayedError<TError> error) =>
new Result<TOk, TError>(error.Value);
}
public readonly struct DelayedOk<T>
{
public T Value { get; }
public DelayedOk(T value)
{
Value = value;
}
}
public readonly struct DelayedError<T>
{
public T Value { get; }
public DelayedError(T value)
{
Value = value;
}
}
public static class Result
{
public static DelayedOk<TOk> Ok<TOk>(TOk ok) =>
new DelayedOk<TOk>(ok);
public static DelayedError<TError> Error<TError>(TError error) =>
new DelayedError<TError>(error);
}
Voltando ao código anterior, agora funcionará exatamente como o esperado:
public class Translator
{
private readonly ITranslationService _translationService;
public Translator(ITranslationService translationService)
{
_translationService = translationService;
}
public async Task<Result<string, string>> TranslateAsync(string text, string language)
{
if (!await _translationService.IsLanguageSupportedAsync(language))
return Result.Error($"Language {language} is not supported");
var translated = await _translationService.TranslateAsync(text, language);
return Result.Ok(translated);
}
}
Resumo
Embora o tipo de inferência no C# tenha seus limites, podemos empurrá -los um pouco mais com a ajuda de operadores de conversão implícitos. Usando um truque simples mostrado neste artigo, podemos simular a inferência do tipo alvo, permitindo algumas oportunidades de design potencialmente interessantes. Solana Token Creator

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.