PROFDINFO.COM

Votre enseignant d'informatique en ligne

Les interfaces

En C#, lorsque nous créons une nouvelle classe, nous sommes limités à dériver d'au plus une autre classe.  Impossible d'avoir, comme en C++, deux ou plusieurs parents à une classe (à part le parent implicite System.Object qui est toujours là).

Pour contourner cette limitation, Microsoft a créé le concept d'interface.  Une interface est comme une classe ne contenant que des méthodes et propriétés abstraites.  Lorsque l'on dérive d'une interface, on doit absolument implémenter toutes les méthodes et propriétés (peu importe comment, tant que la signature est respectée). 

D'après cette définition, une interface ressemble étrangement à une classe abstraite...  Pourquoi alors utiliser une interface?  Parce qu'elle permet un héritage multiple.  En effet, il est possible de dériver de plusieurs interfaces à la fois, ainsi que d'une classe de base en plus si c'est ce que l'on veut faire, alors qu'une classe qui dérive d'une classe abstraite ne peut dériver d'aucune autre classe, abstraite ou non.

Une interface représente en fait un "contrat".  Lorsqu'une classe dérive d'une interface, son créateur a comme responsabilité d'implémenter correctement toutes les méthodes et propriétés qui s'y trouvent.  Lorsqu'un objet dérive d'une interface, nous avons une garantie qu'il implémente toutes les fonctionnalités de l'interface.

Tout comme pour une classe abstraite, il est impossible de créer une instance d'une interface en utilisant le new.  Toutefois, on verra qu'il est possible de créer une instance d'une interface en "castant" un objet implémentant cette interface.  C'est d'ailleurs un design recommandé afin de maximiser le polymorphisme.

Il est également possible de créer une interface qui dérive d'une autre interface (on dira alors que l'interface enfant étend l'interface parent, puisqu'elle hérite de ses méthodes et en rajoute d'autres). 

On peut aussi utiliser le polymorphisme lorsque l'on implémente une interface.  En effet, en créant une classe qui dérive d'une interface, on peut implémenter certaines des méthodes de l'interface avec le mot-clé virtual, permettant ainsi à nos futurs enfants de réimplémenter ces méthodes à leur façon.

Finalement, il est possible de créer des méthodes qui s'attendent à recevoir un objet implémentant une interface quelconque, sans savoir quel est le type de cet objet.  On pourra ensuite passer à ces méthodes n'importe quel objet qui implémente l'interface désirée, sachant que ces méthodes n'utiliseront que les fonctions de l'interface. Un excellent exemple d'abstraction du type.

Déclarer une interface

Une interface se déclare comme une classe mais avec le mot-clé interface au lieu de class.  Bien que ses méthodes soient abstraites, c'est implicite donc on ne doit pas utiliser le mot-clé abstract dans leur déclaration.  Par convention, le nom de l'interface débute généralement par un I majuscule et est suivi d'un adjectif représentant les fonctionnalités à implémenter.  Notez qu'il est inutile d'utiliser les modificateurs d'accès pour les membres d'une interface:  tout ce que contient une interface est nécessairement public. 

Une interface ne peut contenir que des méthodes, propriétés, événements et indexeurs (on verra ces deux derniers éléments plus en détails dans la session). Elle ne peut pas définir d'implémentation de ce qu'elle contient et ne peut pas contenir d'attributs. En ce sens, elle est totalement abstraite, beaucoup plus même qu'une classe abstraite.

Par exemple, on pourrait déclarer une interface IStorable.  Toute classe implémentant cette interface pourrait être écrite et lue sur un support quelconque:

interface IStorable
{
    void Read();
    void Write();
} 

La déclaration est, comme vous pouvez le constater, très simple.  Toute classe qui dérivera de IStorable devra implémenter les méthodes Read() et Write().  L'interface ne se soucie aucunement de la façon dont ces méthodes seront implémentées (que ce soit en écrivant/lisant le contenu des attributs de l'objet dans un fichier ou en écrivant/lisant seulement certains attributs dans une base de données).  Tout ce qui est voulu ici c'est que tout objet implémentant IStorable puisse être écrit quelque part et lu par la suite.

Nous aurons donc une garantie que tout objet implémentant IStorable aura ces fonctionnalités.

On aurait pu également ajouter une propriété abstraite à notre interface:

interface  IStorable
{
    void  Read();
    void  Write();
    int status{get; set;}
}

Ceci aurait garanti que tout objet implémentant IStorable aura une telle propriété, qui pourra être lue ou écrite.  Notez bien que status n'est pas un attribut, puisqu'une interface ne peut pas en contenir.  On ne parle ici que d'une propriété status.  Que l'objet ait effectivement un attribut status correspondant n'est pas essentiel.  Tout ce qu'il faut c'est que la propriété status existe et puisse accepter et retourner une valeur entière sur demande, peu importe d'où elle prend finalement cette valeur.

Dériver de l'interface:  l'implémentation

Lorsqu'une classe dérive d'une autre classe, on appelle la relation entre les deux classes une relation "est un(e)" (is a).  Par exemple, si Tasse dérive de Contenant, on dira que la Tasse est un Contenant.

Lorsqu'une classe dérive d'une interface, on dira plutôt que la relation entre les deux est une relation "implémente" (implements).  Par exemple, si Tasse dérive de l'interface ILavable on dira que la Tasse implémente ILavable, nous garantissant qu'elle est capable d'être lavée.  D'autres classes totalement différentes peuvent implémenter la même interface même si elles ne dérivent pas nécessairement de Contenant (comme par exemple la classe Personne).

Dériver d'une interface se fait de la même façon que dériver d'une autre classe.  On utilise le ":" après le nom de la classe, suivi du nom de l'interface.  Si on dérive de plusieurs interfaces, on peut toutes les énumérer en les séparant par des virgules.  Si en plus on dérive d'une autre classe, on devra mettre le nom de la classe en premier dans l'énumération.

Par exemple, on pourrait imaginer une classe Document qui implémenterait IStorable:

public  class Document:IStorable
{
    #region Membres de IStorable
    public  void Read()
    {
        Console.WriteLine("Je lis le fichier {0} sur le disque",s);
    }

    public void Write()
    {
        Console.WriteLine("J'écris  le fichier {0} sur le disque",s);
    }  
  
    public int status
    {
        get {return _status;}
        set {_status  = value;}
    }
    #endregion

    public Document(string s)
    {
        Console.WriteLine("Je  créé le document {0}",s);
        this.s = s;
    }
	
    private int _status;
    private string s;
}

Remarquez qu'IntelliSense, toujours aussi prévenant, nous écrit automatiquement le squelette de tout ce que l'on doit implémenter lorsqu'il voit que notre Document dérive de IStorable.  En appuyant sur TAB, une région est créée, contenant toutes nos déclarations.  On n'a plus qu'à aller y coder.

Remarquez également que dans cet exemple, un attribut status a été créé, même si ce n'était pas absolument nécessaire (c'est tout de même généralement ce que l'on fera quand on aura à implémenter une propriété...)

Les deux façons d'utiliser l'interface

Comme dans notre exemple Document implémente IStorable, il contient des méthodes Read() et Write() qui peuvent être appelée directement d'un Document, comme toute autre méthode, en faisant par exemple:

Document doc = new  Document("Titre");
doc.Read();

Ceci est très bien et fonctionnera évidemment sans problème.  Toutefois, on préférera souvent, question d'abstraction, créer un objet du type de l'interface en "castant" l'objet qui l'implémente, puis appeler les méthodes de l'interface à partir du nouvel objet.  Par exemple:

Document Doc = new  Document("Titre");
IStorable isDoc =  (IStorable) doc;
isDoc.Read();

Rappelons qu'il est impossible de créer une instance d'une interface en utilisant le new, mais qu'il est possible d'en créer une à partir d'un objet qui l'implémente.  Dans notre exemple, on crée un objet de type IStorable, qui ne contient que les implémentations des méthodes et propriétés d'IStorable dans Document.  Autrement dit, on pourra appeler isDoc.Read(), isDoc.Write() et utiliser isDoc.Status, mais rien d'autre.  Tout attribut ou méthode de Document n'est pas accessible directement par isDoc, mais les méthodes Read() et Write() peuvent y accéder puisqu'elles sont à l'interne.

La raison pour laquelle on recommande de créer une instance de l'interface à partir d'un objet qui l'implémente avant d'utiliser ses membres est simplement pour renforcer l'abstraction du type.  On pourrait manipuler plusieurs objets de types différents implémentant tous la même interface et, à ce moment, se retrouver avec plusieurs objets du type de l'interface est un bon design polymorphique.

Toutefois il s'agit ici d'un choix de design et il n'est pas mauvais d'appeler les membres d'une interface directement à partir de l'objet l'implémentant (sauf dans un cas d'exception rare qu'on verra plus loin!).

Étendre une interface

On peut étendre une interface simplement en créant une nouvelle interface qui dérive d'une autre.  On dira alors que la nouvelle interface étend la première, puisqu'elle hérite de tout ce que la première contient et en ajoute. 

Un objet qui implémente une interface étendue devra implémenter tous les membres de l'interface et de son interface parent.  On considérera alors évidemment qu'il implémente automatiquement l'interface parent.

On peut combiner plusieurs interfaces en une en les étendant.  Il suffit de créer une interface qui dérive de plusieurs autres interfaces et qui, si désiré, ajoute des membres.  Une classe qui implémenterait l'interface résultat serait considérée comme implémentant aussi tous les parents.

Implémenter plusieurs interfaces

On peut implémenter dans une classe autant d'interfaces que l'on veut sans aucun problème.  Le seul hic possible:  plusieurs interfaces peuvent posséder des méthodes ayant la même signature.  Qu'arriverait-il en effet si notre Document implémentait également l'interface ITalk, qui permet de faire lire un document à voix haute avec un simulateur vocal:

interface ITalk
{
    void Read();
}


public class  Document:IStorable, ITalk
{
    // ...
}

Document devra implémenter deux méthodes Read() n'acceptant aucun paramètre et retournant void.  La seule solution à ce moment est d'implémenter au moins une des deux méthodes explicitement, c'est à dire en nommant l'interface à laquelle elle appartient.  Par exemple:

public class Document:IStorable, ITalk
{
    //  Implémentation pour IStorable
    void IStorable.Read()
    {
       Console.WriteLine("Je lis le fichier {0} sur le disque",s);
    }

    //  Implémentation pour ITalk
    public void Read()
    {
        Console.WriteLine("Je lis à voix haute le fichier {0}",s);
    }

    //  ...
}

Ici, on a choisi d'implémenter le Read() de IStorable explicitement, ce qui nous permet d'implémenter celui de ITalk implicitement (comme on faisait depuis le début).  On aurait pu faire le contraire ou encore implémenter les deux Read() explicitement. Ne serait-ce pas plus simple alors de toujours implémenter les méthodes d'interface explicitement dee toute façon? Peut-être...

Mias notez toutefois les conséquences d'une implémentation explicite:

  1. Une méthode implémentée explicitement est automatiquement publique et on ne peut pas utiliser de modificateur dans sa déclaration.
  2. Elle ne peut pas non plus être déclarée virtuelle, ce qui nous empêchera alors de faire du polymorphisme sur cette méthode.
  3. On ne pourra pas accéder à la méthode à partir d'une instance de l'objet – on sera obligé de d'abord caster l'objet en un type interface.

Ce sont des limitations assez sévères. Elles sont inévitables dans le cas où on implémente des interfaces ayant des méthodes à la même signature (ce qui est tout de même rare).  Remarquez qu'il est toujours possible d'implémenter volontairement une méthode de façon explicite même si elle est unique, justement pour forcer l'utilisateur de la classe à caster en un type interface avant d'appeler la méthode. Un choix de design qui dépendra de votre analyse!

S'assurer avant de caster:  l'opérateur is

Lorsque l'on désire caster un objet en un type interface, il est bon de s'assurer que cet objet implémente effectivement l'interface, sinon le programme va planter en levant une exception de type System.InvalidCastException.

On peut bien sûr utiliser les try/catch pour attraper cette exception, mais il est plus simple et plus clair de simplement vérifier avant de caster.

L'opérateur is retourne vrai si l'objet testé est non null et s'il peut être casté en le type donné.

On peut donc faire simplement:

if (doc is IStorable)
{
    IStorable  isDoc = (IStorable) doc;
    isDoc.Read();
}

Une autre alternative:  l'opérateur as

On peut si l'on préfère utiliser l'opérateur as.  Celui-ci vérifie si la conversion est possible.  Si oui, il la fait sur-le-champ et retourne le résultat.  Sinon il retourne null.  L'opérateur as, contrairement à un cast, fonctionne toujours et ne génère jamais d'exceptions.

L'exemple précédent par exemple pourrait être fait ainsi:

IStorable isDoc = doc as IStorable
if (isDoc != null)
    isDoc.Read();

Pouvez-vous déterminer si une des deux méthodes est plus efficace que l'autre?

Appliquer le polymorphisme sur l'implémentation d'une interface

Lorsque l'on implémente une interface, on doit entre autres s'assurer d'implémenter ses méthodes.  Comment nous les implémentons est libre à nous.  Nous pouvons donc, si nous le voulons, les implémenter de façon virtuelle.

Par exemple, notre Document pourrait déclarer la méthode Read() comme étant virtuelle:

{
    #region Membres de IStorable

  
    public virtual void Read()
    {
        Console.WriteLine("Je lis le fichier {0} sur le disque",s);
    }

    public void Write()
    {
        Console.WriteLine("J'écris le fichier {0} sur le disque",s);
    }    

    // ...
}

À ce moment, une classe Note qui dériverait de Document pourrait faire un override sur la méthode Read() et l'implémenter différemment.  Dans ce cas, la version la plus spécialisée serait appelée lorsqu'une Note serait passée à une référence de type Document, comme nous y sommes habitués.  La version la plus spécialisée sera également utilisée si l'on caste notre instance de Note en un objet de type IStorable.

Remarquez que l'on n'est pas obligé (comme le démontre l'exemple ci-haut) de déclarer toutes les méthodes d'une interface comme virtuelles.  Une déclaration différente peut être faite pour chaque méthode.

Les paramètres de type interface

Il est possible (et c'est même l'une des grandes utilisations de l'interface) de déclarer un paramètre à une méthode comme étant d'un type interface.  Par exemple, on pourrait déclarer dans notre classe de Test la méthode:

static void test(IStorable ist)
{
    ist.Read();
}

Ceci indique que la méthode test() s'attend à recevoir un objet qui implémente l'interface IStorable, peu importe son type.  On n'est pas obligé de lui passer un objet que l'on aura casté en IStorable, le cast sera fait implicitement à l'appel de la fonction. Du coup, le programme refusera de compiler si l'on tente de passer à test() un objet qui n'implémente pas IStorable.

Évidemment, à l'intérieur de notre fonction test(), on ne peut qu'utiliser les membres propres à IStorable, ce qui est tout à fait normal puisqu'on n'a aucune idée du type de l'objet.  Tout ce que l'on sait à son sujet, c'est qu'il implémente IStorable.

Cette fonctionnalité est très importante.  Elle nous permet de recevoir n'importe quel objet dans une méthode, pour autant qu'on nous garantisse qu'il implémente certaines fonctionnalités, peu importe comment.  Beaucoup de classes de la librairie .NET utilisent cette façon de faire.  Par exemple, un objet DataGrid peut avoir comme source de données n'importe quel objet qui implémente l'interface IList (on l'utilise généralement pour afficher le contenu d'un objet DataSet, qui va chercher ses données dans un BD).  Si l'on crée une nouvelle classe qui se comporte comme une liste, elle pourra être passée directement à un DataGrid, sans que l'on ait à modifier le DataGrid lui-même!

Les interfaces sont donc une grande force du langage C#, force qui vient compenser en partie la faiblesse du parentage unique.