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 classeA
- 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.
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:
|
|
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 deA
, alors une référence de typeA
peut pointer sur un objet de typeB
.
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é.
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é:
|
|
Du coup, le comportement par défaut n’est pas toujours souhaité:
|
|
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.
Voici le code à ajouter dans la classe 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éthodegetClass
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 typeClass
: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:
|
|
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 .
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
.
|
|
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 lehashCode
! 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.
|
|
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 :
|
|
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é:
|
|
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:
|
|
Depuis Java 16, il est possible de réduire le risque d’erreur avec un cast automatique.
|
|
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…).
|
|