1°/ Préambule & tutoriel sur les exceptions

1.1°/ Principes

  • En C, la façon historique pour une méthode de signaler une erreur est qu'elle renvoie une valeur significative, par exemple -1 ou null.
  • Malheureusement, il y a des situations où la valeur renvoyée peut effectivement être -1 ou null sans pour autant que cela constitue une erreur, d'où l'utilisation de moyens détournés, comme des variables globales, des paramètres en sortie, etc.
  • En C++/Java (et d'autres langages), un autre mécanisme permet de régler ce problème : les exceptions.
  • En Java, une exception est un objet qui est créé pour représenter le cas d'erreur.
  • Toutes les classes d'exception qu'un code peut générer héritent de la classe Exception (qui hérite elle-même de Throwable), et leur nom représente le type de l'erreur.
  • Ces classes contiennent également un attribut String contenant le texte de l'erreur (initialisé via le constructeur) , et une méthode getMessage() pour récupérer ce texte.
  • Quand une méthode détecte un cas d'erreur, elle instancie un objet Exception de la classe représentant au mieux le type d'erreur, puis "jette" cet objet grâce à l'instruction throw.
  • C'est ensuite à la partie de code qui a appelé cette méthode "d'attraper" l'exception, grâce à un bloc try/catch, ou bien de la propager (explicitement ou implicitement cf 1.3)

Exemple :

FileReader f = null;
try {
    f = new FileReader("toto.txt"); // si toto.txt n'existe pas, un objet FileNotFoundException est lancé par FileReader()
    ...
}
catch(IOException e) { ... }
  •  Dans l'exemple ci-dessus, on appelle le constructeur de FileReader pour lire un fichier texte. D'après l'API, si le fichier n'existe pas, la méthode génère une FileNotFoundException.
  • On peut donc en conclure que quelque part dans le code source de cette méthode, il y a une instruction du type : if ( ! fichier_existe) throw new FileNotFoundException().
  • La partie de code qui appelle le constructeur doit attraper cet exception. Pour cela, on inclut la partie de code dans un bloc try, que l'on fait suivre par un bloc catch, qui va capturer l'objet. On remarque qu'il est possible d'utiliser le principe du polymorphisme puisque la variable e est du type IOException, qui est la super-classe de FileNotFoundException.

 

  • Du fait du nombre important de cas d'erreur possibles pour toutes les classes de l'API Java, la hiérarchie des classes d'exception est très volumineuse (NB : on peut de plus l'étendre par héritage)
  • Il y a cependant une distinction primaire qui est faite :
    • les cas d'erreurs critiques qui doivent interrompre l'exécution. Ils sont difficilement prévisibles, notamment liés à du code mal écrit qui fait des opérations invalides en mémoire ou en calcul. C'est la classe RuntimeException et ses sous-classes (NullPointerException, ArithmeticException, ...) qui les représente. On dit que ce sont des exceptions "non vérifiées" à la compilation. 
    • les erreurs probables, pas forcément critiques, qui seront plutôt dépendantes du contexte d'exécution. Elles sont représentées par des sous-classes de Exception (mais pas de RuntimeException). On dit que ce sont des exceptions "vérifiées" à la compilation.
  • Quand on appelle une méthode, elle peut générer des exceptions de ces 2 catégories ... ou pas si tout se passe bien. Mais, s'il y a exception :
    • les exceptions "vérifiées" DOIVENT être explicitement attrapées ou propagées. Si l'exception n'est jamais attrapée, la compilation échouera. C'est de là que vient le terme vérifié.
    • les exceptions "non vérifiées" n'ont pas besoin d'être attrapées (c'est même déconseillé). Par exemple, l'API indique que la méthode charAt() de la classe String peut générer une IndexOutOfBoundsException. C'est une exception non vérifiée donc le compilateur ne va pas vérifier si l'appel à cet méthode se trouve dans un try/catch. Généralement, si une telle exception est générée, on laisse la JVM la propager puis arrêter l'exécution (cf. 1.3.1). 
  • Cas particulier pour les non vérifiées : parfois, on considère que l'exception n'est pas totalement critique et on peut effectivement la capturer. C'est souvent le cas avec par exemple NumberFormatException qui est généré lors de conversions entre chaînes de caractères et entier/double. Capturer l'exception permet notamment de ressaisir la chaîne de caractère invalide.

Exemple :

String s1 = null;
String s2 = "salut";
System.out.println(s1.contains("a")); // génère NullPointerException car s1 = null
System.out.println(Integer.parseInt(s2)); // génère NumberFormatException
  • Dans cet exemple, les appels à contains() et parseInt() vont provoquer des exceptions non vérifiées (c.a.d. sous-classes de RuntimeException).
  • Il ne faut surtout pas capturer la première avec un try/catch car c'est une erreur critique.
  • En revanche, on pourrait capturer la 2ème si le fait d'avoir un résultat de conversion invalide n'est pas une raison pour arrêter le programme.

 

1.2°/ Syntaxe

1.2.1°/ capture des exceptions.

  • Quand une partie de code fait des appels à des méthodes générant potentiellement des exceptions, on met cette partie de code dans un bloc try{}, suivi par un ou plusieurs bloc catch{}, permettant chacun de capture une des exceptions générées lors de l'exécution.

Exemple :

ObjectInputStream ois = null;
Double d = null;
try {
    ois = new ObjectInputStream(new FileInputStream("toto.txt"));
    d = (Double)(ois.readObject());
    System.out.println("lu : "+d);
}
catch(ClassNotFoundException e) {
    System.out.println("pb de classe : "+e.getMessage());
}
catch(IOException e) {
    System.out.println("pb accès fichier : "+e.getMessage());
}
System.out.println("après try/catch");
...

Principe :

  • Si l'exécution d'une instruction génère une exception, l'exécution se poursuit dans le premier bloc catch rencontré avec un type d'exception compatible. Par exemple, si la 1ère instruction génère une exception, l'exécution se poursuit avec le code du 2ème catch (IOException)
  • Après l'exécution du bloc catch, l'exécution se poursuit APRÈS le dernier bloc catch. Par exemple, si le premier bloc catch est exécuté, on passe ensuite directement à l'instruction println("après try/catch") 

 ATTENTION :

  • Si un exception est générée par une instruction d'un bloc try{}, les instructions suivantes NE SERONT JAMAIS EXECUTÉES. Par exemple, si readObject() provoque une exception, le println() juste après ne sera jamais exécuté.
  • La suite de bloc catch doit être interprétée comme un if ... else if ... else if ...

1.2.2°/ Ecrire des méthodes qui génèrent des exceptions

  • Comme dit en introduction, il est parfois pratique d'écrire des méthodes qui génère des exceptions (généralement vérifiées) pour signaler un cas d'erreur, plutôt que de renvoyer une valeur significative.
  • Par exemple, si on écrit une classe pour gérer une liste d'objets, la méthode qui permet de mettre à jour un élément dans la liste peut tomber sur différents cas d'erreurs :
    • l'indice donné en paramètre est invalide (<0 ou > taille liste),
    • l'objet à stocker à cet indice est nul.
  • La solution basique pour régler ces erreurs est de ne pas faire la mise à jour et renvoyer par exemple false pour indiquer que rien ne s'est passé.
  • On peut également écrire la méthode afin qu'elle génère des exceptions. Dans ce cas, il faut que son entête spécifie quels types d'exception grâce au mot-clé throws (avec un s final)

Exemple :

class Population {
    List<Humain> pop;
    ...
    public void setHumanAge(int index, int age) throws IndexOutOfBoundsException, PropertyVetoException {

        if (index <0 || index >= pop.size()) throw new IndexOutOfBoundsException();
        if (age <0 || age > 150) throw new PropertyVetoException();
        ...
    }
    ...
}

Remarques :

  • si ailleurs dans le code, on appelle setHumanAge(), il faudra obligatoirement mettre l'appel dans un try/catch pour capturer PropertyVetoException.
  • en revanche, il n'est pas obligatoire de capturer IndexOutOfboundsException. Mais si on ne la capture pas, elle sera propagée implicitement, ce qui provoquera un arrêt de la JVM.

 

1.3°/ Propagation des exceptions

  • C'est un mécanisme qui permet de "passer" une exception d'une méthode à une autre, soit implicitement pour les exceptions non vérifiées, soit explicitement pour les vérifiées.

1.3.1°/ Propagation implicite (= automatique)

  • Par exemple, supposons que main() appelle meth1(), qui appelle meth2(), qui appelle meth3().
  • Si meth3() génère un exception non vérifiée et que meth2() ne capture pas cette exception => meth2() va propager "automatiquement" l'exception à meth1().
  • Si meth1() ne capture pas cette exception => meth1() va propager "automatiquement" l'exception à main().
  • Si main() ne capture pas cette exception => la JVM arrête l'exécution.

1.3.2°/ Propagation explicite (= choisie par le programmeur)

  • Par exemple, supposons que main() appelle meth1(), qui appelle meth2(), qui appelle meth3().
  • Si meth3() génère un exception vérifiée et que meth2() ne capture pas cette exception => erreur de compilation.
  • 2 solutions :
    • meth2() capture l'exception avec un try/catch
    • meth2() propage l'exception, en ajoutant dans son entête throws nom_exception. C'est donc le même principe que si meth2() génère elle-même l'exception (cf 1.2.2)
  • Si meth2() propage l'exception, meth1() doit elle-même faire un try/catch ou propager l'exception à main().

 

1.4°/ Étendre les classes d'exception

  • Si aucune classe de l'API ne représente de façon pertinente un cas d'erreur, on crée une nouvelle classe d'exception, soit en héritant de RuntimeException (ou ses sous-classes) si le cas d'erreur est critique, soit en héritant de Exception.
  • Comme toute classe, on peut lui donner des attributs et des méthodes, et redéfinir les méthodes héritées.
  • En pratique, dans une telle classe, on se contente souvent d'appeler le super constructeur pour initialiser le message d'erreur.

Exemple :

class PopulationException extends Exception {
    public PopulationException() {
        super("This is a population exception");
    }
}
  • Si besoin, on ajoute des attributs, initialisés via le constructeur, qui vont notamment être utiles pour redéfinir getMessage()

Exemple :

class PopulationException extends Exception {
    Population pop;
    public PopulationException(Population pop) {
        super("This is a population exception");
        this.pop = pop;
    }
    public String getMessage() {
        if (pop.taille() == 0) return "population is empty";
        if (pop.onlyMen() || pop.onlyWomen()) return "population cannot grow";
    }
}

2°/ Rencontre impossible = exception

  • Dans les premiers TP, quand deux humains h1 et h2 sont pris dans la population, la rencontre ne peut donner un bébé que si les deux humains sont de sexes opposés.
  • Si ce n'est pas le cas, la méthode de rencontre renvoie un objet null.
  • Pour bien faire, il vaudrait mieux générer une exception car c'est un cas d'erreur.
  • Pour cela, créez le fichier BreedingForbiddenException.java à partir du code suivant :
class BreedingForbiddenException extends Exception {
 
    protected Humain[] source;
 
    public BreedingForbiddenException(Humain h1, Humain h2) {
	super("naissance impossible : "+h1.getNom()+" et "+h2.getNom()+" sont de meme sexe");
	source = new Humain[2];
	source[0] = h1;
	source[1] = h2;
    }
 
    public Humain[] getHumain() {
	return source;
    }     
}
 
  • Modifiez les classes Humain, Homme, Femme et le moteur du jeu afin d'utiliser l'exception définie ci-dessus lors de rencontres : s'il n'y a pas d'exception, on insère le bébé dans la population, sinon on affiche le message contenu dans l'exception.

 

3°/ Rencontre non productive = exception

 

  • Il est également possible que les deux humains soient de sexes opposés mais les conditions (sur le poids, age, ...) ne sont pas respectées ou bien le tirage aléatoire sur la fertilité a été négatif.
  • Dans ces deux cas, il n'y a pas de nouvel humain et la valeur renvoyée est de nouveau null.
  • Pour éviter ce problème, on va :
    • générer une BreedingForbiddenException quand les conditions sur le poids, age, ... ne sont pas respectées
    • générer une NoBreedingException quand le tirage sur la fertilité ou bien batifolage est négatif.
  • Pour le deuxième cas, créez une classe NoBreedingException qui a comme attribut un Humain représentant la source de l'erreur et dont le message d'erreur est du type : "rencontre improductive : toto n'est pas fertile" ou bien "rencontre infertile : tutu veut batifoler"
  • Dans BreedingForbiddenException, modifiez le code pour avoir un message différent selon le cas (NB: cela impose de redéfinir la méthode getMessage()  ) :
    • même sexe : le message est le même que dans l'exercice 2
    • conditions d'âge et/ou poids non respectées : message donnant les conditions non conformes. Par exemple :  "naissance impossible : toto est trop jeune, tutu est trop vieille, tutu est trop gros"
  • Modifiez Humain, Homme, Femme et le moteur de jeu pour utiliser les deux classes d'exception.

 

4°/ Une super-classe pour les exceptions de rencontre.

  • Créez le fichier MeetingException.java à partir du code suivant :
class MeetingException extends Exception {
 
    protected Humain[] source;
 
    public MeetingException(Humain h1, Humain h2) {
	super("problème de rencontre");
	source = new Humain[2];
	source[0] = h1;
	source[1] = h2;
    }
 
    public Humain[] getHumain() {
	return source;
    }
}
  • Modifiez les deux classes d'exception pour qu'elles héritent de cette classe.

 

5°/ la propagation des exceptions

  • Modifiez la classe Population en ajoutant une méthode rencontre :
public Humain rencontre(int index1, int index2) {
   Humain h1 = getHumain(index1);
   Humain h2 = getHumain(index2);
   return h1.rencontre(h2);     
}
  • Modifiez le moteur de jeu pour appeler cette méthode pour faire lors des rencontres, au lieu de le faire directement.
  • On remarque que la méthode ne fait pas de try/catch. Or, elle génère potentiellement des exceptions. Le compilation va donc échouer.
  • Modifiez le code ci-dessus pour que la méthode propage les exceptions explicitement au moteur de jeu.