Microsoft blijft het .NET-ecosysteem stap voor stap verbeteren, en één van de interessantere toevoegingen van de afgelopen jaren vond ik Generic Math in C# 11.
Met Generic Math kunnen ontwikkelaars generieke wiskundige bewerkingen uitvoeren op verschillende numerieke types, zoals int, double en zelfs eigen datatypes. Dit maakt het mogelijk om flexibele en herbruikbare code te schrijven zonder dat je voor elk type aparte implementaties hoeft te maken.
Dat klinkt misschien als een kleine verbetering, maar in de praktijk lost het een fundamenteel probleem op. Om dat goed te begrijpen, moeten we eerst kijken naar hoe dit er vóór C# 11 uitzag.
Het probleem vóór Generic Math
Stel dat je een simpele optelling wilt implementeren voor verschillende types. Dan kom je al snel uit op type-specifieke methodes:
public static double Add(double x, double y) => x + y; public static int Add(int x, int y) => x + y; public static float Add(float x, float y) => x + y;
Dit zorgt al snel voor veel herhaling in je code. Vooral in grotere codebases wordt dat niet alleen onoverzichtelijk, maar ook foutgevoelig en lastig te onderhouden.
Een veelgebruikte workaround is het gebruik van dynamic,
public static T Add<T>(T x, T y)
{
dynamic num1 = x;
dynamic num2 = y;
return (T)num1 + num2;
}
Op het eerste gezicht lijkt dit een nette oplossing: één methode die werkt voor meerdere types. Hoewel dit werkt, verlies je compile-time veiligheid. Code die compileert kan alsnog falen tijdens runtime:
Add([1, 2], [3, 5, 8]); // Runtime error
Add(new Person { Name = "Amy", Age = 20 },new Person { Name = "Bob", Age = 29 });
Deze voorbeelden compileren gewoon, maar zijn conceptueel onzin.
Vóór Generic Math moest je kiezen tussen:
- duplicatie van code
- flexibiliteit met dynamic, maar zonder type safety
En dat is precies het probleem dat Generic Math oplost.
Wiskundige interfaces
Generic Math introduceert een andere aanpak: kleine, gerichte interfaces die exact beschrijven welke operaties een type moet ondersteunen.
Simpel voorbeeld: Optelling
Heb je bijvoorbeeld alleen optelling nodig:
public static T Add<T>(T x, T y) where T : IAdditionOperators<T, T, T>
{
return x + y;
}
Deze constraint betekent simpelweg:
- dit type moet een +
operatorhebben.
Daardoor kunnen we zeker weten dat de methode de types succesvol kan optellen, in tegenstelling tot wat we zagen in de introductie.
Complexer voorbeeld: gemiddelden berekenen
Heb je meer nodig, dan kun je interfaces combineren:
public static T Average<T>(T[] values)
where T :
IAdditionOperators<T, T, T>,
IDivisionOperators<T, T, T>,
IAdditiveIdentity<T, T>
{
if (values.Length == 0)
return T.AdditiveIdentity;
T sum = T.AdditiveIdentity;
foreach (var v in values)
{
sum += v;
}
return sum / Create(values.Length);
}
Je specificeert dus precies wat je nodig hebt, niet meer en niet minder. Hier zeg je expliciet:
- Het type moet kunnen optellen
- Het type moet kunnen delen
- Het type moet een “nulwaarde” Zero hebben
NET biedt ook bredere interfaces zoals INumber<T> en INumberBase<T> die veel operaties combineren. In de praktijk wil je vaak kleinere, gerichte interfaces gebruiken om je code zo specifiek mogelijk te houden.
Waarom werkt dit? Static abstract members
Als je naar de vorige voorbeelden kijkt, gebeurt er iets wat vóór C# 11 niet mogelijk was:
We gebruiken de + operator op een generiek type T. Maar hoe weet de compiler dat T die operator heeft?
Generic Math is gebouwd op een feature die vóór C# 11 niet bestond: static abstract members in interfaces. Hiermee kun je afdwingen dat een type bepaalde statische functionaliteit implementeert, zoals we zien bij IAdditiveIdentity:
public interface IAdditiveIdentity<TSelf, TResult>
{
static abstract TResult AdditiveIdentity { get; }
}
Dit betekent:
- Elk type dat deze interface implementeert moet deze member implementeren
- de compiler controleert dit tijdens compile-time
Hetzelfde concept geldt voor IDivisionOperators die afdwingt dat een operator overload geïmplementeerd moet worden.
public interface IDivisionOperators<TSelf, TOther, TResult>
where TSelf : IDivisionOperators<TSelf, TOther, TResult>?
{
static abstract TResult operator /(TSelf left, TOther right);
}
Vóór de introductie van static abstract members in interfaces konden interfaces dit simpelweg niet afdwingen. Doordat interfaces voorheen uitsluitend instance members konden bevatten, was het niet mogelijk om statische contracten (zoals het verplicht stellen van operator overloads) via compile-time checks te garanderen.
Generic Math in de praktijk
Met deze bouwstenen kun je generieke code schrijven die werkt voor alle numerieke types.
Generieke optelling
public static T Add<T>(T left, T right) where T : IAdditionOperators<T, T, T>
{
return left + right;
}
// Gebruik:
Add(5, 10);
Add(3.5, 2.5);
Gemiddelde berekenen
public static T Average<T>(T[] values)
where T :
IAdditionOperators<T, T, T>,
IDivisionOperators<T, T, T>,
IAdditiveIdentity<T, T>
{
if (values.Length == 0)
return T.AdditiveIdentity;
T sum = T.AdditiveIdentity;
foreach (var v in values)
{
sum += v;
}
return sum / values.Length;
}
// Gebruik:
Average([1, 2, 3, 4]); // 2 Ints
Average([1.0, 2.0, 3.0, 4.0]); // 2.5 Doubles
Average([1.0, 2, 3.0f, 4]); // 2.5 Gemengde types zijn ook toegestaan
Deze methode werkt direct voor verschillende types, zoals je in de laatste regel ziet!
Eigen numerieke types: Fraction
De echte kracht van Generic Math zie je wanneer je eigen types toevoegt.
Bijvoorbeeld een Fraction:
public readonly struct Fraction :
IAdditionOperators<Fraction, Fraction, Fraction>,
IDivisionOperators<Fraction, Fraction, Fraction>,
IAdditiveIdentity<Fraction, Fraction>
{
public long Numerator { get; }
public long Denominator { get; }
public Fraction(long numerator, long denominator)
{
if (denominator == 0)
throw new DivideByZeroException();
Numerator = numerator;
Denominator = denominator;
}
// Vereist door IAdditiveIdentity<TSelf, TResult>
public static Fraction AdditiveIdentity => new Fraction(0, 1);
// Vereist door IAdditionOperators
public static Fraction operator +(Fraction left, Fraction right)
=> new Fraction(
left.Numerator * right.Denominator + right.Numerator * left.Denominator,
left.Denominator * right.Denominator
);
// Vereist door IDivisionOperators
public static Fraction operator /(Fraction left, Fraction right)
{
if (right.Numerator == 0)
throw new DivideByZeroException();
return new Fraction(
left.Numerator * right.Denominator,
left.Denominator * right.Numerator
);
}
}
Dit werkt niet automatisch. Fraction moet expliciet aangeven welke operatoren en statische members het ondersteunt. Doe je dat niet, dan grijpt de compiler direct in.
Ons eigen datatype kan nu als volgt gebruikt worden.
var fractions = new Fraction[]
{
new Fraction(1, 2),
new Fraction(3, 4),
new Fraction(5, 6)
};
Console.WriteLine(Average(fractions)); // 11/18
Het mooie van Generic Math is dat dit allemaal compile-time wordt afgedwongen. Fraction werkt hier omdat het expliciet ondersteunt wat Average nodig heeft. Probeer je een type te gebruiken dat dat niet doet, dan stopt de compiler je meteen. Geen verborgen magie dus, maar duidelijke contracten tussen je methode en je types.
Conclusie
Vóór Generic Math moest je kiezen tussen flexibiliteit en veiligheid. Met Generic Math krijg je het beste van twee werelden: Compile-time veiligheid én ondersteuning voor eigen numerieke types.
Maar misschien is dat niet eens het grootste voordeel. Met Generic Math kun je eindelijk expliciet modelleren wat een type daadwerkelijk ondersteunt. En dat maakt je code niet alleen herbruikbaarder, maar ook duidelijker en robuuster!