Nous avons vu dans le cours sur les objets qu'on pouvait passer une référence
sur un objet quelconque à la méthode println
de System.out
pour afficher
quelque chose (parfois d'assez peu utile).
Reprenons un exemple élémentaire, la classe suivante :
public class Truc1 {
}
Chaque instance de Truc1
est essentiellement vide et la classe n'offre
aucune méthode de manipulation. Pourtant, le programme suivant fonctionne
parfaitement :
public class TestTruc1 {
public static void main(String[] args) {
Truc1 t1 = new Truc1();
Truc1 t2 = new Truc1();
System.out.println(t1);
System.out.println(t2);
System.out.println(t1.toString());
System.out.println(t2.toString());
}
}
L'affichage produit est de la forme suivante :
Truc1@500fbfa2 Truc1@39ee4dbe Truc1@500fbfa2 Truc1@39ee4dbe
On constate donc que bien que la classe Truc1
ne définisse pas de méthode
toString
, celle-ci est tout de même disponible pour les instances. Un test
avec d'autres méthodes, comme par exemple length
donne un programme qui ne
compile pas. Par exemple, si on tente de compiler le programme suivant :
public class TestTruc1 {
public static void main(String[] args) {
Truc1 t1 = new Truc1();
System.out.println(t1.length());
}
}
le compilateur affiche un message d'erreur, par exemple le suivant :
TestTruc1.java:4: error: cannot find symbol System.out.println(t1.length()); ^ symbol: method length() location: variable t1 of type Truc1
En termes simples, le compilateur indique qu'il ne connaît pas de méthode
length
pour la classe Truc1
, ce qui semble logique. Le cas de toString
semble au contraire illogique.
Object
Pour comprendre la différence entre une méthode arbitraire et la méthode
toString
, il faut d'abord étudier la classe Object
. Elle définit le type
du même nom et propose quelques méthodes importantes dont la méthode
toString
. On peut donc écrire le programme suivant :
public class TestObject {
public static void main(String[] args) {
Object o1 = new Object();
System.out.println(o1.toString());
}
}
dont l'affichage est le suivant :
java.lang.Object@66bfa709
Ici, contrairement à l'exemple précédent, il n'y a rien de surprenant car la
méthode toString
est bien définie directement par la classe Object
. On
constate en outre que l'affichage est de la même forme que ce qu'on obtient en
général pour d'autres objets.
Object
Il se trouve que la méthode toString
utilisée par la classe Truc1
est
justement celle définie par la classe Object
. Plus précisément, toute classe
en Java hérite de la classe Object
, soit directement comme dans le cas de
Truc1
, soit indirectement (cf plus bas).
Le premier effet de l'héritage est de rendre disponible les méthodes de la
classe mère (celle dont on hérite) dans la classe fille (celle qui
hérite). Cela signifie que toutes les classes Java peuvent utiliser la
méthode toString
dont elles héritent en tant que classes filles de la classe
Object
.
Concrètement, l'appel t1.toString()
est donc accepté par le compilateur car
t1
est une référence de type Truc1
et que cette classe hérite (comme
toutes les classes !) de la classe Object
. De ce fait, les instances de
Truc1
peuvent utiliser les méthodes de la classe Object
, en particulier la
méthode toString
. Il faut bien noter que c'est la méthode de cette classe
qui est utilisée, ce qui explique l'affichage de la forme nom de la
classe@code
.
Plus généralement, on peut indiquer spécifiquement en Java qu'une classe A
hérite d'une classe B
. On désigne alors la classe A
comme la classe
fille alors que la classe B
est la classe mère. On procède de la façon
suivante. Soit d'abord une classe B
:
public class B {
public int f() {
return 0;
}
}
On indique que la classe A
hérite de B
grâce au mot clé extends
utilisé
comme suit :
public class A extends B {
public int g() {
return 1;
}
}
Comme dans le cas de Object
, cela signifie que les objets de la classe A
peuvent utiliser les méthodes de la classe B
.
B
ne peuvent pas utiliser les méthodes de la classe
A
En pratique, on peut donc écrire un programme comme ceci :
public class TestAB {
public static void main(String[] args) {
A a = new A();
B b = new B();
System.out.println(a.g());
System.out.println(a.f());
System.out.println(b.f());
}
}
qui affiche :
1 0 0
Comme indiqué dans la remarque ci-dessus, un appel de la forme b.g()
serait
rejeter par le compilateur car la méthode g
est définie dans la classe A
et n'est donc pas accessible pour les instances de la classe B
(car c'est
A
qui hérite de B
et pas le contraire !).
Object
Quand on définit une classe C
sans indiquer de quelle autre classe elle hérite,
alors tout se passe comme si on indiquait que C
hérite de Object
. On peut
rendre cet héritage explicite en écrivant :
public class C extends Object {
...
}
Notons que l'héritage de Java est dit simple c'est-à-dire qu'une classe
hérite d'une autre classe, pas de plusieurs classes (sauf Object
qui
n'hérite de personne). Cela n'empêche pas une classe d'avoir plusieurs
ancètres
, mais la généalogie est linéaire. Par exemple si A
hérite de B
et B
de Object
, alors la classe Object
est la grand mère de la classe
A
. Par transmissions successives, une instance de A
a accès aux méthodes
de B
mais aussi aux méthodes de Object
.
Hériter d'une méthode n'est pas toujours pratique. Par exemple, la méthode
toString
de la classe Object
n'affiche pas grand chose de bien
intéressant. Une classe peut s'affranchir de ce problème en redéfinissant
une méthode : il suffit de donner une nouvelle définition de la méthode dans
la classe fille. Il est conseillé de précéder cette définition par
l'annotation @Override
même si cela n'est pas obligatoire. Voici un exemple
simple avec toString
:
public class Truc2 {
@Override
public String toString() {
return "bla";
}
}
Quand on tente d'afficher un objet de la classe Truc2
ou quand on appelle
directement sa méthode toString
, la méthode utilisée n'est pas celle de
Object
puisque la classe Truc2
elle-même définit bien une telle
méthode. On obtient donc l'affichage de bla
ou comme résultat la chaîne de
caractères "bla"
.
@Override
est justement conseillée car elle demande au
compilateur de vérifier cette règle. Si on change les paramètres, on réalise
une surcharge de méthode, point hors programme. Le type de retour doit aussi
être identique (on peut déroger à cette règle grâce à un mécanisme dit de
covariance mais ce n'est pas non plus au programme du cours).
Dans le cas d'une hiérarchie de classes, c'est-à-dire d'une suite de classes héritant les unes des autres, la redéfinition peut avoir lieu à tous niveaux dans la série d'héritages.
Il faut donc être attentif au type de l'objet appelant une méthode pour bien savoir quelle méthode sera effectivement utilisée, comme l'illustre l'exemple suivant :
class A {
// on définit la méthode a
public int a() {
return 1;
}
}
class B extends A {
// on définit la méthode b
public int b() {
return 2;
}
// on hérite de la méthode a de A
}
class C extends B {
// on redéfinit la méthode a
@Override
public int a() {
return 3;
}
// on hérite de la méthode b de B
}
class D extends B {
// on redéfinit la méthode b
@Override
public int b() {
return 4;
}
// on hérite de la méthode a de B
}
class E extends D {
// on redéfinit la méthode a
@Override
public int a() {
return 5;
}
// on hérite de la méthode b de D
}
public class Hierarchie {
public static void main(String[] args) {
A a = new A();
B b = new B();
C c = new C();
D d = new D();
E e = new E();
System.out.println("A : " + a.a());
System.out.println("B : " + b.a() + " " + b.b());
System.out.println("C : " + c.a() + " " + c.b());
System.out.println("D : " + d.a() + " " + d.b());
System.out.println("E : " + e.a() + " " + e.b());
}
}
L'affichage produit est le suivant :
A : 1 B : 1 2 C : 3 2 D : 1 4 E : 5 4
Pour bien comprendre ce qu'il se passe, il suffit de considérer que
l'ordinateur « remonte » dans la généalogie d'une classe pour savoir quelle
méthode appeler. Par exemple l'appel e.b()
se déroule de la façon suivante :
b
dans la classe E
, sans succès ;E
, soit la classe D
, dans laquelle il
trouve une méthode b
: c'est celle-ci qui est utilisée, d'où l'affichage
de la valeur 4
.
Pour l'appel d.a()
, le mécanisme est le même :
a
dans la classe D
, sans succès ;D
hérite de B
, l'ordinateur cherche alors une méthode a
dans la
classe B
, toujours sans succès ;B
hérite de A
, l'ordinateur cherche maintenant une méthode a
dans A
, ce qu'il trouve, obtenant ainsi la valeur 1
comme résultat.
Ce mécanisme s'applique aussi quand on doit remonter jusqu'à la classe mère de
toutes les classes, Object
. Par exemple, si on ajoutait un
System.out.println(e)
dans le programme, celui-ci afficherait quelque chose
comme E@4130fafb
. En effet, l'ordinateur chercherait alors une méthode
toString
dans la classe E
, puis dans sa mère D
, puis dans sa grand-mère
B
, dans son arrière grand mère A
et enfin dans son arrière arrière grand
mère Object
.
De la même façon qu'une classe A
hérite des méthodes de sa classe mère B
,
tous les objets instances de A
contiennent les variables déclarées dans la
classe B
, en plus des variables déclarées dans A
. Il y a donc héritage des
variables, mais cet héritage se situe au niveau des instances plutôt que des
classes.
Considérons l'exemple suivant :
class Base {
private int x;
public void setX(int x) {
this.x = x;
}
public int getX() {
return x;
}
}
class Deriv extends Base {
private int y;
public void setY(int y) {
this.y = y;
}
public int getY() {
return y;
}
}
public class TestBoth {
public static void main(String[] args) {
Base u = new Base();
u.setX(2);
System.out.println(u.getX());
Deriv w = new Deriv();
w.setX(3);
w.setY(5);
System.out.println(w.getX() + " " + w.getY());
System.out.println(u.getX());
}
}
L'affichage produit est le suivant :
2 3 5 2
On constate qu'il y a bien une variable x
dans l'objet désigné par w
,
puisque la valeur obtenue 3
est bien celle transmise par la méthode
setX
. Pour vérification, on a demandé la valeur de x
pour l'objet désigné
par u
, valeur qui ne change bien sûr pas.
En fait, l'héritage des variables est indispensable pour assurer le bon
fonctionnement de l'héritage des méthodes. En effet, comme les méthodes ont
accès aux variables des objets, et que les méthodes sont hérités, il faut bien
que les variables des objets de la classe mère soient aussi présentes dans les
objets de la classe fille : si ce n'était pas le cas, on ne pourra pas
exécuter les méthodes hérités. Ici, par exemple, les méthodes setX
et getX
nécessitent l'existence d'une variable x
dans l'objet appelant. Quand elles
sont appelées par un objet de la classe Deriv
, cet objet doit donc
nécessairement posséder une telle variable. Comme elle n'est pas déclarée dans
la classe Deriv
, elle provient de la déclaration dans la classe Base
.
En pratique ici, tout se passe comme si on avait recopié toutes les
déclarations et définitions contenues dans la classe Base
dans la classe
Deriv
. Il y a cependant une différence que nous abordons ci-dessous.
Il existe cependant une limitation très forte à l'héritage des variables,
limitation induite par l'emploi du mot clé private
: comme la classe fille
est, par définition, distincte de la classe mère, les méthodes de la classe
fille n'ont pas accès aux variables de la classe mère (même si elles sont bien
présentes dans l'objet !).
Dans l'exemple précédent, la variable x
n'est donc pas accessible
directement dans les méthodes de la classe Deriv
. Si on voulait ajouter une
méthode toString
dans la classe Deriv
, on ne pourrait donc pas procéder
comme suit :
// dans la classe Deriv
@Override
public String toString() {
// ceci ne compile pas
return x + " " + y;
}
Il faudrait par exemple écrire :
// dans la classe Deriv
@Override
public String toString() {
return getX() + " " + y;
}
En général, cette encapsulation très stricte ne pose pas de problème. Il
existe cependant des cas particuliers pour lesquels cette restriction est
gênante. On remplace alors le mot clé private
par le mot clé
protected
. Ceci a pour effet de relâcher l'encapsulation : une variable (ou
une méthode) déclarée comme protected
est accessible dans toutes les classes
qui héritent de la classe déclarant la variable (ou la méthode). On peut donc
reprendre l'exemple ci-dessus de la façon suivante :
public class Base {
protected int x;
public void setX(int x) {
this.x = x;
}
public int getX() {
return x;
}
}
public class Deriv extends Base {
private int y;
public void setY(int y) {
this.y = y;
}
public int getY() {
return y;
}
@Override
public String toString() {
return x + " " + y;
}
}
protected
n'est pas limité aux classes
dérivées mais s'étend à toutes les classes du même package. Cette notion
n'est pas au programme.
Reprenons un exemple du cours sur les types objets :
public class Personne {
private String firstName;
private String lastName;
public Personne(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public String getFirstName() {
return firstName;
}
public char getFirstLetterFirstName() {
return firstName.charAt(0);
}
public String getShortFullName() {
return getFirstLetterFirstName() + ". " + lastName;
}
}
On cherche maintenant à créer un type Etudiant
dérivé de Personne
qui
ajoute la notion de numéro d'étudiant. On peut tenter la construction
suivante :
public class Etudiant extends Personne {
private int num;
public int getNumber() {
return num;
}
}
En pratique, ceci ne fonctionne pas car le compilateur se plaint de l'absence
de constructeur sans paramètre dans la classe Personne
. En effet, comme la
classe Personne
définit un constructeur avec deux paramètres, tout programme
doit nécessairement passer par ce constructeur pour créer un objet de type
Personne
. Un appel de la forme new Personne()
est interdit car il ne donne
pas les deux paramètres demandés par le constructeur.
Or, un Etudiant
est une Personne
, au sens où les variables et les méthodes
de la classe Personne
sont disponibles pour chaque objet de type
Etudiant
. Il est donc normal que pour créer un objet de type Etudiant
, on
doive initialiser un objet Personne
qu'on peut considérer comme « contenu »
dans l'objet Etudiant
.
Le cœur du problème est que les constructeurs ne peuvent pas être hérités (pour diverses raisons, notamment techniques). Il faut donc
Etudiant
un constructeur adapté ;Personne
soit appelé par celui
d'Etudiant
.
La deuxième étape se réalise simplement grâce à une construction spécifique
basée sur le mot clé super
. Pour définir la classe Etudiant
on fait donc :
public class Etudiant extends Personne {
private int num;
public Etudiant(String firstName, String lastName, int num) {
super(firstName,lastName);
this.num = num;
}
public int getNumber() {
return num;
}
}
Comme on doit construire la partie Personne
de l'objet Etudiant
, on
demande naturellement dans le constructeur les mêmes paramètres que pour
Personne
(le nom et le prénom). On demande aussi le numéro d'étudiant. Le
constructeur fixe naturellement la valeur de sa variable num
à celle
transmise en paramètre. Le point intéressant est la ligne
super(firstName,lastName);
(qui doit obligatoirement être la première du
constructeur). Son effet est tout simplement d'appeler le constructeur de
Personne
avec les bons paramètres, afin de permettre à cette partie de
l'objet Etudiant
d'être initialisée correctement (sans pour autant briser
l'encapsulation).
En général, on a le schéma suivant :
public class B {
...
public B(paramètres) {
...
}
...
}
public class A extends B {
...
public A(paramètres) {
super(certains paramètres);
...
}
...
}
Le principe est simplement d'indiquer comment construire l'objet B
à partir
duquel l'objet A
est lui même construit. Tout se joue dans la ligne super
qui précise quels paramètres du constructeur de A
sont transmis au
constructeur de B
.
En Java, l'héritage a deux effets : il permet de réutiliser le code d'une classe dans une autre (on hérite des variables et des méthodes) et il introduit une relation de sous-typage entre la classe mère et la classe fille. Intuitivement, tout objet de la classe fille peut être vu comme un objet de la classe mère (techniquement, le type définit la classe fille est un sous-type du type définit par la classe mère).
Considérons un exemple élémentaire :
class Bidule {
@Override
public String toString() {
return "Bidule";
}
}
public class TestBidule {
public static void main(String[] args) {
Bidule foo = new Bidule();
System.out.println(foo.toString());
Object arf = foo;
System.out.println(arf.toString());
}
}
L'affichage produit par le programme est le suivant :
Bidule Bidule
On constate que l'affectation foo = arf
ne pose pas de problème au compilateur
alors que arf
est de type Object
et foo
de type Bidule
: c'est une
manifestation de la propriété de sous-typage. En effet, Bidule
hérite de
Object
(implicitement) et Bidule
est donc un sous-type de Object
. En
pratique cela veut dire qu'à chaque fois que l'ordinateur attend un référence
vers un Object
, on peut lui donner une référence vers un Bidule
(et plus
généralement vers n'importe quel objet puisque tous les types objets sont
sous-types de Object
).
On constate en outre que l'appel arf.toString()
conduit bien à utiliser la
méthode toString
définie dans la classe Bidule
et non pas celle définie
dans Object
(cf plus bas).
Plus généralement, dès qu'une classe B
est un ancêtre d'une classe A
(même
si l'héritage n'est pas direct), le type A
est un sous-type du type B
. De
ce fait, à chaque fois qu'on attend une référence vers un objet de type B
(par exemple pour la mettre dans une variable de type B
ou pour la
transmettre à une méthode qui attend un paramètre de type B
), on peut
utiliser une référence vers un objet de type A
.
foo = arf
.
L'interprétation du sous-typage est un peu délicate en pratique car on mélange des aspects statiques (déterminés par le compilateur) et dynamiques (mis en œuvre à l'exécution du programme).
Au moment de la compilation, le compilateur analyse le programme en faisant (presque) abstraction du contenu des variables. Contrairement cela a deux conséquences :
foo = bar
, le compilateur
détermine le type de la variable foo
en regardant sa déclaration et le
type de la référence bar
grosso modo de la même façon (il doit parfois
tenir compte du type déclaré pour le résultat d'une méthode, par exemple,
mais le principe est bien de s'appuyer sur la déclaration) ;foo.méthode(paramètres)
,
le compilateur détermine si la méthode
est bien disponible en se basant
uniquement sur le type de la référence foo
et pas sur le type de l'objet
désigné par foo
.Le programme suivant illustre les problèmes que cela pose :
class Bla {
public int foo() {
return 1;
}
}
public class TestBla {
public static void main(String[] args) {
Bla x = new Bla();
Object y = x; // Ok car Bla est un sous-type de Object
Bla z = y; // NE COMPILE PAS : y est de type Object
int u = x.foo(); // Ok foo est bien une méthode de Bla
int v = y.foo(); // NE COMPILE PAS : y est de type Object
}
}
Les deux lignes qui ne compilent pas sont un peu surprenantes car nous savons
en tant que programmeur que l'objet désigné par y
est en fait de type
Bla
. On aimerait donc pouvoir mettre sa référence dans la variable z
ou
encore appeler sa méthode foo
. Ce n'est pas possible car le compilateur
raisonne exclusivement sur le type de la variable y
et pas sur celui de
l'objet qu'elle désigne.
Au moment de l'exécution du programme, l'ordinateur détermine la méthode à
appeler en fonction du type de l'objet appelant et non pas en fonction du type
de la référence utilisée. C'est donc plus ou moins « l'inverse » de ce qui est
fait à la compilation et c'est ce qui permet à la redéfinition de méthodes de
fonctionner correctement. Dans l'exemple ci-dessus avec la classe Bidule
,
c'est ce mécanisme qui explique que l'appel arf.toString()
produit la
chaîne de caractères "Bidule"
et non quelque chose de la
forme "Bidule@f9ee4dbe"
qui aurait été obtenu en
exécutant la méthode toString
de la classe Object
.