5. Interfaces

5.1. Introduction

La notion d'interface est absolument centrale en Java, et massivement utilisée dans le design des API du JDK et de Java EE. Tout bon développeur Java doit absolument maîtriser ce point parfaitement. La notion d'interface est utilisée pour représenter des propriétés transverses de classes. Là où une classe abstraite doit être étendue et spécialisée, une interface nous dit juste que telle classe possède telle propriété, indépendamment de ce qu'elle représente. Cette possibilité est massivement utilisée dans les API standard du JDK, et nous en verrons des exemples précis dans la suite. Prenons l'exemple d'une hiérarchie de classes dont le but est de coder des moyens de locomotion. On peut imaginer une casse Transport, classe de base de laquelle toutes les autres classes vont hériter. Puis des classes Avion, Voiture, Moto, Camion, etc... Nos moyens de locomotion ont besoin de faire le plein (on peut le regretter, mais la vie est ainsi faite). Pour cela ils se rendent dans une station service. Cette station service possède une méthode faireLePlein(...), censée prendre un moyen de transport en paramètre. Ecrivons tout d'abord notre jeu de classes.

Exemple 89. Les classes "moyen de locomotion"

public  class Transport {  
     public  void roule() ;  
}  
  
 public  class Voiture  extends Transport {  
     public  void conduit() ;  
}  
  
 public  class Avion  extends Transport {  
     public  void vole() ;  
}  
  
 public  class Moto  extends Transport {  
     public  void seFaufile() ;  
}  
  
 public  class Velo  extends Transport {  
     public  void pedale() ;  
}

Codons à présent notre station service, notamment sa méthode faireLePlein(...). On pourrait penser que cette méthode faireLePlein(...) peut prendre un objet instance de Transport en paramètres, après tout cette classe est la super classe de toute notre hiérarchie. Malheureusement, dans la hiérarchie de Transport, il y a la classe Velo, et un vélo ne fréquente pas les stations service. Ou en tout cas pas pour y faire le plein. Une mauvaise approche serait de coder la méthode faireLePlein(...) de cette façon.

Exemple 90. Méthode faireLePlein(...) - 1

public  class StationService {  
     public  void faireLePlein(Transport transport) {  
         if (transport  instanceof Velo) {  
             // ne pas faire le plein   
        }  else {  
             // faire le plein  
        }  
    }  
}

Cette méthode fonctionne dans notre cas, mais elle est catastrophique, car elle ne fonctionne que dans notre cas ! Supposons qu'un autre développeur reprenne notre code, et sans connaître l'implémentation de StationService, écrive une autre extension de Transport, Tricycle. Si un tricycle se présente à la station service, notre système aura un problème... Le principal problème de cette approche est qu'il faut modifier le code de cette méthode à chaque fois que l'on ajoute des classes dans la hiérarchie de Transport. De plus, si la classe Transport est exposée dans une API destinée à être réutilisée, si l'on n'y prend pas garde, elle peut être étendue par des classes dont on n'aura jamais connaissance. Une telle approche est donc à proscrire absolument ! C'est là que les interfaces entrent en jeu et nous aident à résoudre notre problème. Écrivons une interface Motorise, et utilisons-la dans notre hiérarchie d'objets.

Exemple 91. Interface Motorise

public  interface Motorise {  // notre interface  
     public  void faisLePlein() ;  
}  
  
 public  class Transport {  // une instance de Transport ne sait pas toujours 
                          // faire le plein  
     public  void roule() {}  
}  
  
 public  class Voiture  extends Transport  implements Motorise {  
     public  void conduit() {}  
     public  void faisLePlein() {}  
}  
  
 public  class Avion  extends Transport  implements Motorise {  
     public  void vole() ;  
     public  void faisLePlein() {}  
}  
  
 public  class Moto  extends Transport  implements Motorise {  
     public  void seFaufile() ;  
     public  void faisLePlein() {}  
}  
  
 public  class Velo  extends Transport {  // ne sait pas faire le plein  
     public  void pedale() ;  
}

On peut alors écrire notre classe StationService de la façon suivante

Exemple 92. Ecriture de StationService avec Motorise

public  class StationService {  
     public  void faireLePlein(Motorise motorise) {  
        motorise.faisLePlein() ;  
    }  
}

Ce qui nous donne un code qui ne dépend plus des classes de la hiérarchie de Transport, et en particulier du fait que l'on peut en ajouter dedans. Ici, notre station service accepte toute instance d'une classe qui possède une méthode faisLePlein(), dont l'existence est spécifiée par l'interface Motorise. Que cette classe soit une extension de Transport ou non n'a pas d'importance, la station service ne le voit même pas. C'est en ce sens que l'on parle de propriétés transverses pour les interfaces. C'est bien sûr ce type d'approche qu'il faut choisir lorsque l'on veut traiter des cas analogues à celui-la.

5.2. Définition

Techniquement, une interface s'écrit comme une classe, à la différence que l'on remplace le mot-clé class par interface.

Exemple 93. Une interface

public  interface Motorise {  // déclarée dans Motorise.java  
     public  void faisLePlein() ;  
}

Le mot-clé abstract peut être omis des signatures de méthodes (on le rencontre parfois, et il est parfaitement légal). Le mot-clé public aussi, car toutes les méthodes d'une interface le sont obligatoirement. Une méthode privée dans une interface ne serait pas vraiment utile... Une interface peut en étendre une autre, et même plusieurs. Elle ne peut pas étendre de classe, abstraite ou concrète. Une classe n' étend pas une interface, elle l'implémente. On utilise pour cela le mot-clé implements. Une classe peut implémenter autant d'interfaces que l'on veut. Une classe concrète doit obligatoirement fournir une implémentation pour toutes les méthodes déclarées par toutes les interfaces qu'elle implémente, soit elle-même, soit une de ses super classes.

Exemple 94. Implémentation d'une interface

public  class Voiture  implements Motorise {  
     public  void faisLePlein() {  
         // corps de la classe  
    }  
}

Une classe abstraite peut implémenter autant d'interfaces que l'on veut. Elle n'est pas tenue d'implémenter les méthodes des interfaces qu'elle utilise, pas même de façon abstraite. Une interface peut être vue comme un niveau d'abstraction au-dessus des classes abstraites, à ceci près que l'on a plus de souplesse avec les interfaces que les classes, notamment en ce qui concerne l'héritage multiple.

5.3. Java 8 et les interfaces

La notion d'interface est profondément modifiée en Java 8. Jusqu'en Java 7 une interface ne peut posséder que des méthodes abstraites (donc des signatures de méthodes) et des constantes. À partir de Java 8, on peut ajouter deux éléments supplémentaires dans une interfaces : des méthodes statiques et des méthodes par défaut.

5.3.1. Méthode statique dans une interface

Une méthode statique est un élément que l'on peut rencontrer dans une classe normale. Un appel statique se fait au travers de la classe, il n'a besoin d'aucune instance pour être exécuté. Java 8 autorise des méthodes statiques dans les interfaces, qui obésissent aux mêmes règles que celles que l'on trouve dans les classes abstraites ou concrètes.

Exemple 95. Méthode statique dans une interface

public  interface Function<T, R> {  
    R apply(T t) ;
    
     static <T> Function<T, T> identity() {
             return t -> t ;
    }
}

La possibilité d'ajouter des méthodes statiques dans les interfaces est une nouveauté de Java 8, cela dit, le concept de méthode statique en lui-même n'est pas nouveau.

5.3.2. Méthode par défaut

La notion de méthode par défaut est, elle, tout à fait nouvelle, et constitue une évolution majeure du langage. Du point de vue technique, une méthode par défaut ressemble à s'y méprendre à une méthode concrète, écrite dans une interface.

Exemple 96. Méthode par défaut dans une interface

public  interface List<T> {  
    
     default  void sort(Comparator<t> comparator) {
        Collections.sort(this, comparator) ;
    }
}

Une méthode par défaut permet d'écrire une méthode dans une interface, en fixant sa signature et en donnant une implémentation. Une classe concrète qui implémente une interface doit nécessairement fournir directement ou indirectement (dans une de ses super-classes) une implémentation pour toutes les méthodes abstraites de cette interface. En revanche, si cette interface possède des méthodes par défaut, toute classe concrète qui l'implémente est libre de fournir sa propre implémentation de cette méthode par défaut, ou de ne rien faire. Dans ce cas, les instances de cette classe utiliseront l'implémentation par défaut fournie dans l'interface. Ce mécanisme n'existe qu'à partir de Java 8. Jusqu'en Java 7 il n'est pas possible de créer des méthodes par défaut dans les interfaces.

5.3.3. Méthodes par défaut et héritage

Il n'existe qu'un seul type d'héritage multiple en Java : l'héritage multiple de type. Effectivement, un type peut étendre autant de type que l'on veut. On peut s'en rendre compte en examinant la classe String.

Exemple 97. Héritage multiple de type sur la classe String

public  class String  implements Serializable, Comparable, CharSequence {  
     // corps de la classe
}

On se rend compte que le type String étend tout à la fois les types Serializable, Comparable et CharSequence. Ce que ne possède pas Java 7 et que Java 8 introduit est l'héritage multiple d' implémentation . Effectivement, une implémentation dans une classe concrète peut étendre plusieurs méthodes par défaut à la fois. Considérons l'exemple suivant.

Exemple 98. Héritage multiple d'implémentation

public  interface A {  
     default  void a() {
         // implémentation
    }
}

 public  interface B {
     default  void a() {
         // implémentation
    }
}

 public  class C  implements A, B {  // !!! erreur de compilation !!!
     // corps de la classe
}

Construisons une instance de C et invoquons la méthode a(). La question se pose : quelle implémentation de la méthode a() va-t-elle être invoquée ? Celle de A ? Celle de B ? La vérité est qu'il y a la une ambiguïté qui n'existait pas en Java 7. L'héritage multiple d'implémentation amène des cas ambiguës, chose que l'on ne connaissait pas en Java. La bonne nouvelle tout de même est que cette ambiguïté peut être détectée à la compilation. Et en fait, dans ce cas, une erreur sera levée par le compilateur, qu'il va nous falloir corriger. Comment corriger cette erreur ? Tout simplement en levant l'ambiguïté. Nous avons deux solutions pour ce faire.
  • Créer une implémentation concrète de la méthode a() dans la classe C. Un implémentation concrète a toujours la priorité sur une implémentation par défaut.
  • Décider qu'une des deux interfaces, par exemple A étend la seconde. Dans ce cas, A devient plus spécifique que B, et l'on dit que c'est l'implémentation la plus spécifique qui a la priorité.
Deux règles très simples sont donc à retenir ici : en cas de conflit l'implémentation gagne contre l'implémentation par défaut, et l'implémentation par défaut plus spécifique gagne contre une moins spécifique. Notons qu'une implémentation peut appeler explicitement une implémentation par défaut grâce à la syntaxe suivante.

Exemple 99. Héritage multiple d'implémentation

public  interface A {  
     default  void a() {
         // implémentation
    }
}

 public  class C  implements A {
     public  void a() {
         return A.super.a() ;  // appelle la méthode par défaut de A
    }
}

Notons enfin qu'une méthode par défaut ne peut pas être une méthode de la classe Object. Dans la mesure où une implémentation surcharge systématiquement une méthode par défaut, on a la garantie qu'une telle méthode ne serait jamais appelée ! Ce point est également détecté à la compilation.

5.3.4. Interface fonctionnelle

La notion d'interface fonctionnelle est également une notion introduite en Java 8. Il n'y a pas d'interface fonctionnelle dans les projets jusqu'en Java 7. La notions d'interface fonctionnelle est rétro-compatible. Cela signifie qu'une interface écrite en Java 7 (ou avant) peut devenir une interface fonctionnelle dans une application Java 8, sans modification, ni même être recompilée. Cette notion est centrale en Java 8, puisque les interfaces fonctionnelles sont les modèles des expressions lambda. Qu'est-ce qu'une interface fonctionnelle ? La définition est très simple : il s'agit d'une interface qui ne possède qu'une unique méthode abstraite . Cette définition mérite quelques commentaires.
  • Une unique méthode abstraite signifie qu'une interface fonctionnelle peut compter autant de méthodes statiques ou de méthodes par défaut que l'on veut. Et c'est bien le cas dans la pratique !
  • Les méthodes statiques et par défaut n'existent qu'à partir de Java 8. En Java 7 et avant, une interface fonctionnelle ne comporte donc qu'une unique méthode.
  • Les méthodes de la classe Object ne comptent pas dans ce total. Une interface fonctionnelle peut donc définir une première méthode abstraite a() et une seconde toString(). Pourquoi ? Tout simplement parce que tous les objets en Java étendent Object, donc on est sûr d'avoir une implémentation de ces méthodes dans les instances effectivement créées. En fait, si l'on déclare une méthode de la classe Object dans une interface, ce n'est pas pour garantir sa présence dans l'instance de cette interface, qui possédera de toute façon cette méthode, mais pour redéfinir la sémantique de cette méthode dans la Javadoc. C'est le cas par exemple dans la méthode equals(Object o), redéfinie dans l'interface Collection.
Dans le cas de Java 8, on peut annoter une interface avec l'annotation @FunctionalInterface. Si cette annotation est présente, alors le compilateur vérifiera que cette interface est bien fonctionnelle, et lèvera une erreur si tel n'est pas le cas.

5.4. Utilisation des interfaces

Les interfaces ont été conçues pour traiter la problématique des propriétés transverses. Prenons deux exemples, tirés de l'API standard du JDK. L'interface Serializable est utilisée comme un tag. Cette interface ne comporte aucune méthode, sa seule fonction est de marquer les classes qui pourront être "sérialisées", c'est-à-dire dont les instances pourront être écrites dans des fichiers ou transmises via un réseau. L'interface Comparable est ajoutée à certaines classes, comme les classes enveloppe des types de base, ou la classe String. Elle indique que les instances de ces classes peuvent être comparées au sens d'un algorithme fourni par l'implémentation. Donc, on peut ranger des instances de Comparable dans des ensembles auto-ordonnés. Nous verrons un exemple précis lorsque nous présenterons l'API Collection.

5.5. Définition de constantes dans les interfaces

Notons que les interfaces peuvent aussi être utilisées pour définir des constantes, à la manière du C. Il n’y a pas d’équivalent strict du #define en Java. Une façon de faire est de placer ces constantes dans une interface. Toutes les classes qui ont besoin d’accéder à ces constantes n’ont plus qu’à déclarer cette interface dans leur clause implements. Voyons ceci sur l’exemple suivant.

Exemple 100. Constante définie dans une interface

public  interface Constantes {  // dans le fichier Constantes.java  
     public  static  final  double G =  9.81 ;  
}  
     
 public  class ChampGravitationnel  // dans le fichier ChampGravitationnel.java  
 implements Constantes {  
     private  double vitesse ;  
        
     public  double calculeVitesse(double temps) {  
         return G*temps ;  
    }  
}

Précisons ici que suivant les auteurs, cette façon de faire est tantôt un pattern, tantôt un anti-pattern. Le fait est que depuis l'introduction des imports statiques, cette méthode de déclaration de constantes n'a plus vraiment de justification. Si l'on peut rencontrer ce genre de choses dans du code Java 1.4, il semble plus curieux de le voir en version 1.5 et suivantes.

5.6. Utilité des interfaces

Les interfaces sont massivement utilisées dans les API Java, que ce soit celles du JDK ou les API avancées de JEE (par exemple). Notamment, spécifier une API signifie le plus souvent proposer un jeu d'interfaces qui exposent des fonctionnalités. À côté, on propose une implémentation de cette API, jeu de classes qui implémentent les interfaces de cette API. C'est le cas dans le JDK de l'API JDBC, qui permet de se connecter aux bases de données. L'API définit tout d'abord l'interface Connection, sans en fournir d'implémentation. Cette implémentation est fournie par les fabricants de bases de données, via les pilotes propres à chaque base. L'interface Connection propose ensuite un jeu de méthodes qui retournent toutes des interfaces également. Par exemple, la méthode createStatement() retourne un objet Statement. Cet objet possède lui-même une méthode execute(String) qui permet de lancer des requêtes SQL sur la base de données. Enfin, lorsque l'on gère des projets volumineux, et que la séparation en modules devient nécessaire, les interfaces se révèlent très utiles pour séparer "ce que fait" chaque module de "la façon dont il le fait". Chaque module expose des interfaces, qui, une fois fixées, ne bougeront plus, alors que les implémentations, non exposées, ont toute latitude pour être redéfinies en permanence. Et pour terminer, il n'est pas possible de ne pas citer ce que l'utilisation des interfaces peut apporter aux tests que tout bon développeur ne manque pas d'écrire en parallèle du développement de son code !
Java langage & API
Retour au blog Java le soir
Cours & Tutoriaux
Table des matières
Introduction : un peu d'histoire
1. Java : genèse d'un nouveau langage
Programmer en Java
1. Un premier exemple
1.1. Aperçu général, cycle de vie
1.2. Un premier programme
1.3. Programmes, applets, servlets, etc...
2. Une première classe
2.1. Écriture d'une classe
2.2. Instanciation de cette classe
3. Types de base, classes et objets
3.1. Types de base
3.2. Classes et objets
Classes Object et String
1. Introduction
2. La classe Object
2.1. La méthode toString()
2.2. La méthode clone()
2.3. La méthode equals()
2.4. La méthode hashCode()
2.5. La méthode finalize()
2.6. La méthode getClass()
3. La classe String
3.1. Introduction
3.2. Construction d'un objet de type String
3.3. Concaténation, StringBuffer et StringBuilder
3.4. Concaténations de chaînes de caractères depuis Java 8
3.5. Concaténations de chaînes de caractères depuis Java 11
3.6. Extraction d'une sous-chaîne de caractères
3.7. Comparaison de deux chaînes de caractères
3.8. Méthodes de comparaisons lexicographiques
3.9. Méthode de recherche de caractères
3.10. Méthode de modification de chaîne
3.11. Méthode de duplication
3.12. Support de l'unicode, internationalisation
Structure d'une classe
1. Introduction
2. Classes
2.1. Classes publiques
2.2. Classes internes
2.3. Classe membre
2.4. Classes locales
2.5. Classes anonymes
2.6. Le mot-clé this
3. Éléments statiques
3.1. Champ statique
3.2. Cas des constantes
3.3. Bloc statique
3.4. Classe membre statique
4. Membres d'une classe, visibilité
4.1. Bloc non statique
4.2. Accès à un membre, visibilité
4.3. Les champs
4.4. Signature d'une méthode
4.5. Les méthodes
4.6. Getters et Setters
5. Constructeur, instanciation
5.1. Chargement d'une classe
5.2. Constructeurs d'une classe
5.3. Instanciation d'un objet
5.4. Destruction d'objets
5.5. Le mot-clé final
6. Énumérations
6.1. Déclaration d'une énumération
6.2. Classe énumération
6.3. Méthode toString()
6.4. Méthode valueOf()
6.5. Méthode values()
6.6. Méthode ordinal()
6.7. Méthode compareTo()
6.8. Constructeurs privés
6.9. Classe utilitaire : EnumSet
Noms, opérateurs, tableaux
1. Introduction
2. Identificateurs, noms et expressions
2.1. Identificateurs et noms
2.2. Expressions
3. Opérateurs, ordre d'exécution
3.1. Ordre d'exécution
3.2. Les opérateurs
3.3. Les opérateurs ++ et --
3.4. Les opérateurs % et /
3.5. Les opérateurs <<, >> et >>>
3.6. L'opérateur instanceof
3.7. Les opérateurs &, | et ^
3.8. Les opérateurs && et ||
3.9. L'opérateur ? ... :
3.10. Les opérateurs d'affectation
4. Tableaux
4.1. Création d'un tableau
4.2. Initialisation d'un tableau
4.3. Utilisation d'un tableau comme un Object
4.4. Tableaux de tableaux
4.5. Copie de tableaux
5. Blocs, boucles et contrôles
5.1. Blocs
5.2. Mots-clés réservés
5.3. Tests : if et switch
5.4. Boucles : for, while, do ... while
5.5. Commandes continue et break
5.6. Commandes return et goto
Nombres, précision, calculs
1. Introduction
2. Calculs
2.1. Précision
2.2. Codage des nombres flottants
2.3. Le mot-clé strictfp
2.4. Conversion de types
3. Dépassements de capacité
3.1. Cas des entiers
3.2. Cas des flottants
3.3. Bibliothèques BigInteger et BigDecimal
4. Fonctions mathématiques
4.1. Fonctions usuelles
4.2. Générateurs aléatoires
5. Classes enveloppe
5.1. Associer les types de base à des objets
5.2. Auto-boxing
Héritage, abstraction, interfaces
1. Introduction
2. Abstraction et encapsulation
2.1. Abstraction
2.2. Encapsulation
3. Héritage
3.1. Définition de l'héritage
3.2. Conséquences pour les membres
3.3. Polymorphisme
3.4. Empêcher l'héritage
4. Classes abstraites
5. Interfaces
5.1. Introduction
5.2. Définition
5.3. Java 8 et les interfaces
5.4. Utilisation des interfaces
5.5. Définition de constantes dans les interfaces
5.6. Utilité des interfaces
Packages
1. Introduction
2. Notion de paquet
2.1. Déclaration d’appartenance à un paquet
2.2. Chargement d’une classe
2.3. Choix de nom
3. Archives, chemin de recherche, classpath
3.1. Archives
3.2. Variable CLASSPATH
3.3. Notion de classloader
3.4. Bilan sur les classes chargées
3.5. Visibilité
3.6. Conseils d'écriture, bibliothèque standard
Exceptions
1. Introduction
2. Erreurs et Exceptions
2.1. Classe Throwable, notion de stack trace
2.2. Classe Error
2.3. Classe RuntimException
2.4. Classe Exception
3. Déclenchement d'une exception
3.1. Exceptions déclenchées par la JVM
3.2. Exceptions déclenchées par l'application
4. Capter une exception
4.1. Traiter une exception localement
4.2. Code de captage
5. Créer ses propres exceptions
Entrées / sorties
1. Introduction
2. Notion de fichier
2.1. Introduction
2.2. La classe File
2.3. Construction d'une instance de File
2.4. Méthodes exposées
3. Flux de sortie
3.1. Introduction, notion de flux
3.2. Écriture de caractères, classe Writer
3.3. Bufferisation, construction d'un flux sur un autre
3.4. Utilisation de PrintWriter
3.5. Écriture d'octets, OuputStream
3.6. Écriture de types primitifs : DataOutputStream
3.7. Écriture d'objets : ObjectOutputStream
4. Flux d'entrée
4.1. Introduction
4.2. Lecture de caractères, classe Reader
4.3. Bufferisation, lecture ligne par ligne
4.4. Lecture d'octets : InputStream
4.5. Lecture de types primitifs : DataInputStream
4.6. Lecture d'objets : ObjectInputStream
5. Lecture et écriture de flux croisés
5.1. Introduction
5.2. Lire des caractères sur un flux binaire : classe InputStreamReader
5.3. Écrire des caractères sur un flux binaire : classe OutputStreamWriter
5.4. Remarque sur les jeux de caractères
6. Serialization d'objets
6.1. Enjeu de la sérialization d'objets
6.2. Serialization d'un objet
6.3. Sérialization d'une grappe d'objets
6.4. Première surcharge : méthode writeObject() readObject()
6.5. Deuxième surcharge : utilisation d'un externalizer
6.6. Troisième surcharge : utilisation d'un objet proxy
7. Flux compressés
7.1. Introduction
7.2. Flux de type gzip
7.3. Flux de type zip