Un objet est un ensemble d'informations liées les unes aux autres, associé à des mécanismes de manipulation des informations en question. Pour créer un objet, il faut instancier une classe qui joue le rôle de patron (de modèle) pour les objets. Cette classe décrit un type objet.
Or, en Java, les informations sont désignées par des variables qui désignent des entités de différentes natures (valeur, tableau ou objet). Un objet contient donc des variables qui représentent les informations associées à l'objet. De ce fait, une classe décrit d'abord ces variables, comme dans l'exemple suivant :
public class PersonneV1 {
private String firstName;
private String lastName;
}
Cette classe crée le type objet PersonneV1
. Les deux lignes qui commencent
par private
indiquent que chaque objet de type PersonneV1
contiendra
deux variables, firstName
et lastName
, toutes deux de type String
. Les
informations associées à un objet de type PersonneV1
seront donc deux
chaînes de caractères.
Pour utiliser le type ainsi défini, on procède comme pour les autres types objets, comme dans l'exemple suivant :
public class PersonneV1Test {
public static void main(String[] args) {
PersonneV1 jd = new PersonneV1();
System.out.println(jd);
}
}
qui affiche
PersonneV1@3f66cb16
comme prévu, car nous n'avons pas expliqué comme convertir l'objet en une unique chaîne de caractères.
Plus généralement, une variable d'instance définie par une ligne de la forme suivante :
private typeDeLaVariable nomDeLaVariable;
On reconnaît une déclaration de variable précédée du mot clé private
qui
indique que la variable concernée n'est utilisable que part l'objet.
Le type PersonneV1
au dessus n'est pas très utile car rien ne permet de
manipuler l'information qu'il contient. En fait, ce type ne fournit aucun
mécanisme de manipulation associé. En Java, ces mécanismes sont fournis par
des méthodes d'instances.
Comme pour les informations, ces méthodes sont définies dans la classe de l'objet, comme dans l'exemple suivant qui définit trois méthodes :
public class PersonneV2 {
private String firstName;
private String lastName;
public String getFirstName() {
return firstName;
}
public char getFirstLetterFirstName() {
return firstName.charAt(0);
}
public String getShortFullName() {
return getFirstLetterFirstName() + ". " + lastName;
}
}
Chaque méthode est de la forme suivante :
public typeDuRésultat nomDeLaMéthode(Paramètres) {
instructions de la méthode
}
Le typeDuRésultat
désigne le type de l'entité renvoyée par la méthode. Dans
les exemples de la classe PersonneV2
, on voit que deux méthodes renvoient
des String
et une un char
. On pourrait aussi renvoyer un tableau. Les
Paramètres
des méthodes seront décrits dans la suite du document.
Le résultat de l'exécution d'une méthode (ou d'une méthode par abus de langage) est la valeur ou la référence qui remplace l'appel de méthode après son exécution dans l'évaluation d'une expression. Par exemple dans
String s = "Toto";
char c = s.charAt(0);
l'expression s.charAt(0)
est évaluée en exécutant les instructions de la
méthode chatAt
(définie dans la classe String
) pour l'objet désigné par
s
puis en remplaçant l'expression complète par le résultat de cette
exécution (ici 'T'
).
Pour définir le résultat de l'exécution d'une méthode, on utilise
l'instruction spécifique return
qui est suivie d'une expression. La valeur
de cette expression est, par définition, le résultat de l'exécution de la
méthode.
Une méthode d'instance est toujours appelée à partir d'un objet (la chaîne
désignée par s
dans l'exemple ci-dessus). De ce fait, une méthode d'instance
peut utiliser les variables de l'objet. C'est pourquoi on peut écrire dans
PersonneV2
la méthode suivante :
public String getFirstName() {
return firstName;
}
En effet, tout objet de la classe PersonneV2
possède deux variables
d'instance, firstName
et lastName
. Toute méthode d'instance de cette
classe peut donc utiliser les variables de l'objet. Le sens de cette méthode
est donc de renvoyer le prénom firstName
de l'objet appelant.
Cependant, la classe PersonneV2
ne fonctionne pas comme prévu. En effet,
l'exemple suivant :
public class PersonneV2Test {
public static void main(String[] args) {
PersonneV2 jd = new PersonneV2();
System.out.println(jd.getFirstName());
System.out.println(jd.getFirstLetterFirstName());
}
}
affiche
null Exception in thread "main" java.lang.NullPointerException at PersonneV2.getFirstLetterFirstName(PersonneV2.java:10) at PersonneV2Test.main(PersonneV2Test.java:5)
c'est-à-dire une erreur d'exécution.
Ceci est une conséquence de l'absence d'initialisation pour les variables des
objets de type PersonneV2
. En effet, quand on revient au code de la classe,
on voit que rien ne donne de valeur aux variables considérées. De ce fait,
elles sont fixées à la valeur null
par Java, ce qui indique qu'elles ne
désignent aucun objet (donc aucune chaîne de caractères).
null
qui ne désigne
aucun objet avec un objet vide, comme par exemple la chaîne ""
qui représente la chaîne vide.
Quand on demande la valeur de firstName
, la méthode renvoie donc
naturellement null
. Le problème vient de la méthode suivante qui tente de
récupérer le premier caractère de la chaîne désignée par firstName
. Comme
cette variable ne désigne en fait aucune chaîne de caractères, on ne peut pas
appeler de méthode, ce qui conduit à l'erreur NullPointerException
indiquée
au dessus.
Pour fixer les valeurs initiales des variables d'un objet, on utilise une méthode spéciale appelée un constructeur. Voici une nouvelle version de la classe d'exemple :
public class PersonneV3 {
private String firstName;
private String lastName;
public PersonneV3(String p,String n) {
firstName = p;
lastName = n;
}
public String getFirstName() {
return firstName;
}
public char getFirstLetterFirstName() {
return firstName.charAt(0);
}
public String getShortFullName() {
return getFirstLetterFirstName() + ". " + lastName;
}
}
Le constructeur est la nouvelle méthode qui utilise la forme particulière suivante :
public nomDeLaClasse(Paramètres) {
instructions du constructeur
}
On peut reprendre l'exemple ci dessus :
public class PersonneV3Test {
public static void main(String[] args) {
PersonneV3 jd = new PersonneV3("John", "Doe");
System.out.println(jd.getFirstName());
System.out.println(jd.getFirstLetterFirstName());
System.out.println(jd.getShortFullName());
}
}
qui affiche
John J J. Doe
Alors qu'une méthode s'appelle en écrivant objet.méthode(paramètres)
, un
constructeur s'appelle en écrivant new NomDeLaClasse(paramètres)
.
Pour comprendre la fonctionnement du constructeur, il faut comprendre celui des paramètres d'une méthode. Ils sont spécifiés sous la forme générale suivante :
typeParamètre1 nomParamètre1, typeParamètre2 nomParamètre2, ..., typeParamètren nomParamètren
Une méthode peut avoir de 0 à n
paramètres. Lors de l'appel de la méthode,
le programme doit fournir autant de valeurs/références que de paramètres, en
respectant les types des paramètres en question. Si une méthode prend 0
paramètre, alors son appel doit comporter seulement une paire de parenthèses.
Par exemple, la méthode charAt
des String
est définie dans la classe de la façon
suivante :
public char charAt(int index) {
...
return ...;
}
La méthode charAt
attend donc un paramètre unique, de type int
. On peut
donc écrire "Toto".charAt(3)
mais pas
"Toto".charAt("T")
, ni "Toto".charAt(true)
ou
encore "Toto".charAt()
.
Dans les instructions de la méthode, chaque paramètre est considéré comme une
variable du type indiqué, dont la valeur est celle donnée au moment de l'appel
de la méthode. Donc, quand on écrit new PersonneV3("John", "Doe")
, les
paramètres p
et m
de la méthode (du constructeur) prennent respectivement
les valeurs "John"
et "Doe"
.
Un constructeur est particulier car :
return
;new NomDeLaClasse(Paramètres)
.Son exécution est un peu différente de celle d'une méthode normale est correspond à la séquence suivante :
null
pour les objets
et les tableaux, 0.0
pour les double
, etc.) ;De ce fait, le vrai rôle du constructeur est d'initialiser les informations représentées par l'objet, plutôt que de « construire » l'objet.
this
Le code des méthodes est généralement dissymétrique quand on manipule plusieurs objets.
Considérons par exemple la classe suivante :
public class Recipient {
private int contenu;
private int contenance;
public Recipient(int max) {
contenu = 0;
contenance = max;
}
public int remplir(int liquide) {
contenu = contenu + liquide;
if(contenu > contenance) {
int trop = contenance - contenu;
contenu = contenance;
return trop;
} else {
return 0;
}
}
public void transvaser(Recipient that) {
contenu = contenu + that.contenu;
if(contenu > contenance) {
that.contenu = contenu - contenance;
contenu = contenance;
}
}
public String toString() {
return contenu + " [" + contenance + "] ";
}
}
On l'utilise avec le programme de test suivant :
public class TestRecipient {
public static void main(String[] args) {
Recipient r = new Recipient(20);
Recipient p = new Recipient(20);
r.remplir(10);
p.remplir(15);
System.out.println(r);
System.out.println(p);
r.transvaser(p);
System.out.println(r);
System.out.println(p);
}
}
qui affiche
10 [20] 15 [20] 20 [20] 5 [20]
Un objet de type Recipient
représente un récipient avec une capacité
maximale en liquide (contenance
) et un contenu actuel (contenu
). On
remarque que la méthode transvaser
, qui représente l'action de transférer le
maximum possible du contenu de that
dans l'objet appelant, traite les deux
objets (l'objet appelant et l'objet that
) de façon dissymétrique. L'accès
aux variables de l'objet appelant est direct alors que pour utiliser les
variables de l'objet that
, on écrit that.contenu
(par exemple).
En réalité, l'accès au contenu d'un objet se fait toujours en écrivant
référence.élément
. Cependant, comme les méthodes d'instance travaillent
dans de nombreux cas seulement sur l'objet appelant, une syntaxe simplifiée
est utilisée, celle qui permet d'accéder directement aux éléments sans passer
par une référence. En interne, le compilateur doit tout de même avoir une
référence vers cet objet appelant. Celle-ci porte le nom this
et est
ajoutée par le compilateur quand il ne peut pas compiler un programme en son
absence. Par exemple, la méthode toString
définie ci-dessus est en fait
comprise par le compilateur comme
public String toString() {
return this.contenu + " [" + this.contenance + "] ";
}
On peut d'ailleurs écrire la méthode de cette façon, même si dans ce cas précis, cela ne sert à rien.
Il y a essentiellement trois utilisations possibles de la référence this
.
On peut l'employer pour rendre le code d'une méthode plus symétrique et de ce
fait plus clair, comme dans cette version de la méthode transvaser
proposée
ci-dessus :
public void transvaser(Recipient that) {
this.contenu = this.contenu + that.contenu;
if(this.contenu > this.contenance) {
that.contenu = this.contenu - this.contenance;
this.contenu = this.contenance;
}
}
Il est parfois utile de renvoyer une référence vers l'objet appelant. C'est
ce qui est fait par la méthode append
des StringBuilder
, ce qui permet
d'enchaîner les appels comme par exemple s.append('a').append(2)
. Dans ce
cas, il suffit de faire un return this
, comme dans cette nouvelle version
de transvaser
:
public void transvaser(Recipient that) {
this.contenu = this.contenu + that.contenu;
if(this.contenu > this.contenance) {
that.contenu = this.contenu - this.contenance;
this.contenu = this.contenance;
}
return this;
}
C'est de loin l'utilisation la plus courante. Considérons d'abord une version
erronée du constructeur de la classe Recipient
défini comme suit :
public Recipient(int contenance) {
contenu = 0;
contenance = contenance;
}
Le constructeur compile parfaitement, mais en pratique, la variable
contenance
de l'objet créé garde sa valeur initiale de 0. En effet, le
compilateur n'utilise la référence this
que si celle-ci est nécessaire pour
compiler une méthode (ou un constructeur). Or ici, ce n'est pas le cas car
contenance
désigne le paramètre du constructeur (et non pas la variable de
l'objet en cours de construction). On réalise donc dans le constructeur
l'opération inutile qui consiste à écrire dans contenance
sa propre valeur
! Pour palier ce problème, on peut utiliser le code suivant :
public Recipient(int contenance) {
contenu = 0;
this.contenance = contenance;
}
Dans ce cas, this.contenance
désigne bien la variable de l'objet en cours
de construction, alors que contenance
désigne le paramètre de la méthode.
La programmation orientée objet s'appuie notamment sur un principe très important dit d'encapsulation. Il s'agit essentiellement de masquer le contenu d'un objet aux autres objets du programme. Il y a deux intérêts majeurs à ce principe :
Java propose un mécanisme général de contrôle de l'accès au contenu des
objets qui s'appuie sur les mots clés (public
par exemple) qui précède les
déclarations de variable et les définitions de méthode (on assimile les
constructeurs à des méthodes dans cette discussion). Nous nous
contenterons ici des deux niveaux principaux :
public
public
, elle
est utilisable sans aucune restriction par n'importe quelle
autre méthode du programme considéré.private
private
, elle
ne peut être utilisée que dans les méthodes de la même classe.
Reprenons l'exemple de la classe Recipient
ci-dessus. Comme la variable
contenu
est private
, on ne peut y accéder que dans les méthodes de la
classe Recipient
elle-même. Ceci explique pourquoi on peut écrire
that.contenu
par exemple, car that
est un objet de type Recipient
. Dans
la méthode main
de la classe TestRecipient
, on ne pourrait pas écrire
r.contenu
(par exemple) car cette méthode main
n'appartient pas à la
classe Recipient
. Si on avait au contraire déclaré contenu
comme
public
, l'accès r.contenu
serait possible.
Au contraire, les méthodes et le constructeur de Recipient
sont déclarés
public
, ce qui autorise n'importe quelle méthode (en particulier la méthode
main
de la classe TestRecipient
) à les utiliser.
En général, on déclare les variables private
et la plupart des méthodes
public
.
En caricaturant, les objets dont l'état est modifiable par des méthodes sont
généralement plus efficaces et plus difficiles à utiliser que des objets dont
l'état n'est plus modifiable après leur création. L'exemple typique est celui
du couple String
et StringBuilder
.
On souhaite réaliser un type objet qui s'appuie sur la méthode
System.currentTimeMillis()
pour fabriquer un chronomètre informatique. On
compare ici une solution modifiable classique avec une solution immuable.
On propose le code suivant :
public class ChronoModifiable {
private boolean started;
private long startTime;
private long accTime;
public ChronoModifiable() {
// constructeur, chronomètre éteint, pas de temps accumulé
started = false;
accTime = 0;
}
public void start() {
if(!started) {
// on démarre le chronomètre en notant l'heure courante
started = true;
startTime = System.currentTimeMillis();
}
}
public void stop() {
if(started) {
// on éteint le chronomètre et on ajoute à l'accumulateur
// le temps écoulé depuis le démarrage
started = false;
accTime += System.currentTimeMillis() - startTime;
}
}
public long read() {
if(started) {
// chronomètre allumé : on renvoie le temps intermédiaire
return System.currentTimeMillis() - startTime + accTime;
} else {
// chronomètre éteint : on renvoie le temps accumulé
return accTime;
}
}
public void reset() {
if(!started) {
// remise à zéro du temps accumulé
accTime = 0;
}
}
public boolean isStarted() {
return started;
}
}
On utilise le code de test ci-dessous.
public class TestChronoModifiable {
public static void main(String[] args) {
ChronoModifiable timer = new ChronoModifiable();
timer.start();
// les cinq lignes qui suivent indiquent à l'ordinateur d'attendre
// 500 millisecondes
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
System.out.println(timer.read());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
timer.stop();
System.out.println(timer.read());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
System.out.println(timer.read());
ChronoModifiable timer2 = timer;
timer.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
timer.stop();
System.out.println(timer.read());
System.out.println(timer2.read());
}
}
L'affiche produit est (grosso modo) le suivant :
500 1001 1001 1501 1501
On remarque un effet d'alias classique : timer
et timer2
désignent le même
objet et le redémarrage du chronomètre par timer
se voit donc quand on lit
timer2
.
Dans la solution immuable, on remplace toutes les modifications de l'état (des
variables) de l'objet par la construction d'un nouvel objet. Ceci nécessite un
constructeur capable de spécifier explicitement les valeurs des trois
variables. On le déclare private
pour qu'il ne puisse pas être utilisé en
dehors de la classe.
On propose le code suivant :
public class ChronoImmuable {
private boolean started;
private long startTime;
private long accTime;
public ChronoImmuable() {
started = false;
accTime = 0;
}
private ChronoImmuable(boolean started,long startTime,long accTime) {
// constructeur privé, utilisable seulement dans la classe
this.started = started;
this.startTime = startTime;
this.accTime = accTime;
}
public ChronoImmuable start() {
if(!started) {
long st = System.currentTimeMillis();
return new ChronoImmuable(true,st,accTime);
} else {
return this;
}
}
public ChronoImmuable stop() {
if(started) {
return new ChronoImmuable(false,startTime,accTime + System.currentTimeMillis() - startTime);
} else {
return this;
}
}
public ChronoImmuable reset() {
if(!started) {
return new ChronoImmuable();
} else {
return this;
}
}
public long read() {
if(started) {
return System.currentTimeMillis() - startTime;
} else {
return accTime;
}
}
public boolean isStarted() {
return started;
}
}
On utilise le code de test ci-dessous.
public class TestChronoImmuable {
public static void main(String[] args) {
ChronoImmuable timer = new ChronoImmuable();
timer = timer.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
System.out.println(timer.read());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
timer = timer.stop();
System.out.println(timer.read());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
System.out.println(timer.read());
ChronoImmuable timer2 = timer.start();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
timer2 = timer2.stop();
System.out.println(timer.read());
System.out.println(timer2.read());
}
}
L'affiche produit est (grosso modo) le suivant :
500 1001 1001 1001 1501
On note l'absence d'effet d'alias : timer2
est arrêté alors que timer
continue à fonctionner.