Samouczek: mieszanie funkcji podczas tworzenia klas przy użyciu interfejsów z domyślnymi metodami interfejsu

Implementację można zdefiniować podczas deklarowania elementu członkowskiego interfejsu. Ta funkcja udostępnia nowe możliwości, w których można zdefiniować domyślne implementacje funkcji zadeklarowanych w interfejsach. Klasy mogą wybierać, kiedy zastąpić funkcje, kiedy używać funkcji domyślnych, a kiedy nie deklarować obsługi dyskretnych funkcji.

Z tego samouczka dowiesz się, jak wykonywać następujące czynności:

  • Tworzenie interfejsów z implementacjami, które opisują dyskretne funkcje.
  • Utwórz klasy korzystające z domyślnych implementacji.
  • Utwórz klasy, które zastępują niektóre lub wszystkie domyślne implementacje.

Wymagania wstępne

Musisz skonfigurować maszynę do uruchamiania platformy .NET, w tym kompilatora języka C#. Kompilator języka C# jest dostępny w programie Visual Studio 2022 lub zestawie .NET SDK.

Ograniczenia metod rozszerzeń

Jednym ze sposobów zaimplementowania zachowania wyświetlanego w ramach interfejsu jest zdefiniowanie metod rozszerzeń, które zapewniają domyślne zachowanie. Interfejsy deklarują minimalny zestaw elementów członkowskich, zapewniając większy obszar powierzchni dla każdej klasy, która implementuje ten interfejs. Na przykład metody rozszerzenia w programie Enumerable zapewniają implementację dowolnej sekwencji jako źródła zapytania LINQ.

Metody rozszerzenia są rozwiązywane w czasie kompilacji przy użyciu zadeklarowanego typu zmiennej. Klasy implementujące interfejs mogą zapewnić lepszą implementację dowolnej metody rozszerzenia. Deklaracje zmiennych muszą być zgodne z typem implementacji, aby umożliwić kompilatorowi wybór tej implementacji. Gdy typ czasu kompilacji jest zgodny z interfejsem, metoda wywołuje metodę rozpoznawania metody rozszerzenia. Innym problemem z metodami rozszerzenia jest to, że te metody są dostępne wszędzie tam, gdzie klasa zawierająca metody rozszerzenia jest dostępna. Klasy nie mogą zadeklarować, czy powinny lub nie powinny udostępniać funkcji zadeklarowanych w metodach rozszerzeń.

Domyślne implementacje można zadeklarować jako metody interfejsu. Następnie każda klasa automatycznie używa domyślnej implementacji. Każda klasa, która może zapewnić lepszą implementację, może zastąpić definicję metody interfejsu lepszym algorytmem. W pewnym sensie ta technika jest podobna do tego, jak można użyć metod rozszerzeń.

W tym artykule dowiesz się, jak implementacje interfejsu domyślnego umożliwiają korzystanie z nowych scenariuszy.

Projektowanie aplikacji

Rozważmy aplikację automatyzacji domu. Prawdopodobnie masz wiele różnych typów świateł i wskaźników, które mogą być używane w całym domu. Każde światło musi obsługiwać interfejsy API, aby je włączać i wyłączać oraz zgłaszać bieżący stan. Niektóre światła i wskaźniki mogą obsługiwać inne funkcje, takie jak:

  • Włącz światło, a następnie wyłącz go po czasomierzu.
  • Miga światło przez pewien czas.

Niektóre z tych rozszerzonych możliwości mogą być emulowane na urządzeniach, które obsługują minimalny zestaw. Oznacza to, że zapewnia domyślną implementację. W przypadku tych urządzeń, które mają większe możliwości wbudowane, oprogramowanie urządzenia będzie korzystać z natywnych możliwości. W przypadku innych świateł mogą zdecydować się na zaimplementowanie interfejsu i użycie domyślnej implementacji.

Domyślne elementy członkowskie interfejsu zapewniają lepsze rozwiązanie dla tego scenariusza niż metody rozszerzeń. Autorzy klas mogą kontrolować, które interfejsy wybierają do zaimplementowania. Wybrane interfejsy są dostępne jako metody. Ponadto, ponieważ domyślne metody interfejsu są domyślnie wirtualne, metoda dispatch zawsze wybiera implementację w klasie.

Utwórzmy kod, aby zademonstrować te różnice.

Tworzenie interfejsów

Zacznij od utworzenia interfejsu, który definiuje zachowanie dla wszystkich świateł:

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

Podstawowa oprawa światła narzutowego może zaimplementować ten interfejs, jak pokazano w poniższym kodzie:

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")}";
}

W tym samouczku kod nie obsługuje urządzeń IoT, ale emuluje te działania, zapisując komunikaty w konsoli programu . Możesz eksplorować kod bez automatyzowania domu.

Następnie zdefiniujmy interfejs dla światła, który może automatycznie wyłączyć się po przekroczeniu limitu czasu:

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

Możesz dodać podstawową implementację do światła narzutowego, ale lepszym rozwiązaniem jest zmodyfikowanie tej definicji interfejsu w celu zapewnienia domyślnej virtual implementacji:

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.");
    }
}

Klasa OverheadLight może zaimplementować funkcję czasomierza, deklarując obsługę interfejsu:

public class OverheadLight : ITimerLight { }

Inny typ światła może obsługiwać bardziej zaawansowany protokół. Może zapewnić własną implementację dla TurnOnForelementu , jak pokazano w poniższym kodzie:

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}";
}

W przeciwieństwie do zastępowania metod klas wirtualnych deklaracja TurnOnFor klasy w HalogenLight klasie nie używa słowa kluczowego override .

Łączenie i dopasowywanie możliwości

Zalety domyślnych metod interfejsu stają się jaśniejsze, ponieważ wprowadzasz bardziej zaawansowane możliwości. Korzystanie z interfejsów umożliwia mieszanie i dopasowywanie możliwości. Umożliwia również każdemu autorowi klas wybór między domyślną implementacją a implementacją niestandardową. Dodajmy interfejs z domyślną implementacją światła migającego:

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.");
    }
}

Domyślna implementacja umożliwia miganie dowolnego światła. Światło narzutowe może dodawać możliwości czasomierza i migania przy użyciu domyślnej implementacji:

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")}";
}

Nowy typ światła obsługuje LEDLight zarówno funkcję czasomierza, jak i funkcję migania bezpośrednio. Ten jasny styl implementuje zarówno interfejsy, jak ITimerLight i IBlinkingLight i i zastępuje metodę Blink :

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")}";
}

Element ExtraFancyLight może bezpośrednio obsługiwać funkcje migacza i czasomierza:

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")}";
}

Utworzony HalogenLight wcześniej element nie obsługuje migania. Dlatego nie należy dodawać elementu IBlinkingLight do listy obsługiwanych interfejsów.

Wykrywanie typów światła przy użyciu dopasowywania wzorców

Następnie napiszmy kod testowy. Aby określić możliwości światła, możesz użyć funkcji dopasowywania wzorców języka C#, sprawdzając, które interfejsy obsługuje. Poniższa metoda wykonuje obsługiwane możliwości każdego światła:

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.");
    }
}

Poniższy kod w Main metodzie tworzy każdy typ światła w sekwencji i testuje, że światło:

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();
}

Jak kompilator określa najlepszą implementację

W tym scenariuszu przedstawiono interfejs podstawowy bez żadnych implementacji. Dodanie metody do interfejsu ILight powoduje wprowadzenie nowych złożoności. Reguły języka rządzące domyślnymi metodami interfejsu minimalizują wpływ na konkretne klasy, które implementują wiele interfejsów pochodnych. Ulepszmy oryginalny interfejs za pomocą nowej metody, aby pokazać, jak zmienia się jego użycie. Każde światło wskaźnika może zgłaszać stan zasilania jako wyliczona wartość:

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

Domyślna implementacja zakłada brak zasilania:

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

Te zmiany są kompilowane w sposób czysty, mimo że ExtraFancyLight zadeklarowana jest obsługa interfejsu ILight i interfejsów pochodnych oraz ITimerLightIBlinkingLight. W interfejsie ILight zadeklarowana jest tylko jedna implementacja "najbliżej". Każda klasa, która zadeklarowała zastąpienie, stanie się jedną "najbliższą" implementacją. W poprzednich klasach przedstawiono przykłady, które zastępują składowe innych interfejsów pochodnych.

Unikaj zastępowania tej samej metody w wielu interfejsach pochodnych. W ten sposób tworzy niejednoznaczne wywołanie metody za każdym razem, gdy klasa implementuje oba interfejsy pochodne. Kompilator nie może wybrać jednej lepszej metody, więc zgłasza błąd. Jeśli na przykład zarówno element IBlinkingLight , jak i ITimerLight zaimplementował przesłonięcia , OverheadLight należy podać bardziej szczegółowe przesłonięciaPowerStatus. W przeciwnym razie kompilator nie może wybrać między implementacjami w dwóch interfejsach pochodnych. Zazwyczaj można uniknąć tej sytuacji, zachowując małe i skoncentrowane na jednej funkcji definicje interfejsu. W tym scenariuszu każda możliwość światła jest własnym interfejsem; tylko klasy dziedziczą wiele interfejsów.

W tym przykładzie przedstawiono jeden scenariusz, w którym można zdefiniować odrębne funkcje, które można mieszać w klasy. Zadeklarujesz dowolny zestaw obsługiwanych funkcji, deklarując interfejsy obsługiwane przez klasę. Użycie wirtualnych domyślnych metod interfejsu umożliwia klasom używanie lub definiowanie innej implementacji dla dowolnych lub wszystkich metod interfejsu. Ta funkcja języka udostępnia nowe sposoby modelowania rzeczywistych systemów, które tworzysz. Domyślne metody interfejsu zapewniają jaśniejszy sposób wyrażania powiązanych klas, które mogą mieszać i dopasowywać różne funkcje przy użyciu wirtualnych implementacji tych funkcji.