Simulando la inferencia de tipo objetivo en C# • Oleksii Holub

Simulando la inferencia de tipo objetivo en C# • Oleksii Holub

 

 

Por encima de todo lo demás en el desarrollo de software, realmente disfruto construyendo marcos que permiten a otros desarrolladores crear algo genial. A veces, al perseguir ese diseño perfecto que tengo en mente, me encuentro con trucos extraños que llevan el lenguaje C# al límite.

Uno de esos casos sucedió no hace mucho tiempo, cuando intentaba descubrir cómo hacer que el compilador determine el tipo genérico de un método basado en su tipo de retorno esperado. Como C# solo puede inferir genéricos de los argumentos del método, esto inicialmente parecía imposible, sin embargo, pude encontrar una manera de hacerlo funcionar.

En este artículo mostraré un pequeño truco que se me ocurrió para simular la inferencia de tipo objetivo, así como algunos ejemplos de dónde puede ser útil.

Tipo de inferencia

La inferencia de tipo, en general, es la capacidad del compilador para detectar automáticamente el tipo de expresión particular, sin que el programador lo especifique explícitamente. Esta característica funciona analizando el contexto en el que se evalúa la expresión, así como las restricciones impuestas por el flujo de datos en el programa.

Al poder detectar el tipo automáticamente, los idiomas que admiten la inferencia del tipo permiten escribir un código más sucinto, al tiempo que mantienen los beneficios completos de un sistema de tipo estático. Esta es la razón por la cual la mayoría de los idiomas escrita estáticamente de la corriente principal tienen inferencia de tipo, en una forma u otra.

C#, siendo uno de esos idiomas, también tiene inferencia de tipo. El ejemplo más simple posible es el var palabra clave:

var x = 5;              // int
var y = "foo";          // string
var z = 2 + 1.0;        // double
var g = Guid.NewGuid(); // Guid

Al hacer una declaración combinada y una operación de asignación con el var Palabra clave, no necesita especificar el tipo de variable. El compilador puede resolverlo en función de la expresión en el lado derecho.

En una vena similar, C# también permite inicializar una matriz sin tener que especificar manualmente su tipo:

var array = new() {"Hello", "world"}; // string()

Aquí, el compilador puede ver que estamos inicializando la matriz con dos elementos de cadena, por lo que puede concluir de manera segura que la matriz resultante es de tipo string(). En algunos casos (muy raros), incluso puede inferir el tipo de una matriz basada en el tipo común más específico entre los elementos individuales:

var array = new() {1, 2, 3.0}; // double()

Sin embargo, el aspecto más interesante de la inferencia de tipos en C# son, por supuesto, métodos genéricos. Al llamar a un método con una firma genérica, podemos omitir argumentos de tipo si se pueden deducir de los valores pasados ​​a los parámetros del método.

Por ejemplo, podemos definir un método genérico List.Create<T>(...) Eso crea una lista de una secuencia de elementos:

public static class List
{
    public static List<T> Create<T>(params T() items) => new List<T>(items);
}

Que a su vez se puede usar así:

var list = List.Create(1, 3, 5); // List<int>

En el escenario anterior podríamos haber especificado el tipo explícitamente escribiendo List.Create<int>(...)pero no tuvimos que hacerlo. El compilador pudo detectarlo automáticamente en función de los argumentos pasados ​​al método, que están limitados por el mismo tipo que la lista devuelta en sí.

Curiosamente, todos los ejemplos mostrados anteriormente se basan en la misma forma de inferencia de tipo, que funciona analizando las restricciones impuestas por otras expresiones, cuyo tipo ya se conoce. En otras palabras, examina el flujo de datos que entrar y saca conclusiones sobre los datos que salir.

Sin embargo, hay escenarios en los que podemos querer que la inferencia de tipo funcione en la dirección opuesta. Veamos dónde podría ser útil.

Escriba inferencia para contenedores de opciones

Si ha estado escribiendo código en un estilo funcional antes, es muy probable que esté íntimamente familiarizado con el Option<T> tipo. Es un contenedor que encapsula un valor único (o ausencia del mismo) y nos permite realizar varias operaciones en el contenido sin observar realmente su estado.

En C#, un tipo de opción generalmente se define encapsulando dos campos: un valor genérico y un indicador que indica si ese valor realmente está establecido. Podría parecer algo así:

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 diseño de API es bastante básico. La implementación anterior oculta el valor subyacente de los consumidores, apareciéndolo solo a través del Match(...) Método, que desenvuelve el contenedor al manejar sus dos estados potenciales. Además, hay Select(...) y Bind(...) Métodos que se pueden usar para transformar el valor de manera segura, independientemente de si realmente se ha establecido o no.

Además, en este ejemplo, Option<T> se define como un readonly struct. Al ver que se devuelve principalmente de los métodos y se hace referencia en ámbitos locales, esta decisión tiene sentido desde un punto de vista de rendimiento.

Solo para que las cosas sean convenientes, también es posible que deseemos proporcionar métodos de fábrica que ayuden a los usuarios a construir nuevas instancias de Option<T> Más 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 se puede usar así:

public static Option<int> Parse(string number)
{
    return int.TryParse(number, out var value)
        ? Option.Some(value)
        : Option.None<int>();
}

Como puede ver, en caso de Option.Some<T>(...)pudimos dejar caer el argumento genérico porque el compilador podría inferirlo del tipo de valueque es int. Por otro lado, lo mismo no funcionaría con Option.None<T>(...) Debido a que no tiene ningún parámetro, por lo tanto, por qué el tipo debía especificarse manualmente.

Aunque el tipo de argumento de tipo para Option.None<T>(...) Parece ser inherentemente obvio desde el contexto, el compilador no puede deducirlo. Esto se debe a que, como se mencionó anteriormente, la inferencia de tipo en C# solo funciona analizando los datos que fluyen y no al revés.

Por supuesto, idealmente, queremos que el compilador descubra el tipo de T en Option.None<T>(...) basado en el tipo de retorno Esta expresión es esperado tener, según lo dictado por la firma del método de contención. Si no, quisiéramos que al menos obtenga el T de la primera rama de la expresión condicional, donde ya se infirió de value.

Desafortunadamente, ninguno de estos es posible con el sistema de tipo de C#porque necesitaría resolver los tipos en reversa, que es algo que no puede hacer. Dicho esto, podemos ayudarlo un poco.

Podemos simular inferencia de tipo objetivo al tener Option.None() devolver un tipo especial no genérico, que representa una opción con inicialización diferida que se puede coaccionar en Option<T>. Así es como se vería eso:

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

Con estos cambios, Option.None ahora devuelve un muñeco NoneOption Objeto, que es esencialmente una opción vacía cuyo tipo aún no se ha decidido. Porque NoneOption no es genérico, también pudimos eliminar los genéricos del método de fábrica correspondiente y convertirlo en una propiedad.

Además, lo hicimos así Option<T> implementa una conversión implícita de NoneOption. Aunque los propios operadores no pueden ser genéricos en C#, aún conservan argumentos de tipo del tipo de declaración, lo que nos permite definir esta conversión para todo lo posible variante de Option<T>.

Todo esto nos permite escribir Option.None y haga que el compilador lo coaccione automáticamente al tipo de destino. Desde el punto de vista del consumidor, parece que hemos implementado con éxito la inferencia de tipo objetivo:

public static Option<int> Parse(string number)
{
    return int.TryParse(number, out var value)
        ? Option.Some(value)
        : Option.None;
}

Tipo de inferencia para contenedores de resultados

Tal como lo hicimos con Option<T>es posible que deseemos aplicar el mismo tratamiento a Result<TOk, TError>. Este tipo cumple un propósito similar, excepto que también tiene un valor completamente incipiente que representa el caso negativo, proporcionando información adicional sobre el error.

Así es como podríamos implementarlo:

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

Y así es como se usaría entonces:

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 puede ver, la situación con respecto a la inferencia de tipo es aún más grave aquí. Ni Result.Ok<TOk, TError>(...) ni Result.Error<TOk, TError>(...) Tenga suficientes parámetros para inferir ambos argumentos genéricos, por lo que nos vemos obligados a especificarlos manualmente en ambos casos.

Tener que escribir este tipo cada vez conduce al ruido visual, la duplicación de código y la mala experiencia del desarrollador en general. Intentemos rectificar esto utilizando la misma técnica de antes:

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

Aquí hemos definido de manera similar DelayedResult<T> que representa la parte inicializada de Result<TOk, TError>. Nuevamente, estamos utilizando operadores de conversión implícitos para coaccionar la instancia retrasada en el tipo de destino.

Hacer todo lo que nos permite reescribir nuestro código así:

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

Esto es un poco mejor pero no ideal. El problema aquí es que la expresión condicional en C# no obliga a sus ramas directamente al tipo esperado, sino que intenta convertir primero el tipo de rama negativa en el tipo de rama positiva. Debido a eso, necesitamos lanzar explícitamente la rama positiva a Result<int, string> para especificar el denominador común.

Sin embargo, este problema puede evitarse por completo si solo usamos una declaración condicional: en su lugar:

public static Result<int, string> Parse(string input)
{
    if (int.TryParse(input, out var value))
        return Result.Ok(value);

    return Result.Error("Invalid value");
}

Estoy mucho más satisfecho con esta configuración. Pudimos eliminar por completo los argumentos genéricos, mientras manteníamos la misma firma y seguridad de tipo que antes. Nuevamente, desde una perspectiva de alto nivel, esto puede parecer que los argumentos genéricos se inferieron de alguna manera del tipo de retorno esperado.

Sin embargo, es posible que haya notado que hay un error en la implementación. Si los tipos de TOk y TError son los mismos, hay una ambigüedad sobre qué estado DelayedResult<T> realmente representa.

Por ejemplo, imagine que estábamos usando nuestro tipo de resultado en el siguiente escenario:

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

Aquí Result.Error<TError>(...) y Result.Ok<TOk>(...) ambos regresan DelayedResult<string>Entonces, el compilador lucha por descubrir qué hacer con él:

Cannot convert expression type 'DelayedResult<string>' to return type 'Result<string,string>'

Afortunadamente, la solución es simple: solo necesitamos representar a cada uno de los estados individuales por separado:

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

Volviendo al código de antes, ahora funcionará exactamente como se esperaba:

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

Resumen

Aunque la inferencia de tipo en C# tiene sus límites, podemos impulsarlos un poco más con la ayuda de operadores de conversión implícitos. Usando un truco simple que se muestra en este artículo, podemos simular la inferencia de tipo objetivo, lo que permite algunas oportunidades de diseño potencialmente interesantes. 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 *