La classe Object et les premières notions d'héritage

La classe Object et les premières notions d’héritage #

Nous allons aborder l’héritage dans ce chapitre. Avant d’expliquer comment réaliser un design basé sur l’héritage, nous allons d’abord comprendre le concept en utilisant des fonctionnalités courantes basées dessus. L’exemple le plus simple est la classe Object. Toute classe en hérite et celle-ci offre quelques fonctionnalités qu’il est possible d’utiliser et/ou de redéfinir.

L’héritage #

L’héritage est un mécaisme qui permet à une classe-fille de récupérer les membres (non privés) d’une classe-mère. On parle également de généralisation/spécialisation. Si une classe B hérite d’une classe A:

  • la classe B expose également les membres non privées (champs et méthodes) de la classe A
  • la classe B peut exposer de nouvelles fonctionnalités -> elle se spécialise
  • la classe A peut être appelée : classe-mère, classe-parente ou super-classe
  • la classe B peut être appelée : classe-fille ou sous-classe
  • la dérivation est un synonyme de l’héritage

Les avantages sont de réduire la duplication de code et de permettre le polymorphisme. Malheureusement, l’héritage non maîtrisé apporte plus d’inconvénients que d’avantages. Nous en reparlerons dans le chapitre qui traite de la conception basée sur l’héritage (lien vers le chapitre ).

La classe Object est une classe qui se trouve en haut de la hierarchie comme l’illsutre la figure ci-dessous. Toute classe dérive implicitement de celle-ci. Etant donné que la classe Object met à disposition plusieurs méthodes, toutes classes les exposent également et il est possible de les redéfinir.

Hiérarchie de classes

Définitions

La redéfinition d’une méthode est l’action de modifier le comportement d’une méthode héritée.

Les principales méthodes de Object sont :

  • String toString()
  • boolean equals(Object obj)
  • int hashCode()

Nous remarquons que nous avons déjà redéfini par le passé la méthode toString() pour notre Counter. Cet un exemple typique de redéfinition. Par défaut, la méthode est implémentée dans Object et elle affiche le nom de la classe, le caractère @, et une représentation hexadécimale du hashCode de l’object: getClass().getName() + '@' + Integer.toHexString(hashCode()).

Si nous souhaitons la redéfinir, nous devons respecter sa signature et il est conseillé d’ajouter une annotation @Override indiquant on compilateur que nous tentons de redéfinir une méthode.

Souvenez-vous de l’appel à toString() sur un Counter avant sa redéfinition:

1
2
Counter c = new Counter();
System.out.println( c.toString() ); // ==> Afficherait "Counter@4926097b"

Après l’avoir redéfinie ainsi dans la classe Counter (ajoutons au passage l’annotation):

    @Override
    public String toString() {
        return "Counter(" + toFourDigits(this.counter) + ")";
    }

Le même appel affiche:

Counter(0000)

Signification de l’héritage #

Il existe une dépendance très forte dans un lien d’héritage. Il s’agit d’une relation de type “est un-e”. Si une classe Patient hérite de la classe Person, alors un patient est une personne. En conséquence, une méthode qui s’attend à recevoir une personne en argument peut recevoir un patient : c’est le polymorphisme de sous-typage !

Définition

De manière générale (hors POO), le polymorphisme est une propriété de ce qui peut prendre plusieurs formes.

Définition

En POO, le polymorphisme de sous-typage permet de substituer un type par un sous-type. Si B hérite de A, alors une référence de type A peut pointer sur un objet de type B.

Prenons en exemple avec notre classe Object. Nous allons créer une référence de ce type et nous allons la faire pointer vers différents objets de ses sous-classes.

Object o = new Object();
o.toString(); // ==>"java.lang.Object@27fa135a"

o = new Counter.withValue(42);
o.toString(); // ==> Counter(0042)

o = List.of(1,2,3);
o.toString(); // ==> [1,2,3]

Remarquez que le type de la référence o est toujours la même : c’est un Object. Par contre, il pointe (à droite du =) sur des objets de types différents. Puisque o est un Object, il est possible d’appeler les membres publiques de celui-ci uniquement et non les membres des objets pointés. Vous pouvez constater que l’appel de la méthode toString produit des résultats différents qui sont propres au type de l’objet référencé cette fois. C’est l’objectif du polymorphisme : d’avoir des comportements différents en fonction de l’objet réellement référencé.

Polymorphisme de sous-typage

La figure ci-dessus exprime le fait que o est une référence qui pointe sur des objets (instanciés et référencés) de types différents. La redéfinition de la méthode toString a permis d’appliquer en comportement différent selon.

Redéfinition du toString #

La redéfinition de cette méthode est courante. Elle est utile pour le logging (processus d’écriture de message durant l’exécution permettant de donner des indications sur l’état du système) ou du debugging. La documentation officile préconise:

  • de toujours redéfinir cette méthode pour nos classes
  • de retourner un résultat concis et informatif

Ne la redéfinissez donc pas pour un cas d’utilisation spécifique.

Redéfinition de l’égalité #

Nous avons vu par le passé les dangers de l’opérateur == qui compare les références. Pour comparer la structure d’un objet, il est nécessaire d’utiliser la méthode equals. La classe Object fournit une implémentation par défaut qui utilise l’opérateur d’égalité:

1
2
3
public boolean equals(Object obj) {
  return (this == obj);
}

Du coup, le comportement par défaut n’est pas toujours souhaité:

1
2
3
Counter c1 = Counter.zero();
Counter c2 = Counter.zero();
Counter c3 = c1;
Expression Résultat
c1 == c2 false
c1 == c3 true
c1.equals(c3) true
c1.equals(c2) false (non souhaité)

Si la comparaison d’objets de nos classes est nécessaire, il faut alors redéfinir la méthode equals. Celle-ci n’est pas toujours triviale et doit suivre certaines règles d’équivalence:

The equals method implements an equivalence relation on non-null object references1:

  • It is reflexive: for any non-null reference value x, x.equals(x) should return true.
  • It is symmetric: for any non-null reference values x and y, x.equals(y) should return true if and only if y.equals(x) returns true.
  • It is transitive: for any non-null reference values x, y, and z, if x.equals(y) returns true and y.equals(z) returns true, then x.equals(z) should return true.
  • It is consistent: for any non-null reference values x and y, multiple invocations of x.equals(y) consistently return true or consistently return false, provided no information used in equals comparisons on the objects is modified.
  • For any non-null reference value x, x.equals(null) should return false.

  1. docs.oracle.com ↩︎

Voici le code à ajouter dans la classe Counter:

1
2
3
4
5
6
7
8
9
    @Override
    public boolean equals(Object o) {
        if(this == o) return true;
        if(o == null || o.getClass() != this.getClass()) {
          return false; 
        }
        Counter other = (Counter)o;
        return this.counter == other.counter;
    }

Danger

Ces règles paraissent simples, mais attention : dans beaucoup d’extraits de code, l’opérateur instanceof est utilisé à la place de la méthode getClass ce qui la rend non symétrique dans certains cas ! Vous verrez un tel cas dans les exercices.

instanceof vs getClass #

L’opérateur instanceof permet de déterminer statiquement si une référence est non-nulle, si elle peut être convertie vers un type connu à l’avance et qu’elle se trouve dans la hiérarchie directe. Une erreur de compilation survient si tel n’est pas le cas.

Soit la hiérarchie de classes suivantes:

            Object  
          /      \  \
    Number       A  String
   /     \       |
Double  Integer  C

et un extrait d’utilisation:

Number n = 3;
n instanceof Object // => true
n instanceof Number // => true
n instanceof Double // => false
n instanceof String // => Compile-time error
n instanceof A // => Compile-time error

La méthode equals doit être symétrique. Un objet de type Double ne pourrait jamais être égal à un objet de type Number. La méthode getClass retourne la classe de l’objet pointé et non de la référence. Il s’agit d’une solution dynamique, aucune vérification n’est faite à la compilation. Le type n’est pas forcément connu à l’avance. La méthode retourne un objet java.lang.Class<T>.

Extrait d’utilisation:

Object o = 3;
Integer i = 2;
o.getClass().equals(Integer.class); // => true
o.getClass() == i.getClass(); // => true, Class est un singleton
o.getClass().isInstance(i); // => true
o.getClass().equals(A.class); // => false
Class.forName("java.lang.Integer").isInstance(o); // => true
Class.forName("java.lang.String").isInstance(o); // => false
Class.forName("ch.hepia.Counter").isInstance(o); // => false

Attention

Remarquez qu’il est exceptionnellement possible d’utiliser l’opérateur == sur des objets de type Class:

o.getClass() != this.getClass()

En effet, la classe retournée est un singleton. Il n’existe qu’une instance en mémoire par classe et elle se trouvera donc toujours à la même adresse.

Utilisation courante #

Beaucoup de fonctionnalités utilisent la méthode equals. Par exemple, pour savoir si un objet se trouve déjà dans une liste, la méthode contains l’utilise sur chaque élément:

1
2
3
4
List<Counter> counters = List.of( Counter.withValue(42) );
counters.contains( Counter.withValue(41) ); // ==> false
counters.contains( Counter.zero() ); // ==> false
counters.contains( Counter.withValue(42) ); // ==> true

C’est généralement dans ce cas que sa redéfinition devient importante.

Redéfinition du hashCode #

Un hash (appelé parfois digest) est le résultat d’une fonction de hachage. Il s’agit d’une emprunte de taille fixée calculée à partir d’une donnée de taille arbitraire. Il est utilisé dans différents domaines (base de données, tableaux associatifs, cryptographie…), souvent pour des questions d’optimisation.

L’illustration ci-dessous est tirée de wikipedia .

Fonction de hashage

Prenons le cas des ensembles : Set. Il s’agit d’une structure de données qui permet de représenter des opérations courantes de la théorie des ensembles (intersection, union, différence d’ensembles…). Une caractéristique d’un ensemble et qu’un élément n’est jamais dupliqué et ne peut s’y trouver qu’une fois tout au plus.

L’implémentation HashSet de Java, que nous allons utiliser comme exemple, utilise une table de hashage pour optimiser les opérations sur les ensembles (pour savoir si un élément s’y trouve déjà par exemple). Ceci évite de devoir parcourir toute la collection. Les opérations utilisent justement la méthode hashCode.

Voyons ce qu’il se passe si nous avons redéfini equals pour notre Counter en ométant de redéfinir hashCode.

1
2
3
4
5
6
7
8
Counter c = Counter.zero();
Set<Counter> counters = new HashSet<>();
counters.add( c ); // ajoute un élément uniquement s'il n'est pas déjà présent
counters.add( Counter.zero() ); /* ajoute le même élément (structurellement) mais l'objet créé
                                   est nouveau et se trouve à un autre emplacement mémoire. Leur hash 
                                   est différent */
counters.size(); // ==> 2  (devrait retourner 1)
counters.contains( Counter.zero() ); // ==> false !

Rappelez-vous que la méthode contains fonctionnait sur les listes (qui utilisent le equals uniquement).

L’implémentation par défaut du hashCode dépend (pour simplifier) de l’emplacement mémoire de l’objet. Dans le cas de notre Counter qui a redéfini uniquement equals, deux compteurs peuvent être égaux et avoir des hash différents. Ce qui est problématique pour notre ensemble.

Attention

Par définition, si deux objets sont égaux ils doivent avoir le même hash. Donc s’ils n’ont pas le même hash, ils ne sont pas égaux.

Voilà pourquoi, lorsque equals est redéfini, il est impératif de redéfinir également le hashCode ! Pour respecter cette règle fondamentale de la logique.

Les fonctionnalités utilisant le hashCode se basent sur cette logique. Voilà pourquoi le résultat n’est pas celui souhaité si la méthode n’est pas redéfinie correctement.

Dans le cas de l’ensemble (HashSet), celui-ci vérifie si le hashCode d’un objet existe déjà. S’il n’existe pas, il peut conclure que l’élément ne s’y trouve pas. S’il existe, il doit vérifier si l’objet qui a le même hash est égal.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// pseudo-code simplifié pour l'insertion d'un objet dans un ensemble
void add(Object object) {
  if ( !exists(object.hashCode() ) {
    this.insert(object);
  } else { /* le hash existe, mais il ne s'agit pas forcément du même objet
            * (deux objets différents peuvent avoir le même hash) */
    List<Object> objectsWithSameHash = this.getAll( object.hashCode() );
    if ( !objectsWithSameHash.contains(object) ) {
      this.insert(object);
    } /* else does not insert */
  } 
}

Heureusement, depuis Java 7, la redéfinition tient sur une ligne grâce aux fonctionnalités de la classe Objects (au pluriel !). Celle-ci fournit la méthode statique hash qui prend un nombre arbitraire d’arguments pour produire un hash de tous ceux-ci, exemple d’une classe Person: Objects.hash(firstname, lastname, age);.

Pour notre classe Counter, voici la redéfinition :

1
2
3
4
5
6
7
  import java.util.Objects;
  ...

    @Override
    public int hashCode() {
        return Objects.hash(this.counter);
    }

Pour utiliser Objects, un import est nécessaire: import java.util.Objects;. Ajoutez cette ligne avant la définition de la classe.

Après cette correction, voici le résultat escompté:

1
2
3
4
5
6
7
Counter c = Counter.zero();
Set<Person> counters = new HashSet<Person>();
counters.add( Counter.zero() ); 
counters.add( Counter.zero() ); 

counters.size(); // ==> 1  (ok !)
counters.contains( Counter.zero() ); // ==> true !

Remarques #

Une redéfinition doit être au moins aussi visible que la méthode redéfinie. Une méthode avec une visibilité de package peut est redéfinie en publique, mais une méthode publique ne peut pas être redéfiniée en droit de package. Enlevez le mot-clé public de la redéfinition hashCode est le compilateur vous l’indiquera:

/Counter.java:68: error: hashCode() in Counter cannot override hashCode() in Object
     int hashCode() {
         ^
  attempting to assign weaker access privileges; was public
1 error

Casting #

Avant de caster un objet, l’opérateur instanceof permet d’éviter des catastrophes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public static void test(Object o) {
    if (o instanceof String) {
        String s = (String)o; // cast
        /* do something with s */
    } else if (o instanceof Counter) {
        Counter counter = (Counter)o; // cast
        /* do something with counter */
    } 
    ...
}

Depuis Java 16, il est possible de réduire le risque d’erreur avec un cast automatique.

1
2
3
4
5
6
7
8
public static void test(Object o) {
    if (o instanceof String s) {
        /* s est maintenant de type String. Casting inutile */
    } else if (o instanceof Counter counter) {
        /* ici, counter est de type Counter. Casting inutile */
    } 
    ...
}

Cette syntaxe permet ce qui s’appelle le pattern matching qui une structure de contrôle permettant l’extracton conditionnelle de composant d’un objet (déjà utilisé par Scala, Swift, Rust, Kotlin…).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
/*            `s` est maintenant utilisable dans la condition 
 *                si `o` est bien un String 
 *                           v                    */
if (o instanceof String s && s.startsWith("H")) {
  /* do something with s */
} else if (o instanceof String s){
  /* do something with s */
} else {
    // can't use s
}











comments powered by Disqus