Tutorial: Explorar o recurso C# 11 – membros virtuais estáticos em interfaces

O C# 11 e o .NET 7 incluem membros virtuais estáticos em interfaces. Esse recurso permite definir interfaces que incluem operadores sobrecarregados ou outros membros estáticos. Depois de definir interfaces com membros estáticos, você pode usar essas interfaces como restrições para criar tipos genéricos que usam operadores ou outros métodos estáticos. Mesmo que você não crie interfaces com operadores sobrecarregados, provavelmente se beneficiará desse recurso e das classes matemáticas genéricas habilitadas pela atualização da linguagem.

Neste tutorial, você aprenderá como:

  • Definir interfaces com membros estáticos.
  • Use interfaces para definir classes que implementam interfaces com operadores definidos.
  • Crie algoritmos genéricos que dependem de métodos de interface estática.

Pré-requisitos

Você precisará configurar seu computador para executar o .NET 7, que dá suporte ao C# 11. O compilador C# 11 está disponível a partir do Visual Studio 2022, versão 17.3 ou do .NET 7 SDK.

Métodos de interface abstrato estáticos

Vamos começar com um exemplo. O método a seguir retorna o ponto médio de dois números double:

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

A mesma lógica funcionaria para qualquer tipo numérico: int, short, long, float, decimal ou qualquer tipo que represente um número. Você precisa ter uma maneira de usar os operadores + e /, e definir um valor para 2. Você pode usar a interface System.Numerics.INumber<TSelf> para escrever o método anterior como o seguinte 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

Qualquer tipo que implemente a interface INumber<TSelf> deve incluir uma definição para operator + e operator /. O denominador é definido por T.CreateChecked(2) para criar o valor 2 para qualquer tipo numérico, o que força o denominador a ser do mesmo tipo que os dois parâmetros. INumberBase<TSelf>.CreateChecked<TOther>(TOther) cria uma instância do tipo do valor especificado e gera um OverflowException se o valor ficar fora do intervalo representável. (Essa implementação tem o potencial de estouro se left e right são valores grandes o suficiente. Há algoritmos alternativos que podem evitar esse possível problema.)

Você define membros abstratos estáticos em uma interface usando a sintaxe familiar: você adiciona os modificadores static e abstract a qualquer membro estático que não forneça uma implementação. O exemplo a seguir define uma interface IGetNext<T> que pode ser aplicada a qualquer tipo que substitua operator ++:

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

A restrição de que o argumento de tipo, T implementa IGetNext<T>, garante que a assinatura do operador inclua o tipo que contém ou seu argumento de tipo. Muitos operadores impõem que seus parâmetros devem corresponder ao tipo ou ser o parâmetro de tipo restrito para implementar o tipo que contém. Sem essa restrição, o operador ++ não pôde ser definido na interface IGetNext<T>.

Você pode criar uma estrutura que cria uma cadeia de caracteres 'A' em que cada incremento adiciona outro caractere à cadeia de caracteres usando o seguinte código:

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

De modo mais geral, você pode criar qualquer algoritmo em que queira definir ++ para significar "produzir o próximo valor desse tipo". O uso dessa interface produz código e resultados claros:

var str = new RepeatSequence();

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

O código anterior gerencia a saída a seguir:

A
AA
AAA
AAAA
AAAAA
AAAAAA
AAAAAAA
AAAAAAAA
AAAAAAAAA
AAAAAAAAAA

Este pequeno exemplo demonstra a motivação para esse recurso. Você pode usar sintaxe natural para operadores, valores constantes e outras operações estáticas. Você pode explorar essas técnicas ao criar vários tipos que dependem de membros estáticos, incluindo operadores sobrecarregados. Defina as interfaces que correspondem aos recursos de seus tipos e declare o suporte desses tipos para a nova interface.

Matemática genérica

O cenário motivador para permitir métodos estáticos, incluindo operadores, em interfaces é dar suporte a algoritmos matemáticos genéricos. A biblioteca de classes base do .NET 7 contém definições de interface para muitos operadores aritméticos e interfaces derivadas que combinam muitos operadores aritméticos em uma interface INumber<T>. Vamos aplicar esses tipos para criar um registro Point<T> que possa usar qualquer tipo numérico para T. O ponto pode ser movido por alguns XOffset e YOffset usando o operador +.

Comece criando um novo aplicativo console usando dotnet new ou o Visual Studio.

A interface pública para o Translation<T> e Point<T> deve se parecer com o seguinte código:

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

Você usa o tipo record para os tipos Translation<T> e Point<T>: ambos armazenam dois valores e eles representam o armazenamento de dados em vez de um comportamento sofisticado. A implementação de operator + seria semelhante ao seguinte código:

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 o código anterior seja compilado, você precisará declarar que T dá suporte à interface IAdditionOperators<TSelf, TOther, TResult>. Essa interface inclui o método estático operator +. Ele declara três parâmetros de tipo: um para o operando à esquerda, um para o operando à direita e outro para o resultado. Alguns tipos implementam + para diferentes tipos de operando e de resultado. Adicione uma declaração de que o argumento T de tipo implementa IAdditionOperators<T, T, T>:

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

Depois de adicionar essa restrição, sua classe Point<T> poderá usar o + para seu operador de adição. Adicione a mesma restrição à declaração Translation<T>:

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

A restrição IAdditionOperators<T, T, T> impede que um desenvolvedor que usa sua classe crie um Translation usando um tipo que não atenda à restrição para a adição a um ponto. Você adicionou as restrições necessárias ao parâmetro de tipo para Translation<T> e Point<T>, portanto, esse código funciona. Você pode testar adicionando código como o seguinte acima das declarações de Translation e Point em seu arquivo 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);

Você pode tornar esse código mais reutilizável declarando que esses tipos implementam as interfaces aritméticas apropriadas. A primeira alteração a ser executada é declarar que Point<T, T> implementa a interface IAdditionOperators<Point<T>, Translation<T>, Point<T>>. O tipo Point usa diferentes tipos para operandos e o resultado. O tipo Point já implementa um operator + com essa assinatura, portanto, adicionar a interface à declaração é tudo o que você precisa:

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

Por fim, ao executar a adição, é útil ter uma propriedade que defina o valor de identidade aditivo para esse tipo. Há uma nova interface para esse recurso: IAdditiveIdentity<TSelf,TResult>. Uma tradução de {0, 0} é a identidade aditiva: o ponto resultante é o mesmo que o operando à esquerda. A interface IAdditiveIdentity<TSelf, TResult> define uma propriedade readonly, AdditiveIdentity, que retorna o valor de identidade. A Translation<T> precisa de algumas alterações para implementar essa interface:

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

Há algumas alterações aqui, então vamos analisá-las uma por uma. Primeiro, você declara que o tipo Translation implementa a interface IAdditiveIdentity:

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

Em seguida, você pode tentar implementar o membro da interface, conforme mostrado no seguinte código:

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

O código anterior não será compilado, pois 0 depende do tipo. A resposta: use IAdditiveIdentity<T>.AdditiveIdentity para 0. Essa alteração significa que suas restrições agora devem incluir essa T implementa IAdditiveIdentity<T>. Isso resulta na seguinte implementação:

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

Agora que você adicionou essa restrição em Translation<T>, precisa adicionar a mesma restrição para 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 exemplo deu uma olhada em como as interfaces para composição matemática genérica. Você aprendeu a:

  • Escrever um método que dependia da interface INumber<T> para que esse método pudesse ser usado com qualquer tipo numérico.
  • Criar um tipo que depende das interfaces de adição para implementar um tipo que dá suporte apenas a uma operação matemática. Que o tipo declara seu suporte para essas mesmas interfaces para que ele possa ser composto de outras maneiras. Os algoritmos são escritos usando a sintaxe mais natural dos operadores matemáticos.

Experimente esses recursos e registre comentários. Você pode usar o item de menu Enviar Comentários no Visual Studio ou criar um novo problema no repositório roslyn no GitHub. Crie algoritmos genéricos que funcionam com qualquer tipo numérico. Criar algoritmos usando essas interfaces em que o argumento de tipo só pode implementar um subconjunto de recursos semelhantes a números. Mesmo que você não crie novas interfaces que usam essas funcionalidades, pode experimentar usá-las em seus algoritmos.

Confira também