Tutoriel : Combiner les fonctionnalités lors de la création de classes à l’aide d’interfaces avec des méthodes d’interface par défaut

Vous pouvez définir une implémentation quand vous déclarez un membre d’une interface. Cette fonctionnalité fournit de nouvelles fonctionnalités dans lesquelles vous pouvez définir des implémentations par défaut pour les fonctionnalités déclarées dans les interfaces. Les classes peuvent choisir quand remplacer les fonctionnalités, quand utiliser la fonctionnalité par défaut et quand ne pas déclarer la prise en charge des fonctionnalités discrètes.

Ce didacticiel vous montre comment effectuer les opérations suivantes :

  • Créez des interfaces avec des implémentations qui décrivent des fonctionnalités discrètes.
  • Créez des classes qui utilisent les implémentations par défaut.
  • Créez des classes qui remplacent tout ou partie des implémentations par défaut.

Prérequis

Vous devez configurer votre machine pour qu’elle exécute .NET, y compris le compilateur C#. Le compilateur C# est accessible via Visual Studio 2022 ou le SDK .NET.

Limitations des méthodes d’extension

Une façon d’implémenter un comportement qui apparaît dans le cadre d’une interface consiste à définir des méthodes d’extension qui fournissent le comportement par défaut. Les interfaces déclarent un ensemble minimal de membres tout en fournissant une plus grande surface d’exposition pour toute classe qui implémente cette interface. Par exemple, les méthodes d’extension dans Enumerable fournissent l’implémentation pour que n’importe quelle séquence soit la source d’une requête LINQ.

Les méthodes d’extension sont résolues au moment de la compilation, à l’aide du type déclaré de la variable. Les classes qui implémentent l’interface peuvent fournir une meilleure implémentation pour n’importe quelle méthode d’extension. Les déclarations de variables doivent correspondre au type d’implémentation pour permettre au compilateur de choisir cette implémentation. Lorsque le type au moment de la compilation correspond à l’interface, les appels de méthode sont résolus à la méthode d’extension. Une autre préoccupation avec les méthodes d’extension est que ces méthodes sont accessibles partout où la classe contenant les méthodes d’extension est accessible. Les classes ne peuvent pas déclarer si elles doivent ou non fournir des fonctionnalités déclarées dans les méthodes d’extension.

Vous pouvez déclarer les implémentations par défaut en tant que méthodes d’interface. Ensuite, chaque classe utilise automatiquement l’implémentation par défaut. Toute classe qui peut fournir une meilleure implémentation peut remplacer la définition de méthode d’interface par un meilleur algorithme. Dans un sens, cette technique ressemble à la façon dont vous pouvez utiliser des méthodes d’extension.

Dans cet article, vous allez découvrir comment les implémentations d’interface par défaut permettent de nouveaux scénarios.

Concevoir l’application

Envisagez une application domotique. Vous avez probablement de nombreux types différents de lumières et d’indicateurs qui pourraient être utilisés dans toute la maison. Chaque lumière doit prendre en charge les API pour les activer et les désactiver, et pour signaler l’état actuel. Certains voyants peuvent prendre en charge d’autres fonctionnalités, comme :

  • Allumer la lumière, puis l’éteindre après une minuterie.
  • Faire clignoter la lumière pendant un certain temps.

Certaines de ces fonctionnalités étendues peuvent être émulées dans les appareils qui prennent en charge l’ensemble minimal. Cela indique la fourniture d’une implémentation par défaut. Pour les appareils qui ont plus de fonctionnalités intégrées, le logiciel de l’appareil utilise les fonctionnalités natives. Pour les autres voyants, ils peuvent choisir d’implémenter l’interface et d’utiliser l’implémentation par défaut.

Les membres d’interface par défaut sont une meilleure solution pour ce scénario que les méthodes d’extension. Les auteurs de classes peuvent contrôler les interfaces qu’ils choisissent d’implémenter. Les interfaces qu’ils choisissent sont disponibles en tant que méthodes. En outre, étant donné que les méthodes d’interface par défaut sont virtuelles par défaut, la répartition de la méthode choisit toujours l’implémentation dans la classe.

Créons le code pour illustrer ces différences.

Créer des interfaces

Commencez par créer l’interface qui définit le comportement de toutes les lumières :

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

Un luminaire de base peut implémenter cette interface, comme indiqué dans le code suivant :

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

Dans ce tutoriel, le code ne pilote pas les appareils IoT, mais émule ces activités en écrivant des messages dans la console. Vous pouvez explorer le code sans automatiser votre maison.

Ensuite, définissons l’interface d’une lumière qui peut s’éteindre automatiquement après un délai d’expiration :

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

Vous pouvez ajouter une implémentation de base au luminaire, mais une meilleure solution consiste à modifier cette définition d’interface pour fournir une implémentation virtual par défaut :

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 peut implémenter la fonction de minuterie en déclarant la prise en charge de l’interface :

public class OverheadLight : ITimerLight { }

Un autre type de lumière peut prendre en charge un protocole plus sophistiqué. Il peut fournir sa propre implémentation pour TurnOnFor, comme illustré dans le code suivant :

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

Contrairement au remplacement de méthodes de classe virtuelle, la déclaration de TurnOnFor dans la classe HalogenLight n’utilise pas le mot clé override.

Combiner les fonctionnalités

Les avantages des méthodes d’interface par défaut deviennent plus clairs à mesure que vous introduisez des fonctionnalités plus avancées. L’utilisation d’interfaces vous permet de combiner des fonctionnalités. Cela permet également à chaque auteur de classe de choisir entre l’implémentation par défaut et une implémentation personnalisée. Ajoutons une interface avec une implémentation par défaut pour un voyant clignotant :

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’implémentation par défaut permet à n’importe quel voyant de clignoter. Le luminaire peut ajouter des fonctionnalités de minuterie et de clignotement à l’aide de l’implémentation par défaut :

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 nouveau type de lumière, le LEDLight, prend en charge directement la fonction de minuterie et la fonction de clignotement. Ce style clair implémente les interfaces ITimerLight et IBlinkingLight et remplace la méthode 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 peut prendre en charge les fonctions de clignotement et de minuterie directement :

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

Le HalogenLight que vous avez créé précédemment ne prend pas en charge le clignotement. Par conséquent, n’ajoutez pas le IBlinkingLight à la liste de ses interfaces prises en charge.

Détecter les types de lumière à l’aide de la correspondance de modèle

Ensuite, écrivons du code de test. Vous pouvez utiliser la fonctionnalité de critères spéciaux de C# pour déterminer les fonctionnalités d’une lumière en examinant les interfaces qu’elle prend en charge. La méthode suivante exerce les fonctionnalités prises en charge de chaque lumière :

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

Le code suivant dans votre méthode Main crée chaque type de lumière dans la séquence et teste cette lumière :

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

Comment le compilateur détermine la meilleure implémentation

Ce scénario montre une interface de base sans implémentations. L’ajout d’une méthode à l’interface ILight introduit de nouvelles complexités. Les règles de langage qui régissent les méthodes d’interface par défaut réduisent l’effet sur les classes concrètes qui implémentent plusieurs interfaces dérivées. Nous allons améliorer l’interface d’origine avec une nouvelle méthode pour montrer comment cela change son utilisation. Chaque voyant lumineux peut signaler son état d’alimentation sous la forme d’une valeur énumérée :

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

L’implémentation par défaut suppose l’absence d’alimentation :

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

Ces modifications compilent correctement, même si le ExtraFancyLight déclare la prise en charge de l’interface ILight et des interfaces dérivées, ITimerLight et IBlinkingLight. Il n’y a qu’une seule implémentation, « la plus proche », déclarée dans l’interface ILight. Toute classe qui a déclaré une substitution devient l’implémentation « la plus proche ». Vous avez vu des exemples dans les classes précédentes qui remplacent les membres d’autres interfaces dérivées.

Évitez de remplacer la même méthode dans plusieurs interfaces dérivées. Cela crée un appel de méthode ambigu chaque fois qu’une classe implémente les deux interfaces dérivées. Le compilateur ne peut pas choisir une seule meilleure méthode ; il génère donc une erreur. Par exemple, si la IBlinkingLight et ITimerLight implémentent un remplacement de PowerStatus, OverheadLight doit fournir un remplacement plus spécifique. Sinon, le compilateur ne peut pas choisir entre les implémentations des deux interfaces dérivées. Vous pouvez généralement éviter cette situation en gardant les définitions d’interface petites et axées sur une fonctionnalité. Dans ce scénario, chaque fonctionnalité d’une lumière est sa propre interface, seulement les classes héritent de plusieurs interfaces.

Cet exemple montre un scénario dans lequel vous pouvez définir des fonctionnalités discrètes pouvant être combinées dans des classes. Vous déclarez n’importe quel ensemble de fonctionnalités prises en charge en déclarant les interfaces prises en charge par une classe. L’utilisation de méthodes d’interface virtuelle par défaut permet aux classes d’utiliser ou de définir une implémentation différente pour tout nombre des méthodes d’interface. Cette fonctionnalité de langage offre de nouvelles façons de modéliser les systèmes réels que vous créez. Les méthodes d’interface par défaut fournissent un moyen plus clair d’exprimer des classes associées qui peuvent combiner différentes fonctionnalités à l’aide d’implémentations virtuelles de ces fonctionnalités.