Concepts de base de la programmation orientée objet
Précédemment, nous avons couvert le parcours historique des langages de programmation primitifs ou bas niveau vers des
langages avancés, notamment Java.
Nous avons brièvement vu la motivation pour la dissimulation de l’information, et pourquoi l’encapsulation et la
sécurité de type sont des concepts puissants pour rendre nos implémentations plus robustes.
Dans cette conférence, nous plongeons dans les concepts de base de la programmation orientée objet, en détaillant plus
particulièrement la philosophie de l’encapsulation et l’interaction de l’héritage avec la sécurité de type.
Conclusion de la conférence
L’encapsulation et l’héritage forment tous deux le tissu de la séparation des préoccupations. En cachant les détails d’implémentation au sein d’une classe, nous pouvons contenir la complexité. L’héritage permet de restructurer notre code pour réutiliser les propriétés existantes des classes (champs et fonctions) sans duplication de code.
Encapsulation et dissimulation de l’information
Lors de la dernière séance, nous avons brièvement vu l’importance de garder une classe secrète.
Pour jouer l’avocat du diable, pourquoi ne pas simplement rendre tous les champs publics ?
- Beaucoup moins de casse-tête à penser
public/private. - Tout fonctionne encore, nous pouvons accéder directement à nos données.
- Notre code est encore plus court, on pourrait même dire que c’est plus élégant que des rangées de getters et setters standard.
Quel est le problème ?
Vous ne pouvez pas compter sur les utilisateurs de vos fonctions pour qu’ils manipulent correctement les internes de l’objet. Très probablement, ils n’ont pas de mauvaises intentions mais ne connaissent simplement pas les détails internes de la classe. Codez de manière défensive, sécurisez les internes de vos objets.
Exemple
Un exemple simple est celui des classes avec des restrictions de base et des plages de valeurs.
Si nous devions implémenter un dé classique, nous nous attendrions à ce que sa valeur soit toujours dans la plage 1-6.
Cependant, si nous avons uniquement des champs publics, il n’y a absolument aucune garantie que cette contrainte soit respectée.
Cependant, si nous rendons le champ privé, nous pouvons implémenter une vérification de cohérence de base dans notre setter :
public class Dice {
private int value = 1;
public Dice() {
}
public int getValue() {
return value;
}
public void setValue(int value) {
if (value > 6 || value < 1) {
throw new RuntimeException("Value not allowed!");
}
this.value = value;
}
}
Masquer la complexité
Une autre motivation pour restreindre l’accès à l’information privée est de cacher les informations privées de la classe, c’est-à-dire les détails d’implémentation.
- Si vous lisez l’heure sur votre réveil, vous voulez juste l’heure – vous ne vous intéressez pas à la façon dont l’horloge fonctionne en interne.
- Il en va de même pour les classes Java.
Protéger les références profondes
Implémenter correctement les getters est parfois moins trivial qu’on pourrait le penser.
- Les getters pour les valeurs primitives sont généralement sûrs, personne ne peut modifier la valeur stockée dans notre classe en utilisant le getter.
- Les getters pour les objets, en revanche, sont une cause fréquente de fuite de secrets de classe. Voici un exemple :
public class University {
// The list is private and only accessible via a getter so we might think this is all safe
private List<Student> students;
// However we can abuse the getter, because it returns a reference, not an object!
public List<Student> getStudents() {
return students;
}
}
Illustration des objects dans le mémoire versus variables:
Il existe plusieurs façons de surmonter ce problème :
- Si nous sommes l’auteur de la classe retournée, nous pouvons nous assurer qu’il existe une variante en lecture seule, en utilisant une interface ou une classe de base commune (un peu plus là-dessus plus tard aujourd’hui).
- Si c’est une collection, nous pouvons utiliser
Collections.immutable...pour créer une variante immuable. - Et enfin, nous pouvons simplement créer une copie profonde du résultat avant de retourner une référence.
Il doit s’agir d’une copie profonde
Les copies seules ne suffisent pas, il doit s’agir d’une copie profonde, c’est-à-dire que chaque objet référencé à l’intérieur doit également être copié. Attention avec Arrays.clone(), cela ne crée que des copies superficielles.
Créer des copies profondes peut être un peu fastidieux, mais il existe quelques astuces pour contourner ce problème (mentionnées en classe).
Classes, héritage et polymorphisme
Commun à la plupart des langages de programmation orientée objet est le concept de hiérarchies.
- Hiérarchies de classes
- Hiérarchies d’interfaces
- Classes implémentant des interfaces
- etc.
Dans la suite, nous parcourrons les différents scénarios et leur utilité.
Héritage simple
Dans le cas le plus simple, une classe peut hériter des propriétés ou du comportement d’une seule autre classe.
On appelle également...
- ... la classe fournissant les propriétés et le comportement originaux la super-classe.
- ... la classe héritant des propriétés et du comportement la sous-classe.
L’intérêt de l’héritage simple est de définir un comportement commun dans la super-classe et un comportement spécialisé dans la sous-classe.
Exemple et visualisation
Toutes les Duck peuvent swim() et quack() :
classDiagram
class Duck {
<<Class>>
+String swim()
+String quack()
}
Pour simplifier, supposons que toutes les méthodes retournent une chaîne vocalisant le son de chaque méthode :
- L’implémentation
swim()desDuckretourne"splash splash splash" - L’implémentation
quack()desDuckretourne"quack quack quack"
public class Duck {
public String swim() {
return "splash splash splash";
}
public String quack() {
return "quack quack quack";
}
}
Nous nous intéressons maintenant à un nouveau type de Canards : les RedheadDuck.
- Les
RedheadDucksont desDucket se comportent presque exactement comme la définition existante deDuck. - Les
RedheadDuckpeuventswim()etquack(). L’implémentation existante est parfaite.
Nous ne voulons pas copier-coller le code existant de Duck, mais puisque les RedheadDuck sont des Duck, nous
pouvons simplement étendre l’implémentation existante :
classDiagram
class Duck {
<<Class>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
Duck <|-- RedheadDuck : extends
- La flèche avec un triangle creux de
RedheadDuckversDuckindique que les deux méthodes sont héritées. - La classe
RedheadDuckne copie pas les définitions de méthodes :
- Néanmoins, ces méthodes existent – elles sont héritées de la super-classe. Ainsi, pour chaque objet
RedheadDuckcréé, nous pouvons appeler ces méthodes :
public static void main() {
RedheadDuck redheadDuck = new RedheadDuck();
// Can be called, and implicitly applies implementation of Duck superclass:
System.out.println(redheadDuck.swim());
System.out.println(redheadDuck.quack());
}
Exemple tiré de "Freeman & Freeman, Head First Design Patterns"
Sous-classes multiples
La relation "est-un", bien que non commutative, peut avoir des alternatives parallèles. Autrement dit, plusieurs sous -classes peuvent partager la même super-classe, sans interférer les unes avec les autres.
Si c’est le cas, toutes les sous-classes héritent des mêmes méthodes de la super-classe.
Exemple et visualisation
Nous pouvons ajouter un type supplémentaire de Duck à notre exemple précédent : les Mallard.
- Les
Mallard, comme lesRedheadDuck, sont desDucket doivent hériter de toutes les méthodes communes. - Encore une fois, nous ne voulons pas copier-coller le code de la classe
Duck, nous utilisons donc simplement une deuxième relationextend:
- Avec maintenant deux types de
Duck, nous pouvons visualiser le diagramme de classes résultant comme suit :
classDiagram
class Duck {
<<Class>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
class Mallard {
<<Class>>
}
Duck <|-- RedheadDuck : extends
Duck <|-- Mallard : extends
Surcharge de méthode
- Jusqu’à présent, nos deux sous-classes
Duckhéritent du comportement exact de la super-classeDuck. - Mais il y a un problème : les
Mallardsont en fait une espèce avec un cri très fort... plutôt un"QUACK !!!".
Je suis un canard colvert bruyant - QUACK !!!
- Mais puisque notre classe
Mallardextends Duck, elle possède toujours lequackpar défaut. - Heureusement, nous pouvons modifier le comportement par défaut en surchargeant certaines méthodes.
class Mallard extends Duck {
// placing a method with identical signature overrides the inherited super-behaviour.
// All other methods, e.g. `swim` remain unaffected.
@Override
public String quack() {
return "QUACK !!!";
}
}
Nous pouvons également visualiser le fait qu’une méthode spécifique a été surchargée :
classDiagram
class Duck {
<<Class>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
class Mallard {
<<Class>>
>>
+String quack()
}
Duck <|-- RedheadDuck
Duck <|-- Mallard
En UML, montrer explicitement les méthodes héritées suggère une implémentation de type Override.
Polymorphisme
Supposons que nous ayons un petit parc avec un tableau de 5 Duck.
Le parc est également censé avoir une méthode makeDuckConcert() qui permet à chaque canard de crier une fois.
- Malheureusement, nous ne savons pas quel type de canards se trouve dans le tableau.
- Il pourrait s’agir uniquement de Ducks classiques. Il pourrait s’agir de Mallards. Il pourrait s’agir de RedheadDucks... ou d’un mélange de tous.
- Alors, comment implémenter la méthode
makeDuckConcertpour s’assurer que les bons cris sont entendus ?
Mauvais : vérifications de type
Nous pourrions itérer sur chaque élément du tableau, puis vérifier le type de canard, et ensuite afficher le cri approprié.
// primitive version, using type checks
String makeDuckConcert() {
// iterate over all ducks (whatever they exact subtype)
for (int i = 0; i < ducks.length; i++) {
// Check if it is a very loud duck
if (ducks[i].getClass() == Mallard.class) {
System.out.println("QUACK !!!");
}
// if it's not a Mallard, print the normal quack
else {
System.out.println("Quack");
}
}
}
Bien que cette solution fonctionne, pouvez-vous repérer 2 problèmes ?
Quel est le problème avec le code ci-dessus ?
Nous avons réimplémenté le comportement de cri, nous avons besoin de connaissances concrètes sur tous les types de canards pouvant exister.
Bon : Délégation
Nous pouvons faire bien mieux !
- Chaque canard sait comment crier lui-même – nous n’avons pas besoin de vérifier son type
- Plutôt que de crier en fonction du type de canard, demandons simplement à chaque canard de crier.
- Le canard peut appliquer lui-même le comportement approprié.
// improved version, using polymorphism
String makeDuckConcert() {
for (int i = 0; i < ducks.length; i++) {
// Just delegate - let the duck itself decide on how to quack.
ducks[i].quack();
}
}
Pourquoi cela fonctionne-t-il ?
- Tous les canards ont garanti une implémentation de
quack(). - Lorsque nous appelons
quack(), cela invoque l’implémentation du type d’objet concret – nous ignorons exactement lequel c’est, mais l’objet le sait !
La technique s’appelle polymorphisme. Nous tirons parti du fait que chaque implémentation apporte son propre comportement et délègue l’exécution réelle aux implémentations individuelles.
Quels sont les ingrédients pour une soupe polymorphique ?
1) Une méthode commune. 2) Des implémentations surchargées. 3) Appeler la méthode commune sur le super-type.
Polymorphisme dans d’autres langages
Java possède un système de types strict, c’est-à-dire que le compilateur ne considère les types comme compatibles que s’ils sont explicitement déclarés.
- Dans les exemples précédents, nous avons pu assigner une instance de
RedheadDuckà une variableDuck. - Cela n’était possible que parce que nous avons explicitement déclaré que
RedheadDuck extends Duck.
Tous les langages n’appliquent pas un système de types strict, par exemple JavaScript ne le fait pas.
- Le polymorphisme est toujours possible, c’est-à-dire que nous pouvons toujours déléguer le comportement concret d’une méthode à l’instance de l’objet.
- Comment est-ce possible ? JavaScript (et d’autres langages faiblement typés) appliquent un système de Duck-Typing.
Duck Typing
L’explication informelle du Duck typing est : "Si ça nage comme un canard et ça cancane comme un canard, c’est probablement un canard".
Plus formellement : la compatibilité des objets est déterminée en fonction des méthodes disponibles, plutôt que d’une hiérarchie explicitement déclarée.
En JavaScript : vous créez un tableau d’objets (qui peuvent être compatibles ou non), puis appelez la méthode désirée (qui peut être disponible ou non). Vous ne saurez qu’à l’exécution si cela fonctionne comme prévu.
Types, interfaces et classes abstraites
Systèmes de types stricts
- Les langages orientés objet stricts appliquent un système de types strict, c’est-à-dire que le compilateur vérifie à
la compilation si les assignations de variables sont possibles.
- Les systèmes de types stricts sont un mécanisme de sécurité pour prévenir les erreurs de programmation. Si le programmeur tente de stocker quelque chose dans une variable incompatible, il est très probable qu’il ait fait une erreur. Le système de types garantit que l’erreur peut être corrigée avant l’exécution du programme, au lieu de provoquer un crash ou pire (comportement imprévisible).
- Certains langages permettent les déclarations de type, mais uniquement à des fins décoratives, par exemple les indications de type en Python ne préviennent pas des attributions incompatibles.
- Par exemple, en Java, il n’est pas possible d’assigner une valeur de type String à un champ
int:
Sous-types et compatibilité
- Le mot-clé
extends, décrivant l’héritage simple, est l’équivalent verbal d’une relation "est-un". - Ce mot-clé permet d’élargir la compatibilité des types :
- Si chaque
RedheadDuckest-unDuck, nous pouvons le stocker dans une variableDuck: - Exemple de code :
- Si chaque
extends n’est pas commutatif
L’héritage de classe n’est pas commutatif, ce qui signifie que bien que chaque objet de la sous-classe puisse être assigné à une variable de la super-classe, cela ne fonctionne pas dans l’autre sens !
Exemple non commutatif :
- Chaque
RedheadDuckest unDuck - Mais pas chaque
Duckest unRedheadDuck!
??? question "Que se passe-t-il exactement si nous essayons de stocker une instance du supertype dans une variable du sous-type ?"
Nous recevrons une erreur du **compilateur**, car la relation `est-un` ne fonctionne que dans l’autre sens :
`error: incompatible types: Duck cannot be converted to RedheadDuck`
Classes abstraites
- Auparavant, notre super-classe (
Duck) était une classe Java standard. - Il était tout à fait possible d’instancier de nouveaux objets et d’appeler les méthodes fournies avec :
void initializeSuperClass() {
Duck duck = new Duck();
System.out.println(duck.swim());
System.out.println(duck.quack());
}
- Cependant, pour être totalement honnête, aucun animal n’est jamais juste un
Duck. Dans la vie réelle, chaque animal est une certaine espèce (hélas sous-classe) deDuck. Nous avons vu :MallardRedheadDuck
- Comment pouvons-nous empêcher l’instanciation de
Duckpur et restreindre les objets aux espèces concrètes (Mallard,RedheadDuck) ?
Classes abstraites
Le mot-clé abstract indique qu’une classe ne peut pas être instanciée. Elle ne peut servir que de super-classe pour d’autres classes, mais il ne peut y avoir aucune instance de la classe abstraite.
Exemple de classe abstraite
Pour notre hiérarchie précédente, abstract est un bon choix :
- Une super-classe
abstract Duckempêche l’instanciation d’objetsDuck(lesMallardetRedheadDuckne sont pas affectés). - Les sous-classes héritent toujours de toutes les méthodes implémentées, c’est-à-dire que
MallardetRedheadDuckont un comportement par défaut pourswim()etquack().
En termes de code, cela se traduit par :
public abstract class Duck {
public String swim() {
return "splash splash splash";
}
public String quack() {
return "quack quack quack";
}
}
Le langage de modélisation unifié (UML) marque également les classes abstraites avec un stéréotype correspondant :
classDiagram
class Duck {
<<Abstract>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
class Mallard {
<<Class>>
}
Duck <|-- RedheadDuck : extends
Duck <|-- Mallard : extends
Quelle est la conséquence, en termes de code ?
- La classe
Duckne peut plus être instanciée. - Les
RedheadDucketMallardpeuvent toujours être instanciés (et assignés à des variablesDuck) comme auparavant.
void initializationExample() {
// This does not work ! Duck is abstract and cannot be instantiated
Duck duck = new Duck();
// But this does work: The sub-classes are not abstract and can be instantiated. The still inherit a method implementation from the super-class:
Duck mallard = new Mallard();
Duck redheadDuck = new redheadDuck();
mallard.quack();
mallard.swim();
redheadDuck.quack();
redheadDuck.swim();
}
Héritage multiple
- L’héritage n’implique souvent pas seulement une paire super-classe/sous-classe, mais une chaîne plus longue.
- Par exemple, nous pouvons imaginer que les
Duck, puisqu’ils sont desBird, devraient également avoir une méthodefly().- La méthode
fly()ne devrait pas être implémentée dansDuck, car d’autres oiseaux non-canards peuvent également voler.
- La méthode
classDiagram
class Bird {
<<Abstract>>
+String fly()
}
class Duck {
<<Abstract>>
+String swim()
+String quack()
}
class RedheadDuck {
<<Class>>
}
class Mallard {
<<Class>>
>>
}
Bird <|-- Duck : extends
Duck <|-- RedheadDuck : extends
Duck <|-- Mallard : extends
L’héritage est transitif
Avec Duck étendant Bird et RedheadDuck + Mallard étendant Duck, la méthode fly() est héritée par toutes les instances de RedheadDuck et Mallard. L’héritage est transitif.
Un effet secondaire pratique est que nous pouvons maintenant ajouter autant de nouveaux sous-types de Duck que nous
voulons, ils pourront toujours fly(), swim() et quack().
Que doit-on ajouter à Duck pour hériter de Bird comme super-classe ?
extends Bird ! Donc la déclaration complète de la classe est maintenant public abstract class Duck extends Bird
Le problème du diamant
- Jusqu’à présent, nous avons seulement considéré les cas où une classe hérite (
extends) d’une seule super-classe. - Mais nous pourrions envisager ce qui devrait se passer lorsqu’une classe hérite de plusieurs super-classes :
classDiagram
class Vehicle {
<<Abstract>>
+String accelerate()
+String slowDown()
}
class Electric {
<<Abstract>>
+String accelerate()
+String chargeBattery()
}
class Combustion {
<<Abstract>>
+String accelerate()
+String fuelUp()
}
class Hybrid {
<<Class>>
}
Vehicle <|-- Electric : extends
Vehicle <|-- Combustion : extends
Electric <|-- Hybrid : extends
Combustion <|-- Hybrid : extends
Ici, nous pouvons dire que les classes Electric et Combustion fournissent chacune leur propre méthode
accelerate(), car elles utilisent en interne des sources d’énergie différentes pour mettre le véhicule en mouvement.
Pourquoi cela s’appelle-t-il le problème du diamant ?
La hiérarchie de classes résultante, telle qu’affichée dans le diagramme UML, montre deux chemins d’extension alternatifs. Le problème est que Hybrid hérite maintenant de deux implémentations conflictuelles de accelerate(). Laquelle doit l’emporter ? Il n’y a pas de bonne réponse, donc certains langages, comme Java, interdisent complètement la double (ou plus) héritage. En Java, il n’est pas possible d’étendre plus d’une super-classe directe.
Interfaces
- Avec le "problème du diamant", nous avons vu comment l’héritage multiple, c’est-à-dire une classe ayant plusieurs super-classes directes, conduit facilement à des conflits.
- Par conséquent, de nombreux langages OO, notamment Java, n’autorisent pas l’héritage multiple.
- Cependant, il existe de bonnes raisons d’exiger que les classes fournissent certaines méthodes spécifiques.
- Cela est possible avec les interfaces en Java :
- Comme pour l’héritage de classe, une classe Java peut
implementsune interface donnée. - Avec le mot-clé
implements, la classe doit fournir toutes les méthodes "mentionnées" dans l’interface. - L’interface ne fournit aucune implémentation réelle, seulement des signatures de méthodes.
- Comme pour l’héritage de classe, une classe Java peut
Exemples d’interfaces dans la vie réelle :
- Prises électriques : quel que soit l’appareil électrique que vous concevez, il doit respecter les spécifications. (Et nous connaissons tous, en voyage, les frustrations liées aux appareils qui ne correspondent pas à la spécification de l’interface.)
- Pédales de véhicule : embrayage à gauche, frein au milieu, accélérateur à droite – mieux vaut ne pas commercialiser une voiture avec d’autres dispositions. Cela causerait des problèmes.
Les interfaces sont des contrats
Les interfaces ne fournissent aucune implémentation, mais constituent un contrat technique. Quel que soit l’appareil (ou la classe) qui prétend implement une interface, le développeur doit s’assurer que les spécifications de l’interface sont respectées.
Exemple et illustration
En UML, les interfaces sont décorées avec le stéréotype <<Interface>>, et les classes qui les implémentent sont
reliées par une flèche en pointillé :
classDiagram
class Vehicle {
<<Interface>>
+String accelerate()
+String slowDown()
}
class Electric {
<<Interface>>
+String chargeBattery()
}
class Combustion {
<<Interface>>
+String fuelUp()
}
class Hybrid {
<<Class>>
+String accelerate()
+String slowDown()
+String chargeBattery()
+String fuelUp()
}
Vehicle <|-- Electric : extends
Vehicle <|-- Combustion : extends
Electric <|.. Hybrid : implements
Combustion <|.. Hybrid : implements
Pour atteindre une implémentation double d’interface, les parents directs peuvent simplement être listés dans la définition de la classe :
public class Hybrid implements Electric, Combustion {
@Override
public String accelerate() {
...
}
@Override
public String slowDown() {
...
}
@Override
public String chargeBattery() {
...
}
@Override
public String fuelUp() {
...
}
Notez comment la classe "Audi" liste les deux méthodes, bien qu’elles soient déjà définies dans l’interface. C’est parce que l’interface ne fournit pas d’implémentation, c’est-à-dire que ces méthodes doivent être implémentées dans la classe Audi. Il n’y a pas de comportement par défaut à hériter comme c’était le cas avec l’extension d’une super-classe.
Extends vs implements
Dans l’exemple ci-dessus, nous avons également une hiérarchie implicite d’interfaces, c’est-à-dire que Electric et
Combustion extends tous deux l’interface de base Vehicle.
Au départ, il peut y avoir une petite confusion sur le moment d’utiliser implements et celui d’utiliser extends,
mais il existe une règle simple à mémoriser : "Extends fonctionne uniquement pour des concepts identiques".
| Super concept | Sous concept | Mot-clé |
|---|---|---|
| Class | Class | extends |
| Interface | Interface | extends |
| Interface | Class | implements |
| Class | Interface | (*) |
Que faut-il utiliser pour (*) ?
Rien ! Une interface définit contractuellement les méthodes à implémenter par une classe, elle ne peut pas hériter d’une classe. Éliminez cette combinaison, elle n’a aucun sens.
Combinaisons de mots-clés restreintes
L’héritage de classes et les extensions d’interfaces sont un tissu puissant pour de nombreux concepts orientés objet. Cependant, certaines combinaisons de mots-clés sont restreintes, car elles entraîneraient des conflits conceptuels :
Pas de méthodes privées dans les interfaces
- Parfois, nous aimerions forcer les implémentations à contenir une méthode privée, utile comme aide interne pour l’implémentation.
- Malheureusement, cela va à l’encontre de la motivation des interfaces : les méthodes
privatesont des détails d’implémentation, et les interfaces sont un contrat pour des interactions en boîte noire. - En fait, il n’est même pas nécessaire de placer le mot-clé
publicdevant les méthodes d’une interface Java : toutes les méthodes d’une interface sont par défaut publiques.
Petite précision : les interfaces peuvent en fait contenir des méthodes privées, mais elles ne peuvent être appelées que par des méthodes
default/staticet ne sont jamais héritées.
Pas de méthodes statiques dans les classes abstraites
- Lors de l’implémentation d’une classe abstraite commune, nous aimerions parfois fournir du code statique, disponible pour toutes les instances, par exemple pour un singleton intégré.
- Malheureusement, cela constitue une contradiction inhérente :
abstractpour une classe signifie : une sous-classe est requise pour que cette fonctionnalité existestaticpour une méthode signifie : cette fonctionnalité peut être accessible, même si aucune instance n’existe.
- Ainsi, en combinaison, on dit simultanément qu’un élément ne doit pas exister et que la fonctionnalité est attribuée à ce qui ne doit pas exister.
Bibliographie
Inspiration et lectures complémentaires pour les esprits curieux :
Voici le lien pour accéder à l’unité de labo : Lab 03