Поделиться через


Учебное пособие: Смешивание функциональных возможностей при создании классов с использованием интерфейсов с методами интерфейса по умолчанию

Вы можете определить реализацию, когда объявляете члена интерфейса. Эта функция предоставляет новые возможности, в которых можно определить реализации по умолчанию для функций, объявленных в интерфейсах. Классы могут выбирать, когда следует переопределять функциональные возможности, когда использовать функции по умолчанию и когда не объявлять поддержку дискретных функций.

В этом руководстве вы узнаете, как:

  • Создайте интерфейсы с реализациями, описывающими дискретные функции.
  • Создайте классы, использующие реализации по умолчанию.
  • Создайте классы, которые переопределяют некоторые или все реализации по умолчанию.

Предпосылки

Необходимо настроить компьютер для запуска .NET, включая компилятор C#. Компилятор C# доступен с Visual Studio 2022или пакета SDK для .NET.

Ограничения методов расширения

Одним из способов реализации поведения, которое отображается в интерфейсе, является определение методов расширения , которые обеспечивают поведение по умолчанию. Интерфейсы объявляют минимальный набор элементов, предоставляя большую область поверхности для любого класса, реализующего этот интерфейс. Например, методы расширения в Enumerable предоставляют реализацию для того, чтобы любая последовательность могла быть источником запроса LINQ.

Методы расширения разрешаются во время компиляции, используя тип, объявленный для переменной. Классы, реализующие интерфейс, могут обеспечить лучшую реализацию для любого метода расширения. Объявления переменных должны соответствовать типу реализации, чтобы компилятор мог выбрать реализацию. Когда тип времени компиляции соответствует интерфейсу, вызовы методов перенаправляются к методу расширения. Еще одна проблема с методами расширения заключается в том, что эти методы доступны, когда класс, содержащий методы расширения, доступен. Классы не могут определить, должны ли они предоставлять возможности, указанные в методах расширения.

Реализации по умолчанию можно объявить как методы интерфейса. Затем каждый класс автоматически использует реализацию по умолчанию. Любой класс, который может обеспечить лучшую реализацию, может переопределить определение метода интерфейса с помощью лучшего алгоритма. В одном смысле этот метод звучит примерно так, как можно использовать методы расширения.

Из этой статьи вы узнаете, как реализации интерфейса по умолчанию позволяют создавать новые сценарии.

Проектирование приложения

Рассмотрим приложение домашней автоматизации. У вас, вероятно, есть много различных типов света и индикаторов, которые могут использоваться во всем доме. Каждый свет должен поддерживать API, чтобы включить и отключить их, а также сообщить о текущем состоянии. Некоторые световые индикаторы и сигнальные лампочки могут поддерживать другие функции, такие как:

  • Включите свет, а затем отключите его после таймера.
  • Мигать светом в течение определенного периода времени.

Некоторые из этих расширенных возможностей можно эмулировать на устройствах, поддерживающих минимальный набор. Это означает предоставление реализации по умолчанию. Для тех устройств, которые имеют больше возможностей, программное обеспечение устройства будет использовать собственные возможности. Для других осветительных приборов они могут выбрать внедрение интерфейса и воспользоваться реализацией по умолчанию.

Элементы интерфейса по умолчанию предоставляют лучшее решение для этого сценария, чем методы расширения. Авторы классов могут контролировать, какие интерфейсы они выбирают для реализации. Эти интерфейсы, которые они выбирают, доступны в качестве методов. Кроме того, поскольку методы интерфейса по умолчанию являются виртуальными по умолчанию, диспетчер метода всегда выбирает реализацию в классе.

Давайте создадим код для демонстрации этих различий.

Создание интерфейсов

Сначала создайте интерфейс, определяющий поведение для всех световых индикаторов:

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

Базовый накладный светильник может реализовать этот интерфейс, как показано в следующем коде:

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

В этом руководстве код не использует устройства Интернета вещей, но эмулирует эти действия, записывая сообщения в консоль. Вы можете изучить код без автоматизации вашего дома.

Далее давайте определим интерфейс для света, который может автоматически отключиться после истечения времени ожидания:

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

Вы можете добавить базовую реализацию в потолочный светильник, но лучшее решение — изменить это определение интерфейса, чтобы обеспечить virtual реализацию по умолчанию.

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

Класс OverheadLight может реализовать функцию таймера, объявив поддержку интерфейса:

public class OverheadLight : ITimerLight { }

Другой тип света может поддерживать более сложный протокол. Он может предоставить собственную реализацию для TurnOnFor, как показано в следующем коде:

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

В отличие от переопределения методов виртуального TurnOnFor класса, объявление в HalogenLight классе не использует ключевое override слово.

Комбинирование и согласование возможностей

Преимущества методов интерфейса по умолчанию становятся более понятными при внедрении более сложных возможностей. Использование интерфейсов позволяет смешивать и сопоставлять возможности. Он также позволяет каждому автору класса выбирать между реализацией по умолчанию и пользовательской реализацией. Давайте добавим интерфейс с реализацией по умолчанию для мигающего света:

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

Реализация по умолчанию позволяет любому свету мигать. Потолочный светильник может иметь функции таймера и мигания с помощью реализации по умолчанию.

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

Новый тип света, LEDLight, напрямую поддерживает как функцию таймера, так и функцию мигания. Этот светлый стиль реализует как интерфейсы ITimerLight, так и IBlinkingLight, и переопределяет метод 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")}";
}

ExtraFancyLight может напрямую поддерживать как функции мигания, так и таймера.

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

Ранее созданный HalogenLight не поддерживает мигание. Поэтому не добавляйте этот элемент IBlinkingLight в список поддерживаемых интерфейсов.

Обнаружение типов света с помощью сопоставления шаблонов

Далее давайте напишем тестовый код. Вы можете использовать функцию сопоставления шаблонов C#, чтобы определить возможности света, проверив, какие интерфейсы она поддерживает. Следующий метод реализует поддерживаемые возможности каждого света:

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

Код в вашем методе Main создает каждый тип света по очереди и проверяет этот свет.

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

Как компилятор определяет лучшую реализацию

В этом сценарии показан базовый интерфейс без каких-либо реализаций. Добавление метода в ILight интерфейс представляет новые сложности. Языковые правила, регулирующие методы интерфейса по умолчанию, минимизируют влияние на конкретные классы, реализующие несколько производных интерфейсов. Давайте расширим исходный интерфейс с помощью нового метода, чтобы показать, как это изменит его использование. Каждый индикатор может сообщать о состоянии питания в виде перечисленного значения:

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

Реализация по умолчанию не предполагает никакой мощности:

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

Эти изменения компилируются чисто, даже если ExtraFancyLight объявляет поддержку интерфейсу ILight и обоим производным интерфейсам, ITimerLight и IBlinkingLight. В интерфейсе ILight объявлена только одна "ближайшая" реализация. Любой класс, который задекларирует переопределение, станет "ближайшей" реализацией. Вы видели примеры в предыдущих классах, которые переопределяли элементы других интерфейсов, производных от базовых.

Избегайте переопределения одного метода в нескольких производных интерфейсах. Это создает неоднозначный вызов метода всякий раз, когда класс реализует оба производных интерфейса. Компилятор не может выбрать лучший метод, и поэтому выдает ошибку. Если IBlinkingLight и ITimerLight реализуют переопределение Power(), OverheadLight нужно предоставить более конкретное переопределение. В противном случае компилятор не может выбрать между реализацией в двух производных интерфейсах. Эта ситуация показана на следующей схеме:

иллюстрация проблемы с бриллиантами с методами интерфейса по умолчанию

На предыдущей схеме показана неоднозначность. OverheadLight не предоставляет реализацию ILight.Power(). И IBlinkingLight, и ITimerLight предоставляют переопределения, которые более конкретны. Вызов ILight.Power() к экземпляру OverheadLight является неоднозначным. Чтобы устранить неоднозначность, необходимо добавить новое переопределение в OverheadLight.

Как правило, эту ситуацию можно избежать, сохраняя определения интерфейса небольшими и ориентированными на одну функцию. В этом сценарии каждая функция света имеет свой отдельный интерфейс; только классы могут наследовать несколько интерфейсов.

В этом примере показан один сценарий, в котором можно определить дискретные функции, которые можно смешать в классы. Вы объявляете любой набор поддерживаемых функций, объявляя, какие интерфейсы поддерживают класс. Использование методов интерфейса по умолчанию позволяет классам использовать или определять другую реализацию для любого или всех методов интерфейса. Эта возможность языка предоставляет новые способы моделирования реальных систем, которые вы создаете. Методы интерфейса по умолчанию предоставляют более четкий способ выражения связанных классов, которые могут смешивать и сопоставлять различные функции с помощью виртуальных реализаций этих возможностей.