Webinar: Evaluation - 05.12
On August 10th, 2021, Microsoft announced the .NET 6 Preview 7 release.
We published and translated this article with the copyright holder's permission. The author is DistortNeo. The article was originally published on Habr.
[The link to the .NET 6 Preview 7 announcement.]
Besides another "spoonful" of syntactic sugar, enhanced libraries functionality, improved UTF-8 support, and so on, Microsoft demonstrates super feature — static abstract interface methods. These allow you to implement arithmetic operators in generics:
T Add<T>(T lhs, T rhs)
where T : INumber<T>
{
return lhs + rhs;
}
So far, in C# you couldn't distract from static methods and write generalized code. This is extremely challenging for methods that exist only as static methods, such as, operators.
For example, in LINQ to objects, .Max, .Sum, .Average functions and so on are implemented separately for each of the simple types. For user-defined types, it is proposed to pass a delegate. This is inconvenient and inefficient — you can make a mistake with multiple code duplication. And the delegate call is not free (however, zero-cost delegates implementation in the JIT compiler is already discussed).
The feature allows writing generalized code as compared to, for example, numeric types, that are restricted by interfaces with the necessary operators. Thus, algorithms may have the following form:
// Interface specifies static properties and operators
interface IAddable<T> where T : IAddable<T>
{
static abstract T Zero { get; }
static abstract T operator +(T t1, T t2);
}
// Classes and structs (including built-ins) can implement interface
struct Int32 : ..., IAddable<Int32>
{
static Int32 I.operator +(Int32 x, Int32 y) => x + y; // Explicit
public static int Zero => 0; // Implicit
}
// Generic algorithms can use static members on T
public static T AddAll<T>(T[] ts) where T : IAddable<T>
{
T result = T.Zero; // Call static operator
foreach (T t in ts) { result += t; } // Use `+`
return result;
}
// Generic method can be applied to built-in and user-defined types
int sixtyThree = AddAll(new [] { 1, 2, 4, 8, 16, 32 });
Static members that are the interface contract's part are declared with static and abstract keywords.
Although the word static is a proper word to describe such methods, one of the recent updates allowed to declare helper static methods in interfaces. That's why, to distinguish helper methods from static contract members, it was decided to use the abstract modifier.
In general, not only operators can be contract members. Any static methods, properties, events also can be contract members. Static interface members are naturally implemented in the class.
You can call static interface methods only via generic type and only if the specific constraint is defined for the type:
public static T AddAll<T>(T[] ts) where T : IAddable<T>
{
T result = T.Zero; // Correct
T result2 = IAddable<T>.Zero; // Incorrect
}
Moreover, static methods never were and never will be virtual:
interface IStatic
{
static abstract int StaticValue { get; }
int Value { get; }
}
class Impl1 : IStatic
{
public static int StaticValue => 1;
public int Value => 1;
}
class Impl2 : Impl1, IStatic
{
public static int StaticValue => 2;
public int Value => 2;
}
static void Print<T>(T obj)
where T : IStatic
{
Console.WriteLine("{0}, {1}", T.StaticValue, obj.Value);
}
static void Test()
{
Impl1 obj1 = new Impl1();
Impl2 obj2 = new Impl2();
Impl1 obj3 = obj2;
Print(obj1); // 1, 1
Print(obj2); // 2, 2
Print(obj3); // 1, 2
}
The static interface method call is defined at the compilation stage (actually, during JIT compilation, not during the C# code build). Thus, we can exclaim: yay, now C# has static polymorphism!
Take a look at the generated IL code for the simplest function adding up two numbers:
.method private hidebysig static !!0/*T*/
Sum<(class [System.Runtime]System.INumber`1<!!0/*T*/>) T>(
!!0/*T*/ lhs,
!!0/*T*/ rhs
) cil managed
{
.maxstack 8
// [4903 17 - 4903 34]
IL_0000: ldarg.0 // lhs
IL_0001: ldarg.1 // rhs
IL_0002: constrained. !!0/*T*/
IL_0008: call !2/*T*/ class ....::op_Addition(!0/*T*/, !1/*T*/)
IL_000d: ret
} // end of method GenericMathTest::Sum
Nothing special: just a non-virtual call of the static interface method for the T type (callvirt – for virtual calls). Of course: you can't make a virtual call without an object.
At first, I thought that this was sugar produced by some magical objects created in a single instance for each type-interface pair. Actually, no. This is a decent implementation of a new feature at the JIT compiler level: for simple types, the compiler generates the instruction of the corresponding operation; for other types, it calls the corresponding method. Therefore, the code with new features will not work on older runtime versions.
Also, we can guess that each combination of generalized types, for whom static interface methods are called, will have the method compiled by the JIT compiler. That is, the performance of generalized methods that call static interface methods should not differ from the performance of individual implementations.
Despite an opportunity to try this feature right now, it is scheduled for the .NET 7 release. After the .NET 6 release, it remains in the preview state. Now, this feature is under development. The details of its implementation may change, so you can't use it right away.
To try the new feature, you need to add the EnablePreviewFeatures=true property to the project file and install the NuGet package – System.Runtime.Experimental:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<LangVersion>preview</LangVersion>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Runtime.Experimental"
Version="6.0.0-preview.7.21377.19" />
</ItemGroup>
</Project>
Of course, you have to install .NET 6 Preview 7 SDK and define net6.0 as the target platform.
Tried it and loved it. This is something that I've been waiting for a long time. Previously, I had to use duct tapes to solve the problem. For example:
interface IOperationProvider<T>
{
T Sum(T lhs, T rhs)
}
void SomeProcessing<T, TOperation>(...)
where TOperation : struct, IOperationProvider<T>
{
T var1 = ...;
T var2 = ...;
T sum = default(TOperation).Sum(var1, var2); // This is zero cost!
}
Instead of such duct tape, you can use the IOperation implementation with the T type and the var1.Sum(var2) call. In this case, virtual calls cause a loss of performance. Moreover, you can't get into all classes and add the interface.
Another benefit is the performance! I ran some benchmarks: the runtime of the usual code and the code with Generic Math turned out to be the same. That is, earlier, I was right about the JIT compilation.
But I was slightly disappointed to know that this feature does not work with enums. You still have to compare them via EqualityComparer<T>.Default.Equals.
Also, I didn't like that I had to use abstract as a duct tape. C# seems to get complicated. Now it's difficult to add new features without affecting previous features. In fact, C# becomes more and more like C++.
0