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.Zero
this leads toT.Zero
. - Polymorphism on operator overloading:
double + double
,int + int
are 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
static
instead ofstatic abstract
. But since C# 8 it is possible to definestatic
members with a body within interfaces. Thus the keywordabstract
is 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 abstract
operators 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>
.
Height
is arecord struct
. This is by no means a requirement ofstatic abstract
. However, value object types defined withstruct
is the right way to implement lightweight numerical types likeint
,double
andHeight
. Alsorecord struct
introduced in C# 10 avoids a lot of boilerplate code (constructor, properties, value-based equality, deconstruction…) as shown by therecord struct
vs.struct
comparison below.
static abstract cannot be made virtual
Here is another code sample that harnesses static abstract
.
- The interface
IVal
defines both anInstanceVal
and aStaticVal
property. - Both
Class1
andClass2
implementIVal
. Class2
derives fromClass1
.Class2.StaticVal
hidesClass1.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.