Generic math e membros static abstract em interfaces
Objective
Antes do .NET 7, um método que somava números precisava de uma sobrecarga por
tipo, ou de um desvio por object com casts, porque um parâmetro de tipo T
não tinha +. Generic math resolve isso com duas peças que chegaram juntas: o
C# 11 permite que uma interface declare membros static abstract e
static virtual (operadores inclusive), e o .NET 7 trouxe uma família de
interfaces em System.Numerics que todos os tipos numéricos nativos
implementam. Este conceito mostra como escolher a menor constraint para um
algoritmo, como encaixar um tipo seu, onde divisão inteira e overflow mordem em
código genérico e o que o JIT realmente produz. O mecanismo de static abstract
foi apresentado no conceito de interfaces; aqui ele vai para o trabalho.
Use Cases
- Um único
Sum,AverageouClampparaint,double,decimal,BigIntegere os seus value types, no lugar de um bloco de sobrecargas. - Tipos de domínio (
Money,Vector2,Meters) participando dos mesmos algoritmos que os números nativos, implementando só os operadores que usam. - Código de biblioteca que faz parse, converte e calcula sem saber o tipo
numérico exato (
IParsable<T>,CreateChecked). - Manipulação de bits escrita uma vez para inteiros de qualquer largura
(
IBinaryInteger<T>).
Deep Dive
A família de interfaces
As interfaces vêm em camadas. As mais grossas descrevem um tipo de número; as mais finas descrevem uma única capacidade:
| Interface | O que oferece |
|---|---|
INumberBase<T> |
T.Zero, T.One, CreateChecked/Saturating/Truncating, IsNaN, IsInteger e os operadores básicos |
INumber<T> |
acrescenta comparação: T.Clamp, T.Max, T.Min, T.Sign |
IBinaryInteger<T> |
PopCount, LeadingZeroCount, RotateLeft, DivRem |
IFloatingPointIeee754<T> |
T.NaN, T.Pi, T.Epsilon, funções trigonométricas e logaritmos |
IAdditionOperators<TSelf, TOther, TResult> e irmãs |
um operador cada: +, -, *, /, %, <<, ==, < |
IAdditiveIdentity<TSelf, TResult> |
T.AdditiveIdentity (o zero do +) |
As interfaces de operador têm três parâmetros de tipo para que entrada e
resultado possam ser diferentes, e de propósito não agrupam operações: a
documentação cita Matrix4x4, que tem * mas não tem /. Tudo isso fica em
System.Numerics, menos as interfaces de parse (IParsable<T>), que estão em
System e apareceram no conceito de parsing.
Um Sum genérico: peça o mínimo
INumber<T> é a constraint óbvia, mas uma soma só precisa de + e de um zero.
Pedir exatamente isso deixa entrar tipos que não são "números":
csharpusing System.Numerics;
static T Sum<T>(IEnumerable<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (var v in values) total += v;
return total;
}
readonly record struct Money(long Cents)
: IAdditionOperators<Money, Money, Money>, IAdditiveIdentity<Money, Money>
{
public static Money operator +(Money a, Money b) => new(a.Cents + b.Cents);
public static Money AdditiveIdentity => new(0);
}
Sum<int>([1, 2, 3]) // 6
Sum<double>([0.5, 0.25]) // 0.75
Sum<decimal>([0.1m, 0.2m]) // 0.3
Sum<BigInteger>([BigInteger.Pow(2, 100), 1]) // 1267650600228229401496703205377
Sum<Money>([new(1050), new(250)]) // Money { Cents = 1300 }T.AdditiveIdentity e + são membros estáticos acessados pelo parâmetro de
tipo. Money não é um número, não tem / nem comparação, e mesmo assim
funciona porque implementa só as duas capacidades que o algoritmo pediu.
total += v compila porque a atribuição composta usa o + que a constraint
garante.
As interfaces mais grossas compensam quando você precisa de muitas capacidades ao mesmo tempo:
csharpstatic T Clamp01<T>(T v) where T : INumber<T> => T.Clamp(v, T.Zero, T.One);
static T Bits<T>(T v) where T : IBinaryInteger<T> => T.PopCount(v);
Clamp01(1.7) + " " + Clamp01(-2f) + " " + Clamp01(0.5m) // 1 0 0.5
Bits(255) + " " + Bits(255UL) + " " + Bits((byte)7) // 8 8 3Misturando tipos: a armadilha da divisão inteira
Código genérico executa os operadores do tipo que recebe, divisão inteira inclusive:
csharpstatic T Average<T>(IReadOnlyList<T> values) where T : INumber<T>
=> Sum(values) / T.CreateChecked(values.Count);
static TResult AverageAs<T, TResult>(IReadOnlyList<T> values)
where T : INumber<T> where TResult : INumber<TResult>
{
TResult total = TResult.Zero;
foreach (var v in values) total += TResult.CreateChecked(v);
return total / TResult.CreateChecked(values.Count);
}
Average<int>([1, 2]) // 1 (divisão de int)
AverageAs<int, double>([1, 2]) // 1.5
AverageAs<int, long>([int.MaxValue, int.MaxValue]) // 2147483647, sem overflowUm segundo parâmetro de tipo para o resultado é o padrão que a documentação
oficial usa para médias. Acumular num TResult mais largo também elimina o
overflow que Average<int> sofreria com valores grandes. CreateChecked é a
conversão que lança exceção quando o valor não cabe; as variantes saturating e
truncating são comparadas no conceito de conversões numéricas.
checked em código genérico
Uma expressão checked dentro de um método genérico chama o operador checked
do tipo. IAdditionOperators declara esse operador como static virtual,
então um tipo que escreve só o + continua satisfazendo a constraint, e num
contexto checked é o + comum dele que roda:
csharpstatic T SumChecked<T>(IEnumerable<T> values)
where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
T total = T.AdditiveIdentity;
foreach (var v in values) total = checked(total + v);
return total;
}
readonly record struct SafeMoney(long Cents) /* mesmas interfaces de Money */
{
public static SafeMoney operator +(SafeMoney a, SafeMoney b) => new(unchecked(a.Cents + b.Cents));
public static SafeMoney operator checked +(SafeMoney a, SafeMoney b) => new(checked(a.Cents + b.Cents));
public static SafeMoney AdditiveIdentity => new(0);
}
Sum<int>([int.MaxValue, 1]) // -2147483648 (dá a volta)
SumChecked<int>([int.MaxValue, 1]) // OverflowException
SumChecked<double>([double.MaxValue, double.MaxValue]) // Infinity
SumChecked<Money>([new(long.MaxValue), new(1)]) // Money { Cents = -9223372036854775808 }
SumChecked<SafeMoney>([new(long.MaxValue), new(1)]) // OverflowExceptionchecked não muda nada para double, e só protege o seu tipo se ele definir
operator checked. Escrever checked no algoritmo é um pedido; cada tipo
decide se atende.
O que o JIT gera
O conceito de generics mostrou que o runtime compila uma cópia separada do
código genérico para cada value type. É isso que deixa generic math barato.
Para este Sum<T>(ReadOnlySpan<T>) com constraint INumberBase<T>, o
DOTNET_JitDisasm num Mac com Apple Silicon (Release, tiered compilation
desligada) mostrou a instanciação de int como um loop simples (recortado,
comentários meus):
text; Assembly listing for method Program:<<Main>$>g__Sum|0_0[int](System.ReadOnlySpan`1[int]):int (FullOpts) mov w2, wzr ; T.Zero G_M000_IG04: ldr w4, [x0, x3] add w2, w4, w2 ; total += v add x3, x3, #4 sub w1, w1, #1 cbnz w1, G_M000_IG04
Nenhuma chamada de interface, nenhum boxing: GC.GetAllocatedBytesForCurrentThread()
em volta de uma chamada sobre 1.000 ints marcou 0 bytes. A referência do C#
explica o motivo: chamadas a membros static abstract não têm despacho em
runtime; são resolvidas a partir do argumento de tipo.
Dentro do LINQ
Enumerable.Sum usa a mesma maquinaria. No código-fonte do .NET 10, as
sobrecargas para IEnumerable<int>, long, float, double e decimal
tentam obter um span da fonte e, quando conseguem, chamam um único
Sum<T, TResult>(ReadOnlySpan<T>) privado com constraint INumber<T>. Esse
método vetoriza só int e long (quando há aceleração de hardware e o span é
grande o bastante) e mantém a checagem de overflow em todos os caminhos. Por
isso new[] { int.MaxValue, 1 }.Sum() lança OverflowException, enquanto um
loop += escrito à mão dá a volta.
O que o compilador recusa
csharpList<INumber<int>> list = [];
// error CS8920: The interface 'INumber<int>' cannot be used as type argument.
// Static member 'IParsable<int>.Parse(string, IFormatProvider?)' does not have
// a most specific implementation in the interface.
var z = INumber<int>.Zero;
// error CS8926: A static virtual or abstract interface member can be accessed
// only on a type parameter.Essas interfaces são constraints, não tipos para variáveis ou coleções. Se o que você quer é "uma lista de números de qualquer tipo", generic math é a ferramenta errada.
Trade-offs
- Tamanho da constraint versus alcance.
INumber<T>é fácil de escrever, mas exclui tipos comoMoney. Interfaces de operador finas alcançam mais tipos e deixam as assinaturas longas.csharpwhere T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T> // Money entra where T : INumber<T> // Money fica de fora - A semântica segue o tipo. O mesmo código trunca a divisão para
int, ignoracheckedparadoublee dá a volta ou lança exceção no seu tipo conforme os operadores dele. Teste o algoritmo com cada tipo que você promete suportar. - Implementar
INumber<T>você mesmo é grande. Ele puxa parse, formatação, comparação e uma dúzia de operadores. Implemente só as interfaces pequenas que os seus algoritmos usam. - Não é ferramenta de polimorfismo. Sem despacho em runtime e sem variáveis
INumber<int>(CS8920, CS8926). Código que precisa lidar com números cujo tipo só se conhece em runtime continua precisando de sobrecargas,switchoudecimalem tudo. - Código de aplicação raramente precisa. A documentação diz que quem mais ganha são autores de bibliotecas. Para um tipo concreto só, um método não genérico é mais fácil de ler.
Documentation Links
- Generic math, .NET, Microsoft Learn
- interface keyword: static abstract and virtual members, C# reference, Microsoft Learn
- Tutorial: Explore static virtual members in interfaces, Microsoft Learn
- What's new in C# 11, Microsoft Learn
- INumber<TSelf> interface, Microsoft Learn
- IAdditionOperators<TSelf,TOther,TResult>.CheckedAddition, Microsoft Learn