L'absence de valeurs

L’absence de valeurs #

Les trois techniques les plus courantes pour représenter l’absence de valeurs sont généralement mauvaises:

  • les valeurs passerelles
  • le null
  • les exceptions

Stratégies inadaptées #

Valeurs passerelles (valeurs arbitraires) #

Ceci offre une très mauvaise séparation de la logique et du traitement de l’absence de valeur avec un risque de bugs accru. De plus, il devient très difficile de retrouver la source de cette erreur.

C’est la stratégie proposée par List<E> et sa méthode indexOf par exemple. Cette stratégie est très utilisée en C également.

méthode description
int indexOf(Object o) Returns the index of the first occurrence of the specified element in this list, or -1 if this list does not contain the element.

Utilisation d’une référence null #

Ceci oblige à constamment vérifier qu’une référence n’est pas nulle et provoque très fréquemment l’exception la plus courante : le NullPointerException. Il devient également très difficile de retrouver qui est la cause de cette référence nulle.

Selon une étude (source OverOps: https://blog.takipi.com ), 97% des exceptions loguées sont causées par 10 exceptions les plus courantes. En tête se trouve le NullPointerException loguées dans 70% des 1000 applications étudiées!

Un constat démontre généralement que le null est très souvent utilisé à tort pour représenter une absence de valeurs.

C’est la stratégie adoptée par Map<K,V> et sa méthode get par exemple.

méthode description
V get(Object key) Returns the value to which the specified key is mapped, or null if this map contains no mapping for the key.

Les exceptions #

Cette technique est sensiblement meilleure. Tous les outils sont mis à disposition de l’utilisateur pour vérifier un état avec d’appliquer une action, mais si l’utilisateur ne l’emploie pas correctement, le mieux est peut-être de lever une exception. Le programme peut planter, mais il sera beaucoup plus simple à identifier le code fautif et donc à corriger rapidement celui-ci.

C’est la stratégie adoptée par Deque<E> qui s’utilise comme une queue ou comme une file.

méthode description
public E getFirst() Retrieves, but does not remove, the first element of this deque. Throws: NoSuchElementException - if this deque is empty.

L’absence de valeurs dans les collections #

Dans le cas des collections de tous types, c’est relativement simple. Il m’arrive pourtant de voir ce genre de code:

1
2
3
4
5
6
7
public List<Integer> emptyList() {
  if ( nothingInterestingToReturn() ) {
    return null; 
  } else {
    ...
  }
}

L’appelant qui s’attend à une liste voudra parcourir celle-ci:

1
2
3
for (int i: emptyList()) {
  /* do something with i */
}

Si une référence nulle est retournée, le code ci-dessus provoquera l’arrêt du programme.

Pour retourner une liste vide, plusieurs solutions existent:

// retourne une liste mutable:
return new ArrayList<>();

// retourne une liste non modifiable:
return List.of();
return Collections.emptyList();

Faites de même dans vos classes:

1
2
3
4
5
6
7
8
public class Test {
  /* A éviter sauf si l'instanciation se fait dans les constructeurs
   *
   * private List<Client> cs; // ici cs référence null
  */
  private List<Client> cs = new ArrayList<>();
  ...
}

Le même raisonnement s’applique pour les tableaux statiques, et de manière générale, pour tous types de collections.

1
2
3
public int[] emptyArray() {
  return {};
}

L’absence de valeurs pour un type particulier (hors collection) #

L’absence de valeur est une valeur qui existe, ou qui n’existe pas. C’est-à-dire que cette valeur est optionnelle.

Proposition #

Si une méthode doit retourner un utilisateur à l’aide d’un identifiant, mais qu’aucun identifiant ne correspond à un utilisateur, plusieurs problèmes peuvent survenir:

public User get(int id) { 
  ...
  /* que faut-il retourner si l'identifiant ne correspond à aucune utilisateur ? */
  return ???;
}

1
2
User user = get(42); // retourne null ou lève une exception
System.out.println( user.email() ); // /!\ NullPointerException si user null

Pour éviter de retourner null ou une exception, une bonne idée, certes simpliste, consiste à encapsuler l’utilisateur à retourner dans une collection qui ne peut contenir au plus qu’un élément.

  • si l’utilisateur à retrouver existe, la liste retournée contient uniquement celui-ci
  • si l’identifiant ne correspond à aucun utilisateur, une liste vide est retournée

Nous avons donc deux états possibles ainsi que des méthodes à disposition pour vérifier l’état. Notre méthode deviendrait:

/* returns a list that contains at most one user */
List<User> get(int id) { ... }

Et son utilisation nous obligerait à vérifier si utilisateur existe dans la liste:

1
2
3
4
5
List<User> maybeUser = get(42);
if ( !maybeUser.isEmpty() ) {
  User user = maybeUser.get(0);
  System.out.println( user.email() );
} 

Pas mal, non ? A noter que j’ai déjà employé ce genre de stratégie avec des langages qui n’offre pas mieux.

Il deviendrait même possible d’utiliser le forEach:

get(42).forEach( user -> System.out.println(user.email()) );

Si la liste est vide, il ne se passe rien, sinon l’action est appliquée (afficher l’email dans un terminal).

Défauts #

Cette approche comporte des défauts: une structure non adaptée est employée pour représenter un comportement sensiblement différent. La valeur optionnelle doit exister ou non, alors qu’une liste peut contenir entre zéro et une infinité d’éléments avec la possibilité d’ajouter des éléments, de les supprimer, de connaître le nombre d’éléments…

Remède #

Proposer une nouvelle structure qui comporte zéro ou une valeur au plus. En Java, c’est le type Optional<T>. Celui-ci agit comme une collection qui peut avoir zéro ou un élément.

Notre méthode devient:

public Optional<User> get(int id) { ... }

Et voici plusieurs utilisations possibles:

1
2
3
4
5
Optional<User> maybeUser = get(42);
if( maybeUser.isPresent() ) {
  User user = maybeUser.get();
  System.out.println( user.email() );
}

Et l’équivalent du forEach (humhum, intéressant):

maybeUser.ifPresent( user -> System.out.println(user.email()) );

Pour retourner un optionnel, il existe des fabriques. Imaginons que nous avons utilisé une Map (dont la méthode get peut retourner null) pour stocker nos utilisateurs:

1
2
3
4
5
6
7
8
9
private Map<Integer, User> maps = ...;

public Optional<User> get(int id) { 
  User u = users.get(id);
  if (u == null) {
    return Optional.empty();
  }
  return Optional.of(u);
}

Remarquez l’analogie avec une liste vide (List.of()) et une liste non vide (List.of(elem)).

Vous pouvez directement employer la fabrique ofNullable pour simplifier le code ci-dessus. Cette méthode retourne un optionnel vide si l’argument est null.

1
2
3
public Optional<User> get(int id) { 
  return Optional.ofNullable( users.get(id) );
}

Fail Fast et système de typage robuste #

Rappelez-vous de la philosophie du Fail Fast qui consistait à planter le plus rapidement possible tout en offrant une bonne visibilité du pourquoi. L'Optional permet de planter encore plus vite : à la compilation.

Le chapitre choisi la conception orientée objet propose un exercice sur des réservations. Pour faire simple, une PendingReservation peut se transformer en une Reservation uniquement si elle est planifiée (contrainte imposée par l’argument et vérifiée à la compilation) et s’il y a au moins une ressource. Cette dernière contrainte est vérifiée lors de l’exécution et lève une exception le cas échéant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PendingReservation {

  ...

  public Reservation scheduleAt(LocalDateTime dateTime) {
      /* à compléter */

      if( resouces().isEmpty() ) {
          throw new IllegalStateException("A reseravtion should have at least one resource");
      } 

      /* à compléter */
  }
  ...
}

Une bonne technique pour éviter un problème et une levée d’exception lors de l’exécution est de l’éviter à la compilation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PendingReservation {

  ...

  public Optional<Reservation> scheduleAt(LocalDateTime dateTime) {
      /* à compléter */

      if( resouces().isEmpty() ) {
        return Optional.empty();
      } 

      /* à compléter */
  }
  ...
}

Retourner un Optional contraint l’utilisateur à gérer cette incohérence. C’est un avantage de plus des fabriques : contrairement à un constructeur qui retourne un objet, une fabrique peut retourner un autre type. Dans notre cas, un optionnel.

Attention
Rappelez-vous qu’une liste vide représente déjà une absence de valeurs, évitez ce genre de retour à vos méthodes:

Optional<List<User>> maybeUsers();

// ceci est suffisant:
List<User> users();

Composition #

Un autre avantage que vous verrez dans les exercices en cours est la composition; chose impossible avec des exceptions. Il est par exemple possible de chaîner les actions:

Optional<Email> emailOfToto = getUser(42).map( user -> user.email() );
// ou un autre exemple
getUser(42).filter( u -> u.age() >= 18 )
           .map( u -> u.email() )
           .ifPresent( email -> System.out.println(email) );











comments powered by Disqus