The ``global'' view of systems can be likened to that of
the creator of a system who must know all interfaces and system
types. In contrast, objects in a system are like Plato's
cave-dwellers who can interact with the universe in which they
live only in terms of observable communications, represented by
the shadows on the walls of their cave. Creators of a system are
concerned with classification while objects that inhabit a system
are concerned primarily with communication.
Peter Wegner
L'école de programmation Algol (cf. chapitre A) a
été la première à proposer une approche de
la programmation qui est devenue classique : un programme est
considéré comme un ensemble de procédures et un ensemble de données,
séparé, sur lequel agissent ces procédures.
Ce principe a parfois été résumé
par une équation célèbre :
Programmes = Algorithmes + Structures de données.
Les méthodes d'analyse qui vont de pair consistent à diviser
pour régner, c'est-à-dire à découper la tâche à effectuer en un
ensemble de modules indépendants, considérés comme des boîtes noires.
Cette technique a fait ses preuves depuis longtemps et continue à être
à la base de la programmation structurée.
Mais elle atteint ses limites lorsque l'univers sur lequel opèrent les
programmes évolue, ce qui est en fait la régle générale plutôt que
l'exception : de nouveaux types de données doivent être pris en
compte, le contexte applicatif dans lequel s'inscrit le programme
change, les utilisateurs du programme demandent de nouvelles
fonctionnalités et l'interopérabilité du programme avec d'autres
programmes, etc.
Or dans un langage comme Pascal ou C, les applications sont découpées
en procédures et en fonctions ; cela permet une bonne décomposition
des traitements à effectuer, mais le moindre changement de la
structuration des données est difficile à mettre en oeuvre,
et peut entraîner de profonds bouleversements dans l'organisation de
ces procédures et fonctions.
C'est ici que la programmation objet apporte un "plus"
fondamental, grâce à la notion d'encapsulation : les données et
les procédures qui manipulent ces données sont regroupées dans une
même entité, appelée l'objet.
Les détails d'implantation de l'objet restent cachés : le monde
extérieur n'a accès à ses données que par l'intermédiaire d'un
ensemble d'opérations qui constituent l'interface de l'objet.
Le programmeur qui utilise un objet dans son programme n'a donc pas à
se soucier de sa représentation physique ; il peut raisonner en termes
d'abstractions.
Java est l'un des principaux représentants actuels de la famille des
langages à objets.
Dans ce chapitre, nous allons nous familiariser progressivement avec
les principales caractéristiques de cette famille, en nous appuyant
comme dans les chapitres précédents sur le langage Java pour illustrer
notre propos.
Mais il faut savoir qu'à certains choix conceptuels près, on
retrouvera le même style de programmation dans d'autres langages de la
famille.
4.1 Retour sur la classe
Nous avons déjà introduit la classe comme permettant de regrouper des
variables pour former une même entité.
Mais en fait, la classe est plus que cela : elle est la description
d'une famille d'objets ayant même structure et même
comportement.
Elle permet donc non seulement de regrouper un ensemble de données,
mais aussi les procédures et fonctions qui agissent sur ces données.
Dans le vocabulaire de la programmation objet, ces procédures et
fonctions sont appelées les méthodes ; elles représentent le
comportement commun de tous les objets appartenant à la classe.
Revenons à notre exemple bancaire pour illustrer notre propos.
Nous avions défini au § 3.1 une classe
CompteBancaire qui contient quatre variables.
Par ailleurs, nous avions défini au § 3.2.3 une
procédure étatCompte dans la classe Banque ; mais
celle-ci accède directement aux variables de la classe
CompteBancaire, ce qui est contraire au principe
d'encapsulation précédemment énoncé.
De même, si on veut vraiment considérer CompteBancaire comme
une "boîte noire", dans laquelle les détails d'implantation sont
cachés, il n'est pas judicieux de laisser le programme de la classe
Banque accéder directement à la variable interne
solde, pour l'incrémenter ou la décrémenter.
Plus généralement, on souhaite avoir le contrôle sur les opérations
autorisées sur les attributs internes d'une classe, ce qui est
impossible à réaliser si les programmes du "monde extérieur à la
classe" peuvent accéder directement à ces attributs.
Nous aboutissons donc à la spécification de l'interface souhaitable
pour un objet de type compte bancaire :
-
il faut pouvoir créditer et débiter le compte,
- il faut être en mesure d'afficher son état.
D'autres méthodes pourront bien entendu venir compléter cette première
interface rudimentaire.
À partir de là, écrivons une nouvelle version de la classe
CompteBancaire ; notez bien la manière de
"penser" quand
on écrit une méthode, par exemple créditer : "je" reçois
en argument un montant, et j'incrémente "mon" solde de ce montant.
Notez aussi que pour interdire l'accès direct aux variables internes, on
fait précéder leur déclaration du mot clé private,
les méthodes, elles, étant caractérisées par le mot clé
public, qui indique qu'elles sont accessibles
de l'extérieur de l'objet.
// Classe CompteBancaire - version 2.0
/**
* Classe représentant un compte bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class CompteBancaire {
private String nom; // le nom du client
private String adresse; // son adresse
private int numéro; // numéro du compte
private int solde; // solde du compte
// Les méthodes
public void créditer(int montant) {
solde += montant;
}
public void débiter(int montant) {
solde -= montant;
}
public void afficherEtat() {
System.out.println("Compte numéro " + numéro +
" ouvert au nom de " + nom);
System.out.println("Adresse du titulaire : " + adresse);
System.out.println("Le solde actuel du compte est de " +
solde + " euros.");
System.out.println("************************************************");
}
}
Nous avons déjà vu qu'il y a un lien fort entre les notions de classe
et de type de données. Le type en lui-même peut être vu
comme l'interface de la classe, c'est-à-dire la spécification
-- ou la signature -- des
opérations valides sur les objets de la classe, alors que la classe
elle-même est une mise en oeuvre concrète de cette interface.
4.1.1 Fonctions d'accès
Une conséquence de l'encapsulation est que les variables d'instance
nom, adresse, numéro et solde, sont
maintenant cachées au monde extérieur.
Malgré tous les avantages de cet état de fait, on peut avoir
besoin de rendre certaines de ces informations accessibles, ne serait-ce
qu'en lecture.
Faudrait-il pour autant les rendre à nouveau publiques dans ce cas ?
Non ! La solution est alors de munir l'interface de la classe de
fonctions d'accès en lecture et/ou en écriture.
Ainsi, si je veux permettre aux utilisateurs de la classe de consulter en
lecture uniquement le nom et l'adresse du titulaire du compte, je pourrais
définir dans la classe CompteBancaire les méthodes suivantes :
public String getNom() {
return nom;
}
public String getAdresse() {
return adresse;
}
L'avantage, par rapport au fait de rendre les variables publiques, est qu'on ne
permet que l'accès en lecture, pas en écriture (c'est-à-dire en
modification).
Et même si on voulait également permettre l'accès en écriture à l'adresse,
par exemple, il faut définir la fonction d'accès
public void setAdresse(String nouvelleAdresse) {
adresse = nouvelleAdresse;
}
plutôt que de rendre la variable publique. En effet, dans une vraie
application, on souhaitera probablement vérifier la cohérence de l'adresse,
mettre à jour certaines tables statistiques de l'agence, voire activer
l'impression d'un nouveau chéquier, par exemple, toutes choses qui sont
possibles dans une méthode telle que setAdresse, mais qui sont
impossibles à contrôler si le "monde extérieur" peut directement
modifier la valeur de la variable adresse.
Le fait de munir ses classes de fonctions d'accès, même triviales, au lieu
de rendre certaines variables publiques, est donc une "bonne pratique"
en programmation qu'il vaut mieux acquérir tout de suite, même si cela peut
parfois sembler fastidieux ou inutile sur les exemples simplistes que nous
utilisons en cours.
Vous pouvez voir illustré l'ajout de telles fonctions à la classe
CompteBancaire au § 6.6, quand nous en aurons besoin
pour définir une interface utilisateur.
Jusqu'à ce moment-là, nous nous en passerons pour ne pas alourdir les
exemples...
4.2 Les objets
Comme nous l'avons vu au § 3.1.1, une classe peut être
considérée comme un "moule", et on crée des objets par
"moulage" à partir du modèle donné par la classe.
Cette opération, qui se fait grâce à l'instruction new déjà
vue, s'appelle l'instanciation, car l'objet créé est dit être
une instance de sa classe.
Celle-ci permet donc de reproduire autant
d'exemplaires -- d'instances -- que nécessaire.
Chaque instance ainsi créée possède son propre exemplaire de chacune
des variables définies dans la classe ; on les appelle ses
variables d'instance.
Ainsi, si je crée deux objets de type CompteBancaire, ils
auront chacun leur propre variable solde, leur propre
variable nom, etc.
Se pose maintenant le problème de l'initialisation de ces variables,
au moment de la création.
Jusqu'à maintenant, dans notre programme bancaire, nous initialisions
ces variables après la création par new, par simple
affectation de leur valeur.
Mais le principe d'encapsulation que nous avons adopté, et la
protection que nous avons donnée aux variables par le mot clé
private, nous interdit maintenant d'accéder directement à ces
variables pour leur affecter une valeur.
Il nous faut donc un mécanisme spécifique d'initialisation ; en Java,
ce mécanisme s'appelle un constructeur.
Un constructeur est une procédure d'initialisation ; il porte
toujours le même nom que la classe pour laquelle il est défini -- sans
attributs de type --, et on
peut lui donner les paramètres que l'on veut.
Modifions donc notre classe CompteBancaire pour lui ajouter
un constructeur :
// Classe CompteBancaire - version 2.1
/**
* Classe représentant un compte bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class CompteBancaire {
private String nom; // le nom du client
private String adresse; // son adresse
private int numéro; // numéro du compte
private int solde; // solde du compte
// Constructeur : on reçoit en paramètres les valeurs du nom,
// de l'adresse et du numéro, et on met le solde à 0 par défaut
CompteBancaire(String unNom, String uneAdresse, int unNuméro) {
nom = unNom;
adresse = uneAdresse;
numéro = unNuméro;
solde = 0;
}
// Les méthodes
public void créditer(int montant) {
solde += montant;
}
public void débiter(int montant) {
solde -= montant;
}
public void afficherEtat() {
System.out.println("Compte numéro " + numéro +
" ouvert au nom de " + nom);
System.out.println("Adresse du titulaire : " + adresse);
System.out.println("Le solde actuel du compte est de " +
solde + " euros.");
System.out.println("************************************************");
}
}
Il est temps d'utiliser des objets de cette classe dans notre
programme bancaire, que nous devons bien évidemment modifier pour
tenir compte de l'approche objet que nous avons prise.
Vous pouvez noter plusieurs choses dans l'exemple qui suit :
-
le passage des arguments au constructeur au moment de
l'allocation mémoire via le mot clé
new ;
- l'appel des méthodes de l'interface par le moyen de la notation
pointée ;
- le fait que nous ne voyons plus du tout apparaître dans ce
programme les détails internes d'implantation d'un compte
bancaire. Le concepteur de CompteBancaire est donc libre de
modifier cette implantation interne, sans que cela influe sur le
programme bancaire, à condition bien sûr que l'interface reste valide.
// Classe Banque - version 3.0
/**
* Classe contenant un programme de gestion bancaire, utilisant
* un tableau de comptes bancaires
* @author Karl Tombre
* @see CompteBancaire, Utils
*/
public class Banque {
public static void main(String[] args) {
CompteBancaire[] tabComptes = new CompteBancaire[10];
// Initialisation des comptes
for (int i = 0 ; i < tabComptes.length ; i++) {
// Lire les valeurs d'initialisation
String monNom = Utils.lireChaine("Nom du titulaire = ");
String monAdresse = Utils.lireChaine("Son adresse = ");
int monNuméro = Utils.lireEntier("Numéro du compte = ");
// Créer le compte -- notez la syntaxe avec new
tabComptes[i] = new CompteBancaire(monNom, monAdresse, monNuméro);
}
boolean fin = false; // variable vraie si on veut s'arrêter
while(true) { // boucle infinie dont on sort par un break
String choix = Utils.lireChaine("Votre choix : [D]ébit, [C]rédit, [F]in ? ");
boolean credit = false; // variable vraie si c'est un crédit
// Récupérer la première lettre de la chaîne saisie
char monChoix = choix.charAt(0);
switch(monChoix) {
case 'C':
case 'c': // Même chose pour majuscule et minuscule
credit = true;
fin = false;
break; // Pour ne pas continuer en séquence
case 'd':
case 'D':
credit = false;
fin = false;
break;
case 'f':
case 'F':
fin = true;
break;
default:
fin = true; // On va considérer que par défaut on s'arrête
}
if (fin) {
break; // sortir de la boucle ici
}
else {
int indice = Utils.lireEntier("Indice dans le tableau des comptes = ");
int montant = Utils.lireEntier("Montant à " +
(credit ? "créditer" : "débiter") +
" = ");
if (credit) {
tabComptes[indice].créditer(montant);
}
else {
tabComptes[indice].débiter(montant);
}
// Afficher le nouveau solde
tabComptes[indice].afficherEtat();
}
}
}
}
Pour conclure ce paragraphe, insistons à nouveau sur le point suivant :
-
À l'intérieur de la classe, les variables d'instance et les
méthodes de la classe, publiques ou privées, sont accessibles
directement, comme nous l'avons vu avec la définition des méthodes
débiter, créditer et afficherEtat, dans
la classe CompteBancaire.
- En revanche, à l'extérieur de la classe, on n'accède aux
variables et méthodes publiques de l'instance d'un objet que par la
notation pointée. Ainsi, dans l'exemple que nous venons de voir,
tabComptes[indice].créditer(montant) signifie que l'on
appelle "la méthode créditer de l'objet
tabComptes[indice]". On ne peut pas accéder aux données
et méthodes privées.
Exercice 6
On souhaite écrire un programme Java qui gère un panier d'emplettes
pour un site de commerce électronique.
On représentera un item des emplettes par une classe Item dont un
embryon est donné ci-dessous ; compléter cette classe en écrivant le corps du
constructeur et de la méthode prix.
public class Item {
private String nom;
private int quantité;
private double prixUnitaire;
Item(String n, int q, double pu) {
// À faire
}
public double prix() {
// À faire
}
}
4.3 Méthodes et variables de classe
Nous avons vu que les méthodes et les variables d'instance sont
propres à chaque instance ; ainsi, chaque objet de type
CompteBancaire possède son propre exemplaire de la variable
solde -- ce qui permet fort heureusement à chaque titulaire
de compte de disposer de son propre solde et non d'un solde commun !
Mais il peut être parfois nécessaire de disposer de méthodes et de
variables qui soient propres à la classe, et non aux instances, et qui
n'existent donc qu'en exemplaire unique, quel que soit le nombre
d'instances.
On les appelle des méthodes et variables de classe, et elles sont
introduites par le mot clé static.
Illustrons tout de suite notre propos.
Nous souhaitons modifier la classe CompteBancaire de manière
à attribuer automatiquement le numéro de compte, par incrémentation
d'une variable de classe, que nous appellerons
premierNuméroDisponible.
Celle-ci est initialisée à 11, et chaque fois qu'on crée un nouveau
compte -- concrètement, dans le constructeur de la classe -- elle est
incrémentée :
// Classe CompteBancaire - version 2.2
/**
* Classe représentant un compte bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class CompteBancaire {
private String nom; // le nom du client
private String adresse; // son adresse
private int numéro; // numéro du compte
private int solde; // solde du compte
// Variable de classe
public static int premierNuméroDisponible = 1;
// Constructeur : on reçoit en paramètres les valeurs du nom et
// de l'adresse, on met le solde à 0 par défaut, et on récupère
// automatiquement un numéro de compte
CompteBancaire(String unNom, String uneAdresse) {
nom = unNom;
adresse = uneAdresse;
numéro = premierNuméroDisponible;
solde = 0;
// Incrémenter la variable de classe
premierNuméroDisponible++;
}
// Les méthodes
public void créditer(int montant) {
solde += montant;
}
public void débiter(int montant) {
solde -= montant;
}
public void afficherEtat() {
System.out.println("Compte numéro " + numéro +
" ouvert au nom de " + nom);
System.out.println("Adresse du titulaire : " + adresse);
System.out.println("Le solde actuel du compte est de " +
solde + " euros.");
System.out.println("************************************************");
}
}
Comme nous avons modifié la signature du constructeur, nous devons
aussi modifier légèrement notre programme bancaire ; je ne donne ici
que les premières lignes de la nouvelle mouture, le reste ne changeant
pas. Notez que l'on ne demande plus que le nom et l'adresse :
// Classe Banque - version 3.1
/**
* Classe contenant un programme de gestion bancaire, utilisant
* un tableau de comptes bancaires
* @author Karl Tombre
* @see CompteBancaire, Utils
*/
public class Banque {
public static void main(String[] args) {
CompteBancaire[] tabComptes = new CompteBancaire[10];
// Initialisation des comptes
for (int i = 0 ; i < tabComptes.length ; i++) {
// Lire les valeurs d'initialisation
String monNom = Utils.lireChaine("Nom du titulaire = ");
String monAdresse = Utils.lireChaine("Son adresse = ");
// Créer le compte -- notez la syntaxe avec new
tabComptes[i] = new CompteBancaire(monNom, monAdresse);
}
// etc.
À part la modification sur le nombre de questions posées à
l'utilisateur en phase de création du tableau, le programme a le même
comportement qu'avant :
Nom du titulaire = Karl
Son adresse = Sornéville
Nom du titulaire = Luigi
Son adresse = Rome
\emph{... Je vous passe un certain nombre de lignes - c'est fastidieux}
Nom du titulaire = Robert
Son adresse = Vandoeuvre
Votre choix : [D]ébit, [C]rédit, [F]in ? D
Indice dans le tableau des comptes = 3
Montant à débiter = 123
Compte numéro 4 ouvert au nom de François
Adresse du titulaire : Strasbourg
Le solde actuel du compte est de -123 euros.
************************************************
Votre choix : [D]ébit, [C]rédit, [F]in ? C
Indice dans le tableau des comptes = 0
Montant à créditer = 9800
Compte numéro 1 ouvert au nom de Karl
Adresse du titulaire : Sornéville
Le solde actuel du compte est de 9800 euros.
************************************************
Votre choix : [D]ébit, [C]rédit, [F]in ? F
4.3.1 Retour sur la procédure main
Vous avez maintenant les éléments vous permettant de mieux comprendre
la syntaxe a priori rébarbative que nous vous avons imposée dès
le premier programme, à savoir public static void
main(String[] args) :
-
En Java, la classe est l'unité de compilation et rien ne peut être
défini en dehors d'une classe ; la classe
Banque joue donc le rôle de "classe d'hébergement" du
programme.
- Cependant, nous ne manipulons jamais d'instances de cette
classe Banque -- la procédure main doit exister en
exemplaire unique pour la classe, c'est donc une méthode de classe,
d'où le mot clé static. D'ailleurs, dans la version 2.2 de
la classe Banque (cf. § 3.2.3), nous avions
une autre procédure, étatCompte, qui était également
définie comme méthode de classe, avec le mot clé static.
- Nous avons déjà expliqué que main est un nom réservé au
"commencement" du programme (cf. § 3.2.4) et qu'il
prend en paramètres un tableau de chaînes de caractères,
correspondant à des arguments passés au moment du lancement du
programme, d'où le paramètre formel String[] args.
4.3.2 Comment accéder aux variables et méthodes de classe ?
Nous ne nous sommes pas posés pour l'instant la question de l'accès
aux variables et méthodes de classe, car jusqu'à maintenant nous ne
les avons utilisées qu'au sein de la classe dans laquelle elles sont
définies, et la notation directe s'applique donc, comme pour les
méthodes et variables d'instance.
Mais la variable de classe premierNuméroDisponible ayant été
déclarée publique, on pourrait imaginer que
l'on souhaite y accéder directement depuis un programme ou une autre
classe.
Dans un programme extérieur, il n'est pas pour autant conseillé
d'écrire2 :
CompteBancaire c = new CompteBancaire("Capitaine Haddock", "Boucherie Sanzot");
System.out.println("Le premier numéro disponible est : " + c.premierNuméroDisponible);
car premierNuméroDisponible appartient à la classe, et non à
l'instance c.
Il vaut donc mieux utiliser la notation pointée en précisant que c'est à la
classe qu'appartient la variable :
System.out.println("Le premier numéro disponible est : " +
CompteBancaire.premierNuméroDisponible);
C'est d'ailleurs ce que nous ferons lorsque nous aurons besoin de
sauvegarder cette variable dans un fichier (§ 6.3.2).
Vous avez d'ailleurs déjà utilisé -- sans le savoir -- des variables de
classe dans les programmes vus jusqu'à maintenant.
Par exemple, System.out désigne la variable de classe
out de la classe System, comme nous le verrons au
§ 6.3.1.
Les fonctions lireChaine et lireEntier sont
également des méthodes de classe que nous avons définies dans la
classe Utils, et que nous avons utilisées
en employant la syntaxe que nous venons de voir, à savoir
Utils.lireChaine.
4.4 Exemple d'une classe prédéfinie : String
La chaîne de caractères est une structure de données très
fréquente dans les langages de programmation.
Dans beaucoup de langages, elle est définie comme un tableau de
caractères.
Mais en Java, c'est l'approche objet qui est employée, et les
bibliothèques standards de Java fournissent la classe String,
que nous avons déjà eu l'occasion d'utiliser.
String fournit les opérations courantes sur les chaînes,
comme nous allons le voir dans ce paragraphe.
Ces opérations sont disponibles sous forme de méthodes définies dans
la classe String.
Mais String a aussi une particularité.
C'est la seule classe pour laquelle un opérateur est
redéfini3 :
nous avons déjà eu l'occasion d'utiliser l'opérateur + sur des
chaînes de caractères, et nous avons vu qu'il permet la concaténation
de deux chaînes, comme dans :
"Adresse du titulaire : " + adresse.
Par ailleurs, Java prévoit une autre facilité syntaxique pour les
chaînes de caractères, à savoir l'emploi des guillemets (").
Quand nous écrivons String salutation = "bonjour";
nous créons en fait une constante de type String, qui vaut
"bonjour".
L'affectation de celle-ci à la variable salutation revient
tout simplement à faire "pointer" l'adresse contenue dans celle-ci
sur la "case" où est stockée cette constante -- souvenez-vous que
les variables de types définis par des classes sont toujours des
références (cf. § 3.1.1).
On pourrait bien sûr recourir plus classiquement à un constructeur,
comme pour toute autre classe, en écrivant String salutation =
new String("bonjour"); mais ce serait moins efficace, puisqu'on
crée dans ce cas deux objets de type String, la constante
"bonjour" puis la nouvelle chaîne via le constructeur.
Sans chercher à être exhaustifs, nous donnons ici une liste très
partielle des méthodes disponibles dans la classe String ;
comme pour toutes les autres classes des bibliothèques de base de
Java, la documentation complète est disponible à partir de ma page web
à l'école (rubrique APIs de Java).
J'indique à chaque fois la signature des méthodes ; pour
compareTo par exemple, cette signature est int
compareTo(String), ce qui signifie qu'on l'utilisera de la manière
suivante :
String s = ....;
String t = ...;
if (s.compareTo(t) > 0) {
System.out.println("La chaîne " + s + " est supérieure à la chaîne " + t);
}
Méthode |
Description |
char charAt(int) |
Rend le caractère à la position indiquée. Nous avons déjà utilisé
cette méthode. |
int compareTo(String) |
Comparaison lexicographique avec la chaîne donnée en
paramètre. Rend 0 si les deux chaînes sont égales, un nombre
négatif si la chaîne est inférieure à celle donnée en paramètre, un
nombre positif si elle est supérieure. |
boolean endsWith(String) |
Teste si la chaîne se termine par le suffixe donné en paramètre. |
boolean equals(Object) |
Teste l'égalité avec un autre objet. Nous avons déjà utilisé cette
méthode. |
boolean equalsIgnoreCase(String) |
Teste l'égalité avec une autre chaîne sans tenir compte des
majuscules/minuscules. |
int lastIndexOf(String) |
Rend l'index dans la chaîne de la dernière position de la
sous-chaîne donnée en paramètre. |
int length() |
Rend la longueur de la chaîne. |
String toLowerCase() |
Conversion de la chaîne en minuscules. |
String toUpperCase() |
Conversion de la chaîne en majuscules. |
4.4.1 Application : recherche plus conviviale du compte
Nous allons appliquer l'interface que nous venons de voir à notre programme
bancaire.
En effet, jusqu'à maintenant, nous demandions à l'utilisateur de
donner l'indice dans le tableau des comptes, ce qui est peu naturel.
Nous allons maintenant retrouver le compte dans le tableau par simple
indication du nom du titulaire.
Il faudra alors parcourir le tableau pour chercher un compte dont le
titulaire correspond au nom cherché.
Nous permettrons à l'utilisateur de ne pas tenir compte des majuscules
ou des minuscules, en utilisant la méthode equalsIgnoreCase
En préalable, notons que nous n'avons pas pour l'instant de moyen
d'accéder au nom du titulaire du compte, puisque la variable
nom est privée.
Nous pourrions la déclarer publique, mais ce serait contraire au
principe d'encapsulation ; préférons donc définir des méthodes d'accès
en lecture au nom et à l'adresse du titulaire du compte.
Notez bien que si j'avais déclaré ces variables publiques, je pourrais
les lire, mais
aussi les modifier depuis l'extérieur de la classe, alors qu'avec la
solution préconisée, je ne permets que l'accès en lecture :
// Classe CompteBancaire - version 2.3
/**
* Classe représentant un compte bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class CompteBancaire {
private String nom; // le nom du client
private String adresse; // son adresse
private int numéro; // numéro du compte
private int solde; // solde du compte
// Variable de classe
public static int premierNuméroDisponible = 1;
// Constructeur : on reçoit en paramètres les valeurs du nom et
// de l'adresse, on met le solde à 0 par défaut, et on récupère
// automatiquement un numéro de compte
CompteBancaire(String unNom, String uneAdresse) {
nom = unNom;
adresse = uneAdresse;
numéro = premierNuméroDisponible;
solde = 0;
// Incrémenter la variable de classe
premierNuméroDisponible++;
}
// Les méthodes
public void créditer(int montant) {
solde += montant;
}
public void débiter(int montant) {
solde -= montant;
}
public void afficherEtat() {
System.out.println("Compte numéro " + numéro +
" ouvert au nom de " + nom);
System.out.println("Adresse du titulaire : " + adresse);
System.out.println("Le solde actuel du compte est de " +
solde + " euros.");
System.out.println("************************************************");
}
// Accès en lecture
public String nom() {
return this.nom;
}
public String adresse() {
return this.adresse;
}
}
Vous remarquerez que j'ai donné à ces méthodes les mêmes noms qu'aux
variables auxquelles elles accèdent.
Dans le corps des méthodes, j'utilise le mot clé
this, qui référencie toujours l'objet courant --
"moi-même", qui possède les variables et les instances4 --
et la notation this.nom indique donc "ma variable privée
nom".
Ceci n'était pas stricto sensu nécessaire dans le cas
présent, car il n'y a pas ambiguité, mais cela augmente la lisibilité
du programme quand il y a deux attributs de même nom.
Voici le programme bancaire modifié ; vous noterez l'emploi de la
méthode equalsIgnoreCase de la classe String :
// Classe Banque - version 3.2
/**
* Classe contenant un programme de gestion bancaire, utilisant
* un tableau de comptes bancaires
* @author Karl Tombre
* @see CompteBancaire, Utils
*/
public class Banque {
public static void main(String[] args) {
CompteBancaire[] tabComptes = new CompteBancaire[10];
// Initialisation des comptes
for (int i = 0 ; i < tabComptes.length ; i++) {
// Lire les valeurs d'initialisation
String monNom = Utils.lireChaine("Nom du titulaire = ");
String monAdresse = Utils.lireChaine("Son adresse = ");
// Créer le compte -- notez la syntaxe avec new
tabComptes[i] = new CompteBancaire(monNom, monAdresse);
}
boolean fin = false; // variable vraie si on veut s'arrêter
while(true) { // boucle infinie dont on sort par un break
String choix = Utils.lireChaine("Votre choix : [D]ébit, [C]rédit, [F]in ? ");
boolean credit = false; // variable vraie si c'est un crédit
// Récupérer la première lettre de la chaîne saisie
char monChoix = choix.charAt(0);
switch(monChoix) {
case 'C':
case 'c': // Même chose pour majuscule et minuscule
credit = true;
fin = false;
break; // Pour ne pas continuer en séquence
case 'd':
case 'D':
credit = false;
fin = false;
break;
case 'f':
case 'F':
fin = true;
break;
default:
fin = true; // On va considérer que par défaut on s'arrête
}
if (fin) {
break; // sortir de la boucle ici
}
else {
// Demander un nom
String nomAChercher = Utils.lireChaine("Nom du client = ");
boolean trouvé = false; // rien trouvé pour l'instant
int indice = 0;
for (int i = 0 ; i < tabComptes.length ; i++) {
if (nomAChercher.equalsIgnoreCase(tabComptes[i].nom())) {
trouvé = true; // j'ai trouvé
indice = i; // mémoriser l'indice
break; // plus besoin de continuer la recherche
}
}
if (trouvé) { // Si j'ai trouvé quelque chose
int montant = Utils.lireEntier("Montant à " +
(credit ? "créditer" : "débiter") +
" = ");
if (credit) {
tabComptes[indice].créditer(montant);
}
else {
tabComptes[indice].débiter(montant);
}
// Afficher le nouveau solde
tabComptes[indice].afficherEtat();
}
else {
System.out.println("Désolé, " + nomAChercher +
" ne fait pas (encore) partie de nos clients.");
}
}
}
}
}
Et voici une trace d'exécution de cette nouvelle version :
Nom du titulaire = Karl Tombre
Son adresse = Sornéville
Nom du titulaire = Luigi Liquori
Son adresse = Rome
\emph{... Je vous passe un certain nombre de lignes - c'est fastidieux}
Nom du titulaire = Bill Clinton
Son adresse = Washington
Votre choix : [D]ébit, [C]rédit, [F]in ? c
Nom du client = Karl Tombre
Montant à créditer = 4500
Compte numéro 1 ouvert au nom de Karl Tombre
Adresse du titulaire : Sornéville
Le solde actuel du compte est de 4500 euros.
************************************************
Votre choix : [D]ébit, [C]rédit, [F]in ? D
Nom du client = Jacques Chirac
Désolé, Jacques Chirac ne fait pas (encore) partie de nos clients.
Votre choix : [D]ébit, [C]rédit, [F]in ? F
Ce programme souffre encore de plusieurs faiblesses, ou disons de
manques que je vous laisse compléter en exercice5 :
-
Nous partons ici du principe que le nom est unique, mais dans ce
cas nous aurions dû vérifier au moment de la création des comptes
que nous n'acceptons pas d'homonymes. Alternativement, on pourrait
imaginer un système où l'on cherche sur l'adresse si on trouve des
homonymes.
- Il y aurait des manières plus astucieuses d'organiser le tableau
des comptes, par exemple en le triant par ordre lexicographique sur
les comptes, ce qui permettrait une recherche plus efficace.
4.5 La composition : des objets dans d'autres objets
Le programme bancaire commence à devenir complexe ; on peut relever qu'il mèle
dans la même procédure main des éléments d'interface
homme--machine (les dialogues avec l'utilisateur) et la gestion du
tableau des comptes.
De plus, il est assez rigide, car il fixe à exactement 10 le nombre de
comptes à gérer.
Il est donc temps de modéliser par une classe à part entière la notion
d'agence bancaire, qui pour l'instant est juste représenté par le
tableau.
Cela nous donnera l'occasion de composer un objet (l'agence bancaire)
à partir d'autres objets (les comptes individuels)6.
Spécifions une version sommaire de l'interface d'une agence bancaire.
Il faut être capable de :
-
ajouter un nouveau compte ;
- afficher l'état de tous les comptes de l'agence ;
- retrouver un compte en donnant simplement le nom du titulaire.
Les choix de représentation interne sont les suivants :
-
Un tableau de comptes, comme dans le cas précédent. Mais nous
voulons maintenant être capables de faire varier de manière
dynamique la capacité de ce tableau.
Pour cela, nous choisissons une capacité de départ de 10 comptes, et
quand cette capacité est atteinte, nous créons un nouveau tableau de
capacité incrémentée de 10, dans lequel nous recopions les
comptes existants. Nous avons donc besoin d'une variable d'instance
pour mémoriser la capacité courante du tableau, et nous utilisons
une méthode privée, augmenterCapacité, pour l'opération
d'incrémentation de la capacité7.
- Un compteur indiquant le nombre de comptes effectivement
présents dans le tableau. Nous ne sommes donc plus condamnés à en
donner tout de suite 10.
Ceci nous conduit à la classe AgenceBancaire suivante :
// Classe AgenceBancaire - version 1.0
/**
* Classe représentant une agence bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class AgenceBancaire {
private CompteBancaire[] tabComptes; // le tableau des comptes
private int capacitéCourante; // la capacité du tableau
private int nbComptes; // le nombre effectif de comptes
// Constructeur -- au moment de créer une agence, on crée un tableau
// de capacité initiale 10, mais qui ne contient encore aucun compte
AgenceBancaire() {
tabComptes = new CompteBancaire[10];
capacitéCourante = 10;
nbComptes = 0;
}
// Méthode privée utilisée pour incrémenter la capacité
private void augmenterCapacité() {
// Incrémenter la capacité de 10
capacitéCourante += 10;
// Créer un nouveau tableau plus grand que l'ancien
CompteBancaire[] tab = new CompteBancaire[capacitéCourante];
// Recopier les comptes existants dans ce nouveau tableau
for (int i = 0 ; i < nbComptes ; i++) {
tab[i] = tabComptes[i];
}
// C'est le nouveau tableau qui devient le tableau des comptes
// (l'ancien sera récupéré par le ramasse-miettes)
tabComptes = tab;
}
// Les méthodes de l'interface
// Ajout d'un nouveau compte
public void ajout(CompteBancaire c) {
if (nbComptes == capacitéCourante) { // on a atteint la capacité max
augmenterCapacité();
}
// Maintenant je suis sûr que j'ai de la place
// Ajouter le nouveau compte dans la première case vide
// qui porte le numéro nbComptes !
tabComptes[nbComptes] = c;
// On prend note qu'il y a un compte de plus
nbComptes++;
}
// Récupérer un compte à partir d'un nom donné
public CompteBancaire trouverCompte(String nom) {
boolean trouvé = false; // rien trouvé pour l'instant
int indice = 0;
for (int i = 0 ; i < nbComptes ; i++) {
if (nom.equalsIgnoreCase(tabComptes[i].nom())) {
trouvé = true; // j'ai trouvé
indice = i; // mémoriser l'indice
break; // plus besoin de continuer la recherche
}
}
if (trouvé) {
return tabComptes[indice];
}
else {
return null; // si rien trouvé, je rend la référence nulle
}
}
// Afficher l'état de tous les comptes
public void afficherEtat() {
// Il suffit d'afficher l'état de tous les comptes de l'agence
for (int i = 0 ; i < nbComptes ; i++) {
tabComptes[i].afficherEtat();
}
}
}
Nous allons maintenant construire une nouvelle version de notre
programme bancaire.
Profitons de cette "mise à plat" pour modifier un peu les
dialogues et leur traitement.
Vous remarquerez que maintenant, ce programme ne contient plus que des
instructions de dialogue d'un côté, et des appels à l'interface des
classes CompteBancaire et GestionBancaire de
l'autre ; nous avons donc bien séparé ces deux aspects, ce qui rend le
programme plus lisible :
// Classe Banque - version 4.0
/**
* Classe contenant un programme de gestion bancaire, utilisant
* un objet de type AgenceBancaire
* @author Karl Tombre
* @see AgenceBancaire, CompteBancaire, Utils
*/
public class Banque {
public static void main(String[] args) {
AgenceBancaire monAgence = new AgenceBancaire();
while (true) { // boucle infinie dont on sort par un break
String monNom = Utils.lireChaine("Donnez le nom du client (rien=exit) : ");
if (monNom.equals("")) {
break; // si on ne donne aucun nom on quitte la boucle
}
else {
// Vérifier si le compte existe
CompteBancaire monCompte = monAgence.trouverCompte(monNom);
if (monCompte == null) {
// rien trouvé, on le crée
System.out.println("Ce compte n'existe pas, nous allons le créer");
String monAdresse = Utils.lireChaine("Adresse = ");
// Créer le compte
monCompte = new CompteBancaire(monNom, monAdresse);
// L'ajouter aux comptes de l'agence
monAgence.ajout(monCompte);
}
String choix = Utils.lireChaine("Votre choix : [D]ébit, [C]rédit ? ");
boolean credit = false; // variable vraie si c'est un crédit
// Récupérer la première lettre de la chaîne saisie
char monChoix = choix.charAt(0);
if (monChoix == 'c' || monChoix == 'C') {
int montant = Utils.lireEntier("Montant à créditer = ");
monCompte.créditer(montant);
}
else if (monChoix == 'd' || monChoix == 'D') {
int montant = Utils.lireEntier("Montant à débiter = ");
monCompte.débiter(montant);
}
else {
System.out.println("Choix invalide");
}
// Dans tous les cas, afficher l'état du compte
monCompte.afficherEtat();
}
}
// Quand on sort de la boucle, afficher l'état global de l'agence
System.out.println("Voici le nouvel état des comptes de l'agence");
monAgence.afficherEtat();
}
}
Voici une trace d'exécution de ce programme :
Donnez le nom du client (rien=exit) : \emph{Karl Tombre}
Ce compte n'existe pas, nous allons le créer
Adresse = \emph{Sornéville}
Votre choix : [D]ébit, [C]rédit ? \emph{D}
Montant à débiter = \emph{1000}
Compte numéro 1 ouvert au nom de Karl Tombre
Adresse du titulaire : Sornéville
Le solde actuel du compte est de -1000 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{Karl Tombre}
Votre choix : [D]ébit, [C]rédit ? \emph{C}
Montant à créditer = \emph{2000}
Compte numéro 1 ouvert au nom de Karl Tombre
Adresse du titulaire : Sornéville
Le solde actuel du compte est de 1000 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{Luigi Liquori}
Ce compte n'existe pas, nous allons le créer
Adresse = \emph{Rome}
Votre choix : [D]ébit, [C]rédit ? \emph{c}
Montant à créditer = \emph{8900}
Compte numéro 2 ouvert au nom de Luigi Liquori
Adresse du titulaire : Rome
Le solde actuel du compte est de 8900 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{Jacques Jaray}
Ce compte n'existe pas, nous allons le créer
Adresse = \emph{Laxou}
Votre choix : [D]ébit, [C]rédit ? \emph{F}
Choix invalide
Compte numéro 3 ouvert au nom de Jacques Jaray
Adresse du titulaire : Laxou
Le solde actuel du compte est de 0 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{kArL toMbre}
Votre choix : [D]ébit, [C]rédit ? \emph{c}
Montant à créditer = \emph{2400}
Compte numéro 1 ouvert au nom de Karl Tombre
Adresse du titulaire : Sornéville
Le solde actuel du compte est de 3400 euros.
************************************************
Donnez le nom du client (rien=exit) :
Voici le nouvel état des comptes de l'agence
Compte numéro 1 ouvert au nom de Karl Tombre
Adresse du titulaire : Sornéville
Le solde actuel du compte est de 3400 euros.
************************************************
Compte numéro 2 ouvert au nom de Luigi Liquori
Adresse du titulaire : Rome
Le solde actuel du compte est de 8900 euros.
************************************************
Compte numéro 3 ouvert au nom de Jacques Jaray
Adresse du titulaire : Laxou
Le solde actuel du compte est de 0 euros.
************************************************
Exercice 7
Voici une classe Point rudimentaire :
public class Point {
private double x;
private double y;
Point(double unX, double unY) {
x = unX;
y = unY;
}
public double getX() { return x; }
public double getY() { return y; }
}
On souhaite définir une classe décrivant des rectangles à partir de leurs
deux coins supérieur gauche et inférieur droit (dans le repère habituel en
graphisme sur ordinateur, c'est à dire origine en haut à gauche, x
croissant vers la droite, y croissant vers le bas).
L'embryon de cette classe est donné ci-dessous :
public class Rectangle {
private Point coinSupGauche;
private Point coinInfDroit;
Rectangle(Point p1, Point p2) {
coinSupGauche = new Point(Math.min(p1.getX(), p2.getX()),
Math.min(p1.getY(), p2.getY()));
coinInfDroit = new Point(Math.max(p1.getX(), p2.getX()),
Math.max(p1.getY(), p2.getY()));
}
}
Ajouter à cette classe les deux méthodes suivantes :
-
aire(), qui rend l'aire du rectangle ;
- plusPetitQue(Rectangle r), qui rend une valeur
vraie si l'aire du rectangle est inférieure à celle de r.
4.6 L'héritage
Quand le cahier des charges d'une application devient important, il
vient un moment où il n'est plus ni pratique, ni économique de gérer
tous les cas possibles dans une seule classe.
Ainsi, supposons qu'il y a deux types de comptes bancaires : les
comptes de dépôt et les comptes d'épargne.
Pour les premiers, on permet au solde d'être négatif, mais des agios
sont déduits chaque jour si le solde est négatif.
Pour les seconds, le solde doit toujours rester positif, mais on
ajoute des intérêts calculés chaque jour8.
On pourrait bien entendu gérer ces différents cas de figure dans une
seule et même classe CompteBancaire, mais celle-ci
deviendrait complexe et peu évolutive, puisqu'un grand nombre de ses
méthodes devraient prévoir les différences de traitement entre
comptes de dépôt et comptes d'épargne.
On pourrait aussi choisir de faire deux classes indépendantes,
CompteDepot et CompteEpargne, mais on est alors
condamné à dupliquer un grand nombre d'informations et de traitements
communs aux deux types de comptes.
C'est dans de telles situations que le recours à l'héritage est
particulièrement approprié.
En effet, l'héritage est un mécanisme qui permet de partager et de
réutiliser des données et des méthodes.
L'héritage permet d'étendre une classe existante, au lieu d'en créer
une nouvelle ex nihilo, l'idée étant de
tendre vers une hiérarchie de classes réutilisables.
Il s'agit en fait d'un problème de partage efficace de ressources.
La classe peut en effet être considérée comme un réservoir de
ressources, à partir duquel il est possible de définir d'autres
classes plus spécifiques, complétant les ressources de leur "classe
mère", dont elles héritent.
Les ressources les plus générales sont donc mises en commun dans
des classes qui sont ensuite spécialisées par la définition de
sous-classes.
Une sous-classe est en effet une spécialisation de la description
d'une classe, appelée sa superclasse, dont elle partage -- on
dit aussi qu'elle hérite -- les variables et les méthodes.
La spécialisation d'une classe peut être réalisée selon deux
techniques.
La première est l'enrichissement : la sous-classe est dotée de
nouvelles variables et/ou de nouvelles méthodes, représentant les
caractéristiques propres au sous-ensemble d'objets décrit par la
sous-classe.
La seconde technique est la substitution, qui consiste à donner une
nouvelle définition à une méthode héritée, lorsque celle-ci se révèle
inadéquate ou trop générale pour l'ensemble des objets décrits par la
sous-classe.
La notion d'héritage peut être vue sous deux angles complémentaires. Quand
une classe B hérite de la classe A (on dit aussi que B est dérivée de
A), l'ensemble des instances de A contient celui des instances de
B. Du point de vue extensionnel, la sous-classe B peut donc être
considérée comme un sous-ensemble de la classe A. Mais en même temps,
du fait des possibilités d'enrichissement, l'ensemble des propriétés
de A est un sous-ensemble des propriétés de B !
Le mot clé utilisé en Java pour marquer l'héritage,
extends, reflète d'ailleurs bien cet état de
fait.
Reprenons notre nouveau cahier des charges pour illustrer cette
notion.
Tout d'abord, nous allons décider de faire de CompteBancaire
une classe abstraite, c'est-à-dire une classe qu'il est
interdit d'instancier directement.
En effet, nous ne voulons avoir que des comptes de dépôt et des comptes
d'épargne, et aucun compte dont la catégorie n'est pas définie.
Il faut donc dans notre cas interdire à qui que ce soit de créer un
objet par instanciation de CompteBancaire.
Les classes abstraites sont souvent utilisées dans les langages à
objets pour factoriser toutes les caractéristiques
communes à un ensemble de classes, qui deviennent ensuite des
sous-classes de la classe abstraite.
Le fait d'avoir une classe abstraite nous permet aussi de définir des
méthodes abstraites, c'est-à-dire des méthodes dont nous définissons
la signature, mais dont nous ne donnons aucune implantation dans la
classe abstraite.
Pour ne pas être à son tour abstraite, une sous-classe doit alors
obligatoirement donner une définition de cette méthode.
Aussi bien la classe abstraite que la méthode abstraite sont
introduites par le mot clé abstract.
Dans l'exemple donné ci-après, nous avons ajouté à
CompteBancaire la méthode abstraite
traitementQuotidien, qui correspond au traitement qui est
supposé être effectué tous les jours -- ou plutôt toutes les nuits --
sur tous les comptes par un hypothétique programme de gestion des
comptes.
Une autre modification que nous sommes amenés à effectuer concerne la
protection des variables d'instance.
Jusqu'à maintenant, elles étaient toutes déclarées private.
Mais nous allons avoir besoin d'accéder à la variable solde
dans les classes héritées ; or une variable privée n'est même pas
visible dans les sous-classes qui en héritent !
D'un autre côté, nous ne souhaitons pas que solde devienne
une variable publique.
Java prévoit un niveau de protection intermédiaire, qui correspond à
ce que nous cherchons : une variable ou une méthode protégée (mot clé
protected) est privée sauf pour les
sous-classes de la classe où elle est définie, ainsi que pour les
classes appartenant au même package (cf. § 6.2).
Les autres variables de CompteBancaire n'ont quant à elles
aucune raison de ne pas rester privées.
Voici donc la nouvelle version de la classe CompteBancaire :
// Classe CompteBancaire - version 3.0
/**
* Classe abstraite représentant un compte bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public abstract class CompteBancaire {
private String nom; // le nom du client
private String adresse; // son adresse
private int numéro; // numéro du compte
protected int solde; // solde du compte -- variable protégée
// Variable de classe
public static int premierNuméroDisponible = 1;
// Constructeur : on reçoit en paramètres les valeurs du nom et
// de l'adresse, on met le solde à 0 par défaut, et on récupère
// automatiquement un numéro de compte
CompteBancaire(String unNom, String uneAdresse) {
nom = unNom;
adresse = uneAdresse;
numéro = premierNuméroDisponible;
solde = 0;
// Incrémenter la variable de classe
premierNuméroDisponible++;
}
// Les méthodes
public void créditer(int montant) {
solde += montant;
}
public void débiter(int montant) {
solde -= montant;
}
public void afficherEtat() {
System.out.println("Compte numéro " + numéro +
" ouvert au nom de " + nom);
System.out.println("Adresse du titulaire : " + adresse);
System.out.println("Le solde actuel du compte est de " +
solde + " euros.");
System.out.println("************************************************");
}
// Accès en lecture
public String nom() {
return this.nom;
}
public String adresse() {
return this.adresse;
}
// méthode abstraite, doit être implantée dans les sous-classes
// traitement quotidien appliqué au compte par un gestionnaire de comptes
public abstract void traitementQuotidien();
}
Nous allons maintenant créer les deux sous-classes
CompteDepot et CompteEpargne.
Examinons en détail la première :
// Classe CompteDepot - version 1.0
/**
* Classe représentant un compte de dépôt, sous-classe de CompteBancaire.
* @author Karl Tombre
*/
public class CompteDepot extends CompteBancaire {
private double tauxAgios; // taux quotidien des agios
Vous avez sûrement noté l'emploi du mot clé
extends, qui permet d'indiquer la relation
d'héritage entre CompteDepot et CompteBancaire.
Le premier problème qui se pose à nous est la manière d'initialiser
une instance de cette nouvelle classe.
Un compte de dépôt est un compte bancaire avec des propriétés en plus,
mais pour l'initialiser, le constructeur de CompteDepot ne
doit pas omettre d'initialiser la partie héritée, c'est-à-dire
d'appeler le constructeur de CompteBancaire.
Il doit en fait y avoir une chaîne d'appels aux constructeurs des
superclasses.
Ici, nous utilisons le mot clé super, qui
permet dans un constructeur d'appeler le constructeur de la
superclasse. Cet appel doit être la première instruction donnée dans le
constructeur de la classe :
// Constructeur
CompteDepot(String unNom, String uneAdresse, double unTaux) {
// on crée déjà la partie commune
super(unNom, uneAdresse);
// puis on initialise le taux des agios
tauxAgios = unTaux;
}
La seule méthode à redéfinir dans la classe CompteDepot est
afficherEtat, pour
laquelle on souhaite afficher une ligne de plus, indiquant qu'on a
bien un compte de dépôts.
Nous trouvons ici un deuxième emploi du mot clé super : en
conjonction avec la notation pointée, il permet d'appeler une méthode
masquée par l'héritage, en l'occurrence la méthode
afficherEtat de CompteBancaire, que nous sommes
justement en train de redéfinir :
// Méthode redéfinie : l'affichage
public void afficherEtat() {
System.out.println("Compte de dépôts");
super.afficherEtat(); // appeler la méthode de même nom dans la superclasse
}
Il nous reste à définir la méthode traitementQuotidien, qui
était définie comme abstraite dans la superclasse.
Notez la double conversion de solde en double pour
effectuer les opérations internes en réel double précision, puis du
résultat à débiter en int pour rester dans des calculs
entiers9.
Cette conversion utilise l'opération de cast, notée par un type
entre parenthèses.
Notez aussi l'appel direct à la méthode débiter, héritée de
CompteBancaire :
// Définition de la méthode de traitementQuotidien
public void traitementQuotidien() {
if (solde < 0) {
débiter((int) (-1.0 * (double) solde * tauxAgios));
}
}
}
Donnons plus rapidement la deuxième sous-classe,
CompteEpargne. On notera juste la redéfinition en plus de la
méthode débiter, pour vérifier que le solde ne devient pas
négatif :
// Classe CompteEpargne - version 1.0
/**
* Classe représentant un compte d'épargne, sous-classe de CompteBancaire.
* @author Karl Tombre
*/
public class CompteEpargne extends CompteBancaire {
private double tauxIntérêts; // taux d'intérêts par jour
// Constructeur
CompteEpargne(String unNom, String uneAdresse, double unTaux) {
// on crée déjà la partie commune
super(unNom, uneAdresse);
// puis on initialise le taux d'intérêt
tauxIntérêts = unTaux;
}
// Méthode redéfinie : l'affichage
public void afficherEtat() {
System.out.println("Compte d'épargne");
super.afficherEtat(); // appeler la méthode de même nom dans la superclasse
}
// Méthode redéfinie : débiter -- interdit de passer en-dessous de 0
public void débiter(int montant) {
if (montant <= solde) {
solde -= montant;
}
else {
System.out.println("Débit non autorisé");
}
}
// Définition de la méthode de traitementQuotidien
public void traitementQuotidien() {
créditer((int) ((double) solde * tauxIntérêts));
}
}
4.6.1 Héritage et typage
Nous avons vu qu'une classe peut être assimilée à un type, dans la
mesure où elle sert à définir des objets auxquels s'applique un
ensemble d'opérations.
La règle usuelle en programmation est de vérifier la correction des
programmes avant l'exécution, c'est-à-dire de garantir avant
l'exécution du programme que les méthodes appelées existent bien, que
les variables sont du bon type, etc.
C'est déjà pour cette raison que Java, comme beaucoup d'autres
langages, est fortement typé (cf. § 2.2) et qu'il faut
connaître d'avance -- c'est-à-dire au moment de la compilation
(cf. § 1.2) -- le type de toutes les variables et la
signature de toutes les méthodes utilisées.
Comment cette notion de typage fort s'articule-t-elle avec l'héritage ?
D'une certaine manière -- bien que cela soit un peu réducteur du point
de vue formel --
une sous-classe peut être considérée comme définissant un
sous-type. Il y a donc compatibilité de types ; en reprenant notre
exemple, une variable déclarée de type CompteBancaire peut
référencer un objet instance de CompteDepot ou de
CompteEpargne.
Attention, le contraire n'est pas vrai a priori !
Bien entendu, quand on manipule une variable de type
CompteBancaire, c'est l'interface définie par cette classe
qui est accessible, même si l'objet référencé possède d'autres
attributs de par son "appartenance" à une sous-classe de
CompteBancaire.
Nous allons mettre cette propriété à profit pour proposer une version
légèrement modifiée de la classe AgenceBancaire :
// Classe AgenceBancaire - version 1.1
/**
* Classe représentant une agence bancaire et les méthodes qui lui
* sont associées.
* @author Karl Tombre
*/
public class AgenceBancaire {
private CompteBancaire[] tabComptes; // le tableau des comptes
private int capacitéCourante; // la capacité du tableau
private int nbComptes; // le nombre effectif de comptes
// Constructeur -- au moment de créer une agence, on crée un tableau
// de capacité initiale 10, mais qui ne contient encore aucun compte
AgenceBancaire() {
tabComptes = new CompteBancaire[10];
capacitéCourante = 10;
nbComptes = 0;
}
// Méthode privée utilisée pour incrémenter la capacité
private void augmenterCapacité() {
// Incrémenter la capacité de 10
capacitéCourante += 10;
// Créer un nouveau tableau plus grand que l'ancien
CompteBancaire[] tab = new CompteBancaire[capacitéCourante];
// Recopier les comptes existants dans ce nouveau tableau
for (int i = 0 ; i < nbComptes ; i++) {
tab[i] = tabComptes[i];
}
// C'est le nouveau tableau qui devient le tableau des comptes
// (l'ancien sera récupéré par le ramasse-miettes)
tabComptes = tab;
}
// Les méthodes de l'interface
// Ajout d'un nouveau compte
public void ajout(CompteBancaire c) {
if (nbComptes == capacitéCourante) { // on a atteint la capacité max
augmenterCapacité();
}
// Maintenant je suis sûr que j'ai de la place
// Ajouter le nouveau compte dans la première case vide
// qui porte le numéro nbComptes !
tabComptes[nbComptes] = c;
// On prend note qu'il y a un compte de plus
nbComptes++;
}
// Récupérer un compte à partir d'un nom donné
public CompteBancaire trouverCompte(String nom) {
boolean trouvé = false; // rien trouvé pour l'instant
int indice = 0;
for (int i = 0 ; i < nbComptes ; i++) {
if (nom.equalsIgnoreCase(tabComptes[i].nom())) {
trouvé = true; // j'ai trouvé
indice = i; // mémoriser l'indice
break; // plus besoin de continuer la recherche
}
}
if (trouvé) {
return tabComptes[indice];
}
else {
return null; // si rien trouvé, je rend la référence nulle
}
}
// Afficher l'état de tous les comptes
public void afficherEtat() {
// Il suffit d'afficher l'état de tous les comptes de l'agence
for (int i = 0 ; i < nbComptes ; i++) {
tabComptes[i].afficherEtat();
}
}
// Traitement quotidien de tous les comptes
public void traitementQuotidien() {
for (int i = 0 ; i < nbComptes ;i++) {
tabComptes[i].traitementQuotidien();
}
}
}
En fait, la seule différence est l'ajout de la méthode
traitementQuotidien, qui permet d'appliquer la méthode
traitementQuotidien à tous les comptes.
Mais il y a une autre différence qui n'apparaît pas dans le code :
maintenant, il est impossible de créer des instances de la classe
CompteBancaire, puisque c'est une classe abstraite.
Le tableau tabComptes est un tableau d'objets de type
CompteBancaire, qui contiendra des références à des instances
soit de
CompteDepot, soit de CompteEpargne.
Grâce à la propriété de sous-typage, ces deux types d'objets peuvent
être regroupés dans un même tableau -- à condition bien entendu d'y
accéder via l'interface qu'ils ont en commun, à savoir
l'interface de la classe abstraite CompteBancaire.
C'est ce qui nous permet dans les méthodes afficherEtat et
traitementQuotidien de parcourir le tableau en demandant
à chaque objet d'effectuer l'opération afficherEtat qui lui
est propre.
4.6.2 Liaison dynamique
L'approche objet induit en fait une programmation par requêtes
adressées aux interfaces des classes : on n'appelle pas directement
une fonction, mais on demande à un objet d'exécuter une méthode de son
interface.
Se pose alors la question de savoir quand il faut déterminer quelle
fonction physique il faut concrètement appeler, c'est-à-dire quand il
faut réaliser la liaison entre la requête et la méthode de la classe
appropriée.
S'il fallait que le compilateur connaisse précisément
d'avance quelle méthode de quelle classe doit être appelée, on
perdrait le bénéfice de l'héritage.
En effet, il serait alors impossible de garantir un comportement
correct des méthodes afficherEtat et
traitementQuotidien de la classe AgenceBancaire,
puisque ce n'est qu'au moment de l'exécution que l'on saura lequel des
comptes du tableau est un compte de dépôts et lequel est un compte
d'épargne.
C'est pourquoi, dans les langages à objets, la liaison entre la requête
et la méthode effectivement activée est dynamique.
Le compilateur est capable d'établir que la méthode existe bien --
puisque afficherEtat et traitementQuotidien
appartiennent bien toutes deux à l'interface de la classe
CompteBancaire, et que les instructions
tabComptes[i].afficherEtat() et
tabComptes[i].traitementQuotidien() sont donc bien valides --
mais le choix de la méthode qui s'exécutera est différé jusqu'au
moment de l'exécution, quand on constate qu'à l'indice i, le
tableau comporte soit une instance de CompteDepot, soit une
instance de CompteEpargne.
Nous pouvons donc écrire une nouvelle version de notre programme de
gestion bancaire ; à la création d'un nouveau compte, nous demandons
maintenant à l'utilisateur quel type de compte doit être créé.
Dans la version actuelle, nous avons fixé dans le programme le taux
d'intérêt des comptes d'épargne et le taux des agios ; bien entendu,
une version plus complète devrait prévoir de saisir ces taux, et
éventuellement de pouvoir les modifier.
Le reste du programme ne change pas, la liaison dynamique se chargeant
d'activer les méthodes appropriées.
Nous terminons le programme par l'appel de la méthode
traitementQuotidien, suivi d'un nouvel affichage de l'état des
comptes de l'agence, pour illustrer le traitement différencié qui est
fait :
// Classe Banque - version 4.1
/**
* Classe contenant un programme de gestion bancaire, utilisant
* un objet de type AgenceBancaire
* @author Karl Tombre
* @see AgenceBancaire, CompteBancaire, Utils
*/
public class Banque {
public static void main(String[] args) {
AgenceBancaire monAgence = new AgenceBancaire();
while (true) { // boucle infinie dont on sort par un break
String monNom = Utils.lireChaine("Donnez le nom du client (rien=exit) : ");
if (monNom.equals("")) {
break; // si on ne donne aucun nom on quitte la boucle
}
else {
// Vérifier si le compte existe
CompteBancaire monCompte = monAgence.trouverCompte(monNom);
if (monCompte == null) {
// rien trouvé, on le crée
System.out.println("Ce compte n'existe pas, nous allons le créer");
String monAdresse = Utils.lireChaine("Adresse = ");
// Choisir le type de compte
String type =
Utils.lireChaine("Compte de [D]épôt (défaut) ou d'[E]pargne ? ");
char choixType = type.charAt(0);
// Créer le compte de type demandé
// On autorise les lettres accentuées ou non
if (choixType == 'e' || choixType == 'E' ||
choixType == 'é' || choixType == 'É') {
monCompte = new CompteEpargne(monNom, monAdresse, 0.00015);
}
else {
monCompte = new CompteDepot(monNom, monAdresse, 0.00041);
}
// L'ajouter aux comptes de l'agence
monAgence.ajout(monCompte);
}
String choix = Utils.lireChaine("Votre choix : [D]ébit, [C]rédit ? ");
boolean credit = false; // variable vraie si c'est un crédit
// Récupérer la première lettre de la chaîne saisie
char monChoix = choix.charAt(0);
if (monChoix == 'c' || monChoix == 'C') {
int montant = Utils.lireEntier("Montant à créditer = ");
monCompte.créditer(montant);
}
else if (monChoix == 'd' || monChoix == 'D') {
int montant = Utils.lireEntier("Montant à débiter = ");
monCompte.débiter(montant);
}
else {
System.out.println("Choix invalide");
}
// Dans tous les cas, afficher l'état du compte
monCompte.afficherEtat();
}
}
// Quand on sort de la boucle, afficher l'état global de l'agence
System.out.println("Voici le nouvel état des comptes de l'agence");
monAgence.afficherEtat();
System.out.println("-----------------------------------");
System.out.println("On applique un traitement quotidien");
System.out.println("-----------------------------------");
monAgence.traitementQuotidien();
monAgence.afficherEtat();
}
}
Voici une trace de l'exécution de ce programme ; vous noterez le débit
non autorisé du compte d'épargne -- puisqu'on passerait en-dessous de
0 --, le traitement différencié des deux comptes dans l'affichage des
états, et le résultat différent du traitement quotidien :
Donnez le nom du client (rien=exit) : \emph{Karl}
Ce compte n'existe pas, nous allons le créer
Adresse = \emph{Sornéville}
Compte de [D]épôt (défaut) ou d'[E]pargne ? \emph{D}
Votre choix : [D]ébit, [C]rédit ? \emph{D}
Montant à débiter = \emph{50000}
Compte de dépôts
Compte numéro 1 ouvert au nom de Karl
Adresse du titulaire : Sornéville
Le solde actuel du compte est de -50000 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{Luigi}
Ce compte n'existe pas, nous allons le créer
Adresse = \emph{Rome}
Compte de [D]épôt (défaut) ou d'[E]pargne ? \emph{é}
Votre choix : [D]ébit, [C]rédit ? \emph{C}
Montant à créditer = \emph{50000}
Compte d'épargne
Compte numéro 2 ouvert au nom de Luigi
Adresse du titulaire : Rome
Le solde actuel du compte est de 50000 euros.
************************************************
Donnez le nom du client (rien=exit) : \emph{Luigi}
Votre choix : [D]ébit, [C]rédit ? \emph{D}
Montant à débiter = \emph{60000}
Débit non autorisé
Compte d'épargne
Compte numéro 2 ouvert au nom de Luigi
Adresse du titulaire : Rome
Le solde actuel du compte est de 50000 euros.
************************************************
Donnez le nom du client (rien=exit) :
Voici le nouvel état des comptes de l'agence
Compte de dépôts
Compte numéro 1 ouvert au nom de Karl
Adresse du titulaire : Sornéville
Le solde actuel du compte est de -50000 euros.
************************************************
Compte d'épargne
Compte numéro 2 ouvert au nom de Luigi
Adresse du titulaire : Rome
Le solde actuel du compte est de 50000 euros.
************************************************
-----------------------------------
On applique un traitement quotidien
-----------------------------------
Compte de dépôts
Compte numéro 1 ouvert au nom de Karl
Adresse du titulaire : Sornéville
Le solde actuel du compte est de -50020 euros.
************************************************
Compte d'épargne
Compte numéro 2 ouvert au nom de Luigi
Adresse du titulaire : Rome
Le solde actuel du compte est de 50007 euros.
************************************************
Et si je veux interdire la définition de sous-classes ?
Il arrive que l'on souhaite qu'une classe donnée ne puisse pas servir
de superclasse à des sous-classes.
La raison principale est souvent une question de sécurité : la
compatibilité de type entre une classe et ses sous-classes pourrait
permettre à des programmeurs mal intentionnés de définir une
sous-classe qui viendrait se substituer à la classe d'origine, en
donnant l'apparence d'avoir le même comportement, alors qu'elle peut
faire des choses radicalement différentes.
Le mot clé final permet d'indiquer qu'une
classe ne peut pas avoir de sous-classes.
La classe String, par exemple, est tellement vitale pour les
programmes que les concepteurs de Java l'ont déclarée finale :
public final class String {
...
}
- 1
- Notez bien que l'initialisation de la variable de classe ne se fait pas
dans un constructeur, mais directement dans la classe, au moment où elle
est déclarée.
- 2
- Bien que Java le permette...
- 3
- En C++, un langage proche de Java par la syntaxe, on peut
redéfinir des opérateurs sur toutes les classes.
- 4
- De manière caractéristique, c'est le mot clé self qui est
utilisé dans le langage Smalltalk pour désigner l'objet courant.
- 5
- Si je vous donnais les solutions de tous les problèmes dans ce
polycopié, je me retrouverais en panne d'idées nouvelles pour les TDs !
- 6
- Il arrive qu'il y ait confusion entre l'emploi de la composition et
celui de l'héritage. Une bonne manière de choisir l'outil le plus
approprié est de se demander si B est une sorte de A (dans ce
cas B hérite de A) ou si B contient des objets de type A
(dans ce cas, on définit une variable d'instance de type A dans B).
- 7
- Un vrai programmeur Java ne ferait pas ce genre de gymnastique ;
il utiliserait directement la classe Vector, disponible
dans la bibliothèque de base Java, qui met justement en
oeuvre un tableau de taille variable. Mais il nous semble utile
d'avoir fait soi-même ce genre de manipulation au moins une fois,
c'est pourquoi nous construisons ici notre propre tableau
extensible.
- 8
- Pour garder des programmes illustratifs simples et éviter une
gestion lourde de dates et de périodes, nous avons bien entendu
simplifié énormément la gestion de comptes bancaires, pour laquelle
les intérêts et les agios sont calculés beaucoup moins souvent mais
tiennent en revanche compte des dates et des durées.
Depuis le début, nous avons aussi simplifié le problème des sommes
en ne comptant qu'avec des valeurs entières ; cela nous évite des
problèmes d'arrondi à deux chiffres après la virgule, mais va nous
compliquer un peu la vie quand il s'agira de calculer des intérêts
et des agios.
- 9
- Une conséquence de la formule choisie est que notre banque ne débite
rien si les agios quotidiens ne sont que des centimes...