Tutorial: Funcionalidade mix ao criar classes usando interfaces com métodos de interface padrão

Você pode definir uma implementação ao declarar um membro de uma interface. Essa funcionalidade fornece novos recursos em que você pode definir implementações padrão para recursos declarados em interfaces. As classes podem escolher quando substituir a funcionalidade, quando usar a funcionalidade padrão e quando não declarar suporte a recursos discretos.

Neste tutorial, você aprenderá como:

  • Crie interfaces com implementações que descrevem recursos discretos.
  • Crie classes que usam as implementações padrão.
  • Crie classes que substituam algumas ou todas as implementações padrão.

Pré-requisitos

Você precisa configurar o computador para executar o .NET, incluindo o compilador C#. O compilador C# está disponível com o Visual Studio 2022 ou o SDK do .NET.

Limitações de métodos de extensão

Uma maneira de implementar o comportamento que aparece como parte de uma interface é definindo métodos de extensão que fornecem o comportamento padrão. As interfaces declaram um conjunto mínimo de membros, fornecendo uma área de superfície maior para qualquer classe que implemente essa interface. Por exemplo, os métodos de extensão em Enumerable fornecem a implementação para qualquer sequência ser a fonte de uma consulta LINQ.

Os métodos de extensão são resolvidos no tempo de compilação, usando o tipo declarado da variável. As classes que implementam a interface podem fornecer uma implementação melhor para qualquer método de extensão. As declarações de variável devem corresponder ao tipo de implementação para permitir que o compilador escolha essa implementação. Quando o tipo de tempo de compilação corresponder à interface, as chamadas de método são resolvidas para o método de extensão. Outra preocupação com os métodos de extensão é que eles podem ser acessados pelo mesmo local que a classe contendo os métodos de extensão. As classes não poderão declarar se devem ou não fornecer recursos declarados nos métodos de extensão.

Você pode declarar as implementações padrão como métodos de interface. Em seguida, cada classe usa automaticamente a implementação padrão. Qualquer classe que possa fornecer uma implementação melhor pode substituir a definição do método de interface por um algoritmo melhor. De certa forma, essa técnica é semelhante à forma de utilização dos métodos de extensão.

Neste artigo, você aprenderá como as implementações de interface padrão habilitam novos cenários.

Criar o aplicativo

Considere um aplicativo de automação residencial. Você provavelmente tem muitos tipos de luzes e indicadores que podem ser usados em toda a casa. Cada luz deve dar suporte a APIs para ativá-las, desativá-las e relatar o estado atual. Algumas luzes e indicadores podem dar suporte a outros recursos, como:

  • Ativar a luz e desligá-la após um temporizador.
  • Piscar a luz por um período de tempo.

Alguns desses recursos estendidos podem ser emulados em dispositivos que dão suporte ao conjunto mínimo. Isso indica o fornecimento de uma implementação padrão. Para os dispositivos com mais funcionalidades internas, o software do dispositivo usaria os recursos nativos. Para outras luzes, eles podem optar por implementar a interface e usar a implementação padrão.

Os membros de interface padrão são uma solução melhor para esse cenário do que os métodos de extensão. Os autores de classe podem controlar quais interfaces eles escolhem implementar. Essas interfaces escolhidas estão disponíveis como métodos. Além disso, como os métodos de interface padrão são virtuais por padrão, a expedição de método sempre escolhe a implementação na classe.

Vamos criar o código para demonstrar essas diferenças.

Criar interfaces

Comece criando a interface que define o comportamento de todas as luzes:

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

Uma luminária suspensa básica pode implementar essa interface, conforme mostrado no seguinte código:

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

Neste tutorial, o código não conduz os dispositivos IoT, mas emula essas atividades gravando mensagens no console. É possível explorar o código sem automatizar sua casa.

Em seguida, vamos definir a interface para uma luz que pode ser desativada automaticamente após um tempo limite:

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

Você pode adicionar uma implementação básica à luz suspensa, mas uma solução melhor é modificar essa definição de interface para fornecer uma implementação padrão 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.");
    }
}

A classe OverheadLight pode implementar a função de temporizador declarando suporte à interface:

public class OverheadLight : ITimerLight { }

Um tipo de luz diferente pode dar suporte a um protocolo mais sofisticado. Ele pode fornecer sua própria implementação para TurnOnFor, conforme mostrado no seguinte código:

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

Ao contrário da substituição de métodos de classe virtual, a declaração de TurnOnFor na classe HalogenLight não usa a palavra-chave override.

Recursos combinados

As vantagens dos métodos de interface padrão ficam mais claras à medida que você introduz recursos mais avançados. O uso de interfaces permite combinar recursos. Também permite que cada autor de classe escolha entre uma implementação padrão ou uma implementação personalizada. Vamos adicionar uma interface com uma implementação padrão para uma luz piscando:

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

A implementação padrão permite que qualquer luz pisque. A luz suspensa pode adicionar recursos de temporizador e piscar usando a implementação padrão:

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

Um novo tipo de luz LEDLight, dá suporte à função de temporizador e à função de piscar diretamente. Esse estilo de luz implementa as interfaces ITimerLight e IBlinkingLight e substitui o método 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")}";
}

Um ExtraFancyLight pode dar suporte diretamente a funções de piscar e temporizador:

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

O HalogenLight criado anteriormente não dá suporte a piscar. Portanto, não adicione IBlinkingLight à lista de interfaces com suporte.

Detectar os tipos de luz usando a correspondência de padrões

Em seguida, vamos gravar um código de teste. Você pode usar o recurso de correspondência de padrões do C# para determinar as funcionalidades de uma luz examinando quais interfaces ele dá suporte. O método a seguir exercita as funcionalidades com suporte de cada luz:

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

O código a seguir no método Main cria cada tipo de luz na sequência e testa essa luz:

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

Como o compilador determina a melhor implementação

Esse cenário mostra uma interface base sem implementações. A adição de um método à interface ILight, introduz novas complexidades. As regras de linguagem que regem os métodos de interface padrão minimizam o efeito nas classes concretas que implementam várias interfaces derivadas. Vamos aprimorar a interface original com um novo método para demonstrar como isso altera seu uso. Cada luz do indicador pode relatar seu status de energia como um valor enumerado:

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

A implementação padrão pressupõe que não há energia:

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

Essas alterações são compiladas de forma limpa, embora ExtraFancyLight declare suporte para a interface ILight e as interfaces derivadas ITimerLight e IBlinkingLight. Há apenas uma implementação "mais próxima" declarada na interface ILight. Qualquer classe que declarasse uma substituição se tornaria a única implementação "mais próxima". Você viu exemplos nas classes anteriores que substituíram os membros de outras interfaces derivadas.

Evite substituir o mesmo método em várias interfaces derivadas. Isso cria uma chamada de método ambíguo sempre que uma classe implementa ambas as interfaces derivadas. O compilador não pode escolher um único método, logo, um erro é gerado. Por exemplo, se IBlinkingLight e ITimerLight implementaram uma substituição de PowerStatus, OverheadLight precisaria fornecer uma substituição mais específica. Caso contrário, o compilador não poderá escolher entre as implementações nas duas interfaces derivadas. Normalmente, você pode evitar essa situação mantendo as definições de interface pequenas e focadas em um recurso. Nesse cenário, cada funcionalidade de uma luz é a própria interface, e várias interfaces são herdadas apenas por classes.

Este exemplo mostra um cenário em que você pode definir recursos discretos que podem ser misturados em classes. Você declara qualquer conjunto de funcionalidades com suporte declarando a quais interfaces uma classe dá suporte. O uso de métodos de interface padrão virtual permite que as classes usem ou definam uma implementação diferente para um ou todos os métodos de interface. Essa funcionalidade de linguagem fornece novas maneiras de modelar os sistemas do mundo real sendo criados. Os métodos de interface padrão fornecem uma maneira mais clara de expressar classes relacionadas que podem combinar diferentes recursos usando implementações virtuais deles.