Шаблоны творческого проектирования в Core Java

1. Введение

Шаблоны проектирования - это общие шаблоны, которые мы используем при написании нашего программного обеспечения . Они представляют собой устоявшиеся передовые практики, разработанные с течением времени. Затем они могут помочь нам убедиться, что наш код хорошо спроектирован и хорошо построен.

Шаблоны создания - это шаблоны проектирования, которые фокусируются на том, как мы получаем экземпляры объектов . Обычно это означает, как мы создаем новые экземпляры класса, но в некоторых случаях это означает получение уже созданного экземпляра, готового для использования.

В этой статье мы собираемся вернуться к некоторым распространенным шаблонам творческого проектирования. Мы посмотрим, как они выглядят и где их найти в JVM или других основных библиотеках.

2. Заводской метод

Паттерн Factory Method - это способ отделить создание экземпляра от класса, который мы создаем. Это сделано для того, чтобы мы могли абстрагироваться от точного типа, позволяя нашему клиентскому коду вместо этого работать в терминах интерфейсов или абстрактных классов:

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

Здесь нашему клиентскому коду никогда не нужно знать о SomeImplementation , и вместо этого он работает в терминах SomeInterface . Но даже более того, мы можем изменить тип, возвращаемый нашей фабрикой, и клиентский код не должен меняться . Это может даже включать динамический выбор типа во время выполнения.

2.1. Примеры в JVM

Возможно, наиболее известными примерами этого шаблона JVM являются методы создания коллекций в классе Collections , такие как singleton () , singletonList () и singletonMap (). Все они возвращают экземпляры соответствующей коллекции - Set , List или Map - но точный тип не имеет значения . Кроме того, метод Stream.of () и новые методы Set.of () , List.of () и Map.ofEntries () позволяют нам делать то же самое с более крупными коллекциями.

Существует множество других примеров этого, включая Charset.forName () , который вернет другой экземпляр класса Charset в зависимости от запрашиваемого имени, и ResourceBundle.getBundle () , который загрузит другой пакет ресурсов в зависимости от на указанное имя.

Не все из них также должны предоставлять разные экземпляры. Некоторые из них - просто абстракции, чтобы скрыть внутреннюю работу. Например, Calendar.getInstance () и NumberFormat.getInstance () всегда возвращают один и тот же экземпляр, но точные данные не имеют отношения к клиентскому коду.

3. Абстрактная фабрика

Шаблон «Абстрактная фабрика» - это шаг вперед, где используемая фабрика также имеет абстрактный базовый тип. Затем мы можем написать наш код в терминах этих абстрактных типов и каким-то образом выбрать конкретный экземпляр фабрики во время выполнения.

Во-первых, у нас есть интерфейс и несколько конкретных реализаций той функциональности, которую мы действительно хотим использовать:

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

Далее у нас есть интерфейс и некоторые конкретные реализации для фабрики, чтобы получить вышеуказанное:

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

Затем у нас есть другой фабричный метод для получения абстрактной фабрики, через которую мы можем получить фактический экземпляр:

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

Здесь у нас есть интерфейс FileSystemFactory, который имеет две конкретные реализации. Мы выбираем точную реализацию во время выполнения, но код, который ее использует, не должен заботиться о том, какой экземпляр фактически используется . Затем каждый из них возвращает отдельный конкретный экземпляр интерфейса FileSystem , но, опять же, нашему коду не нужно заботиться о том, какой именно экземпляр у нас есть.

Часто мы получаем саму фабрику, используя другой фабричный метод, как описано выше. В нашем примере метод getFactory () сам по себе является фабричным методом, который возвращает абстрактный FileSystemFactory, который затем используется для создания FileSystem .

3.1. Примеры в JVM

Есть множество примеров этого шаблона проектирования, используемого в JVM. Чаще всего встречаются пакеты XML - например, DocumentBuilderFactory , TransformerFactory и XPathFactory . Все они имеют специальный фабричный метод newInstance (), позволяющий нашему коду получить экземпляр абстрактной фабрики .

Внутри этого метода используется ряд различных механизмов - свойства системы, файлы конфигурации в JVM и интерфейс поставщика услуг - чтобы попытаться решить, какой именно конкретный экземпляр использовать. Затем это позволяет нам устанавливать альтернативные библиотеки XML в наше приложение, если мы хотим, но это прозрачно для любого кода, фактически использующего их.

Как только наш код вызовет метод newInstance () , он получит экземпляр фабрики из соответствующей библиотеки XML. Затем эта фабрика конструирует фактические классы, которые мы хотим использовать, из той же самой библиотеки.

Например, если мы используем реализацию Xerces по умолчанию JVM, мы получим экземпляр com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl , но если мы хотим вместо этого использовать другую реализацию, то вызовем newInstance () вместо этого прозрачно вернет это.

4. Строитель

Шаблон Builder полезен, когда мы хотим более гибко построить сложный объект. Он работает, имея отдельный класс, который мы используем для создания нашего сложного объекта, и позволяя клиенту создавать его с помощью более простого интерфейса:

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

Это позволяет нам индивидуально предоставлять значения для марки , модели , дверей и цвета , а затем, когда мы строим автомобиль , все аргументы конструктора преобразуются в сохраненные значения.

4.1. Примеры в 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.

Аналогичным образом мы можем рассматривать экземпляр Thread, представляющий текущий поток, как одноэлементный. Часто бывает много экземпляров этого, но по определению существует один экземпляр для каждого потока. Вызов Thread.currentThread () из любого места при выполнении в одном потоке всегда будет возвращать один и тот же экземпляр.

9. Резюме

В этой статье мы рассмотрели различные шаблоны проектирования, используемые для создания и получения экземпляров объектов. Мы также рассмотрели примеры этих шаблонов, которые также используются в основной JVM, поэтому мы можем увидеть их в использовании таким образом, который уже приносит пользу многим приложениям.