Tutorial: Erkunden von C# 11-Features: Statische virtuelle Member in Schnittstellen

C# 11 und .NET 7 enthalten statische virtuelle Member in Schnittstellen. Mit diesem Feature können Sie Schnittstellen definieren, die überladene Operatoren oder andere statische Member enthalten. Nachdem Sie Schnittstellen mit statischen Membern definiert haben, können Sie diese Schnittstellen als Einschränkungen verwenden, um generische Typen zu erstellen, die Operatoren oder andere statische Methoden verwenden. Selbst wenn Sie keine Schnittstellen mit überladenen Operatoren erstellen, können Sie von diesem Feature und den generischen mathematischen Klassen profitieren, die durch das Sprachupdate zur Verfügung stehen.

In diesem Tutorial lernen Sie Folgendes:

  • Definieren von Schnittstellen mit statischen Membern
  • Verwenden von Schnittstellen zum Definieren von Klassen, die Schnittstellen mit definierten Operatoren implementieren
  • Erstellen generischer Algorithmen, die auf statischen Schnittstellenmethoden basieren

Voraussetzungen

Sie müssen Ihren Computer für die Ausführung von .NET 7 einrichten, damit C# 11 unterstützt wird. Der C# 11-Compiler steht ab Version 17.3 von Visual Studio 2022 oder ab dem .NET 7 SDK zur Verfügung.

Statische abstrakte Schnittstellenmethoden

Beginnen wir mit einem Beispiel. Die folgende Methode gibt den Mittelwert zweier Zahlen vom Typ double zurück:

public static double MidPoint(double left, double right) =>
    (left + right) / (2.0);

Die gleiche Logik kann für jeden numerischen Typ verwendet werden, also für int, short, long, float, decimal und für jeden Typ, der eine Zahl darstellt. Sie benötigen eine Möglichkeit, die Operatoren + und / zu verwenden und einen Wert für 2 zu definieren. Sie können die Schnittstelle System.Numerics.INumber<TSelf> verwenden, um die vorherige Methode als die folgende generische Methode zu schreiben:

public static T MidPoint<T>(T left, T right)
    where T : INumber<T> => (left + right) / T.CreateChecked(2);  // note: the addition of left and right may overflow here; it's just for demonstration purposes

Jeder Typ, der die Schnittstelle INumber<TSelf> implementiert, muss eine Definition für operator + und für operator / enthalten. Der Nenner wird von T.CreateChecked(2) definiert, um den Wert 2 für einen beliebigen numerischen Typ zu erstellen. Dadurch muss der Nenner den gleichen Typ haben wie die beiden Parameter. INumberBase<TSelf>.CreateChecked<TOther>(TOther) erstellt eine Instanz des Typs aus dem angegebenen Wert und löst eine Überlaufausnahme (OverflowException) aus, wenn der Wert außerhalb des darstellbaren Bereichs liegt. (Bei dieser Implementierung kann es zu einem Überlauf kommen, wenn left und right jeweils groß genug sind. Es gibt allerdings alternative Algorithmen, mit denen sich dieses potenzielle Problem vermeiden lässt.)

Statische abstrakte Member in einer Schnittstelle werden mithilfe einer vertrauten Syntax definiert: Sie fügen jedem statischen Member, der keine Implementierung bereitstellt, die Modifizierer static und abstract hinzu. Im folgenden Beispiel wird eine Schnittstelle vom Typ IGetNext<T> definiert, die auf einen beliebigen Typ angewendet werden kann, der operator ++ überschreibt:

public interface IGetNext<T> where T : IGetNext<T>
{
    static abstract T operator ++(T other);
}

Die Einschränkung, dass IGetNext<T> durch das Typargument T implementiert wird, stellt sicher, dass die Signatur für den Operator den enthaltenden Typ oder das zugehörige Typargument enthält. Viele Operatoren erzwingen, dass die zugehörigen Parameter mit dem Typ übereinstimmen oder dem Typparameter entsprechen müssen, der zum Implementieren des enthaltenden Typs eingeschränkt ist. Ohne diese Einschränkung könnte der Operator ++ nicht in der Schnittstelle IGetNext<T> definiert werden.

Mithilfe des folgenden Codes können Sie eine Struktur erstellen, die eine aus A-Zeichen bestehende Zeichenfolge erstellt, der mit jedem Inkrement ein weiteres Zeichen hinzugefügt wird:

public struct RepeatSequence : IGetNext<RepeatSequence>
{
    private const char Ch = 'A';
    public string Text = new string(Ch, 1);

    public RepeatSequence() {}

    public static RepeatSequence operator ++(RepeatSequence other)
        => other with { Text = other.Text + Ch };

    public override string ToString() => Text;
}

Ganz allgemein können Sie einen beliebigen Algorithmus erstellen, bei dem ++ ggf. bedeuten soll, dass der nächste Wert dieses Typs erstellt werden soll. Die Verwendung dieser Schnittstelle liefert übersichtlichen Code und klare Ergebnisse:

var str = new RepeatSequence();

for (int i = 0; i < 10; i++)
    Console.WriteLine(str++);

Das vorherige Beispiel erzeugt die folgende Ausgabe:

A
AA
AAA
AAAA
AAAAA
AAAAAA
AAAAAAA
AAAAAAAA
AAAAAAAAA
AAAAAAAAAA

Dieses kleine Beispiel veranschaulicht die Motivation für dieses Feature. Sie können natürliche Syntax für Operatoren, konstante Werte und andere statische Operationen verwenden. Sie können diese Techniken untersuchen, wenn Sie mehrere Typen erstellen, die auf statischen Membern basieren (einschließlich überladener Operatoren). Definieren Sie die Schnittstellen, die den Funktionen Ihrer Typen entsprechen, und deklarieren Sie dann die Unterstützung dieser Typen für die neue Schnittstelle.

Generische Mathematik

Das Szenario, aufgrund dessen statische Methoden (einschließlich Operatoren) in Schnittstellen zugelassen wurden, ist die Unterstützung von Algorithmen mit generischer Mathematik. Die .NET 7-Basisklassenbibliothek enthält Schnittstellendefinitionen für zahlreiche arithmetische Operatoren sowie abgeleitete Schnittstellen, die viele arithmetische Operatoren in einer Schnittstelle vom Typ INumber<T> kombinieren. Verwenden Sie nun diese Typen, um einen Datensatz vom Typ Point<T> zu erstellen, der einen beliebigen numerischen Typ für T verwenden kann. Der Punkt kann mithilfe des Operators + um XOffset und YOffset verschoben werden.

Erstellen Sie zunächst eine neue Konsolenanwendung – entweder mithilfe von dotnet new oder über Visual Studio.

Die öffentliche Schnittstelle für Translation<T> und Point<T> sollte wie der folgende Code aussehen:

// Note: Not complete. This won't compile yet.
public record Translation<T>(T XOffset, T YOffset);

public record Point<T>(T X, T Y)
{
    public static Point<T> operator +(Point<T> left, Translation<T> right);
}

Der Typ record wird sowohl für den Typ Translation<T> als auch für den Typ Point<T> verwendet: Beide speichern zwei Werte und stehen für Datenspeicherung (nicht für anspruchsvolles Verhalten). Die Implementierung von operator + sieht wie folgt aus:

public static Point<T> operator +(Point<T> left, Translation<T> right) =>
    left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };

Damit der vorherige Code kompiliert werden kann, muss deklariert werden, dass T die Schnittstelle IAdditionOperators<TSelf, TOther, TResult> unterstützt. Diese Schnittstelle enthält die statische Methode operator +. Sie deklariert drei Typparameter: einen für den linken Operanden, einen für den rechten Operanden und einen für das Ergebnis. Einige Typen implementieren + für verschiedene Operanden- und Ergebnistypen. Deklarieren Sie, dass IAdditionOperators<T, T, T> vom Typargument T implementiert wird:

public record Point<T>(T X, T Y) where T : IAdditionOperators<T, T, T>

Nachdem Sie diese Einschränkung hinzugefügt haben, kann Ihre Klasse Point<T> den Operator + als Additionsoperator verwenden. Fügen Sie die gleiche Einschränkung für die Deklaration Translation<T> hinzu:

public record Translation<T>(T XOffset, T YOffset) where T : IAdditionOperators<T, T, T>;

Die Einschränkung IAdditionOperators<T, T, T> verhindert, dass Entwickler*innen, die Ihre Klasse verwenden, eine Translation mit einem Typ erstellen, der die Einschränkung für die Addition zu einem Punkt nicht erfüllt. Sie haben dem Typparameter für Translation<T> und Point<T> die erforderlichen Einschränkungen hinzugefügt, damit dieser Code funktioniert. Zum Testen können Sie in Ihrer Datei Program.cs über den Deklarationen von Translation und Point Code wie den folgenden hinzufügen:

var pt = new Point<int>(3, 4);

var translate = new Translation<int>(5, 10);

var final = pt + translate;

Console.WriteLine(pt);
Console.WriteLine(translate);
Console.WriteLine(final);

Zur Verbesserung der Wiederverwendbarkeit dieses Codes können Sie deklarieren, dass diese Typen die entsprechenden arithmetischen Schnittstellen implementieren. Die erste erforderliche Änderung besteht darin, zu deklarieren, dass Point<T, T> die Schnittstelle IAdditionOperators<Point<T>, Translation<T>, Point<T>> implementiert. Der Typ Point verwendet unterschiedliche Typen für Operanden und für das Ergebnis. Der Typ Point implementiert bereits einen Operator vom Typ „+“ (operator +) mit dieser Signatur. Daher müssen Sie der Deklaration lediglich die Schnittstelle hinzufügen:

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>

Bei der Addition ist es außerdem hilfreich, eine Eigenschaft zu haben, die den Wert der additiven Identität für diesen Typ definiert. Für dieses Feature gibt es eine neue Schnittstelle: IAdditiveIdentity<TSelf,TResult>. Eine Translation von {0, 0} ist die additive Identität: Der resultierende Punkt ist mit dem linken Operanden identisch. Die Schnittstelle IAdditiveIdentity<TSelf, TResult> definiert eine einzelne schreibgeschützte Eigenschaft (AdditiveIdentity), die den Identitätswert zurückgibt. Zum Implementieren dieser Schnittstelle sind einige Änderungen an Translation<T> erforderlich:

using System.Numerics;

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Translation<T> AdditiveIdentity =>
        new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);
}

Da hier mehrere Änderungen vorgenommen wurden, werden sie im Anschluss nacheinander erläutert. Als Erstes wird deklariert, dass der Typ Translation die Schnittstelle IAdditiveIdentity implementiert:

public record Translation<T>(T XOffset, T YOffset) : IAdditiveIdentity<Translation<T>, Translation<T>>

Als Nächstes können Sie versuchen, den Schnittstellenmember wie im folgenden Code gezeigt zu implementieren:

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: 0, YOffset: 0);

Dieser Code wird nicht kompiliert, da 0 vom Typ abhängt. Die Lösung: Verwenden Sie IAdditiveIdentity<T>.AdditiveIdentity für 0. Aufgrund dieser Änderung müssen Ihre Einschränkungen jetzt die Information enthalten, dass IAdditiveIdentity<T> von T implementiert wird. Dies hat folgende Implementierung zur Folge:

public static Translation<T> AdditiveIdentity =>
    new Translation<T>(XOffset: T.AdditiveIdentity, YOffset: T.AdditiveIdentity);

Nachdem Sie diese Einschränkung für Translation<T> hinzugefügt haben, muss die gleiche Einschränkung auch für Point<T> hinzugefügt werden:

using System.Numerics;

public record Point<T>(T X, T Y) : IAdditionOperators<Point<T>, Translation<T>, Point<T>>
    where T : IAdditionOperators<T, T, T>, IAdditiveIdentity<T, T>
{
    public static Point<T> operator +(Point<T> left, Translation<T> right) =>
        left with { X = left.X + right.XOffset, Y = left.Y + right.YOffset };
}

Dieses Beispiel hat einen Einblick in die Zusammensetzung der Schnittstellen für generische Mathematik gegeben. Sie haben Folgendes gelernt:

  • Schreiben einer Methode, die auf der Schnittstelle INumber<T> basiert, damit diese Methode mit einem beliebigen numerischen Typ verwendet werden kann.
  • Erstellen eines Typs, der auf den Additionsschnittstellen basiert, um einen Typ zu implementieren, der nur eine einzelne mathematische Operation unterstützt. Dieser Typ deklariert die Unterstützung der gleichen Schnittstellen, sodass er auf andere Arten zusammengesetzt werden kann. Die Algorithmen werden mit der natürlichsten Syntax mathematischer Operatoren geschrieben.

Experimentieren Sie mit diesen Features, und hinterlassen Sie Feedback. Sie können das Menüelement Feedback senden in Visual Studio verwenden oder auf GitHub ein neues Issue im Roslyn-Repository erstellen. Erstellen Sie generische Algorithmen, die mit einem beliebigen numerischen Typ funktionieren. Erstellen Sie Algorithmen mithilfe dieser Schnittstellen, bei denen das Typargument nur eine Teilmenge zahlenähnlicher Funktionen implementieren kann. Selbst wenn Sie keine neuen Schnittstellen erstellen, die diese Funktionen verwenden, können Sie mit ihrer Verwendung in Ihren Algorithmen experimentieren.

Siehe auch