Esercitazione: Combinare funzionalità in durante la creazione di classi usando interfacce con metodi di interfaccia predefiniti

È possibile definire un'implementazione quando si dichiara un membro di un'interfaccia. Questa funzionalità offre nuove funzionalità in cui è possibile definire implementazioni predefinite per le funzionalità dichiarate nelle interfacce. Le classi possono scegliere quando eseguire l'override delle funzionalità, quando usare la funzionalità predefinita e quando non dichiarare il supporto per le funzionalità discrete.

Questa esercitazione illustra come:

  • Creare interfacce con implementazioni che descrivono funzionalità discrete.
  • Creare classi che usano le implementazioni predefinite.
  • Creare classi che eseguono l'override di alcune o tutte le implementazioni predefinite.

Prerequisiti

È necessario configurare il computer per eseguire .NET, incluso il compilatore C#. Il compilatore C# è disponibile con Visual Studio 2022o .NET SDK.

Limitazioni dei metodi di estensione

Un modo per implementare il comportamento visualizzato come parte di un'interfaccia consiste nel definire metodi di estensione che forniscono il comportamento predefinito. Le interfacce dichiarano un set minimo di membri fornendo una superficie maggiore a qualsiasi classe che implementa tale interfaccia. Ad esempio, i metodi di estensione in Enumerable forniscono l'implementazione di qualsiasi sequenza come origine di una query LINQ.

I metodi di estensione vengono risolti in fase di compilazione, usando il tipo dichiarato della variabile. Le classi che implementano l'interfaccia possono fornire un'implementazione migliore per qualsiasi metodo di estensione. Le dichiarazioni di variabili devono corrispondere al tipo di implementazione per consentire al compilatore di scegliere tale implementazione. Quando il tipo in fase di compilazione corrisponde all'interfaccia, le chiamate al metodo vengono risolte nel metodo di estensione. Un altro problema con i metodi di estensione è che questi metodi sono accessibili ovunque la classe contenente i metodi di estensione sia accessibile. Le classi non possono dichiarare se devono o non devono fornire funzionalità dichiarate nei metodi di estensione.

È possibile dichiarare le implementazioni predefinite come metodi di interfaccia. Quindi, ogni classe usa automaticamente l'implementazione predefinita. Qualsiasi classe in grado di fornire un'implementazione migliore può eseguire l'override della definizione del metodo di interfaccia con un algoritmo migliore. In un certo senso, questa tecnica sembra simile all’uso dei metodi di estensione.

In questo articolo si apprenderà come le implementazioni di interfacce predefinite abilitano nuovi scenari.

Progettare l'applicazione

Si consideri un'applicazione di automazione domestica. Probabilmente avete molti tipi diversi di luci e indicatori che potrebbero essere utilizzati in tutta la casa. Ogni luce deve supportare le API per attivarle e disattivarle e segnalare lo stato corrente. Alcuni indicatori e luci possono supportare altre funzionalità, ad esempio:

  • Accendere la luce, quindi disattivarla dopo un timer.
  • Far lampeggiare la luce per un periodo di tempo.

Alcune di queste funzionalità estese possono essere emulate nei dispositivi che supportano il set minimo. Questo indica che fornisce un'implementazione predefinita. Per i dispositivi con più funzionalità integrate, il software del dispositivo userà le funzionalità native. Per altre luci, possono scegliere di implementare l'interfaccia e usare l'implementazione predefinita.

I membri di interfaccia predefiniti offrono una soluzione migliore per questo scenario rispetto ai metodi di estensione. Gli autori di classi possono controllare quali interfacce scelgono di implementare. Tali interfacce scelte sono disponibili come metodi. Inoltre, poiché i metodi di interfaccia predefiniti sono virtuali per impostazione predefinita, il metodo dispatch sceglie sempre l'implementazione nella classe.

Creare il codice per illustrare queste differenze.

Creare interfacce

Per iniziare, creare l'interfaccia che definisce il comportamento per tutte le luci:

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

Una fixture di illuminazione sovraccarico di base potrebbe implementare questa interfaccia, come illustrato nel codice seguente:

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 questa esercitazione il codice non esegue l'unità dei dispositivi IoT, ma emula tali attività scrivendo messaggi nella console. È possibile esplorare il codice senza automatizzare la casa.

Definire quindi l'interfaccia per una luce che può essere disattivata automaticamente dopo un timeout:

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

È possibile aggiungere un'implementazione di base alla luce del sovraccarico, ma una soluzione migliore consiste nel modificare questa definizione di interfaccia per fornire un'implementazione virtual predefinita:

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

La classe OverheadLight può implementare la funzione timer dichiarando il supporto per l'interfaccia :

public class OverheadLight : ITimerLight { }

Un tipo di luce diverso può supportare un protocollo più sofisticato. Può fornire la propria implementazione per TurnOnFor, come illustrato nel codice seguente:

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

A differenza dell'override dei metodi della classe virtuale, la dichiarazione di TurnOnFor nella classe HalogenLight non usa la parola chiave override.

Combinare e associare funzionalità

I vantaggi dei metodi di interfaccia predefiniti diventano più chiari quando si introducono funzionalità più avanzate. L'uso delle interfacce consente di combinare e associare funzionalità. Consente inoltre a ogni autore di classi di scegliere tra l'implementazione predefinita e un'implementazione personalizzata. Aggiungere un'interfaccia con un'implementazione predefinita per una luce lampeggiante:

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

L'implementazione predefinita consente a qualsiasi luce di lampeggiare. La luce overhead può aggiungere funzionalità timer e lampeggiante usando l'implementazione predefinita:

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

Un nuovo tipo di luce, LEDLight supporta sia la funzione timer che la funzione lampeggiante direttamente. Questo stile chiaro implementa le interfacce ITimerLight e IBlinkingLight ed esegue l'override del metodo 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")}";
}

Un ExtraFancyLight può supportare direttamente le funzioni lampeggiante e timer:

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

L'oggetto HalogenLight creato in precedenza non supporta il lampeggiamento. Non aggiungere quindi IBlinkingLight all'elenco delle interfacce supportate.

Rilevare i tipi di luce usando criteri di ricerca

Successivamente, scrivere un codice di test. È possibile usare la funzionalità di criteri di ricerca di C# per determinare le funzionalità di una luce esaminando le interfacce supportate. Il metodo seguente illustra le funzionalità supportate di ogni luce:

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

Il codice seguente nel metodo Main crea ogni tipo di luce in sequenza e verifica la luce:

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

Come il compilatore determina l'implementazione migliore

Questo scenario mostra un'interfaccia di base senza implementazioni. L'aggiunta di un metodo nell'interfaccia ILight introduce nuove complessità. Le regole del linguaggio che regolano i metodi di interfaccia predefiniti riducono al minimo l'effetto sulle classi concrete che implementano più interfacce derivate. Verrà ora migliorata l'interfaccia originale con un nuovo metodo per mostrare come cambia l'uso. Ogni luce indicatore può segnalare lo stato di alimentazione come valore enumerato:

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

L'implementazione predefinita non presuppone alcun potere:

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

Queste modifiche vengono compilate in modo pulito, anche se ExtraFancyLight dichiara il supporto per l'interfaccia ILight e entrambe le interfacce derivate, ITimerLight e IBlinkingLight. Nell'interfaccia ILight è dichiarata solo un'implementazione "più vicina". Qualsiasi classe che ha dichiarato un override diventerà l'implementazione “più vicina”. Sono stati illustrati esempi nelle classi precedenti che eseguono l’override dei membri di altre interfacce derivate.

Evitare di eseguire l'override dello stesso metodo in più interfacce derivate. In questo modo viene creata una chiamata di metodo ambigua ogni volta che una classe implementa entrambe le interfacce derivate. Il compilatore non è in grado di selezionare un singolo metodo migliore in modo da generare un errore. Ad esempio, se sia l'oggetto IBlinkingLight che ITimerLight ha implementato un override di PowerStatus, è necessario che OverheadLightfornisca un override più specifico. In caso contrario, il compilatore non può scegliere tra le implementazioni nelle due interfacce derivate. In genere è possibile evitare questa situazione mantenendo le definizioni di interfaccia ridotte e incentrate su una funzionalità. In questo scenario, ogni funzionalità di una luce è una propria interfaccia; solo le classi ereditano più interfacce.

Questo esempio illustra uno scenario in cui è possibile definire funzionalità discrete che possono essere miste in classi. È possibile dichiarare qualsiasi set di funzionalità supportate dichiarando quali interfacce supportano una classe. L'uso di metodi di interfaccia predefinita virtuale consente alle classi di usare o definire un'implementazione diversa per uno o tutti i metodi di interfaccia. Questa funzionalità del linguaggio offre nuovi modi per modellare i sistemi reali che si stanno creando. I metodi di interfaccia predefiniti offrono un modo più chiaro per esprimere le classi correlate che possono combinare e associare funzionalità diverse usando implementazioni virtuali di tali funzionalità.