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
AssertionError
quand
arrive une condition impossible, essentiellement pour les endroit de
code inatteignables (ex cas par défaut du switch)Exception
RuntimeException
NumberFormatException
de Integer.parseInt
)IllegalArgumentException
quand les arguments fournis sont inccorects,
IllegalStateException
quand la méthode ne devrait pas être
appelée à ce stade de l'exécution du programme et
UnsupportedOperationException
quand l'implémentation d'une
interface choisit de ne pas fournir d'implémentation pour l'une de ses
méthodes quand la documentation de l'interface l'autorise par
le terme (optional operation), comme par exemple pour la
méthode Collection.remove
.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.
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 une classe B
hérite de ou implémente A
, cela a deux conséquences :
B
récupère tous les membres visibles (champs, méthodes, ...) de A
;B
devient un sous-type de A
, c'est-à-dire que partout où l'on doit fournir un A
(champs, paramètre de méthode, valeur de retour), on peut à la place envoyer un B
.
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.
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.