Interfaces et méthodes par défaut

Interfaces et méthodes par défaut #

Vous allez comprendre l’importance des interfaces pour éviter la duplication de code et vous familiariser avec la notion de méthode par défaut, c’est à dire des méthodes concrète. Nous allons exploiter une technique intéressate : celle de la réalisation de méthodes concrètes à l’aide de méthodes abstraites !

Possibilités #

Pour étudier les possibilités offertes par les interfaces, nous allons améliorer notre étude de cas sur le gestionnaire de ressources. Nous souhaitons élaborer un service de persistance des données pour les salles de cours. Etant donné que nous ne connaissons pas encore quelle sera la meilleure base de données à utiliser, nous allons nous mettre d’accord sur les fonctionnalités qui nous seront indispensables. Nous réaliserons ensuite une première mise-en-oeuvre InMemory, c’est-à-dire que tant que le système tourne, nous pouvons stocker et récupérer nos ressources, mais si nous redémarrons le programme, nous perdrons le contenu.

Voici notre classe Room :

classDiagram class Room { +Room(String id, int capacity) +info() String +capacity() int +id() String }

Voici une ébauche avec les fonctionnalités qui nous intéressent:

1
2
3
4
5
6
7
8
9
public interface RoomDatabase {
  void insert(Room room);
  List<Room> rooms();
  boolean exists(String id);
  void insertIfNotPresent(Room room);
  Room roomById(String id);
  int totalCapacity();
  int countRooms();
}

Toutes ces méthodes sont dites abstraites car elles ne sont pas réalisées. Elles sont publiques par défaut, donc nul besoin de précéder ces méthodes par le mot-clé public.

La puissance des interfaces est offerte par des versions relativement récentes de Java. Il est possible:

  • d’avoir des constances (Java 7)
  • des méthodes statiques (Java 8)
  • des méthodes par défaut (concrète) (Java 8)
  • des méthodes privées (Java 9)

Dès lors, il est très courant de réaliser des méthodes concrètes (par défaut) dans les interfaces. Ceci permet de réduire la duplication de code. La magie est de réaliser des méthodes concrètes à l’aide de méthodes abstraites. Ainsi, l’utilisateur implémente le minimum de méthodes tout en ayant un maximum de fonctionnalités gratuites. Dans l’exemple de notre RoomDatabase nous pouvons en rendre plusieurs par défaut :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public interface RoomDatabase {
  void insert(Room room);
  List<Room> rooms();
  default boolean exists(String id) {
    /*                 +---- Appel d'une méthode abstraire
                       v                                   */
    for(Room r: this.rooms()){
      if( r.id().equals(id) ) {
        return true;
      }
    }
    return false;
  }
  default void insertIfNotPresent(Room room) {
    if( !this.exists(room.id()) ){
      this.insert(room);
      /*     ^
             +---- Appel d'une méthode abstraire */
    }
  }
  default Room roomById(String id) {
    /*                 +---- Appel d'une méthode abstraire
                       v                                   */
    for(Room r: this.rooms()){
      if( r.id().equals(id) ) {
        return r;
      }
    }
    throw new NoSuchElementException("Room not found");
  }
  default int totalCapacity() {
    int res = 0;
    /*                 +---- Appel d'une méthode abstraire
                       v                                   */
    for(Room r: this.rooms()){
      res += r.capacity();
    }
    return res;
  }
  default int countRooms() {
    /*            +---- Appel d'une méthode abstraire
                  v                                   */
    return this.rooms().size();
  }
}

Voyez l’avantage : pour réaliser une implémentation concrète, il suffit de définir insert et rooms et toutes les autres fonctionnalités sont offertes. Il est facile de faire une version in memory pour débuter rapidement un développement ou une version fake (bidon) pour des tests unitaires par exemple. L’utilisateur peut toujours redéfinir les méthodes par défauts. Une vraie base de données à tout intérêt à le faire puisque certaines requêtes seraient probablement bien plus efficaces si elles étaient réalisées par un moteur de base de données.

Réalisons une première version in memory qui permettrait rapidement de nous concentrer sur d’autres aspects importants du projet :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class InMemoryRoomDatabase implements RoomDatabase {
  private Map<String,Room> rooms = new HashMap<>();
  public void insert(Room room) {
    // Attention: cette version remplace la salle si elle existe déjà !
    this.rooms.put(room.id(), room);
  }
  public List<Room> rooms() {
    return List.copyOf(this.rooms.values());
  }

  /* Nous redéfinissons quand-même ci-dessous ces deux méthodes pour les rendre plus
     performantes */
  @Override
  public boolean exists(String id) {
    return this.rooms.containsKey(id);
  }
  @Override
  public void insertIfNotPresent(Room room) {
    return this.rooms.putIfAbsent(room.id(), room);
  }
}

Cette version redéfinit les méthodes par défaut exists et insertIfNotPresent.

Nous réalisons ensuite une version avec des données bidons :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class FakeRoomDatabase implements RoomDatabase {
  private List<Room> rooms = new ArrayList<>();
  public FakeRoomDatabase() {
    rooms.addAll(List.of( new Room("A406", 40), new Room("A432", 55) ));
  }
  public void insert(Room room) {
    this.rooms.add(room);
  }
  public List<Room> rooms() {
    return List.copyOf( this.rooms );
  }
}

Les méthodes de l’interface doivent être redéfinies avec une visibilité publique.

Inversion de dépendances #

Toute notre logique peut maintenant dépendre d’une abstraction. Pour le prouver, réalisons une première méthode qui permet de copier les données d’une base de données à une autre ainsi qu’une deuxième méthode qui vérifie si l’ajout d’une salle se comporte correctement:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static void migrate(RoomDatabase from, RoomDatabase to) {
  for(Room r: from.rooms()){
    to.insert(r);
  }
}
public static boolean insertWithCheck(RoomDatabase source, Room room) { 
  final int initialNbRooms = source.countRooms();
  final int initialCapacity = source.totalCapacity();
  source.insert( room );
  return source.countRooms() == initialNbRooms + 1 && source.totalCapacity() == initialCapacity + room.capacity();
}

Nous parlons d’inversion de dépendances lorsque tout dépend d’une abstraction. Nos implémentations (FakeRoomDatabase et InMemoryDatabase) dépendent de l’interface RoomDatabase tout comme nos deux méthodes qui représentent notre logique-métier (migrate et insertWithCheck).

Fonctionnalités et limitations #

Les interfaces peuvent contenir des méthodes abstraites, concrètes, statiques ou privées et des constantes. Il est possible d’hériter de plusieurs interfaces.

Il est par contre impossible d’y avoir la description d’un état à l’aide de champs ou de constructeurs. Si tel devait être le cas, vous pouvez employez des classes abstraites que nous aborerons à la prochaine section.

Exemple d’une classe héritant de plusieurs interfaces #

La classe IntSequence représente une séquence d’entiers (ou liste d’entiers). Elle respecte trois interfaces ( IntGrowable, Clearable et Removable)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
interface IntGrowable {
  void add(int i);
}
interface Clearable {
  void clear();
}
interface Removable {
  void remove(int index);
}
class IntSequence implements Growable, Clearable, Removable {
  public void add(int i) { ... }
  public void clear() { ... }
  public void remove(int index) { ... }
}

Exemple d’une interface héritant de plusieurs interfaces #

Une interface peut hériter d’une ou plusieurs interfaces à l’aide du mot clé extends :

1
2
3
4
5
/*              +------------ le mot clé n'est pas `implements` pour
                v             les interfaces qui héritent d'interfaces ! */
interface I3 extends I1, I2 {
  ...
}

Une classe qui hérite d’une interface doit implémenter toutes les méthodes abstraites pour devenir une classe concrète. Si tel n’est pas le cas, la classe reste abstraite et ne peut être instanciée.

Conclusion #

Les interfaces mettent à disposition un puissant mécanisme d’abstraction. Elles permettent de décrire le contrat de certains de nos services ou de nos classes. Le code peut être compilé sans avoir à implémenter les méthodes. Il est possible de simuler une implémentation rapidement pour nous concentrer sur l’essence même de notre réalisation plutôt que sur des détails d’implémentation qui sont parfois inconnus en début de projet. Préférez toujours l’héritage d’interfaces à l’héritage de classes, même abstraite. Rappelez-vous qu’un des objectifs de l’héritage est de réduire la duplication de code et que grâce aux méthodes par défaut, il est possible de l’atteindre.

Quiz 1 #

Soient les déclarations suivantes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// MyContrat.java
public interface MyContract {
  
  void myAction(); 
  
  default void myActionWithLog() { 
    log("Starting...");
    myAction();
    log("Ending...");
  } 

  private void log(String msg) { 
    System.out.println("- " + msg); 
  }
}

1
2
3
4
5
6
// FunnyImpl.java
public class FunnyImpl implements MyContract {
  public void myAction() {
    System.out.println("This is a funny action");
  }
}

1
2
3
4
5
6
// BadImpl.java
public class BadImpl implements MyContract {
  public void myAction() {
    System.out.println("This is a bad action");
  }
}

Que va afficher l’extrait de code suivant:

MyContract c = new FunnyImpl();
c.myActionWithLog();

c = new BadImpl();
c.myActionWithLog();

Quiz 2 #

Nous avons à disposition des fonctionnalités sur les objets Runnable, représentés par les méthodes statiques runThis et runThat. Nous avons malheureusement des objets de la classe classe Test qui nous sont retournés d’une API tierce sur laquelle nous n’avons pas le contrôle. Nous aimerions toutefois passer des objets Test à toutes méthodes prenants en arguments des Runnables au moindre effort. La méthode execute devrait être exécutée lorsqu’un run est appelé.

Vous ne pouvez modifier que la méthode main et vous avez la possibilité de créer d’autres types. Vous ne pouvez pas modifier l’interface Runnable, ni la classe Test.

1
2
3
public interface Runnable {
  void run();
}

1
2
3
4
public class Test {
  ...
  void execute() { ... }
}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// App.java
public class App {
   public static void runThat(Runnable r) { 
     System.out.println("Run that: ");
     r.run(); 
     /* do something else */
   }
   public static void runThis(Runnable r) { 
     System.out.println("Run this: ");
     r.run(); 
     /* do something else */
   }
   
   public static void main(String[] args){
      Test t = new Test();
      runThis( t ); // NE COMPILE PAS, Test n'est pas un Runnable
      runThat( t ); // NE COMPILE PAS, Test n'est pas un Runnable
   }
}











comments powered by Disqus