Concepts de programmation orientée objet, suite
Dans ce cours, nous poursuivons la vue d’ensemble des concepts de la programmation orientée objet. Nous examinons en détail les mécanismes de comparaison d’objets, ainsi que le double envoi (double dispatch) comme concept polymorphique avancé.
Résumé du cours
Utilisez equals pour comparer des objets, pas ==. Remplacez le code en dur qui combine des classes par une délégation polymorphique, en utilisant le double envoi (double dispatch). N'attrapez pas toute exception.
Comparaison d’objets
- Pour les valeurs primitives, il est standard de faire des comparaisons avec l’opérateur
==. - Cependant, comparer des objets avec
==est une mauvaise pratique et doit être évité dans la plupart des cas. - Nous allons maintenant examiner les concepts sous-jacents de
==, retracer le problème pour la comparaison d’objets, et finalement apprendre à comparer correctement des objets.
Rappel du modèle de variable
La valeur stockée dans une variable Java dépend du type de la variable :
- Primitives : la variable stocke directement une valeur, par exemple pour un
intla valeur42, ou pour uncharla valeurM. - Objets : la variable stocke l’adresse mémoire allouée à l’objet cible.
== n’est pas sécuritaire pour la comparaison d’objets
== compare toujours la valeur de la variable, peu importe son type.
Illustration : comparaison de primitives
- Lorsqu’on compare des primitives, les valeurs correspondantes des variables sont comparées directement.
- C’est correct, car on compare les valeurs de deux variables (
a: 42,b: 42). 42est identique à42, donc l’opérateur==retourne correctementtrue.
Illustration : deux variables primitives, aucun objet alloué.
Illustration : comparaison d’objets
- Pour les objets, la variable ne stocke pas directement la valeur de l’objet, mais une référence mémoire vers l’espace alloué à cet objet.
- Un exemple est celui des objets
String: on ne connaît pas la longueur d’une chaîne, donc elle ne peut pas être une primitive – plus laStringest longue, plus de mémoire doit être allouée.
Illustration : trois variables
String, référant deux objets en mémoire.
- Comme précédemment, l’opérateur
==compare seulement les valeurs de chaque variable. - Mais attention ! Les valeurs ne sont que des références –
a == bcompare@4fe7(référence pour la Stringa) n’est pas la même que@739b(référence pour la Stringb), donc l’opérateur==retourne correctementfalse.
Ce n’est probablement pas ce que le programmeur avait en tête. Dans l’illustration ci-dessus, on voit que les deux
objets ont le même contenu : "42".
Intuitivement, on aurait attendu que == retourne true, puisque les deux objets sont égaux.
La comparaison d’objets avec == peut-elle parfois donner le résultat attendu ?
Oui, on peut avoir de la chance et comparer deux références vers le même objet. Dans l’exemple ci-dessus, b == c aurait retourné true, puisque les deux variables référencent le même objet.
Equals
- Comme nous l’avons vu, la comparaison directe des valeurs de variables ne correspond pas à notre compréhension intuitive de la comparaison – nous ne voulons pas comparer des références, mais bien les objets derrière ces références.
- Un deuxième piège est : nous ne cherchons pas à savoir si les objets sont identiques, mais s’ils sont égaux :
- Identiques : les deux variables pointent vers le même objet (nous avons déjà l’opérateur
==pour cela). - Égaux : les deux variables pointent vers des objets qui peuvent être considérés comme sémantiquement indiscernables.
- Identiques : les deux variables pointent vers le même objet (nous avons déjà l’opérateur
Délégation
Notre objectif est d’avoir une méthode equals fiable, pour nous dire si deux objets sont sémantiquement identiques.
- Malheureusement, nous ne pouvons pas implémenter une méthode de comparaison universelle : chaque classe a des champs
différents à comparer, et ils ne sont pas tous pertinents pour déterminer l’équivalence.
- En fait, toutes les classes héritent d’une méthode
equalspar défaut de la super-classe impliciteObject–
mais son implémentation est trompeuse : elle appelle simplement l’opérateur==.
- En fait, toutes les classes héritent d’une méthode
- Ainsi, nous n’implémentons pas un mécanisme de comparaison universel, mais nous déléguons la comparaison aux objets
eux-mêmes. Nous appelons une méthode spéciale
equalssur un objet, et nous le laissons déterminer s’il est sémantiquement identique à l’autre.
Avons-nous déjà vu ce concept de délégation à une implémentation inconnue ?
Oui ! C’est le polymorphisme. Nous ne savons pas comment la méthode equals est implémentée, et ce n’est pas important – en tant qu’appelant, nous laissons l’objet déterminer par lui-même comment effectuer la comparaison.
Implémentation
- Lorsqu’on implémente une méthode
equals, on doit déterminer si un objet (this) est sémantiquement identique à un autre objet (other), passé en paramètre. - La signature de la méthode est donc :
Pour implémenter cette méthode, nous devons considérer plusieurs possibilités :
- La référence
otherest-elle en fait l’objet identique (nous nous comparons à nous-mêmes) :
Si oui -return true - La référence
otherest-elle réellement un objet, et non simplementnull?
Si non -return false; - L’objet
otherest-il compatible (nous ne pouvons pas comparer des pommes et des poires) ?
Si non -return false; - L’objet
otherest-il sémantiquement identique, en termes de tous les champs pertinents ?
Remarque : Les objets peuvent référencer d’autres objets, donc lors de l’implémentation du point 4, nous ne devons pas utiliser aveuglément
==pour toutes les comparaisons de champs.
La plupart du temps, la méthode equals ressemble finalement à quelque chose comme :
@Override
public boolean equals(Object o) {
// 1)
if (this == o) {
return true;
}
// 2), 3)
if (o == null || getClass() != o.getClass()) {
return false;
}
Bar b = (Bar) o;
// 4) Compare relevant fields (foo is primitive, baz is an object)
return foo == b.getFoo() && baz.equals(b.getBaz());
}
Utilisez la puissance de votre IDE
Écrire une méthode equals est principalement du code répétitif. Vous pouvez utiliser un IDE pour générer le code, en sélectionnant rapidement quels champs de l’objet sont pertinents pour la comparaison.
Tri
- Dans certains scénarios, tester l’équivalence ne suffit pas.
- Par exemple, pour le tri, nous avons besoin d’une notion de ordre, c’est-à-dire que lors de la comparaison directe de deux objets, nous devons savoir quel objet est considéré comme "plus petit" ou "plus grand" que l’autre.
Exemple
- Pour une liste de valeurs
Integer[1, 4, 3, 2], nous conviendrions tous que le tri devrait donner[1, 2, 3, 4]. - Mais que faire si nous avons une liste d’objets
Student?- Devrait-on trier par numéro d’inscription ?
- Devrait-on trier par date de naissance ?
- Devrait-on trier par nom, par ordre alphabétique ?
Sémantique
- Java fournit une interface intégrée :
Comparable<T> - L’interface ne fournit qu’une seule méthode :
compareTo(T o)
Notez que l’interface utilise les
Genericspour définir les classes avec lesquelles un objet peut être comparé. Nous étudierons lesGenericsen détail dans le prochain cours.
- Les implémentations de la méthode
compareTodoivent respecter les règles suivantes :- Retourne un
intnégatif lorsque l’objetthisest inférieur à l’autre objet (o). - Retourne zéro lorsque l’objet
thisest égal à l’autre objet (o). - Retourne un
intpositif lorsque l’objetthisest supérieur à l’autre objet (o).
- Retourne un
Il n’existe pas d’implémentation compareTo par défaut
Contrairement à la méthode equals, la classe de base Object ne fournit pas d’implémentation par défaut pour compareTo. L’interface Comparable doit être implémentée pour utiliser le tri dans les collections et les tableaux.
Utilisation
L’avantage clé de l’interface Comparable est qu’elle assure une intégration transparente des classes personnalisées
avec les utilitaires Java existants, notamment les collections et les tableaux.
Étant donné une implémentation de l’objet étudiant :
public class Student implements Comparable<Student> {
private String name;
private int birthYear;
private long identifier;
// Constructor, getters...
@Override
public int compareTo(Student o) {
// Must return negative value if this is less than other
return birthYear - o.birthYear;
}
// toSting method
}
Nous pouvons maintenant trier facilement les étudiants en fonction des critères définis dans compareTo :
main() {
// Create a list with some students
Student knuth = new Student("Knuth", 1938, 394876339);
Student turing = new Student("Turing", 1912, 438579348);
Student lovelace = new Student("Lovelace", 1815, 564598398);
Student hopper = new Student("Hopper", 1906, 983567346);
List<Student> students = new LinkedList<>();
students.add(knuth);
...
// Sort (this internally calls the compareTo method)
Collections.sort(students);
// Then print students
for (Student student : students) {
System.out.println(student);
}
}
Imprime :
Student [name=Lovelace, birthYear=1815, identifier=564598398]
Student [name=Hopper, birthYear=1906, identifier=983567346]
Student [name=Turing, birthYear=1912, identifier=438579348]
Student [name=Knuth, birthYear=1938, identifier=394876339]
Que faut-il changer pour trier les étudiants par nom à la place ?
Le nom est une String. Dans l’implémentation de compareTo, il suffit de déléguer à la méthode compareTo existante de String : return name.compareTo(o.name);
Hashcode
- Dans certains cas, comparer ou rechercher des objets peut considérablement réduire les performances (par exemple, beaucoup d’objets à comparer, objets avec de nombreux champs).
- Une astuce courante pour accélérer les choses est de calculer une "empreinte" pour chaque objet.
- Peut être facilement calculée à partir des champs de l’objet.
- Est "quasi unique".
- Peut être facilement comparée.
- Le mécanisme courant pour calculer une telle empreinte est le "hachage", également appelé digest.
- Certaines classes, par exemple
String, disposent d’un mécanisme de hachage intégré. Pour d’autres, nous devons fournir notre propre implémentation.
Exemple MD5
À titre d’illustration, examinons "Message Digest 5", ou simplement le hachage MD5.
- Toute série de bits peut être utilisée en entrée.
- L’entrée peut être longue ou courte.
- L’entrée peut être des chaînes, des objets Java sérialisés, des images…
- La fonction MD5 produit une série de bits de longueur fixe (représentée sous forme de chaîne hexadécimale).
| Valeur | Hachage MD5 |
|---|---|
Maximilian |
3b7e729d70a604751ff8d993b0f247f7 |
MGL7010 |
f4ee9623b54932983bfefab256366155 |
Des collisions sont inévitables
Comme nous mappions un ensemble d’entrées illimité à un ensemble de sorties limité, des collisions doivent exister. La comparaison d’objets ne doit pas être effectuée uniquement sur la base du hachage.
Un exemple visuel de collision : ces deux images d’un bateau et d’un avion :

Quelle est la causalité entre equals et hashCode ?
Deux objets considérés comme égaux doivent retourner le même hashCode, mais l’inverse n’est pas nécessairement vrai.
Certaines classes Java fournies, par exemple
String, ont une implémentation par défaut dehashCode. Elles ont également des collisions. Par exemple,"Teheran"et"Siblings"donnent le même résultat de hashCode.
Implémentation de hashCode
Comme pour les fonctions equals et compareTo, tout dépend des champs à considérer pour créer un hash.
Ensuite, c’est du code prêt à l’emploi. Par exemple, pour notre classe Student, nous pouvons fournir une
implémentation de hashCode comme suit :
Collections hachées
Avec un hashCode fourni, nous pouvons maintenant utiliser efficacement des structures de données basées sur des tables
de hachage internes :
main() {
// Create some students
Student knuth = new Student("Knuth", 1938, 394876339);
Student turing = new Student("Turing", 1912, 438579348);
Student lovelace = new Student("Lovelace", 1815, 564598398);
Student hopper = new Student("Hopper", 1906, 983567346);
// Create a hashSet, to quickly find objects:
Set<Student> students = new HashSet<>();
students.add(knuth);
students.add(turing);
students.add(lovelace);
students.add(hopper);
// Search for specific entry, without having to traverse all set:
System.out.println(students.contains(lovelace));
}
Affiche : true
Double dispatch
- Avec
equals,compareToethashCode, nous avons vu trois exemples où le comportement est délégué à un objet. - Nous appelons également cette technique single dispatch, car la décision sur un comportement spécifique est déléguée au type de l’objet à l’exécution.
- Ensuite, nous examinons une variante avancée de cette technique, appelée double dispatch.
Idée
- Le double dispatch, tout comme le single dispatch, consiste à déléguer un comportement basé sur le type à un objet à l’exécution.
- La différence est qu’avec le double dispatch, le comportement dépend de deux types, qui contribuent tous deux à la décision à l’exécution.
Exemple avec des formes
Considérons le scénario suivant. Il existe plusieurs classes pour les formes, par exemple :
SquareTriangleCircle
Si nous souhaitons maintenant implémenter un mécanisme pour déterminer à quoi ressemble une intersection, nous pouvons facilement visualiser que le résultat dépend fortement des deux types impliqués à l’exécution :
Implémentation
- Intuitivement, nous pourrions tenter d’implémenter le polymorphisme en surchargeant une méthode
intersectavec des sous-types. - Malheureusement, cela ne fonctionnera pas, car les paramètres de méthode sont liés au moment de la compilation, et non à l’exécution : Il n’y a pas de surcharge polymorphique de méthode en Java.
L’astuce consiste à passer des paramètres polymorphiques de méthode à une classe dédiée pour les dispatches : Nous
appelons cette classe un Visitor, les Shapes accueillent un visiteur pour chaque dispatch et laissent le visiteur
déterminer comment procéder :
classDiagram
class ShapeVisitor {
<<Interface>>
+visit(Square)
+visit(Triangle)
+visit(Circle)
}
class IntersectionVisitor {
<<Class>>
+visit(Square)
+visit(Triangle)
+visit(Circle)
}
class SquareVisitor {
<<Class>>
+visit(Square)
+visit(Triangle)
+visit(Circle)
}
class TriangleVisitor {
<<Class>>
+visit(Square)
+visit(Triangle)
+visit(Circle)
}
class CircleVisitor {
<<Class>>
+visit(Square)
+visit(Triangle)
+visit(Circle)
}
ShapeVisitor <|.. IntersectionVisitor: implements
ShapeVisitor <|.. SquareVisitor: implements
ShapeVisitor <|.. TriangleVisitor: implements
ShapeVisitor <|.. CircleVisitor: implements
Que sont finalement les visiteurs ?
Les visiteurs intègrent le comportement pour des formes spécifiques / des combinaisons de formes directement dans le code.
Exemple de code
IntersectionVisitor(excerpt):public class IntersectionVisitor implements ShapeVisitor { // Stores shape internally, for second dispatch. private Shape firstShape; public IntersectionVisitor(Shape firstShape) { this.firstShape = firstShape; } public void visit(Circle circle) { // Second dispatch assignment and call firstShape.acceptAndVisit(new CircleVisitor()); } //... }CircleVisitor(excerpt):Square/Triangle:- Lanceur :
Étape par étape
Étape par étape, voici ce qui se passe :
mainest appelé- Premier dispatch, accept : Circle.acceptAndVisit est appelé (le payload est un nouveau
IntersectionVisitoravec un objetTrianglestocké à l’intérieur) - Premier dispatch, visit : IntersectionVisitor.visit est appelé (le payload est l’objet
Circle, passé commethis) - Second dispatch, accept : Triangle.acceptAndVisit (IntersectionVisitor passe
CircleVisitorà l’objetTrianglestocké en interne) - Second dispatch, visit : CircleVisitor.visit est appelé, avec l’objet
Triangleen argument (Trianglede l’appel précédent se passe lui-même commethis)
Gestion des erreurs
- La gestion des erreurs en Java distingue trois niveaux de gravité différents.
- Selon le niveau de gravité, d’autres recommandations (ou exigences au niveau du compilateur) s’appliquent.
- Commun à toutes les catégories, elles représentent une interruption de l’exécution normale du programme. Java dispose
d’un objet intégré pour stocker des informations sur la nature du problème :
Throwable - Chaque concept est lui-même une extension de classe de ce concept de base :
classDiagram
class Throwable {
<<Class>>
}
class Exception {
<<Class>>
}
class Error {
<<Class>>
}
class RuntimeException {
<<Class>>
}
Throwable <|-- Error: extends
Throwable <|-- Exception: extends
Exception <|-- RuntimeException: extends
Dans la suite, nous examinerons de plus près les trois catégories et les conditions qu’elles impliquent.
Exceptions vérifiées (Checked exceptions)
-
Les exceptions vérifiées sont la catégorie la moins grave. Elles couvrent des situations nécessitant un écart par rapport au chemin d’exécution prévu, mais qui peuvent généralement être corrigées.
-
Exemples :
IOExceptionlors de l’accès à une ressource réseau. Il n’est pas nécessaire de bloquer complètement le programme, mais l’utilisateur doit être informé du problème.FileNotFoundExceptionlors de l’accès à une ressource sur disque. Le chemin fourni contient probablement une faute de frappe et peut être corrigé.ParseExceptionlors de la tentative de désérialisation d’un objet. Il est possible que le mauvais objet ait été fourni et qu’une nouvelle tentative soit possible.
-
Exigences du compilateur : pour tout ce qui peut mal se passer et est susceptible de se produire de temps en temps, le compilateur exige une gestion explicite des exceptions.
- Option 1 : Déclarer que la méthode peut
throwsune exception vérifiée, dans la signature de la méthode. - Option 2 :
Catchl’exception sur place et implémenter la gestion des erreurs.
- Option 1 : Déclarer que la méthode peut
Il faut choisir
Les exceptions vérifiées doivent être gérées : soit en les déclarant dans la signature de la méthode, soit en les interceptant. Si aucune de ces options n’est appliquée, le compilateur rejettera le programme.
Déclaration de throws dans la signature
Déclarer une exception pouvant être levée via la signature de méthode équivaut à dire : "Si quelque chose tourne mal, ce n’est pas mon problème."
La syntaxe pour déclarer qu’une exception peut potentiellement être levée est :
public void doSomethingThatMayFail() throws IOException {
// Some code here that potentially throws IOException
}
Les méthodes peuvent déclarer throws pour plusieurs exceptions ; dans ce cas, elles sont simplement séparées par des
virgules :
public void doSomethingThatMayFail() throws IOException, FileNotFoundException {
// Some code here that potentially throws IOException
// A bit later more code that potentially throws a FileNotFoundException
}
Interception sur place
Intercepter sur place équivaut à dire : "Si quelque chose tourne mal, je m’en occupe immédiatement".
Si elle est interceptée, une exception est "désamorcée", c’est-à-dire que le bloc catch définit ce qui doit se passer
si l'exception correspondante se produit :
public void doSomethingThatMayFail() {
try {
// Sending the program execution to sleep for a while (this may fail with a checked exception)
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Something went terribly wrong when putting the program to sleep.");
}
}
Ne pas intercepter la superclasse Exception
Les programmeurs inexpérimentés ont tendance à utiliser catch(Exception e)... eh bien, regardez la hiérarchie des exceptions, cela couvre toutes les exceptions, et votre bloc catch sera probablement déclenché pour des problèmes non liés. Toujours intercepter des types d’exception spécifiques, jamais la superclasse commune Exception !
Exceptions à l’exécution (Runtime exceptions)
-
Les exceptions à l’exécution (appelées aussi "unchecked exceptions") sont la catégorie la plus grave. Elles couvrent des situations indiquant des bugs sérieux dans le code. En général, elles ne sont pas gérées, car la solution n’est pas de réagir à l’exécution, mais de corriger le code.
-
Exemples :
NullPointerExceptionlors de l’accès à une variablenull.DivisionByZeroExceptionlors d’une division arithmétique par0.ArrayIndexOutOfBoundsExceptionlors de l’accès à une position inexistante dans un tableau.
-
Exigences du compilateur : aucune, le compilateur ne peut pas empêcher ces exceptions de se produire et ne requiert pas leur gestion à l’exécution.
Gestion des exceptions à l’exécution
Au niveau du code, le mieux est simplement d’ignorer les exceptions à l’exécution. Si votre programme a un bug, ne tentez pas de le masquer - corrigez ce bug !
- Ne les déclarez pas comme
throwsdans la signature de la méthode : - Ne créez pas de
try-catchautour des instructions à risque :
foo() {
try {
String s = null;
s.length(); // throws NullPointerException
} catch (NullPointerException e) {
System.out.println("Caught a NullPointerException!");
}
}
Exceptions personnalisées
- Les exceptions ne sont que des classes. Vous pouvez absolument définir vos propres exceptions personnalisées pour vos situations exceptionnelles qui nécessitent une gestion d’erreur.
- Réfléchissez au caractère de votre exception :
- Si elle indique une situation qui peut être gérée, faites-en une exception vérifiée :
YourException extends Exception - Si votre exception indique un bug dans le code, faites-en une exception à l’exécution :
YourException extends RuntimeException
- Si elle indique une situation qui peut être gérée, faites-en une exception vérifiée :
/**
* Custom exception to be thrown whenever access to a model breaks consistency with existing model
* state, e.g. usage of a player index out of bounds, or attempting to claim or clear a field that
* is already taken or not owned.
*/
public class ModelAccessInconsistencyException extends RuntimeException {
/**
* Custom exception constructor.
*
* @param errorMessage as the String message to assign to the exception object.
*/
public ModelAccessConsistencyException(String errorMessage) {
super(errorMessage);
}
}
Ne pas utiliser les exceptions comme outil de contrôle classique
Les exceptions ont la puissante capacité d’intercepter le code en plein milieu de l’exécution et de sauter directement au bloc catch correspondant. N’utilisez pas ce mécanisme pour des tâches de contrôle classiques – Java n’a pas d’instruction jump / goto, et pour une bonne raison. Les exceptions sont réservées aux situations exceptionnelles, c’est-à-dire celles qui nécessitent une gestion immédiate pour éviter un crash du programme.
Erreurs
La dernière catégorie est celle des Error. Les Error correspondent à des problèmes majeurs et catastrophiques, et
la meilleure chose à faire est d’arrêter complètement la JVM.
Bien qu’il soit possible d’intercepter des Error (sous-classe de Throwable), intercepter une Error est extrêmement
rare et probablement pas ce que vous souhaitez.
Par exemple, si la JVM manque de ressources avec un VirtualMachineError, il n’y a tout simplement pas grand-chose à
faire.
Récapitulatif
| Catégorie | Exemple | Gestion |
|---|---|---|
| Exception vérifiée | FileNotFoundException |
Déclaration throws ou interception requise par le compilateur |
| Exception à l’exécution (unchecked) | NullPointerException |
Déclaration throws ou interception non requise |
| Error | VirtualMachineError |
Ne doit pas être géré |
Pourquoi la documentation logicielle mentionne-t-elle généralement seulement les exceptions vérifiées ?
Habituellement, vous ne verrez que les exceptions vérifiées dans la documentation JavaDoc, car ce sont les seules exceptions mentionnées dans la signature de la méthode (throws). Si une exception vérifiée est levée, l’appelant de la méthode doit connaître ses détails pour réagir correctement. Les exceptions à l’exécution et les erreurs, en revanche, signalent des bugs ou des erreurs graves d’exécution. Celles-ci ne doivent pas être documentées mais corrigées.
Bibliographie
Inspiration et lectures supplémentaires pour les esprits curieux :
- Java HashSet API
- Baeldung : Java
equalsethashCodecomme contrats - Java Design Patterns - Collision d’objets avec double dispatch
- Java Exception API
Voici le lien pour accéder à l’unité de labo : Lab 04