Tutorial: Exploración de características de C# 11: miembros virtuales estáticos en interfaces

C# 11 y .NET 7 incluyen miembros virtuales estáticos en interfaces. Esta característica permite definir interfaces que incluyen operadores sobrecargados u otros miembros estáticos. Una vez que haya definido interfaces con miembros estáticos, puede usar esas interfaces como restricciones para crear tipos genéricos que usen operadores u otros métodos estáticos. Incluso si no crea interfaces con operadores sobrecargados, es probable que se beneficie de esta característica y de las clases matemáticas genéricas habilitadas por la actualización del lenguaje.

En este tutorial, aprenderá a:

  • Definir interfaces con miembros estáticos.
  • Usar interfaces para definir clases que implementan interfaces con operadores definidos.
  • Crear algoritmos genéricos que se basen en métodos de interfaz estática.

Requisitos previos

Deberá configurar la máquina para ejecutar .NET 7, que admite C# 11. El compilador de C# 11 está disponible a partir de la versión 17.3 de Visual Studio 2022 o del SDK de .NET 7.

Métodos de interfaz abstracta estática

Comencemos con un ejemplo. El método siguiente devuelve el punto medio de dos números double:

public static double MidPoint(double left, double right) =>
    (left + right) / (2.0);

La misma lógica funcionaría para cualquier tipo numérico: int, short, long, floatdecimal o cualquier tipo que represente un número. Debe tener una manera de usar los operadores + y /, y de definir un valor para 2. Puede usar la interfaz System.Numerics.INumber<TSelf> para escribir el método anterior como el siguiente método genérico:

public static T MidPoint<T>(T left, T right)
    where T : INumber<T> => (left + right) / T.CreateChecked(2);  // note: the addition of left and right may overflow here; it's just for demonstration purposes

Cualquier tipo que implemente la interfaz INumber<TSelf> debe incluir una definición para operator + y operator /. El denominador se define mediante T.CreateChecked(2) a fin de crear el valor 2 para cualquier tipo numérico, lo que obliga al denominador a ser del mismo tipo que los dos parámetros. INumberBase<TSelf>.CreateChecked<TOther>(TOther) crea una instancia del tipo a partir del valor especificado y genera un elemento OverflowException si el valor está fuera del intervalo que se puede representar. (Esta implementación tiene el potencial de desbordamiento si left y right son valores lo suficientemente grandes. Hay algoritmos alternativos que pueden evitar este posible problema).

Los miembros abstractos estáticos se definen en una interfaz con una sintaxis conocida: se agregan los modificadores static y abstract a cualquier miembro estático que no proporcione una implementación. En el ejemplo siguiente se define una interfaz IGetNext<T> que se puede aplicar a cualquier tipo que invalide operator ++:

public interface IGetNext<T> where T : IGetNext<T>
{
    static abstract T operator ++(T other);
}

La restricción de que el argumento de tipo, T, implementa IGetNext<T> garantiza que la firma del operador incluya el tipo contenedor o su argumento de tipo. Muchos operadores exigen que sus parámetros coincidan con el tipo o que sean el parámetro de tipo restringido para implementar el tipo contenedor. Sin esta restricción, el operador ++ no se podría definir en la interfaz IGetNext<T>.

Puede crear una estructura que cree una cadena de caracteres "A", donde cada incremento agregue otro carácter a la cadena mediante el código siguiente:

public struct RepeatSequence : IGetNext<RepeatSequence>
{
    private const char Ch = 'A';
    public string Text = new string(Ch, 1);

    public RepeatSequence() {}

    public static RepeatSequence operator ++(RepeatSequence other)
        => other with { Text = other.Text + Ch };

    public override string ToString() => Text;
}

Por lo general, puede crear cualquier algoritmo en el que quiera definir ++ para que "genere el siguiente valor de este tipo". El uso de esta interfaz genera código y resultados claros:

var str = new RepeatSequence();

for (int i = 0; i < 10; i++)
    Console.WriteLine(str++);

El ejemplo anterior genera el siguiente resultado:

A
AA
AAA
AAAA
AAAAA
AAAAAA
AAAAAAA
AAAAAAAA
AAAAAAAAA
AAAAAAAAAA

En este pequeño ejemplo se muestra la motivación de esta característica. Puede usar sintaxis natural para operadores, valores constantes y otras operaciones estáticas. Puede explorar estas técnicas al crear varios tipos que dependen de miembros estáticos, incluidos los operadores sobrecargados. Defina las interfaces que coincidan con las funcionalidades de los tipos y, a continuación, declare la compatibilidad de esos tipos con la nueva interfaz.

Matemáticas genéricas

El escenario que inspira el permitir métodos estáticos, incluidos los operadores, en interfaces es la compatibilidad con algoritmos matemáticos genéricos . La biblioteca de clases base de .NET 7.0 contiene definiciones de interfaz para muchos operadores aritméticos, así como interfaces derivadas que combinan muchos operadores aritméticos en una interfaz INumber<T>. Vamos a aplicar esos tipos para crear un registro Point<T> que pueda usar cualquier tipo numérico para T. Algunos objetos XOffset y YOffset pueden mover el punto con el operador +.

Empiece por crear una nueva aplicación de consola, ya sea mediante dotnet new o Visual Studio.

La interfaz pública de Translation<T> y Point<T> debe parecerse al código siguiente:

// Note: Not complete. This won't compile yet.
public record Translation<T>(T XOffset, T YOffset);

public record Point<T>(T X, T Y)
{
    public static Point<T> operator +(Point<T> left, Translation<T> right);
}

Use el tipo recordpara los tipos Translation<T> y Point<T>: ambos almacenan dos valores y representan el almacenamiento de datos en lugar de un comportamiento sofisticado. La implementación de operator + se parecería al código siguiente:

public static Point<T> operator +(Point<T> left, Translation<T> right) =>
    left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };

Para que el código anterior se compile, deberá declarar que T admite la interfaz IAdditionOperators<TSelf, TOther, TResult>. Esa interfaz incluye el método estático operator +. Declara tres parámetros de tipo: uno para el operando izquierdo, otro para el operando derecho y otro para el resultado. Algunos tipos implementan + para diferentes operandos y tipos de resultado. Agregue una declaración de que el argumento de tipo T implementa IAdditionOperators<T, T, T>:

public record Point<T>(T X, T Y) where T : IAdditionOperators<T, T, T>

Después de agregar esa restricción, la clase Point<T> puede usar + para su operador de suma. Agregue la misma restricción en la declaración Translation<T>:

public record Translation<T>(T XOffset, T YOffset) where T : IAdditionOperators<T, T, T>;

La restricción IAdditionOperators<T, T, T> impide que un desarrollador use la clase para crear un elemento Translation mediante un tipo que no cumpla la restricción de la suma a un punto. Ha agregado las restricciones necesarias al parámetro de tipo para Translation<T> y Point<T>, así que este código funciona. Para probarlo, agregue código similar al siguiente encima de las declaraciones de Translation y Point en el archivo Program.cs:

var pt = new Point<int>(3, 4);

var translate = new Translation<int>(5, 10);

var final = pt + translate;

Console.WriteLine(pt);
Console.WriteLine(translate);
Console.WriteLine(final);

Puede hacer que este código sea más reutilizable declarando que estos tipos implementan las interfaces aritméticas adecuadas. El primer cambio que se va a realizar es declarar que Point<T, T> implementa la interfaz IAdditionOperators<Point<T>, Translation<T>, Point<T>>. El tipo Point usa diferentes tipos para los operandos y el resultado. El tipo Point ya implementa un elemento operator + con esa firma, por lo que todo lo que debe hacer es agregar la interfaz a la declaración:

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>

Por último, al realizar la suma, resulta útil tener una propiedad que defina el valor de identidad aditivo de ese tipo. Hay una nueva interfaz para esa característica: IAdditiveIdentity<TSelf,TResult>. Una traducción de {0, 0} es la identidad aditiva: el punto resultante es el mismo que el operando izquierdo. La interfaz IAdditiveIdentity<TSelf, TResult> define una propiedad de solo lectura, AdditiveIdentity, que devuelve el valor de identidad. El elemento Translation<T> necesita algunos cambios para implementar esta interfaz:

using System.Numerics;

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Translation<T> AdditiveIdentity =>
        new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);
}

Hay algunos cambios aquí, así que vamos a revisarlos uno por uno. En primer lugar, se declara que el tipo Translation implementa la interfaz IAdditiveIdentity:

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>

A continuación, podría intentar implementar el miembro de interfaz tal y como se muestra en el código siguiente:

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: 0, YOffset: 0);

El código anterior no se compilará, ya que 0 depende del tipo. La respuesta: use IAdditiveIdentity<T>.AdditiveIdentity para 0. Este cambio significa que las restricciones deben incluir ahora que T implementa IAdditiveIdentity<T>. Esto da como resultado la siguiente implementación:

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);

Ahora que ha agregado esa restricción en Translation<T>, debe agregar la misma restricción a Point<T>:

using System.Numerics;

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Point<T> operator +(Point<T> left, Translation<T> right) =>
        left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };
}

Este ejemplo le ha permitido ver cómo se componen las interfaces matemáticas genéricas. Ha aprendido a:

  • Escribir un método que depende de la interfaz INumber<T>, por lo que se podría usar con cualquier tipo numérico.
  • Crear un tipo que se basa en las interfaces de suma para implementar un tipo que solo admita una operación matemática. Ese tipo declara su compatibilidad con esas mismas interfaces para que se pueda componer de otras maneras. Los algoritmos se escriben con la sintaxis más natural de los operadores matemáticos.

Experimente con estas características y registre comentarios. Puede usar el elemento de menú Enviar comentarios de Visual Studio o crear un nuevo problema en el repositorio roslyn de GitHub. Cree algoritmos genéricos que funcionen con cualquier tipo numérico. Cree algoritmos con estas interfaces en las que el argumento de tipo solo pueda implementar un subconjunto de funcionalidades de tipo numérico. Aunque no cree interfaces que usen estas capacidades, puede experimentar con ellas en los algoritmos.

Consulte también