Aller au contenu

Les interfaces

Définition

Une interface est un type qui définit un contrat que des classes, des structs ou des records peuvent implémenter. Ce contrat décrit notamment les méthodes, propriétés, indexeurs et événements que ces types doivent proposer. Il définit généralement leur signature sans préciser la manière dont ils doivent être implémentés, même si, depuis la version 8 du langage C#, certains membres peuvent également disposer d’une implémentation par défaut. Par convention, le nom d’une interface commence par la lettre I.

L’interface suivante représente, par exemple, un produit :

public interface IProduct
{
    string Name { get; }

    decimal GetPrice();
}

Cette interface indique que tout type qui l’implémente doit proposer une propriété nommée Name de type string et une méthode nommée GetPrice qui retourne un montant de type decimal. Elle ne précise toutefois pas comment le nom est stocké ni comment le prix est calculé. Ces responsabilités appartiennent aux types qui implémentent cette interface.

Enfin, les membres d’une interface sont public par défaut. Depuis la version 8 du langage C#, certains membres d’interface peuvent en effet déclarer explicitement un autre niveau d’accessibilité.

Rôles des interfaces dans un logiciel

Les interfaces jouent un rôle important dans la conception des logiciels. Elles permettent tout d’abord de mettre en œuvre le polymorphisme, en manipulant des objets de types différents qui respectent un même contrat.

Elles favorisent également un faible couplage, car un composant peut dépendre d’une abstraction plutôt que d’une classe concrète. Cet aspect sera très utilisé lors de la conception réalisée dans les Design Patterns. L’implémentation utilisée peut ainsi être remplacée sans modifier le code qui dépend de l’interface. Ce mécanisme facilite notamment l’injection de dépendances, grâce à laquelle les objets nécessaires à un composant lui sont fournis.

Les interfaces améliorent la modularité et l’évolutivité du logiciel. Elles facilitent la mise en place de tests automatisés, en permettant de remplacer les implémentations réelles par des doublures de test (par l'utilisation de mocks par exemple).

Enfin, elles contribuent à l’application du principe d’inversion des dépendances (principe SOLID DIP (Dependency Inversion)) : les composants de haut niveau et ceux de bas niveau dépendent alors d’abstractions plutôt que directement les uns des autres.

Implémentation d’une interface

Une classe implémente une interface en faisant suivre son nom du caractère : puis du nom de l’interface. Voici un exemple avec la classe Donut :

public class Donut : IProduct
{
    public string Name { get; }

    public decimal Price { get; }

    public Donut(string name, decimal price)
    {
        Name = name;
        Price = price;
    }

    public decimal GetPrice()
    {
        return Price;
    }
}

La classe Donut s’engage ainsi à respecter le contrat défini par l’interface IProduct. Elle fournit une implémentation publique de chacun des membres déclarés par cette interface.

Manipulation d’un objet au travers d’une interface

Une variable dont le type est une interface, peut référencer un objet dont le type implémente cette interface :

IProduct product = new Donut("Donut au chocolat", 2.50m);

La variable product donne uniquement accès aux membres proposés par l’interface IProduct, même si l’objet qu'elle contient est une instance de la classe Donut.

Implémentation de plusieurs interfaces

Dans le langage C#, un type peut implémenter plusieurs interfaces. Si nous définissons ces trois interfaces :

public interface IProduct
{
    string Name { get; }

    decimal GetPrice();
}

public interface IPreparable
{
    void Prepare();
}

public interface IStockable
{
    int StockQuantity { get; set; }
}

Alors la classe Donut peut les implémenter :

public class Donut : IProduct, IPreparable, IStockable
{
    public string Name { get; init; }

    public decimal Price { get; init; }

    public int StockQuantity { get; set; }

    public Donut(string name, decimal price, int stockQuantity)
    {
        Name = name;
        Price = price;
        StockQuantity = stockQuantity;
    }

    public decimal GetPrice()
    {
        return Price;
    }

    public void Prepare()
    {
        // Préparation.
    }
}

Un même objet peut alors être manipulé au travers de plusieurs interfaces :

Donut donut = new("Donut au chocolat", 2.50m, 10);

IProduct product = donut;
IPreparable preparable = donut;
IStockable stockable = donut;

Ces quatre variables référencent le même objet, mais elles n’en exposent pas les mêmes membres :

  • La variable donut donne accès à tous les membres publics de la classe Donut.
  • La variable product donne accès aux membres de IProduct.
  • La variable preparable donne accès aux membres de IPreparable.
  • La variable stockable donne accès aux membres de IStockable.

Ainsi, une interface représente une vue particulière d’un objet.

Héritage d’interfaces

Une interface peut hériter d’une ou de plusieurs autres interfaces. Dans cet exemple, l'interface IStockableProduct hérite de l'interface IProduct :

public interface IProduct
{
    string Name { get; }

    decimal GetPrice();
}

public interface IStockableProduct : IProduct
{
    int StockQuantity { get; set; }
}

Une classe qui implémente l’interface IStockableProduct doit implémenter tous ses membres, y compris ceux hérités de l’interface IProduct :

public class Donut : IStockableProduct
{
    public string Name { get; init; }

    public decimal Price { get; init; }

    public int StockQuantity { get; set; }

    public Donut(string name, decimal price, int stockQuantity)
    {
        Name = name;
        Price = price;
        StockQuantity = stockQuantity;
    }

    public decimal GetPrice()
    {
        return Price;
    }
}

Un objet de cette classe peut être manipulé au travers des deux interfaces :

Donut donut = new("Donut au chocolat", 2.50m, 10);

IStockableProduct stockableProduct = donut;
IProduct product = donut;

Une interface peut également hériter de plusieurs interfaces :

public interface IOrderableProduct : IProduct, IStockable, IPreparable
{
    void AddToOrder();
}

L’héritage d’interfaces permet ainsi de composer un contrat à partir de plusieurs contrats existants.

Implémentation explicite d’une interface

Une classe peut également implémenter explicitement un membre d’une interface. Pour cela, le nom du membre doit être préfixé par le nom de l’interface :

public interface IProduct
{
    decimal GetPrice();
}

public class Donut : IProduct
{
    // Membres de la classe

    decimal IProduct.GetPrice()
    {
        return 2.50m;
    }
}

Le membre implémenté explicitement n’est pas accessible directement depuis un objet de type Donut :

Donut donut = new("Donut au chocolat", 2.50m, 10);

// donut.GetPrice(); // Erreur de compilation

L’objet doit être manipulé au travers de l’interface :

IProduct product = donut;

decimal price = product.GetPrice();

L’implémentation explicite permet de rendre un membre accessible uniquement au travers de l’interface concernée. Elle permet aussi de fournir des comportements différents, lorsque plusieurs interfaces déclarent des membres possédant la même signature.

Membres portant le même nom dans plusieurs interfaces

Deux interfaces peuvent déclarer des membres possédant la même signature :

public interface IProduct
{
    string GetDescription();
}

public interface ILoggable
{
    string GetDescription();
}

Une seule méthode publique peut implémenter simultanément les deux contrats :

public class Donut : IProduct, ILoggable
{
    // Membres de la classe

    public string GetDescription()
    {
        return "Donut au chocolat";
    }
}

Dans ce cas, la même méthode est exécutée, quelle que soit l’interface au travers de laquelle l’objet est manipulé :

Donut donut = new("Donut au chocolat", 2.50m, 10);

IProduct product = donut;
ILoggable loggable = donut;

Console.WriteLine(product.GetDescription());
Console.WriteLine(loggable.GetDescription());

Si les deux interfaces doivent exécuter des comportements différents, leurs membres peuvent être implémentés de manière explicite :

public class Donut : IProduct, ILoggable
{
    // Membres de la classe

    string IProduct.GetDescription()
    {
        return "Donut au chocolat";
    }

    string ILoggable.GetDescription()
    {
        return "Objet de type Donut";
    }
}

Le comportement exécuté dépend alors de l’interface utilisée :

Donut donut = new("Donut au chocolat", 2.50m, 10);

IProduct product = donut;
ILoggable loggable = donut;

Console.WriteLine(product.GetDescription());
// Donut au chocolat

Console.WriteLine(loggable.GetDescription());
// Objet de type Donut

Implémentation par défaut des membres

Depuis la version 8 du langage C#, un membre d’interface peut posséder une implémentation par défaut. Les types qui implémentent l’interface peuvent alors bénéficier de ce comportement, sans avoir à le redéfinir. Voici un exemple :

public interface IProduct
{
    decimal GetPrice();

    decimal GetPriceWithTax(decimal taxRate)
    {
        return GetPrice() * (1 + taxRate);
    }
}

La classe Donut doit implémenter la méthode GetPrice, mais elle peut bénéficier de l’implémentation par défaut de GetPriceWithTax lorsque l’objet est manipulé au travers de l’interface IProduct :

IProduct product = new Donut("Donut au chocolat", 2.50m, 10);

decimal priceWithTax = product.GetPriceWithTax(0.20m);

Cette méthode n’étant pas déclarée comme membre de la classe Donut, elle ne peut pas être appelée directement au travers d’une variable de type Donut :

Donut donut = new("Donut au chocolat", 2.50m, 10);

// donut.GetPriceWithTax(0.20m); // Erreur de compilation

Cependant, la classe Donut peut fournir sa propre implémentation publique de cette méthode :

public class Donut : IProduct
{
    // Membres de la classe

    public decimal GetPriceWithTax(decimal taxRate)
    {
        return Math.Round(Price * (1 + taxRate), 2);
    }
}

Ainsi, la méthode GetPriceWithTax définie dans cette classe est applicable aux objets de type Donut.

En résumé, l'implémentation des membres par défaut dans les interfaces permet de faire évoluer une interface existante en ajoutant des membres, sans avoir à les implémenter immédiatement dans tous les types qui implémentent l'interface. Même si cet aspect peut s'avérer pratique, il doit être utilisé avec modération : le rôle d'une interface est avant tout de définir un contrat, et non l’implémentation de comportements.