- Avec un langage de programmation orientée objet, la création d'objet est essentielle. Les objets sont généralement créés en instantiant des classes (qui peuvent être considérées comme des sortes de prototype).
- L'instantiation implique la réservation d'espace mémoire pour l'objet et l'initialisation de ses champs.
- Typiquement on implante un constructeur pour la classe (ou on laisse le constructeur par défaut) et on appelle l'opérateur d'instantiation (new en Java) avec les paramètres du constructeur.
-
La construction devient problématique :
- si l'on a beaucoup de classes avec des relations d'héritage (quelle classe instantier ? et dans quelle situation ?)
- si les constructeurs ont de nombreux arguments (il peut être fastidieux de tous les gérer)
-
Si l'on écrit une bibliothèque, on cherche à éviter à l'utilisateur de celle-ci d'instancier lui-mêmes les objets ; on pourra mettre en œuvre les patterns suivants :
- Le singleton si une seule instance de la classe suffit (voire le multiton si un nombre figé d'instances est attendu)
- La factory ou le builder si la classe nécessite de nombreux paramètres pour être instanciée (ou si nous utilisons des mécanismes de recyclage)
- Un système d'injection de dépendances si l'on souhaite une instanciation automatique
Factory et Builder
Présentation
- La factory (fabrique en français) est une sorte d'usine à objets
- Pour instantier une classe, normalement il faut connaître son type et utiliser l'opérateur new
- Dans certains cas, on souhaiterait instantier des classes différentes qui partagent un ancêtre commun selon des paramètres qui varient.
-
La Factory évolue vers un Builder lorsque l'on a une classe de fabrication disposant de setters afin de spécifier progressivement les différents paramètres
- Cela est particulièrement utile si les paramètres sont nombreux
-
Rien n'empêche de créer des classes fabrique héritant d'une autre classe fabrique (Abstract Factory) :
- On adapte ainsi le comportement de la fabrique à certaines caractéristiques essentielles
Exemple 1 : un gestionnaire de notes
Notre gestionnaire doit pouvoir gérer des notes présentes dans un fichier local, une base de données ou un service web.
On peut donc avoir une classe ancêtre NoteBackend (gestion du stockage de notes) avec les classes dérivées suivantes :
- FileNoteBackend pour le système de stockage en fichier local
- DatabaseNoteBackend pour le stockage en base de données
- WebNoteBackend pour le stockage en service web
On peut créer une Factory pour initialiser le backend approprié :
public class NoteBackendFactory { /** Create a new note backend * The location parameter is an URL and according to the scheme, the most adapted backend is selected: * file://... : a file backend is chosen * db:// : a database backend is employed * http:// : a web service backend is used */ public NoteBackend createBackend(String location) { if (location.startsWith("file://") { ... } else if (location.startsWith("db://") { ... } else if (location.startsWith("http://")) { ... } } }
On peut toutefois noter que certains backends nécessitent potentiellement des paramètres supplémentaires.
C'est le cas par exemple des backends base de données et service web qui requièrent l'utilisation d'identifiants (utilisateur, mot de passe) contrairement au backend de fichier local.
On peut donc modifier la Factory pour s'y adapter :
public class NoteBackendBuilder { private final String location; private String login = null; private String password = null; public NoteBackendFactory(String location) { this.location = location; } public void setCredentials(String login, String password) { this.login = login; this.password = password; } public NoteBackend create() { ... }
On dérive alors d'un pattern Factory vers un pattern Builder puisque nous avons ajouté une méthode afin de paramétrer le backend créé. Un des intérêts du Builder est de pouvoir ajouter ultérieurement de nouvelles méthodes (setters) pour définir des paramètres optionnels sans avoir à changer le code existant.
Il est possible également de créer une hiérarchie de builders, i.e. différents builders liés par des relations d'héritage. Nous pourrions par exemple avoir les builders suivants :
- AbstractNoteBackendBuilder : builder abstrait ancêtre de tous les builders
- LocalFileNoteBackendBuilder : builder héritant du builder abstrait pour créer un backend de stockage sur ficher local
- WebServiceNoteBackendBuilder : builder pour créer un backend de stockage utilisant un web service
- ...
Il serait même envisageable de créer un méta-builder qui serait un builder de builder...
Comme pour tous les patterns, il faut toutefois rester parcimonieux : l'usage du pattern ne doit pas complexifier l'architecture mais la rendre plus flexible, modulaire et évolutive. Si notre application est destinée à ne fonctionner qu'avec un seul système de stockage, l'utilisation d'une Factory ou d'un Builder pourrait paraître superflue.
Exemple 2 : StringBuilder
- Comme son nom l'indique un StringBuilder permet de construire des chaînes de caractères.
- Il est possible aussi de construire des chaînes par concaténation successive de String mais cela est potentiellement coûteux car chaque concaténation entraîne une nouvelle allocation de String et les chaînes concaténées doivent être récupérées par le ramasse-miettes.
- Un StringBuilder pré-alloue un tableau de char pour une suite assez longue de caractères, puis on réaliser des appels successifs à append(element) pour rajouter des chaînes, caractères, entiers... à cette chaîne en construction.
- On notera que les méthodes append(...) existent en plusieurs versions avec des types en paramètre différent ; ces méthodes retournent toujours le StringBuilder lui-même ce qui permet de chaîner les appels.
- La méthode toString() est appelée finalement pour obtenir une String construite.
Exemple pratique : construisons une chaînes contenant les entiers de 1 à 100
StringBuilder sb = new StringBuilder(); for (int i = 1; i <= 99; i++) sb.append(i).append(","); String s = sb.append(100).toString();
Clonage d'objet (prototype)
Présentation
- Typiquement, les objets sont créés depuis une classe (instantiation).
- On pourrait aussi créer un objet depuis un autre objet en le clonant.
-
En Java, on peut utiliser à cet effet la méthode clone() :
- Cette méthode est par défaut protected : elle n'est donc pas accessible en standard depuis l'extérieur de la classe
- Pour la rendre accessible, il faut la redéfinir en public : ``public Object clone() throws CloneNotSupportedException { return super.clone(); }`
- Attention, l'implantation par défaut de clone() dans Object ne réalise qu'une copie superficielle, il faut donc généralement redéfinir clone() pour gérer une copie profonde (ce qui revient à cloner aussi par transitivité les champs de l'objet). L'existence de références circulaires peut poser des problèmes de récursivité infinie lors du clonage.
- Le clonage cohabite mal avec les champs immuables (de type final en Java) : l'idée sous-jacente du clonage est de construire un nouvel objet en copiant un objet existant puis en réalisant des modifications sur cet objet ; les champs doivent donc être modifiables.
- Le clonage est intéressant lorsque l'initialisation de l'objet est complexe (nombreux champs) et que l'on souhaite obtenir des objets quasi-identiques ne présentant que peu de variations.
Exemple : clonage de brebis
public class SheepCloning { public interface Animal { public char[] getDNA(); } public class Sheep implements Animal { protected char[] dna ; … public char[] getDNA() { return dna ; } } public class CloneableSheep extends Sheep implements Cloneable { CloneableSheep shallowClone() { return (CloneableSheep)super.clone() ; } /** Return a deep clone */ CloneableSheep clone() { CloneableSheep sheep = (CloneableSheep)super.clone() ; sheep.dna = new char[this.dna.length] ; // we copy the DNA array System.arraycopy(this.dna, 0, sheep.dna, 0, this.dna.length) ; return sheep ; } } public static void main(String[] args) { Sheep dolly = … ; // Can be created with a factory Sheep dolly2 = (Sheep)dolly.shallowClone() ; Sheep dolly3 = (Sheep)dolly.clone() ; Virus virus = ...; // We create a nasty virus virus.attack(dolly3) ; // The virus only modifies the DNA of dolly3 (for a deep cloning) virus.attack(dolly2) ; // The virus modifies the shared DNA of dolly2 and dolly }
Singleton
- Le pattern singleton est utilisé lorsque nous avons besoin que d'un unique objet instantié pour une classe.
-
Usages classiques :
- Utilisation d'un seul objet pour représenter une configuration, un état global
- Mise en place d'une Factory disposant d'un constructeur sans argument
- ...
-
Dans cette situation, on pourrait tout gérer au niveau de la classe sans créer d'objet : on déclare static tous les membres de la classe (champs et méthodes)
- Cette approche est à éviter dans un contexte de POO : on limite les déclarations static au minimum
- On préfère que tous les membres ne soient pas static ; seul un seul membre reste statique : le seul objet instantié est référencé en tant que champ statique
Exemple : une Factory singleton
public class NoteBackendFactory { private static NoteBackendFactory singleton; /** We create the instante only the first time it is requested */ public static NoteBackendFactory getInstance() { if (singleton == null) singleton = new NoteBackendFactory(); return singleton; } /** The constructor is declared private to force the use of the singleton; * thus a user could not accidentely use the constructor */ private NoteBackendFactory() { } public NoteBackend create() { ... }
Remarque : l'exemple proposé ne fonctionne que dans un contexte d'utilisation avec une seule thread. Si plusieurs threads exécutent la méthode getInstance() simultanément, il y a un risque que deux instances du singleton soient créées ! Il est donc plus prudent dans ce cas de réécrire ainsi la méthode getInstance() :
public static NoteBackendFactory getInstance() { private static volatile NoteBackendFactory singleton; // we add the volatile keyword to force field update accross threads if (singleton == null) { synchronized(NoteBackendFactory.class) { // We do a second check protected in a synchronized section to be sure than a second thread has not already initialized the instance if (singleton == null) singleton = new NoteBackendFactory(); } return singleton; } }
Multiton
- On peut étendre le concept du singleton à plusieurs instances (2, 3, 4...) avec le multiton
- Utile lorsque la classe doit être instantiée un nombre limité et fixé de fois
-
Ce pattern se rapproche du pattern Flyweight où un grand nombre de références pointe vers un nombre limité d'instances :
- Si des objets partagent beaucoup de contenu commun, il peut être judicieux de regrouper ce contenu sous la forme d'un seul objet et partager la référence de celui-ci.
- Généralement, une map peut être utile pour indexer les objets afin de rechercher si une instance qui nous intéresse existe : si c'est le cas, nous ne l'instantions pas (et on utilise l'instance déjà existante).
- Les énumérations (Enum) sont un exemple typique d'utilisation du multiton avec toutes les instances de la classe fixée à la compilation
Recyclage
Présentation
- Créer un objet a un coût temporel et mémoriel
- Dans certaines situations, on détruit et on créé beaucoup d'objets : pourquoi ne pas recycler un objet que l'on vient de détruire ?
- Cela implique d'utiliser des objets mutables (pas de champ final) avec un setter permettant de changer le contenu de l'objet
- L'utilisation typique est pour les applications demandant une forte réactivité pour lesquelles le déclenchement du ramasse-miettes peut être pénalisant (exemple : jeu graphique impliquant de nombreux agents évolutifs)
Exemple : fenêtre d'un terminal
Pour modéliser la fenêtre d'affichage d'un terminal, on créé une classe TextWindow contenant une matrice de lettres DisplayedChar. Plutôt que de créer plusieurs fois les DisplayedChar, nous les crééons une unique fois et nous utilisons un setter pour changer leur contenu. DisplayedChar représente donc une cellule d'affichage pouvant changer.
public class DisplayedChar { private char c; // character private Style s; // style of display (bold, striken, underlined, color...) public char getChar() { return c; } public Style getStyle() { return s; } public void setContent(char character, Style style) { this.c = character; this.s = style; } }
public class TextWindow { private DisplayedChar[][] textMatrix; public TextWindow(int w, int h) { this.textMatrix = new DisplayedChar[w][h]; // we create a DisplayedChar for each cell for (int i = 0; i < h; i++) for (int j = 0; j < w; j++) this.textMatrix[i][j] = new DisplayedChar(); // create using the default constructor } public void setText(int x, int y, String text, Style style) { for (int i = 0; i < text.length(); i++) if (x + i < textMatrix[y].length) textMatrix[y][x + i].setContent(text.charAt(i), style); } }
Maintenant, imaginons qu'il soit possible "d'effacer" des cellules (on met la valeur d'une cellule de la matrice à null) ; on pourra alors conserver les DisplayedChar enlevées de la matrice dans une liste en vue de recyclage :
public class TextWindow2 { private DisplayedChar[][] textMatrix; private List<DisplayedChar> charPool; public TextWindow2(int w, int h) { // we create a matrix filled with null references this.textMatrix = new DisplayedChar[w][h]; this.charPool = new ArrayDeque<DisplayedChar>(); } /** This method could have been moved to the DisplayedChar class * with the charPool as a static member */ private DisplayedChar createDisplayedChar(char character, Style style) { DisplayedChar dc = null; if (charPool.size() > 0) { // we recycle an existing displayed char dc = charPool.remove(0); // remove the object at the beginning of the list, the object that has been waiting the most to be recycled } else { dc = new DisplayedChar(); } dc.setContent(character, style); return dc; } /** Clear a line from the text matrix */ public void clearLine(int y) { for (int i = 0; i < textMatrix[y].length; i++) if (textMatrix[y][i] != null) { charPool.add(textMatrix[y][i]); // add the object in the recycling pool textMatrix[y][i] = null; } } /** Modify the text matrix by putting a text at a given position on the grid */ public void setText(int x, int y, String text, Style style) { for (int i = 0; i < text.length(); i++) if (x + i < textMatrix[y].length) textMatrix[y][x + i] = createDisplayedChar(text.charAt(i), style); } }
Injection de dépendance
- L'injection de dépendance permet d'instancier automatiquement les classes utiles
- Lorsqu'un champ d'une classe ou les paramètres d'un constructeur (ou un setter) a besoin d'instances de classes injectées, on utilise l'annotation @Inject
-
En Java, le mécanisme d'injection de dépendance est normalisé par la JSR 330
- Cette JSR définit les différentes classes annotations utilisées pour l'injection (@Inject, @Named, @Qualifier, @Scope, Provider...)
- Ces classes sont fournies dans le paquetage javax.inject
-
Aucune implantation d'injecteur est fournie en standard dans le JDK (uniquement les annotations et interfaces), il est nécessaire d'utiliser une bibliothèque fournissant un système d'injection :
- Weld : il s'agit d'une implantation conforme à la spécification CDI (Context and Dependency Injection), une spécification plus avancée que la JSR 330 utilisée avec Java Enterprise Edition
- Guice : cette implantation est plus légère que Weld (ne respecte pas CDI mais la JSR 330 uniquement)
- Spring Dependency Injection : cette implantation est utilisée par le framework web Spring (respecte la JSR 330, pas CDI)
- Il existe aussi des injecteurs travaillant statiquement (les injections sont résolues à la compilation) tels que Dagger (utilisable aussi sous Android)
-
Quel injecteur utiliser ?
-
Pour un injecteur dynamique (injectant à l'exécution) :
- Si on utilise un framework web, préférentiellement celui fourni par le framework : Weld pour Java EE, Spring DI pour Spring
- Sinon, Guice (plus léger que d'incorporer des composants d'un framework web)
- Pour un injecteur statique : Dagger
-
Pour un injecteur dynamique (injectant à l'exécution) :
- La plupart des injecteurs actuels se conforment aux annotations de la JSR 330 ce qui est une garantie d'interopérabilité (facilité accrue de changer d'injecteur)
- Pour les examples de la suite de cette section, nous utiliserons Guice
Si nous utilisons Guice, nous devons déclarer cette bibliothèque comme dépendance ; par exemple dans le pom.xml en utilisant Maven :
<dependency> <groupId>com.google.inject</groupId> <artifactId>guice</artifactId> <version>5.1.0</version> </dependency>
Pour construire une instance d'une classe, nous avons besoin d'initialiser ses champs. On fait appel pour cela à l'injecteur avec l'annotation @Inject. Nous pouvons utiliser cette annotation dans trois contextes :
- Soit sur la déclaration d'un champ de la classe
- Soit sur le constructeur de la classe
- Soit sur un setter de la classe
Prenons pour exemple une classe chargée d'afficher un Hello World personnalisé :
public class HelloPrinter { // injection sur un champ @Inject private String name; private final MessageDisplayer displayer; private final Random random; private int displayMinTimes; private int displayMaxTimes; // injection par constructeur @Inject public HelloPrinter(MessageDisplayer displayer, Random random) { this.displayer = displayer; this.random = random; } // injection par setter @Inject public void setDisplayTimes(int displayMinTimes, int displayMaxTimes) { this.displayMinTimes = displayMinTimes; this.displayMaxTimes = displayMaxTimes; } public void sayHello() { int i = random.nextInt(displayMinTimes, displayMaxTimes); IntStream.range(0, i).forEach(x -> {displayer.display(x + ": Hello World " + name);}); } }
Généralement, il est préférable de rendre les champs figés avec final et d'utiliser uniquement une injection sur le constructeur ; l'exemple précédent utilise les trois types d'injection à titre d'illustration des possibilités d'injection.
MessageDisplayer est une interface que nous avons écrite pour afficher un message :
public interface MessageDisplayer { void display(String message); }
On peut implanter différentes classes utilisant l'interface MessageDisplayer, par exemple une classe affichant sur System.out et une autre sur System.err. Ces implantations sont en visibilité package :
@Singleton class StdoutMessageDisplayer implements MessageDisplayer { public void display(String message) { System.out.println(message); } } @Singleton class StderrMessageDisplayer implements MessageDisplayer { public void display(String message) { System.err.println(message); } }
Notons l'annotation @Singleton qui signifie que si l'injecteur utilise ces classes pour l'injection, il n'utilisera qu'une unique instance dans tous le programme (sinon une instance différente serait créée par défaut à chaque site d'injection).
Un problème demeure : comment l'injecteur peut-il savoir s'il doit injecter StdoutMessageDisplayer ou StderrMessageDisplayer à l'endroit où un MessageDisplayer est nécessaire ?
Pour cela, il est nécessaire de configurer l'injecteur avec un module :
class HelloPrinterInjectionModule extends AbstractModule { @Override protected void configure() { bind(MessageDisplayer.class).to(StdoutMessageDisplayer.class); bind(MessageDisplayer.class).annotatedWith(Names.named("stdout")).to(StdoutMessageDisplayer.class); bind(MessageDisplayer.class).annotatedWith(Names.named("stderr")).to(StderrMessageDisplayer.class); } }
La configuration réalisée signifie que si un type MessageDisplayer est rencontré, par défaut un StdoutMessageDisplayer sera injecté. Mais nous ajoutons deux règles dérogatoires qui examine la présence d'une annotation @Named("stdout") ou @Named("stderr") sur le site d'injection pour choisir l'un ou l'autre des MessageDisplayer. Par exemple si nous souhaitons utiliser StderrMessageDisplayer, nous pouvons écrire le constructeur de HelloPrinter ainsi :
public class HelloPrinter { // ... @Inject public HelloPrinter(@Named("stderr") MessageDisplayer displayer, Random random) { this.displayer = displayer; this.random = random; } // ... }
Configurons maintenant l'injection du générateur pseudo-aléatoire Random. Pour cela, nous allons écrire dans le module une méthode chargée de créer l'objet Random avec une graine prédéfinie (pour générer de façon déterministe toujours les mêmes nombres à chaque exécution) :
public class HelloPrinterInjectionModule extends AbstractModule { // ... @Provides Random createRandom() { return new Random(1L); } // ... }
Il nous reste maintenant à injecter le nom ainsi que le nombre minimal et maximal d'affichages. Nous pourrions utiliser un Record pour représenter ces paramètres :
public record HelloPrinterParams(String name, int displayMinTimes, int displayMaxTimes) {}
Nous pouvons instantier le module en passant ces paramètres :
public class HelloPrinterInjectionModule extends AbstractModule { private final HelloPrinterParams params; public HelloPrinterInjectionModule(HelloPrinterParams params) { super(); this.params = params; } @Override protected void configure() { bind(MessageDisplayer.class).to(StdoutMessageDisplayer.class); bind(MessageDisplayer.class).annotatedWith(Names.named("stdout")).to(StdoutMessageDisplayer.class); bind(MessageDisplayer.class).annotatedWith(Names.named("stderr")).to(StderrMessageDisplayer.class); bind(String.class).annotatedWith(Names.named("name")).toInstance(params.name()); bind(int.class).annotatedWith(Names.named("displayMinTimes")).toInstance(params.displayMinTimes()); bind(int.class).annotatedWith(Names.named("displayMaxTimes")).toInstance(params.displayMaxTimes()); } @Provides Random createRandom() { return new Random(1L); } }
Nous devons également pour éviter toute ambiguïté pour l'injection, utiliser des annotations pour marquer les sites d'injection des paramètres name, minDisplayTimes et maxDisplayTimes, par exemple :
@Inject public void setDisplayTimes(@Named("displayMinTimes") int displayMinTimes, @Named("displayMaxTimes") int displayMaxTimes) { this.displayMinTimes = displayMinTimes; this.displayMaxTimes = displayMaxTimes; }
Il faut maintenant activer l'injecteur dans le main de notre programme avec le bon module de configuration (sachant que si notre programme est complexe, il est possible d'utiliser plusieurs modules d'injection) :
public class HelloPrinterMain { public static void main(String[] args) { var params = new HelloPrinterParams( args.length >= 1 ? args[0] : "foobar", // name args.length >= 3 ? Integer.parseInt(args[1]) : 0, // minDisplayTimes args.length >= 3 ? Integer.parseInt(args[2]) : 100 // maxDisplayTimes ); var injector = Guice.createInjector(new HelloPrinterInjectionModule(params)); var hp = injector.getInstance(HelloPrinter.class); hp.sayHello(); } }
⚠ Guice étant un injecteur dynamique, l'injection est réalisée à l'exécution. La dynamicité de l'injection apporte l'avantage que nous pouvons configurer le module selon des paramètres d'exécution (par exemple les arguments de args). L'inconvénient est qu'il est possible que notre configuration apportée par le module soit incomplète voire ambigúe si l'injecteur n'arrive pas à trouver une valeur pour un site d'injection. Dans ce cas une exception ConfigurationException est levée.
Quelques remarques :
- Il est possible de créer ses propres annotations pour les sites d'injection à la place de l'utilisation de l'annotation standard @Named("...")
- Guice ne permet pas l'usage direct de fichiers de configuration pour paramétrer l'injection : la configuration est réalisée en héritant de AbstractModule ; les injecteurs de type CDI permettent l'usage de fichiers de configuration XML