Previous Up Next

B.4  Exceptions

Une fois un programme accepté par le compilateur, il n’est pas garanti qu’aucune erreur se produira. Bien au contraire, l’expérience nous apprend que des erreurs se produiront.

B.4.1  Exceptions lancées par le système d’exécution

Certaines erreurs empêchent l’exécution de se poursuivre dès qu’elles sont commises. Par exemple, si nous cherchons à accéder à la quatrième case d’un tableau qui ne comprend que trois cases, le système d’exécution ne peut plus rien faire de raisonnable et il fait échouer le programme.

class Test {
  public static void main(String args[])  {
    int [] a = {2, 3, 5};
    System.out.println(a[3]);
  }
}

Nous obtenons,

% java Test
Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 3
        at Test.main(Test.java:4)

Le message affiché nous signale que notre programme a échoué à cause d’une exception dont le nom est ArrayIndexOutOfBoundsException (qui semble à peu près clair). L’affichage offre quelques bonus, l’indice fautif, et surtout la ligne du programme qui a lancé l’exception. D’autres exemples classiques d’erreur sanctionnée par une exception sont le déréférencement de null (sanctionné par NullPointerException) ou la division par zéro (sanctionnée par ArithmeticException).

Lancer une exception ne signifie pas du tout que le programme s’arrête immédiatement. On peut expliquer le mécanisme ainsi : l’exception remplace un résultat attendu (par exemple le résultat d’une division) et ce résultat « exceptionnel » est propagé à travers toutes les méthodes en attente d’un résultat, jusqu’à la méthode main. Le système d’exécution de Java affiche alors un message pour signaler qu’une exception a été lancée. Pour préciser cet effet de propagation considérons l’exemple suivant d’un programme Square.

class Square {
  static int read(String s)  {
    return Integer.parseInt(s); // Lire l'entier en base 10, voir B.6.1.1
  }

  public static void main(String[] args)  {
    int i = read(args[0]);
    System.out.println(i*i);
  }
}

Le programme Square est censé afficher le carré de l’entier passé sur la ligne de commande.

% java Square 11
121

Mais si nous donnons un argument qui n’est pas la représentation d’un entier en base dix, nous obtenons ceci :

% java Square bonga
Exception in thread "main" java.lang.NumberFormatException: For input string: "bonga"
  at java.lang.NumberFormatException.forInputString(NumberFormatException.java:48)
  at java.lang.Integer.parseInt(Integer.java:447)
  at java.lang.Integer.parseInt(Integer.java:497)
  at Square.read(Square.java:3)
  at Square.main(Square.java:7)

Où on voit bien la suite des appels en attente que l’exception a du remonter avant d’atteindre main.

Le mécanisme des exceptions est intégré dans le langage, ce qui veut dire que nous pouvons manipuler les exceptions. Ici, la survenue de l’exception trahit une erreur de programmation : en réaction à une entrée incorrecte le programme devrait normalement avertir l’utilisateur et donner une explication. Pour ce faire on attrape l’exception à l’aide d’une instruction spécifique.

  public static void main(String[] args)  {
    try {
      int i = read(args[0]);
      System.out.println(i*i);
    } catch (NumberFormatException e) {
      System.err.println("Usage : java Square nombre") ;
    }
  }

Et on obtient alors.

% java Square bonga
Usage : java Square nombre

Sans trop rentrer dans les détails, l’instruction try { instrtry catch ( E e ) { instrfail } s’exécute comme l’instruction instrtry, mais si une exception E est lancée lors de cette exécution, alors l’instruction instrfail est exécutée. En ce cas, l’exception attrapée disparaît. Dans l’instruction instrfail on peut accéder à l’exception attrapée par le truchement de la variable e. Cela permet par exemple d’afficher l’exception coupable par e.toString() comme ici, ou plus fréquemment le message contenu dans l’exception par e.getMessage().

  public static void main(String[] args)  {
    try {
      int i = read(args[0]);
      System.out.println(i*i);
    } catch (NumberFormatException e) {
      System.err.println("Usage : java Square nombre") ;
      System.err.println("Échec sur : " + e) ;
    }
  }

Et on obtient alors

% java Square bonga
Usage : java Square nombre
Échec sur : java.lang.NumberFormatException: For input string: "bonga"

Pour être tout à fait complet sur l’exemple de Square, il y a encore un problème. Nous écrivons read(arg[0]) sans vérification que le tableau arg possède bien un élément. Nous devons aussi envisager d’attraper une exception ArrayIndexOutOfBoundsException. Une solution possible est d’écrire :

  private static void usage() {
    System.err.println("Usage : java Square nombre") ;
    System.exit(2) ;
  }

  public static void main(String[] args)  {
    try {
      int i = read(args[0]);
      System.out.println(i*i);
    } catch (NumberFormatException _) {
      usage() ;
    } catch (ArrayIndexOutOfBoundsException _) {
      usage() ;
    }
  }

On note qu’il peut en fait y avoir plusieurs clauses catch. Les deux clauses catch appellent ici la méthode usage, qui affiche un bref message résumant l’usage correct du programme et arrête l’exécution.

B.4.2  Lancer une exception

Lorsque l’on écrit un composant quelconque, c’est-à-dire du code réutilisable par autrui, on se trouve parfois dans la situation de devoir signaler une erreur. Il faut alors procéder exactement comme le système d’exécution de Java et lancer une exception. Ainsi l’utilisateur de notre composant a l’opportunité de pouvoir réparer son erreur. Au pire, un message compréhensible sera affiché.

Supposons par exemple une classe des piles d’entiers. Une tentative de de dépiler sur une pile vide se solde par une erreur, on lance alors une exception par l’instruction throw.

int pop() {
  if (isEmpty()) throw new Error ("Pop: empty stack") ;
  …
}

Il apparaît clairement qu’une exception est un objet d’une classe particulière, ici la classe Error. En raison de sa simplicité nous utilisons systématiquement Error dans nos exemples.

Si vous prenez la peine de lire la documentation vous verrez que dans l’esprit des auteurs de la bibliothèque, les exceptions Error ne sont pas censées être attrapées.

An Error […] indicates serious problems that a reasonable application should not try to catch. Most such errors are abnormal conditions.

Pour signaler des erreurs réparables, la documentation encourage plutôt la classe Exception.

The class Exception and its subclasses […] indicates conditions that a reasonable application might want to catch.

En fait, il vaut mieux ne pas lancer Exception, mais plutôt une exception par nous définie (comme une sous-classe de Exception) ; ceci afin d’éviter les interférences avec les exceptions lancées par les méthodes de la bibliothèque.

Nous procédons donc en deux temps, d’abord déclaration de la classe de notre exception.

class Stack {
  ⋮
  static class Empty extends Exception { }
  ⋮
}

La classe Empty est définie comme un membre statique de la classe Stack (oui, c’est possible), de sorte que l’on la désigne normalement comme Stack.Empty. Ce détail mis à part, il s’agit d’une déclaration de classe normale, mais qui ne possède aucun membre en propre. Le code indique simplement que Empty est une sous-classe (voir B.2.3 et B.1.5) de Exception. Un constructeur par défaut Empty () est implicitement fourni, qui se contente d’appeler le constructeur Exception ().

Ensuite, notre exception est lancée comme d’habitude.

int pop() {
  if (isEmpty()) throw new Empty () ;
  …
}

Mais alors, la compilation de la classe Stack échoue.

% javac Stack.java
Stack.java:13: unreported exception Stack.Empty;
              must be caught or declared to be thrown
    if (isEmpty()) throw new Empty () ;
                   ^
1 error

En effet, Java impose de déclarer qu’une méthode lance une exception Exception (mais pas une exception Error). On déclare que la méthode pop lance l’exception Empty par le mot clé throws (noter le « s »).

int pop() throws Empty {
    if (isEmpty()) throw new Empty () ;
    …
}

On peut considérer que les exceptions lancées par une méthode font partie de sa signature (voir B.3.3), c’est-à-dire font partie des informations à connaître pour pouvoir appeler la méthode.

Les déclarations obligatoires des exceptions lancées sont assez contraignantes, en effet il faut tenir compte non seulement des exceptions lancées directement, mais aussi de celles lancées indirectement par les méthodes appelées. Ainsi si on conçoit une méthode remove(int n) pour enlever n éléments d’une pile, on doit tenir compte de l’exception Empty éventuellement lancée par pop.

void remove (int n) throws Empty {
  for ( ; n > 0 ; n--) {
    pop() ;
  }
}

Noter qu’il est également possible pour remove de ne pas signaler que la pile comportait moins de n éléments. Cela revient à attraper l’exception Empty, et c’est d’autant plus facile que remove ne renvoie pas de résultat.

void remove (int n) {
  try {
    for ( ; n > 0 ; n--) {
      pop() ;
    }
  } catch (Empty e) { }
}

B.4.3  Complément : programmer avec les exceptions

Un exemple plus sophistiqué que remove de la section précédente est celui d’une méthode int sum(int n) qui conceptuellement dépile les n premiers éléments et renvoie leur somme. Dans le cas où la pile contient moins de n éléments on demande que la pile ne soit pas modifiée et que sum lance une exception spécifique TooSmall.

static class TooSmall extends Exception { }

int sum(int n) throws TooSmall {
  if (n <= 0) return 0 ;

  int x = 0  ; // Initialisation inutile, mais réclamée par le compilateur
  try {
    x = pop() ;
    return x + sum(n-1) ;
  } catch (Empty e) { // Lancé par pop
      throw new TooSmall () ;
  } catch (TooSmall e) { // Lancé par sum, il faut ré-empiler x
      push(x) ;
      throw e ;
  }
}

On observe que la nature de l’exception attrapée dénonce la méthode qui l’a lancée et donc commande la remise en pile de x ou non.


Previous Up Next