Créer un type objet simple

Rappels et principe

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.

Attention, même un type objet très simple, réduit par exemple à une chaîne de caractères, produit un affichage de la forme ci-dessus.

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.

Obtenir des informations

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.

Définition d'une méthode

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.

Résultat

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.

Objet appelant

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.

Constructeur

Exemple

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

Attention de ne pas confondre la référence 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.

Les constructeurs

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

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

Exécution du constructeur

Un constructeur est particulier car :

  • il ne déclare pas de type de résultat ;
  • il ne contient pas d'instruction return ;
  • il est appelé par l'instruction 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 :

  1. l'ordinateur crée un objet en mémoire (en réservant de la place pour les variables) ;
  2. les variables sont initialisées à zéro (par exemple null pour les objets et les tableaux, 0.0 pour les double, etc.) ;
  3. les instructions du constructeur sont exécutées ;
  4. l'appel du constructeur est remplacé par la référence vers l'objet qui vient d'être créé.

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.

En tant que méthode, le constructeur a accès aux variables de l'objet et c'est ainsi qu'il les initialise.

La référence this

Dissymétrie

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

Référence vers l'objet appelant

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.

Utilisation

Il y a essentiellement trois utilisations possibles de la référence this.

Symétrisation

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;
    }
}

Renvoyer l'objet appelant

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;
}

Gérer les collisions d'identificateur

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.

Il est bien sûr plus simple de choisir des noms différents pour les paramètres du constructeur et pour les variables de l'objet. Cependant, la tradition en Java est d'utiliser les mêmes noms pour rendre le constructeur plus clair. Cela nécessite en contrepartie l'emploi explicite de this dans le code.

Encapsulation

Principe

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 :

  1. le code ainsi réalisé peut évoluer plus facilement (par modifications successives) car on peut changer totalement sa représentation interne sans pour autant devoir modifier le reste du code qui constitue le programme. Un exemple simple est celui des nombres complexes, qu'on peut représenter sous forme cartésienne ou polaire. Du point de vue des utilisateurs de ces nombres, peut importe comment ils sont représentés, du moment qu'ils fonctionnent comme attendu.
  2. on peut garantir que les objets créés sont toujours valides. Par exemple un objet rationnel peut être décrit par un numérateur et un dénominateur. Ce dernier ne peut pas être nul. Grâce à l'encapsulation, on peut assurer que cela sera toujours le cas.

Mécanisme

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
quand une variable ou une méthode est déclarée public, elle est utilisable sans aucune restriction par n'importe quelle autre méthode du programme considéré.
private
quand une variable ou une méthode est déclarée 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.

Objets modifiables et objets immuables

Principes

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.

Exemple

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.

Solution modifiable simple

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.

Solution immuable

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.