Modelli di design creativi in ​​Core Java

1. Introduzione

I design pattern sono modelli comuni che utilizziamo durante la scrittura del nostro software . Rappresentano le migliori pratiche consolidate sviluppate nel tempo. Questi possono quindi aiutarci a garantire che il nostro codice sia ben progettato e ben costruito.

I modelli creazionali sono modelli di progettazione che si concentrano su come otteniamo istanze di oggetti . In genere, questo significa come costruiamo nuove istanze di una classe, ma in alcuni casi significa ottenere un'istanza già costruita pronta per essere utilizzata.

In questo articolo, rivisiteremo alcuni modelli di design creazionali comuni. Vedremo come sono e dove trovarli all'interno della JVM o di altre librerie principali.

2. Metodo di fabbrica

Il pattern Factory Method è un modo per noi di separare la costruzione di un'istanza dalla classe che stiamo costruendo. In questo modo possiamo astrarre il tipo esatto, consentendo al nostro codice client di funzionare invece in termini di interfacce o classi astratte:

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

Qui, il codice del nostro client non ha mai bisogno di sapere di SomeImplementation e invece funziona in termini di SomeInterface . Ancor più di questo, però, possiamo cambiare il tipo restituito dalla nostra fabbrica e il codice cliente non ha bisogno di cambiare . Ciò può anche includere la selezione dinamica del tipo in fase di esecuzione.

2.1. Esempi nella JVM

Forse gli esempi più noti di questo modello, la JVM, sono i metodi di creazione della raccolta nella classe Collections , come singleton () , singletonList () e singletonMap (). Tutti restituiscono istanze della raccolta appropriata - Set , List o Map - ma il tipo esatto è irrilevante . Inoltre, il metodo Stream.of () ei nuovi metodi Set.of () , List.of () e Map.ofEntries () ci consentono di fare lo stesso con raccolte più grandi.

Ci sono anche molti altri esempi di questo, incluso Charset.forName () , che restituirà un'istanza diversa della classe Charset a seconda del nome richiesto, e ResourceBundle.getBundle () , che caricherà un diverso bundle di risorse a seconda sul nome fornito.

Non tutti questi devono fornire istanze diverse. Alcuni sono solo astrazioni per nascondere i meccanismi interni. Ad esempio, Calendar.getInstance () e NumberFormat.getInstance () restituiscono sempre la stessa istanza, ma i dettagli esatti sono irrilevanti per il codice client.

3. Fabbrica astratta

Il pattern Abstract Factory è un passo oltre questo, in cui la fabbrica utilizzata ha anche un tipo di base astratto. Possiamo quindi scrivere il nostro codice in termini di questi tipi astratti e selezionare in qualche modo l'istanza di fabbrica concreta in fase di esecuzione.

Innanzitutto, abbiamo un'interfaccia e alcune implementazioni concrete per le funzionalità che vogliamo effettivamente utilizzare:

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

Successivamente, abbiamo un'interfaccia e alcune implementazioni concrete per la fabbrica per ottenere quanto sopra:

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

Abbiamo quindi un altro metodo factory per ottenere la factory astratta attraverso la quale possiamo ottenere l'istanza effettiva:

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

Qui abbiamo un'interfaccia FileSystemFactory che ha due implementazioni concrete. Selezioniamo l'esatta implementazione in fase di runtime, ma il codice che la utilizza non ha bisogno di preoccuparsi di quale istanza viene effettivamente utilizzata . Questi quindi restituiscono ciascuna un'istanza concreta diversa dell'interfaccia FileSystem , ma ancora una volta, il nostro codice non ha bisogno di preoccuparsi esattamente di quale istanza abbiamo.

Spesso, otteniamo la fabbrica stessa utilizzando un altro metodo di fabbrica, come descritto sopra. Nel nostro esempio qui, il metodo getFactory () è esso stesso un metodo factory che restituisce un FileSystemFactory astratto che viene quindi utilizzato per costruire un FileSystem .

3.1. Esempi nella JVM

Ci sono molti esempi di questo modello di progettazione utilizzato in tutta la JVM. I più comunemente visti riguardano i pacchetti XML, ad esempio DocumentBuilderFactory , TransformerFactory e XPathFactory . Questi hanno tutti uno speciale metodo factory newInstance () per consentire al nostro codice di ottenere un'istanza della factory astratta .

Internamente, questo metodo utilizza una serie di meccanismi diversi - proprietà di sistema, file di configurazione nella JVM e interfaccia del provider di servizi - per provare a decidere esattamente quale istanza concreta utilizzare. Questo ci consente quindi di installare librerie XML alternative nella nostra applicazione, se lo desideriamo, ma questo è trasparente per qualsiasi codice che le utilizza effettivamente.

Una volta che il nostro codice ha chiamato il metodo newInstance () , avrà quindi un'istanza della factory dalla libreria XML appropriata. Questa factory quindi costruisce le classi effettive che vogliamo utilizzare dalla stessa libreria.

Ad esempio, se stiamo utilizzando l'implementazione Xerces predefinita di JVM, otterremo un'istanza di com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl , ma se volessimo invece utilizzare un'implementazione diversa, chiamando newInstance () lo restituirebbe in modo trasparente invece.

4. Builder

Il pattern Builder è utile quando vogliamo costruire un oggetto complicato in modo più flessibile. Funziona avendo una classe separata che usiamo per costruire il nostro oggetto complicato e consentendo al client di crearlo con un'interfaccia più semplice:

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

Questo ci consente di fornire individualmente valori per marca , modello , porte e colore , quindi quando costruiamo l' auto , tutti gli argomenti del costruttore vengono risolti nei valori memorizzati.

4.1. Esempi nella JVM

There are some very key examples of this pattern within the JVM. The StringBuilder and StringBuffer classes are builders that allow us to construct a long String by providing many small parts. The more recent Stream.Builder class allows us to do exactly the same in order to construct a 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. Lazy Initialization

We use the Lazy Initialization pattern to defer the calculation of some value until it's needed. Sometimes, this can involve individual pieces of data, and other times, this can mean entire objects.

This is useful in a number of scenarios. For example, if fully constructing an object requires database or network access and we may never need to use it, then performing those calls may cause our application to under-perform. Alternatively, if we're computing a large number of values that we may never need, then this can cause unnecessary memory usage.

Typically, this works by having one object be the lazy wrapper around the data that we need, and having the data computed when accessed via a getter method:

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

Computing pi is an expensive operation and one that we may not need to perform. The above will do so on the first time that we call getValue() and not before.

5.1. Examples in the 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.

In modo simile, potremmo considerare l' istanza Thread che rappresenta il thread corrente come un singleton. Spesso ci saranno molte istanze di questo, ma per definizione, c'è una singola istanza per thread. Chiamare Thread.currentThread () da qualsiasi punto in esecuzione nello stesso thread restituirà sempre la stessa istanza.

9. Riepilogo

In questo articolo, abbiamo esaminato vari modelli di progettazione diversi utilizzati per creare e ottenere istanze di oggetti. Abbiamo anche esaminato esempi di questi modelli utilizzati anche all'interno della JVM principale, in modo da poterli vedere in uso in un modo che molte applicazioni già beneficiano.