C# 11 proposed interface members declared as static abstract. This is useful to handle both:
- Polymorphism at the type level, for example when abstracting the concept of zero across numeric types
double.Zero,int.Zerothis leads toT.Zero. - Polymorphism on operator overloading:
double + double,int + intare abstracted withT + T.
This new feature was driven by the .NET 7.0 Generic Math which we will detail later in this article. But first, let’s start with an example:
Abstracting the Average Operation
Here is a simple implementation of the average operation on double:
|
1 2 3 4 5 6 7 8 9 10 11 |
double average = Average(1, 2, 3, 4); Console.WriteLine(average); static double Average(params double[] array) { if (array.Length == 0) { return 0; } double result = 0; foreach (var val in array) { result += val; } return result / array.Length; } |
The average operation can be abstracted as long as we can abstract the operations add and divide and the concepts of zero and one. This is now possible with static abstract members.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
Height heightAverage = Average(new Height(1), new Height(2), new Height(3), new Height(4)); Console.WriteLine(heightAverage.Val); static T Average<T>(params T[] array) where T : IMeasurable<T> { if(array.Length == 0) { return T.Zero; } T result = T.Zero; T denominator = T.Zero; foreach (T val in array) { result = result + val; denominator = denominator + T.One; } return result / denominator; } public interface IMeasurable<T> where T : IMeasurable<T> { static abstract T operator +(T a, T b); static abstract T operator /(T a, T b); static abstract T One { get; } static abstract T Zero { get; } } public record struct Height(double Val) : IMeasurable<Height> { public static Height One => new (1); public static Height Zero => new (0); public static Height operator +(Height a, Height b) { return new Height(a.Val + b.Val); } public static Height operator /(Height a, Height b) { return new Height(a.Val / b.Val); } } |
To compile and run this code you need to modify the .csproj file with at least .NET version 7.0 or upper like <TargetFramework>net8.0</TargetFramework>
|
1 2 3 4 5 6 7 8 9 |
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <LangVersion>11.0</LangVersion> </PropertyGroup> </Project> |
Here are a few remarks about this code sample:
- It would have been cleaner with only the keyword
staticinstead ofstatic abstract. But since C# 8 it is possible to definestaticmembers with a body within interfaces. Thus the keywordabstractis required to not mess up with this existing feature. - The generic constraint
IMeasurable<T> where T : IMeasurable<T>looks awkward. It is required to be able to usestatic abstractoperators within the interfaceIMeasurable<T>. Without this constraint, the compiler emits an error. This is because most operators require the types of their arguments to be of the containing type. Thus arguments of operators declared withinIMeasurable<T>must be of typeIMeasurable<T>.
Heightis arecord struct. This is by no means a requirement ofstatic abstract. However, value object types defined withstructis the right way to implement lightweight numerical types likeint,doubleandHeight. Alsorecord structintroduced in C# 10 avoids a lot of boilerplate code (constructor, properties, value-based equality, deconstruction…) as shown by therecord structvs.structcomparison below.
static abstract cannot be made virtual
Here is another code sample that harnesses static abstract.
- The interface
IValdefines both anInstanceValand aStaticValproperty. - Both
Class1andClass2implementIVal. Class2derives fromClass1.Class2.StaticValhidesClass1.StaticVal.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
Class1 obj1 = new Class1(); Class2 obj2 = new Class2(); Class1 obj3 = obj2; ConsoleWriteLine(obj1); ConsoleWriteLine(obj2); ConsoleWriteLine(obj3); static void ConsoleWriteLine<T>(T obj) where T : IVal { Console.WriteLine("StaticVal:{0}, InstanceVal:{1}", T.StaticVal, obj.InstanceVal); } interface IVal { static abstract int StaticVal { get; } int InstanceVal { get; } } class Class1 : IVal { public static int StaticVal => 1; public virtual int InstanceVal => 1; } class Class2 : Class1, IVal { public new static int StaticVal => 2; public override int InstanceVal => 2; } |
Here is the result of running this program:
|
1 2 3 |
StaticVal:1, InstanceVal:1 StaticVal:2, InstanceVal:2 StaticVal:1, InstanceVal:2 |
At runtime, the JIT builds the closed generic method ConsoleWriteLine<Class1>(Class1) to handle both calls ConsoleWriteLine(obj1) and ConsoleWriteLine(obj3). This is because both references obj1 and obj3 are typed with Class1. Thus in the context of ConsoleWriteLine<Class1>(...), the call to T.StaticVal translate to Class1.StaticVal.
static abstract members cannot be made virtual. This makes sense because virtual/override really means: take account of the type of the object at runtime to dispatch the call to the best-suited implementation. But with the keyword static there is no object involved, only type.
Here is the code of ConsoleWriteLine<T>(T val) where T : IVal decompiled to IL. We can see the usage of call IVal::get_StaticVal() and callvirt IVal::get_InstanceVal(). The JIT might have been updated to bind call IVal::get_StaticVal() with the implementation Class1::get_StaticVal() at runtime (ECMA discussion here). However this tweet from Miguel de Icaza suggests that the runtime hasn’t been updated, so I am not sure on this point.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// [8 55 - 8 56] IL_0000: nop // [9 4 - 9 86] IL_0001: ldstr "StaticVal:{0}, InstanceVal:{1}" IL_0006: constrained. !!0/*T*/ IL_000c: call int32 IVal::get_StaticVal() IL_0011: box [System.Runtime]System.Int32 IL_0016: ldarga.s obj IL_0018: constrained. !!0/*T*/ IL_001e: callvirt instance int32 IVal::get_InstanceVal() IL_0023: box [System.Runtime]System.Int32 IL_0028: call void [System.Console]System.Console::WriteLine(string, object, object) IL_002d: nop // [10 1 - 10 2] IL_002e: ret |
static abstract and the Generic Math Library
Clearly the static abstract feature was driven by the Generic Math library.With .NET 7.0, System.Runtime.dll introduced a number of new interfaces to abstract usual math operations. The new interface INumber<Self> defines what a number is and its operations:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
#region Assembly System.Runtime, Version=7.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a // C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\7.0.0-preview.3.22175.4\ref\net7.0\System.Runtime.dll namespace System { public interface INumber<TSelf> : IAdditionOperators<TSelf, TSelf, TSelf>, IAdditiveIdentity<TSelf, TSelf>, IComparable, IComparable<TSelf>, IComparisonOperators<TSelf, TSelf>, IEqualityOperators<TSelf, TSelf>, IEquatable<TSelf>, IDecrementOperators<TSelf>, IDivisionOperators<TSelf, TSelf, TSelf>, IFormattable, IIncrementOperators<TSelf>, IModulusOperators<TSelf, TSelf, TSelf>, IMultiplicativeIdentity<TSelf, TSelf>, IMultiplyOperators<TSelf, TSelf, TSelf>, IParseable<TSelf>, ISpanFormattable, ISpanParseable<TSelf>, ISubtractionOperators<TSelf, TSelf, TSelf>, IUnaryNegationOperators<TSelf, TSelf>, IUnaryPlusOperators<TSelf, TSelf> where TSelf : INumber<TSelf> { static abstract TSelf One { get; } static abstract TSelf Zero { get; } static abstract TSelf Abs(TSelf value); static abstract TSelf Clamp(TSelf value, TSelf min, TSelf max); static abstract TSelf Create<TOther>(TOther value) where TOther : INumber<TOther>; static abstract TSelf CreateSaturating<TOther>(TOther value) where TOther : INumber<TOther>; static abstract TSelf CreateTruncating<TOther>(TOther value) where TOther : INumber<TOther>; [return: TupleElementNames(new[] { "Quotient", "Remainder" })] static abstract (TSelf Quotient, TSelf Remainder) DivRem(TSelf left, TSelf right); static abstract TSelf Max(TSelf x, TSelf y); static abstract TSelf Min(TSelf x, TSelf y); static abstract TSelf Parse(string s, NumberStyles style, IFormatProvider? provider); static abstract TSelf Parse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider); static abstract TSelf Sign(TSelf value); static abstract bool TryCreate<TOther>(TOther value, out TSelf result) where TOther : INumber<TOther>; static abstract bool TryParse([NotNullWhen(true)] string? s, NumberStyles style, IFormatProvider? provider, out TSelf result); static abstract bool TryParse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider, out TSelf result); } } |
All the good old number implementations now implement the interface INumber<Self> as shown by this code query executed against the .NET 7 compiled System.Runtime.dll assembly:
This is quite a welcomed addition to .NET and C#! For example, we can now have a generic implementation of the exponential formula:
An implementation of this formula can work with double, float, Complex<T> where T: IFloatingPoint<T> or Matrix<T> where T: IFloatingPoint<T>. And yes, e raised to the power of a matrix makes send as explained in this video. This is pretty cool and this is why the original code name for static abstract and generic math was PartyDonk.
And last but not least, thanks to all the past efforts done on the runtime about making value-objects work seamlessly with generics (like avoiding boxing in most situations…) existing numerical types improved with generic math don’t suffer from any performance penalty.
static abstract: Not Just for Math
This new addition to C# and .NET will change the life of math-oriented .NET developers. One great use case of static abstract is Generic Parsing and Generic Factory explained in this post: The .NET 7.0 IParsable<TSelf> interface
Certainly static abstract will be useful in various scenarios beyond math, numerical, parsing, and factory. Indeed, this new feature lets us specify static only interfaces to parametrize some generic methods without the need to instantiate any object:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
Algorithm<ImplA>(); // No need to instantiate ImplA to parameter the algorithm with it Algorithm<ImplB>(); static void Algorithm<T>() where T : IInterface { T.DoSomething1(); T.DoSomething2(); } interface IInterface { static abstract void DoSomething1(); static abstract void DoSomething2(); } class ImplA : IInterface { public static void DoSomething1() { /* do smthing 1 */ } public static void DoSomething2() { /* do smthing 2 */ } } class ImplB : IInterface { public static void DoSomething1() { /* do smthing 1 */ } public static void DoSomething2() { /* do smthing 2 */ } } |
Notice that in the code above ImplA and ImplB cannot be made static although they are not meant to be instantiated.
Conclusion
Once again, the .NET platform advances, and the .NET team addresses a gap. While it’s true that C# is becoming increasingly complex each year, new features like static abstract members and generic math genuinely enhance the code. Additionally, we can agree that improving math-related code is not merely a niche scenario.







