Modèles de conception créatifs dans Core Java

1. Introduction

Les modèles de conception sont des modèles courants que nous utilisons lors de l'écriture de notre logiciel . Ils représentent les meilleures pratiques établies au fil du temps. Ceux-ci peuvent ensuite nous aider à nous assurer que notre code est bien conçu et bien construit.

Les modèles de création sont des modèles de conception qui se concentrent sur la façon dont nous obtenons des instances d'objets . Généralement, cela signifie comment nous construisons de nouvelles instances d'une classe, mais dans certains cas, cela signifie obtenir une instance déjà construite prête à être utilisée.

Dans cet article, nous allons revoir certains modèles de conception créatifs courants. Nous verrons à quoi ils ressemblent et où les trouver dans la JVM ou d'autres bibliothèques principales.

2. Méthode d'usine

Le modèle de méthode d'usine est un moyen pour nous de séparer la construction d'une instance de la classe que nous construisons. C'est ainsi que nous pouvons abstraire le type exact, permettant à notre code client de fonctionner à la place en termes d'interfaces ou de classes abstraites:

class SomeImplementation implements SomeInterface { // ... } 
public class SomeInterfaceFactory { public SomeInterface newInstance() { return new SomeImplementation(); } }

Ici, notre code client n'a jamais besoin de connaître SomeImplementation , et à la place, il fonctionne en termes de SomeInterface . Encore plus que cela, cependant, nous pouvons changer le type renvoyé par notre usine et le code client n'a pas besoin de changer . Cela peut même inclure la sélection dynamique du type au moment de l'exécution.

2.1. Exemples dans la JVM

Les exemples les plus connus de ce modèle de la JVM sont peut-être les méthodes de construction de collection sur la classe Collections , comme singleton () , singletonList () et singletonMap (). Tous ces éléments renvoient des instances de la collection appropriée - Set , List ou Map - mais le type exact n'est pas pertinent . De plus, la méthode Stream.of () et les nouvelles méthodes Set.of () , List.of () et Map.ofEntries () nous permettent de faire de même avec des collections plus volumineuses.

Il existe également de nombreux autres exemples de cela, notamment Charset.forName () , qui retournera une instance différente de la classe Charset en fonction du nom demandé, et ResourceBundle.getBundle () , qui chargera un ensemble de ressources différent en fonction sur le nom fourni.

Tous ces éléments n'ont pas non plus besoin de fournir des instances différentes. Certains ne sont que des abstractions pour cacher le fonctionnement interne. Par exemple, Calendar.getInstance () et NumberFormat.getInstance () renvoient toujours la même instance, mais les détails exacts ne sont pas pertinents pour le code client.

3. Usine abstraite

Le modèle Abstract Factory est une étape au-delà de cela, où la fabrique utilisée a également un type de base abstrait. Nous pouvons ensuite écrire notre code en fonction de ces types abstraits et sélectionner d'une manière ou d'une autre l'instance d'usine concrète au moment de l'exécution.

Tout d'abord, nous avons une interface et des implémentations concrètes pour la fonctionnalité que nous voulons réellement utiliser:

interface FileSystem { // ... } 
class LocalFileSystem implements FileSystem { // ... } 
class NetworkFileSystem implements FileSystem { // ... } 

Ensuite, nous avons une interface et des implémentations concrètes pour que l'usine obtienne ce qui précède:

interface FileSystemFactory { FileSystem newInstance(); } 
class LocalFileSystemFactory implements FileSystemFactory { // ... } 
class NetworkFileSystemFactory implements FileSystemFactory { // ... } 

Nous avons alors une autre méthode de fabrique pour obtenir la fabrique abstraite à travers laquelle nous pouvons obtenir l'instance réelle:

class Example { static FileSystemFactory getFactory(String fs) { FileSystemFactory factory; if ("local".equals(fs)) { factory = new LocalFileSystemFactory(); else if ("network".equals(fs)) { factory = new NetworkFileSystemFactory(); } return factory; } }

Ici, nous avons une interface FileSystemFactory qui a deux implémentations concrètes. Nous sélectionnons l'implémentation exacte au moment de l'exécution, mais le code qui l'utilise n'a pas besoin de se soucier de l'instance réellement utilisée . Celles-ci renvoient chacune une instance concrète différente de l' interface FileSystem , mais encore une fois, notre code n'a pas besoin de se soucier exactement de l'instance que nous avons.

Souvent, nous obtenons l'usine elle-même en utilisant une autre méthode d'usine, comme décrit ci-dessus. Dans notre exemple ici, la méthode getFactory () est elle-même une méthode de fabrique qui renvoie un FileSystemFactory abstrait qui est ensuite utilisé pour construire un FileSystem .

3.1. Exemples dans la JVM

Il existe de nombreux exemples de ce modèle de conception utilisé dans la JVM. Les plus courants concernent les packages XML - par exemple, DocumentBuilderFactory , TransformerFactory et XPathFactory . Ils ont tous une méthode de fabrique spéciale newInstance () pour permettre à notre code d'obtenir une instance de la fabrique abstraite .

En interne, cette méthode utilise un certain nombre de mécanismes différents - propriétés système, fichiers de configuration dans la JVM et l'interface du fournisseur de services - pour essayer de décider exactement quelle instance concrète utiliser. Cela nous permet ensuite d'installer des bibliothèques XML alternatives dans notre application si nous le souhaitons, mais cela est transparent pour tout code qui les utilise réellement.

Une fois que notre code a appelé la méthode newInstance () , il aura alors une instance de la fabrique à partir de la bibliothèque XML appropriée. Cette fabrique construit ensuite les classes réelles que nous voulons utiliser à partir de cette même bibliothèque.

Par exemple, si nous utilisons l'implémentation JVM par défaut de Xerces, nous obtiendrons une instance de com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl , mais si nous voulions utiliser à la place une implémentation différente, appelez newInstance () renverrait cela de manière transparente à la place.

4. Constructeur

Le modèle Builder est utile lorsque nous voulons construire un objet compliqué de manière plus flexible. Cela fonctionne en ayant une classe séparée que nous utilisons pour construire notre objet compliqué et en permettant au client de le créer avec une interface plus simple:

class CarBuilder { private String make = "Ford"; private String model = "Fiesta"; private int doors = 4; private String color = "White"; public Car build() { return new Car(make, model, doors, color); } }

Cela nous permet de fournir individuellement des valeurs pour la marque , le modèle , les portes et la couleur , puis lorsque nous construisons la voiture , tous les arguments du constructeur sont résolus en valeurs stockées.

4.1. Exemples dans la JVM

Il existe des exemples très clés de ce modèle dans la JVM. Les classes StringBuilder et StringBuffer sont des générateurs qui nous permettent de construire un long String en fournissant de nombreuses petites parties . La classe Stream.Builder plus récente nous permet de faire exactement la même chose pour construire un Stream :

Stream.Builder builder = Stream.builder(); builder.add(1); builder.add(2); if (condition) { builder.add(3); builder.add(4); } builder.add(5); Stream stream = builder.build();

5. Initialisation paresseuse

Nous utilisons le modèle d'initialisation paresseuse pour différer le calcul d'une valeur jusqu'à ce qu'il soit nécessaire. Parfois, cela peut impliquer des éléments de données individuels, et d'autres fois, cela peut signifier des objets entiers.

Ceci est utile dans un certain nombre de scénarios. Par exemple, si la construction complète d'un objet nécessite un accès à une base de données ou au réseau et que nous n'avons peut-être jamais besoin de l'utiliser, l'exécution de ces appels peut entraîner une sous-performance de notre application . Sinon, si nous calculons un grand nombre de valeurs dont nous n'avons peut-être jamais besoin, cela peut entraîner une utilisation inutile de la mémoire.

En règle générale, cela fonctionne en ayant un objet comme wrapper paresseux autour des données dont nous avons besoin, et en ayant les données calculées lors de l'accès via une méthode getter:

class LazyPi { private Supplier calculator; private Double value; public synchronized Double getValue() { if (value == null) { value = calculator.get(); } return value; } }

Le calcul de pi est une opération coûteuse et que nous n'avons peut-être pas besoin d'effectuer. Ce qui précède le fera la première fois que nous appelons getValue () et pas avant.

5.1. Exemples dans la JVM

Examples of this in the JVM are relatively rare. However, the Streams API introduced in Java 8 is a great example. All of the operations performed on a stream are lazy, so we can perform expensive calculations here and know they are only called if needed.

However, the actual generation of the stream itself can be lazy as well. Stream.generate() takes a function to call whenever the next value is needed and is only ever called when needed. We can use this to load expensive values – for example, by making HTTP API calls – and we only pay the cost whenever a new element is actually needed:

Stream.generate(new BaeldungArticlesLoader()) .filter(article -> article.getTags().contains("java-streams")) .map(article -> article.getTitle()) .findFirst();

Here, we have a Supplier that will make HTTP calls to load articles, filter them based on the associated tags, and then return the first matching title. If the very first article loaded matches this filter, then only a single network call needs to be made, regardless of how many articles are actually present.

6. Object Pool

We'll use the Object Pool pattern when constructing a new instance of an object that may be expensive to create, but re-using an existing instance is an acceptable alternative. Instead of constructing a new instance every time, we can instead construct a set of these up-front and then use them as needed.

The actual object pool exists to manage these shared objects. It also tracks them so that each one is only used in one place at the same time. In some cases, the entire set of objects gets constructed only at the start. In other cases, the pool may create new instances on demand if it's necessary

6.1. Examples in the JVM

The main example of this pattern in the JVM is the use of thread pools. An ExecutorService will manage a set of threads and will allow us to use them when a task needs to execute on one. Using this means that we don't need to create new threads, with all of the cost involved, whenever we need to spawn an asynchronous task:

ExecutorService pool = Executors.newFixedThreadPool(10); pool.execute(new SomeTask()); // Runs on a thread from the pool pool.execute(new AnotherTask()); // Runs on a thread from the pool

These two tasks get allocated a thread on which to run from the thread pool. It might be the same thread or a totally different one, and it doesn't matter to our code which threads are used.

7. Prototype

We use the Prototype pattern when we need to create new instances of an object that are identical to the original. The original instance acts as our prototype and gets used to construct new instances that are then completely independent of the original. We can then use these however is necessary.

Java has a level of support for this by implementing the Cloneable marker interface and then using Object.clone(). This will produce a shallow clone of the object, creating a new instance, and copying the fields directly.

This is cheaper but has the downside that any fields inside our object that have structured themselves will be the same instance. This, then, means changes to those fields also happen across all instances. However, we can always override this ourselves if necessary:

public class Prototype implements Cloneable { private Map contents = new HashMap(); public void setValue(String key, String value) { // ... } public String getValue(String key) { // ... } @Override public Prototype clone() { Prototype result = new Prototype(); this.contents.entrySet().forEach(entry -> result.setValue(entry.getKey(), entry.getValue())); return result; } }

7.1. Examples in the JVM

The JVM has a few examples of this. We can see these by following the classes that implement the Cloneable interface. For example, PKIXCertPathBuilderResult, PKIXBuilderParameters, PKIXParameters, PKIXCertPathBuilderResult, and PKIXCertPathValidatorResult are all Cloneable.

Another example is the java.util.Date class. Notably, this overrides the Object.clone() method to copy across an additional transient field as well.

8. Singleton

The Singleton pattern is often used when we have a class that should only ever have one instance, and this instance should be accessible from throughout the application. Typically, we manage this with a static instance that we access via a static method:

public class Singleton { private static Singleton instance = null; public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }

There are several variations to this depending on the exact needs — for example, whether the instance is created at startup or on first use, whether accessing it needs to be threadsafe, and whether or not there needs to be a different instance per thread.

8.1. Examples in the JVM

The JVM has some examples of this with classes that represent core parts of the JVM itselfRuntime, Desktop, and SecurityManager. These all have accessor methods that return the single instance of the respective class.

Additionally, much of the Java Reflection API works with singleton instances. The same actual class always returns the same instance of Class, regardless of whether it's accessed using Class.forName(), String.class, or through other reflection methods.

De la même manière, nous pouvons considérer l' instance de Thread représentant le thread actuel comme un singleton. Il y aura souvent de nombreuses instances de cela, mais par définition, il y a une seule instance par thread. L'appel de Thread.currentThread () de n'importe où s'exécutant dans le même thread retournera toujours la même instance.

9. Résumé

Dans cet article, nous avons examiné différents modèles de conception utilisés pour créer et obtenir des instances d'objets. Nous avons également examiné des exemples de ces modèles tels qu'ils sont également utilisés dans la JVM principale, afin que nous puissions les voir en cours d'utilisation d'une manière dont de nombreuses applications bénéficient déjà.