Van duplicatie naar type-safe generics in .NET

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 + operator hebben.  

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! 

Meer weten over werken bij Cloud Republic?

Bij Cloud Republic werken echte teamplayers. We zijn vastberaden om, samen met elkaar, steengoede oplossingen te ontwikkelen. We zijn trots op onze cultuur die persoonlijke en professionele groei stimuleert door samenwerking en creativiteit en kunnen niet wachten om jou hiermee kennis te laten maken.

Altijd de laatste trends en development nieuws in je inbox?

Schrijf je in voor onze .NET updates. Hier delen we de onze blogs, cases en tips over de nieuwste tech. Zo weet je zeker dat altijd op de hoogte blijft van de laatste trends in software development.