Retour sur la conversion en chaîne de caractères

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).

Un exemple simple

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.

La classe 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.

L'héritage depuis 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.

Cas général

Classe mère et classe fille

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.

Attention, le contraire est faux : les objets de la classe 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 !).

Encore 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.

Redéfinition d'une méthode

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".

Attention, la méthode doit être redéfinie dans la classe fille avec exactement les mêmes paramètres que dans la classe mère. L'utilisation de @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 :

  1. l'ordinateur cherche une méthode b dans la classe E, sans succès ;
  2. il remonte alors à la mère de 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 :

  1. l'ordinateur cherche une méthode a dans la classe D, sans succès ;
  2. comme D hérite de B, l'ordinateur cherche alors une méthode a dans la classe B, toujours sans succès ;
  3. comme 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.

Les variables

Principe

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.

Encapsulation ferme

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;
    }
}
L'accès 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.

Constructeurs

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

  1. ajouter à la classe Etudiant un constructeur adapté ;
  2. faire en sorte que le constructeur de 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.

Sous-typage

Principe

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.

Attention, ceci ne fonctionne bien sûr par dans l'autre sens. Dans l'exemple ci-dessus, on ne pourrait donc pas écrire foo = arf.

Aspects statiques et dynamiques

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).

Phase statique à la compilation

Au moment de la compilation, le compilateur analyse le programme en faisant (presque) abstraction du contenu des variables. Contrairement cela a deux conséquences :

  1. quand il analyse une affectation de la forme 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) ;
  2. quand il analyse un appel de méthode de la forme 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.

Phase dynamique à l'exécution

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.