Compléments pour le cours INF431

Exceptions et Errors (Julien Cervelle)

Différents types d'exception

En java, les objets que l'on peut lancer ont pour supertype commun Throwable. Il y a trois sortes de d'objets lançables : les sous-types de Error, les sous-types de Exception et les sous-types de RuntimeException. Le diagramme de classe est le suivant :

                      Throwable
                          |
                     -----+-----
                     |         |
                   Error   Exception
                               |
                         RuntimeException

Les caractéristiques des objets lançables dépendent des classes, parmi ces trois là, dont ils héritent (directement ou indirectement).

Error
Exception
RuntimeException

Remarque: différence entre RuntimeException et Error

Quand une Error est levée, le bug vient du code/du paquetage qui lève l'exception (code censé être inatteignable atteint, fonction récursive sans arrêt, LinkedList remplie indéfiniment). C'est pourquoi cela ne doit jamais arriver dans un programme sans bug.

Quand une RuntimeException est levée, ce indique que c'est celui qui utilise la méthode/le paquetage appelé qui a commis un bug (sous réserve que le paquetage en question soit sans bug, ce qui normalement le cas des classes standard de java).

On préfère effectuer les tests de validité des arguments avant d'appeler la méthode pour plusieures raisons. La première est d'efficacité : la création d'une exception est lourde à cause du tracé des méthodes traversées. La seconde est que cela permet que les conditions d'erreur non prévues par le programmeur soient plus vite détectées.

Par exemple, dans le code suivant issu du TD n°6 :

  try {
    return arretes.get(v1).get(v2).valeurArc();
  }
  catch (NullPointerException e) {
    throw new IllegalArgumentException("pas d'arc entre "+v1+" et "+v2);
  }

trois possible références à null peuvent causer l'exception. Cependant, le fait arretes vaille null n'est en général pas un cas envisagé. On préfère donc le code :

  Map<Ville,Arc<Ville>> voisins = arretes.get(v1);
  if (voisins==null)
    throw new IllegalArgumentException("la ville "+v1+" n'est pas un sommet du graphe");
  Arc<Ville> arc = voisins.get(v2);
  if (arc==null)
    throw new IllegalArgumentException("pas d'arc entre "+v1+" et "+v2);
  return arc.valeurArc();

où l'oubli de l'initialisation de arretes lève directement une NullPointerException qui simplifie le débogage.

Règle absolue

Les trois supertypes d'objets lançables Error, Exception et RuntimeException ne doivent jamais être utilisés directement. Il ne doivent jamais être lancés car on serait, dans ce cas, tenté de les attraper. Il ne faut jamais les attraper, car on attrape par la même occasion les autres sous-types de l'exception, parfois sans y penser. Il faut donc des sous-types plus précis, soit ceux à connaître, soit créées pour le programme.

L'exemple suivant illustre la raison pour laquelle l'utilisation des ces types directement peut conduire à des erreurs et retarder le débogage. Supposons avoir le code suivant :

class A {
  boolean casDErreur() {
    ...
  }
  void f() {
    if (casDErreur())
      throw new RuntimeException("erreur");
  }
}

Une classe qui utilise la méthode ci-dessus pourrait être

class Test {
  A a;
  public void test() {
    try {
      a.f();
    }
    catch (RuntimeException e) {
      System.err.println("erreur");
    } 
  }
}

Supposons qu'à l'exécution, le message erreur apparaîsse. On pourrait penser que la méthode f a levé l'exception du fait que la méthode casDErreur() a retourné vrai. Cependant, dans cet exemple, le bug vient du fait que a n'est pas initialisé et vaut null. Par conséquent, l'appel a.f() a levé une NullPointerException qui est attrapée par le catch (RuntimeException e). Néanmoins, plutôt que de chercher le vrai bug (trivial), on cherchera inutilement pendant un temps précieux pourquoi dans la méthode f() de A, casDErreur() a renvoyé true.

Quand doit-on hériter ? (Julien Cervelle)

Généralités

Quand une classe B hérite de ou implémente A, cela a deux conséquences :

Héritage vs. délégation

En pratique, on utilise l'héritage soit pour créer un sous-type de A, soit pour redéfinir des méthodes et donc créer un nouveau comportement à partir de A (les deux quand on implémente une interface). En revanche, on n'hérite pas pour récupérer les méthodes et champs et donc les fonctionnalités de A. Ceci pour les raisons suivantes.

La première est qu'il n'est pas utile d'hériter de A pour en récupérer les fonctionnalités. Il suffit juste d'avoir un champ de type A et de l'utiliser à la place de this (on appelle ceci délégation). Par exemple, supposons que l'on veuille implémenter une liste qui affiche tous les accès qui lui sont fait. On peut écrire :

class VerboseList<T> {
  private final LinkedList<T> list = new LinkedList<T>();
  public boolean add(T t) {
    System.out.println("added "+t);
    return list.add(t);
  }
  public boolean remove(Object o) {
    if (!list.remove(o))
      return false;
    System.out.println("removed "+o);
    return true;
  }
}

En plus, cette manière de coder permet de supprimer des fonctionnalités. Par exemple, dans le cas précédent, seules les méthodes add et remove sont fournies. En héritant de LinkedList, on aurait été obligé de redéfinir la plupart des méthodes, dont iterator ce qui nous aurait contraint à implémenter un nouvel Iterator pour afficher les appels à Iterator.remove.

Mais le fait de pouvoir faire autrement ne suffit pas à montrer pourquoi il est déconseillé d'hériter pour récupérer une fonctionnalité. Le problème vient du fait d'être un sous-type imposé par l'héritage. Cela rigidifie le code : une fois que l'on a hérité d'une classe, on ne peut pas changer d'implémentation de la fonctionnalité héritée sans briser la compatibilité ascendante. On a un exemple dans le JDK avec la class Properties qui permet de charger une table associative String,String à partir d'un fichier, soit en XML soit au format d'une entrée "clef=valeur" par ligne. Comme la classe hérite de Hashtable, les mainteneurs du JDK ne peuvent plus changer l'implémentation choisie pour prendre HashMap de java 1.2 sans briser la compatibilité ascendante. Les programmes qui comporteraient :

  Hashtable table = new Properties("toto.properties");

ne compileraient plus.

De plus, à l'époque où Properties a été définie, les types paramétrés n'existaient pas en java, la classe hérite en fait de Hashtable<Object,Object>. Comme on hérite de toutes les méthodes, on se retrouve avec un méthode put(Object,Object) qui permet de mettre des clefs et valeurs d'un type invalide (pas String) dans la table.

Pratiques concernant l'héritage

En résumé, on utilise l'héritage :

et non pour réutiliser une fonctionnalité. En règle générale, si l'on hérite sans redéfinir ni implémenter aucune méthode, on n'avait pas besoin d'hériter.

De plus, en régle générale, on peut implémenter des interfaces d'autres développeurs, on n'hérite que de ses propres classes (avec quelques exceptions comme AbstractList qui sont faites pour ça et ultra-documentées et ultra-stables, en conséquences) car pour hériter, il faut connaître intégralement le code de la superclasse. En effet, modifier le comportement d'une méthode ne fait pas que changer le comportement de l'objet pour les personnes qui vont appeler cette méthode, mais aussi pour les méthodes de la superclasse qui l'appellent. Par exemple, supposons que l'on redéfinisse une classe pour lui ajouter un champ et que l'on modifie toString pour que l'affichage reflète ce champ. Si la superclasse utilise la méthode pour écrire des informations (sur le net ou dans un fichier) fixé par un protocole ou une grammaire, il est possible que la classe ne fonctionne plus correctement.