Tutorial: Untermischen von Funktionalität beim Erstellen von Klassen mithilfe von Schnittstellen mit Standardschnittstellenmethoden

Sie können eine Implementierung definieren, wenn Sie einen Member einer Schnittstelle deklarieren. Dieses Feature bietet neue Funktionen, mit denen Sie Standardimplementierungen für Funktionen definieren können, die in Schnittstellen deklariert werden. Klassen können auswählen, wann die Funktionalität überschrieben werden soll, wann die Standardfunktionalität verwendet werden soll und wann keine Unterstützung für diskrete Features deklariert werden soll.

In diesem Tutorial lernen Sie, wie die folgenden Aufgaben ausgeführt werden:

  • Erstellen von Schnittstellen mit Implementierungen, die diskrete Features beschreiben.
  • Erstellen von Klassen, die die Standardimplementierungen verwenden.
  • Erstellen von Klassen, die einige oder alle Standardimplementierungen überschreiben.

Voraussetzungen

Sie müssen Ihren Computer für das Ausführen von .NET einschließlich des C#-Compilers einrichten. Der C#-Compiler ist mit Visual Studio 2022 oder dem .NET SDK verfügbar.

Einschränkungen von Erweiterungsmethoden

Eine Möglichkeit, Verhalten zu implementieren, das als Teil einer Schnittstelle auftritt, besteht darin, Erweiterungsmethoden zu definieren, die das Standardverhalten bereitstellen. Schnittstellen deklarieren einen minimalen Satz von Membern und bieten gleichzeitig eine größere Oberfläche für jede Klasse, die diese Schnittstelle implementiert. Die Erweiterungsmethoden in Enumerable stellen beispielsweise die Implementierung für jede beliebige Sequenz als Quelle einer LINQ-Abfrage bereit.

Erweiterungsmethoden werden zur Kompilierzeit mithilfe des deklarierten Typs der Variablen aufgelöst. Klassen, die die Schnittstelle implementieren, können eine bessere Implementierung für jede beliebige Erweiterungsmethode bereitstellen. Variablendeklarationen müssen dem implementierenden Typ entsprechen, damit der Compiler diese Implementierung auswählen kann. Wenn der Kompilierzeittyp mit der Schnittstelle übereinstimmt, werden Methodenaufrufe in die Erweiterungsmethode aufgelöst. Ein weiteres Problem bei Erweiterungsmethoden ist, dass auf diese Methoden überall dort zugegriffen werden kann, wo auf die Klasse, die die Erweiterungsmethoden enthält, zugegriffen werden kann. Klassen können nichts deklarieren, wenn sie in Erweiterungsmethoden deklarierte Funktionen bereitstellen oder nicht bereitstellen sollten.

Sie können die Standardimplementierungen als Schnittstellenmethoden deklarieren. Anschließend verwendet jede Klasse automatisch die Standardimplementierung. Jede Klasse, die eine bessere Implementierung bereitstellen kann, kann die Definition der Schnittstellenmethode mit einem besseren Algorithmus überschreiben. In gewisser Weise klingt diese Technik ähnlich wie die Verwendung von Erweiterungsmethoden.

In diesem Artikel erfahren Sie, wie Standardschnittstellenimplementierungen neue Szenarien ermöglichen.

Entwerfen der Anwendung

Stellen Sie sich eine Anwendung für Smart Home-Automatisierung vor. Sie haben wahrscheinlich viele verschiedene Arten von Leuchten und Indikatoren, die im gesamten Haus verwendet werden könnten. Jede Leuchte muss APIs unterstützen, um sie ein- und auszuschalten und den aktuellen Zustand zu melden. Einige Leuchten und Indikatoren unterstützen möglicherweise andere Funktionen, beispielsweise:

  • Einschalten der Leuchte und Ausschalten anhand eines Timers.
  • Blinklichtfunktion der Leuchte für einen bestimmten Zeitraum.

Einige dieser erweiterten Funktionen können auf Geräten emuliert werden, die den minimalen Satz unterstützen. Dies bedeutet, dass eine Standardimplementierung bereitgestellt wird. Für Geräte, die über mehr integrierte Funktionen verfügen, würde die Gerätesoftware die nativen Funktionen verwenden. Für andere Leuchten können sie sich entscheiden, die Schnittstelle zu implementieren und die Standardimplementierung zu verwenden.

Standardschnittstellenmember stellen eine bessere Lösung für dieses Szenario bereit als Erweiterungsmethoden. Klassenautoren können steuern, welche Schnittstellen sie implementieren möchten. Diese Schnittstellen, die sie auswählen, sind als Methoden verfügbar. Da Standardschnittstellenmethoden standardmäßig virtuell sind, wählt die Methodenbindung außerdem immer die Implementierung in der-Klasse aus.

Erstellen wir den Code, um diese Unterschiede zu veranschaulichen.

Erstellen von Schnittstellen

Beginnen Sie, indem Sie die Schnittstelle erstellen, die das Verhalten für alle Leuchten definiert:

public interface ILight
{
    void SwitchOn();
    void SwitchOff();
    bool IsOn();
}

Eine einfache Deckenleuchte könnte diese Schnittstelle wie im folgenden Code dargestellt implementieren:

public class OverheadLight : ILight
{
    private bool isOn;
    public bool IsOn() => isOn;
    public void SwitchOff() => isOn = false;
    public void SwitchOn() => isOn = true;

    public override string ToString() => $"The light is {(isOn ? "on" : "off")}";
}

In diesem Tutorial werden keine IoT-Geräte durch den Code gesteuert, sondern diese Aktivitäten werden emuliert, indem Nachrichten in die Konsole geschrieben werden. Sie können den Code untersuchen, ohne Ihr Haus zu automatisieren.

Definieren wir nun die Schnittstelle für eine Leuchte, die nach einem Timeout automatisch ausgeschaltet werden kann:

public interface ITimerLight : ILight
{
    Task TurnOnFor(int duration);
}

Sie könnten eine Basisimplementierung zur Deckenleuchte hinzufügen, aber eine bessere Lösung besteht darin, diese Schnittstellendefinition zu ändern, um eine virtual-Standardimplementierung bereitzustellen:

public interface ITimerLight : ILight
{
    public async Task TurnOnFor(int duration)
    {
        Console.WriteLine("Using the default interface method for the ITimerLight.TurnOnFor.");
        SwitchOn();
        await Task.Delay(duration);
        SwitchOff();
        Console.WriteLine("Completed ITimerLight.TurnOnFor sequence.");
    }
}

Die OverheadLight-Klasse kann die Timerfunktion implementieren, indem sie Unterstützung für die Schnittstelle deklariert:

public class OverheadLight : ITimerLight { }

Ein anderer Leuchtentyp unterstützt möglicherweise ein anspruchsvolleres Protokoll. Er kann seine eigene Implementierung für TurnOnFor bereitstellen, wie im folgenden Code gezeigt:

public class HalogenLight : ITimerLight
{
    private enum HalogenLightState
    {
        Off,
        On,
        TimerModeOn
    }

    private HalogenLightState state;
    public void SwitchOn() => state = HalogenLightState.On;
    public void SwitchOff() => state = HalogenLightState.Off;
    public bool IsOn() => state != HalogenLightState.Off;
    public async Task TurnOnFor(int duration)
    {
        Console.WriteLine("Halogen light starting timer function.");
        state = HalogenLightState.TimerModeOn;
        await Task.Delay(duration);
        state = HalogenLightState.Off;
        Console.WriteLine("Halogen light finished custom timer function");
    }

    public override string ToString() => $"The light is {state}";
}

Im Gegensatz zum Überschreiben von Methoden der virtuellen Klasse verwendet die Deklaration von TurnOnFor in der HalogenLight-Klasse nicht das Schlüsselwort override.

Mix-und-Match-Funktionen

Die Vorteile von Standardschnittstellenmethoden werden deutlicher, wenn Sie erweiterte Funktionen einführen. Durch die Verwendung von Schnittstellen können Sie Mix-und-Match-Funktionen verwenden. Außerdem kann jeder Klassenautor zwischen der Standardimplementierung und einer benutzerdefinierten Implementierung wählen. Fügen wir eine Schnittstelle mit einer Standardimplementierung für eine blinkende Leuchte hinzu:

public interface IBlinkingLight : ILight
{
    public async Task Blink(int duration, int repeatCount)
    {
        Console.WriteLine("Using the default interface method for IBlinkingLight.Blink.");
        for (int count = 0; count < repeatCount; count++)
        {
            SwitchOn();
            await Task.Delay(duration);
            SwitchOff();
            await Task.Delay(duration);
        }
        Console.WriteLine("Done with the default interface method for IBlinkingLight.Blink.");
    }
}

Die Standardimplementierung ermöglicht jeder Leuchte das Blinken. Die Deckenleuchte kann sowohl Timer- als auch Blinkfunktionen mit der Standardimplementierung hinzufügen:

public class OverheadLight : ILight, ITimerLight, IBlinkingLight
{
    private bool isOn;
    public bool IsOn() => isOn;
    public void SwitchOff() => isOn = false;
    public void SwitchOn() => isOn = true;

    public override string ToString() => $"The light is {(isOn ? "on" : "off")}";
}

Ein neuer Leuchtentyp (LEDLight) unterstützt die Timerfunktion und die Blinkfunktion direkt. Dieser Leuchtenstil implementiert sowohl die ITimerLight- als auch die IBlinkingLight-Schnittstelle und überschreibt die Blink-Methode:

public class LEDLight : IBlinkingLight, ITimerLight, ILight
{
    private bool isOn;
    public void SwitchOn() => isOn = true;
    public void SwitchOff() => isOn = false;
    public bool IsOn() => isOn;
    public async Task Blink(int duration, int repeatCount)
    {
        Console.WriteLine("LED Light starting the Blink function.");
        await Task.Delay(duration * repeatCount);
        Console.WriteLine("LED Light has finished the Blink function.");
    }

    public override string ToString() => $"The light is {(isOn ? "on" : "off")}";
}

Ein ExtraFancyLight-Element unterstützt ggf. Blink- und Timerfunktionen direkt:

public class ExtraFancyLight : IBlinkingLight, ITimerLight, ILight
{
    private bool isOn;
    public void SwitchOn() => isOn = true;
    public void SwitchOff() => isOn = false;
    public bool IsOn() => isOn;
    public async Task Blink(int duration, int repeatCount)
    {
        Console.WriteLine("Extra Fancy Light starting the Blink function.");
        await Task.Delay(duration * repeatCount);
        Console.WriteLine("Extra Fancy Light has finished the Blink function.");
    }
    public async Task TurnOnFor(int duration)
    {
        Console.WriteLine("Extra Fancy light starting timer function.");
        await Task.Delay(duration);
        Console.WriteLine("Extra Fancy light finished custom timer function");
    }

    public override string ToString() => $"The light is {(isOn ? "on" : "off")}";
}

Das HalogenLight-Element, das Sie zuvor erstellt haben, unterstützt kein Blinken. Fügen Sie IBlinkingLight daher nicht der Liste der unterstützten Schnittstellen dieses Elements hinzu.

Erkennen der Leuchtentypen mithilfe von Musterabgleich

Schreiben wir nun etwas Testcode. Sie können das Feature Musterabgleich von C# verwenden, um die Funktionen einer Leuchte zu ermitteln, indem Sie untersuchen, welche Schnittstellen sie unterstützt. Die folgende Methode gibt die unterstützten Fähigkeiten der einzelnen Leuchten aus:

private static async Task TestLightCapabilities(ILight light)
{
    // Perform basic tests:
    light.SwitchOn();
    Console.WriteLine($"\tAfter switching on, the light is {(light.IsOn() ? "on" : "off")}");
    light.SwitchOff();
    Console.WriteLine($"\tAfter switching off, the light is {(light.IsOn() ? "on" : "off")}");

    if (light is ITimerLight timer)
    {
        Console.WriteLine("\tTesting timer function");
        await timer.TurnOnFor(1000);
        Console.WriteLine("\tTimer function completed");
    }
    else
    {
        Console.WriteLine("\tTimer function not supported.");
    }

    if (light is IBlinkingLight blinker)
    {
        Console.WriteLine("\tTesting blinking function");
        await blinker.Blink(500, 5);
        Console.WriteLine("\tBlink function completed");
    }
    else
    {
        Console.WriteLine("\tBlink function not supported.");
    }
}

Der folgende Code in der Main-Methode erstellt alle Leuchtentypen nacheinander und testet die einzelnen Leuchten:

static async Task Main(string[] args)
{
    Console.WriteLine("Testing the overhead light");
    var overhead = new OverheadLight();
    await TestLightCapabilities(overhead);
    Console.WriteLine();

    Console.WriteLine("Testing the halogen light");
    var halogen = new HalogenLight();
    await TestLightCapabilities(halogen);
    Console.WriteLine();

    Console.WriteLine("Testing the LED light");
    var led = new LEDLight();
    await TestLightCapabilities(led);
    Console.WriteLine();

    Console.WriteLine("Testing the fancy light");
    var fancy = new ExtraFancyLight();
    await TestLightCapabilities(fancy);
    Console.WriteLine();
}

So ermittelt der Compiler die beste Implementierung

Dieses Szenario zeigt eine Basisschnittstelle ohne Implementierungen. Durch das Hinzufügen einer Methode zur ILight-Schnittstelle werden neue Komplexitäten eingeführt. Die Sprachregeln, die für Standardschnittstellenmethoden gelten, minimieren die Auswirkungen auf die konkreten Klassen, die mehrere abgeleitete Schnittstellen implementieren. Erweitern wir die ursprüngliche Schnittstelle durch eine neue Methode, um zu zeigen, wie sich dadurch ihre Verwendung ändert. Jede Indikatorleuchte kann ihren Energiestatus als Enumerationswert melden:

public enum PowerStatus
{
    NoPower,
    ACPower,
    FullBattery,
    MidBattery,
    LowBattery
}

Die Standardimplementierung geht von einer fehlenden Stromversorgung aus:

public interface ILight
{
    void SwitchOn();
    void SwitchOff();
    bool IsOn();
    public PowerStatus Power() => PowerStatus.NoPower;
}

Diese Änderungen werden ordnungsgemäß kompiliert, auch wenn ExtraFancyLight Unterstützung für die ILight-Schnittstelle und die beiden abgeleiteten Schnittstellen ITimerLight und IBlinkingLight deklariert. Es gibt nur eine „nächste“ Implementierung, die in der ILight-Schnittstelle deklariert ist. Jede Klasse, die eine Überschreibung deklariert hat, würde zur „nächsten“ Implementierung werden. Sie haben in den vorhergehenden Klassen Beispiele gesehen, die die Member anderer abgeleiteter Schnittstellen überschreiben.

Vermeiden Sie das Überschreiben derselben Methode in mehreren abgeleiteten Schnittstellen. Auf diese Weise wird ein mehrdeutiger Methodenaufruf erstellt, wenn eine Klasse beide abgeleiteten Schnittstellen implementiert. Der Compiler kann keine einzelne bessere Methode auswählen, sodass er einen Fehler ausgibt. Wenn z.B. sowohl IBlinkingLight als auch ITimerLight eine Überschreibung von PowerStatus implementiert hat, müsste OverheadLight eine spezifischere Überschreibung bereitstellen. Andernfalls kann der Compiler nicht zwischen den Implementierungen in den beiden abgeleiteten Schnittstellen wählen. Sie können diese Situation in der Regel vermeiden, indem Sie Schnittstellendefinitionen klein halten und sich auf eine Funktion konzentrieren. In diesem Szenario ist jede Funktion einer Leuchte eine eigene Schnittstelle. Nur Klassen erben mehrere Schnittstellen.

Dieses Beispiel zeigt ein Szenario, in dem Sie diskrete Features definieren können, die in Klassen gemischt werden können. Sie deklarieren einen beliebigen Satz unterstützter Funktionen, indem Sie deklarieren, welche Schnittstellen eine Klasse unterstützt. Durch die Verwendung von virtuellen Standardschnittstellenmethoden können Klassen eine andere Implementierung für beliebige oder alle Schnittstellenmethoden verwenden oder definieren. Diese Sprachfunktion bietet neue Möglichkeiten zum Modellieren der realen Systeme, die Sie entwickeln. Standardschnittstellenmethoden bieten eine bessere Möglichkeit, verwandte Klassen auszudrücken, die Mix-and-Match-Funktionen verwenden, indem sie virtuelle Implementierungen dieser Funktionen verwenden.