Всем хорошо известны интерфейсы — то есть контракты, которым должны соответствовать классы, однако мало кто слышал про универсалии, которые являются последним словом в дизайне ПО. С точки зрения философии, откуда это понятие исходит, универсалия — это свойство, которое присуще двум или более сущностям определенной категории, например свойство "цветной" присуще всем объектам, у которых может быть цвет: если бы мы моделировали Pencil, то есть карандаш, мы бы сказали, что Pencil implements IColorful, посколько он имеет цвет. Но интерфейсы — понятние широкое, которое также используется для описания поведения. Я же предлагаю ввести специальную категорию интерфейсов, называемых универсалиями, у которых есть всего 1 свойство, содержащее конкретный объект, для задачи внедрения зависимостей и уменьшению бойлерплейта.
Наглядный пример
Для того, чтобы понять, про что я говорю, возьмем пример из проекта Google Stylesheets, который является компилятором CSS. Как и любой компилятор, программа основывается на парсинге синтаксиса, трансформации его в AST и последующей модификации этого AST через так называемые "прогоны" (passes) — в один прогон, мы переименуем white
в #fff
для уменьшения размера файла, во время второго, вставим значения переменных и т.д.
Таких прогонов в компиляторе около 40, некоторые простые, некоторые довольно сложные, но речь сейчас не про них. Дело в том, что каждому прогону нужен VisitController, то есть объект, который предоставляет прогону некоторые API, например, если требуется модифицировать правило, то мы должны вызвать visitController.replaceCurrentBlockChildWith
из прогона. И поэтому получается, что в каждом из них мы должны прописать private final VisitController visitController
, принять его через конструктор и записать.
/**
* Compiler pass that removes declaration nodes that have all the property
* values marked as default.
*
* @author oana@google.com (Oana Florescu)
*/
public class RemoveDefaultDeclarations extends DefaultTreeVisitor
implements CssCompilerPass {
private final MutatingVisitController visitController;
private boolean canRemoveDefaultValue = false;
public RemoveDefaultDeclarations(MutatingVisitController visitController) {
this.visitController = visitController;
}
@Override
public void runPass() {
visitController.startVisit(this);
}
}
Таким образом, во всех 40 файлах нам нужно прописать частное поле и инициализировать его через конструктор. А еще бывает, что у класса таких объектов может быть несколько: некоторым прогонам также нужен errorManager
, чтобы добавить предупреждение, если потребуется. Если честно, это немного напрягает, потому что чувствуешь, что занимаешься не полезной работой, а генерацией бойлерплейта для передачи зависимостей. Поэтому я предлагаю делать это через паттерн универсалий, в данном случае UVisitController и UErrorManager:
UVisitController
.visitController UErrorManager
.errorManager
В других словах, вместо того, чтобы вручную добавлять свойство, содержащее объект, в класс, мы просто декларативно прописываем универсалию, то есть интерфейс всего с одним свойством, которое указывает на факт того, что объект имеет доступ к предмету универсалии (RemoveDefaultDeclarations implements UVisitController
значит, что у RemoveDefaultDeclarations есть свойство visitController). В дополнение, чтобы облегчить себе жизнь еще больше, мы добавим автоматическую инициализацию универсалий.
Автоматическая инициализация
Для того, чтобы автоматически перенести зависимости из списка аргументов, полученных в конструкторе, в свойства экземпляра, каждая универсалия должна предоставить default method init_XUniversal
, например:
package com.google.common.css.compiler.ast;
import java.util.HashMap;
import eco.artd.IUniversal;
public interface UErrorManager extends IUniversal {
final static String UNIVERSAL_NAME = "ErrorManagerUniversal";
default public IErrorManager getErrorManager() {
var smap = getSymbols().get(UNIVERSAL_NAME);
if (!smap.containsKey(this)) {
smap.put(this, null);
}
var obj = smap.get(this);
return (IErrorManager) obj;
};
default public void init_UErrorManager(Object[] args) {
if (!getSymbols().containsKey(UNIVERSAL_NAME)) {
getSymbols().put(UNIVERSAL_NAME, new HashMap<Object, Object>());
}
for (var arg : args) {
if (arg instanceof IErrorManager) {
var smap = getSymbols().get(UNIVERSAL_NAME);
smap.put(this, arg);
}
}
}
}
Такой метод пройдется по всем параметрам конструктора и выберет те, которые соответствуют типу универсалии, и выставит их для экзмемпляра. По причине того, что мы не можем ничего хранить внутри интерфейса при работе с default методами через private fields, саму зависимость придется хранить в стороннем статическом HashMap'e, доступным через getSymbols(). Тут нужно помнить, что интерфейсы не поддерживают свойств, поэтому мы всегда должны использовать геттеры (visitController
-> getVisitController()
).
Теперь нам не только не нужно знать, сколько именно зависимостей есть у объекта, но и не нужно соблюдать порядок, в котором мы передаем их конструктору.
public class RemoveDefaultDeclarations extends DefaultTreeVisitor
implements CssCompilerPass, UVisitController, UErrorManager {
private boolean canRemoveDefaultValue = false;
public RemoveDefaultDeclarations(boolean canRemoveDefaultValue,Object ...args) {
this.canRemoveDefaultValue=canRemoveDefaultValue;
this.init(args)
}
}
class PassRunner {
run() {
new RemoveDefaultDeclarations(true, visitController, errorManager)
// или
new RemoveDefaultDeclarations(true, errorManager, visitController)
}
}
Для того, чтобы инициализировать универсалии, вызовем метод init со списком rest аргументов к конструктору. Если имеются аргументы, не относящиеся к зависимостям, они могут быть перечислены первыми.
Рефлексия
Хотя мы и добавили метод init_UErrorManager
, мы никак не указали, как его вызвать. Это делает метод init базового интерфейса IUniversal, который должен будет быть вызван вручную из конструктора.
package eco.artd;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
public interface IUniversal {
public static final HashMap<String, HashMap<Object, Object>> SYMBOLS = new HashMap<String, HashMap<Object, Object>>();
default public HashMap<String, HashMap<Object, Object>> getSymbols() {
return IUniversal.SYMBOLS;
}
default void init(Object[] args) {
for (var m : this.getClass().getMethods()) {
var name = m.getName();
if (name.startsWith("init_")) {
try {
m.invoke(this, new Object[] { args });
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
System.out.println("Could not init trait " + m.getName().replace("init_", ""));
e.printStackTrace();
}
}
}
}
}
Этот метод найдет в экземпляре класса все методы, которые начинаются со слова init_
и вызовет их. По сути, каждая универсалия имеет свой метод init, но чтобы они не перезаписывали друг-друга, мы называем их уникальным именем, но с одним и тем же префиксом. В результате, поведение из каждого из методов будет объедено воедино при помощи рефлексии.
Продвинутое использование
Я надеюсь, сам концепт, изложенный выше, понятен всем: делаем такой интерфейс, который оборачивает объект в единственный геттер, и добавляем init метод, который выберет подходящие зависимости из списка аргументов конструктора. Довольно очевидно, неправда ли? И если интерфейсы мы называем по принципу IColor, то его универсалия будет называться UColor (color universal).
Давайте рассмотрим немного более продвинутый вариант, когда зависимости могут передаваться через сущности неявно:
new RemoveDefaultDeclarations(this.getErrorManager(),visitController) // явно
new RemoveDefaultDeclarations(this,visitController) // неявно
На примере выше, this
— это сам PassRunner, которые стартует пробеги по AST. Дело в том, что на сам PassRunner поставлена универсалия UErrorManager, поэтому мы можем использовать этот класс как контейнер зависимости, способный делится ею. Для этого нужно улучшить логику универсалий, и сделать так, чтобы любая другая сущность, которая имеет универсалию, передавала бы ее другим экземплярам с той же универсалией.
Для этого, просто немного модифицируем init_UErrorManager
:
public interface UErrorManager extends IUniversal {
default public void init_UErrorManager(Object[] args) {
if (!getSymbols().containsKey(UNIVERSAL_NAME)) {
getSymbols().put(UNIVERSAL_NAME, new HashMap<Object, Object>());
}
for (var arg : args) {
if (arg instanceof IErrorManager) {
var smap = getSymbols().get(UNIVERSAL_NAME);
smap.put(this, arg);
}
/* + */ else if(arg instanceof UErrorManager) {
/* + */ var smap = getSymbols().get(UNIVERSAL_NAME);
/* + */ smap.put(this, ((UErrorManager) arg).getErrorManager());
/* + */ }
}
}
}
Теперь мы можем делиться зависимостями от родителя к детям, просто кидая родителя в качестве аргумента к конструктору детей! В нашем простом примере было всего две зависимости, errorManager
и visitController
, поэтому преимущества не сразу очевидны, но если бы их было 10 (напр., logger, database, назовите свою), то вместо кода вроде
new SimplifyLinearGradient(this.database,this.logger,this.errorManager).runPass();
new EliminateEmptyRulesetNodes(this.database,this.logger,this.errorManager).runPass();
мы могли бы писать просто
new SimplifyLinearGradient(this).runPass();
new EliminateEmptyRulesetNodes(this).runPass();
Так мы довольно легко можем убрать бойлерплейт код для передачи и приема зависимостей, оставив лишь полезный код, который несет смысловую нагрузку.
Ограничения
Хоть метод и позволяет легко переиспользовать свойства и передавать зависимости, на текущий момент есть ряд недостатков:
1. Символы доступны глобально через getSymbols() а не через private поля, отчего страдает безопасность, ведь теперь любой код может самовольно перезаписать зависимости любого экземпляра.
2. Через JavaDoc, будут видны поля init_UErrorManager
, init_UVisitController
однако они не публичные, а исполняются из метода init системой, но интерфейсы поддерживают только публичные методы.
3. При компиляции в GraalVM, потребуется дописать дополнительную конфигурацию для рефлексии:
[
{
"name" : "com.google.common.css.compiler.ast.UVisitController",
"methods" : [
{ "name" : "init_UVisitController" }
]
},
{
"name" : "com.google.common.css.compiler.ast.UErrorManager",
"methods" : [
{ "name" : "init_UErrorManager" }
]
}
]
4. Возможен конфликт зависимостей при неявной передаче: один контейнер может иметь универсалию со ссылкой на один объект, а другой, с такой же универсалию, сошлется на другой. Будет выбран последний.
5. Теперь конструкторы классов не будут статически проверяться на то, что им передали нужную зависимость, потому что rest аргументы к конструктору типизированы как Object.
Заключение
В целом, прицип универсалий существенно упрощает задачу dependency injection, потому что теперь сами интерфейсы будут нести ответственность за инициализацию полей из конструктора. Конечно, можно использовать фреймворки для dependency injection, такие как Google Guice и аннотацию @Inject, но такой подход я называю "глобальным", потому что он делает что-то, что вне контроля разработчика (подходит для applications), однако он не очень подходит для написания библиотек, авторы которых не хотят диктовать своим пользователям, какой dependency injection framework использовать, поэтому я называю его "локальным" — он не изменяет ход выполнения программы, а просто является паттерном.
В сегодняшней матирале мы так же увидели зачаток субьектно-ориентированного программирования: комбинированый метод init, исполняющий все остальные init_X
. Традиционно, в ООП используется такой подход, когда при расширении класса метод будут перезаписан любым другим методом с тем же именем. В СОП, с другой стороны, методы могут накладываться друг на друга, чтобы таким образом исполняться вместе. Иногда это бывает очень полезно и выгодно: представьте, например, сценарий, когда каждый интерфейс мог бы предоставить свой метод destruct, и был бы исполнен не один последний destruct, а все. В нашем случае, из-за того, что Java не дает возможности делать это напрямую, пришлось воспользоваться рефлексией.
Сегодня вышла Java 21. Как обычно, много технических нововведений, производительность, теория типов и т.п., но ничего, связанного с дизайном, то есть с возможностью писать красивый, выразительный код без бойлерплейта. Например, почему бы не сделать специальный тип named record, который бы работал как HashMap, при этом имел простетский синтакс, вроде:
new EliminateEmptyRulesetNodes({
database: this.database,
errorManager: this.errorManager,
}).runPass();
Таким образом, не нужно было бы создавать по 20 конструкторов с аргументами в продуманном порядке. При наличии named records, можно было бы передать нужное значение в любом порядке. Это только одна идея, про СОП/АОП я вообще молчу — эти вещи должны были быть частью всех ООП языков уже 25 лет назад, а не тем, о чем половина программистов и не слышала.
Мы все настолько привыкли, что "дизайн" встроен в ЯП и ограничен интерфейсами и парой паттернов, что не понимаем, что построение красивой, эстетической архитектуры это работа по моделированию, отдельная от написания кода. С наличием правильных инструментов, такая работа может быть очень интересной и продуктивной, однако поскольку ЯП пилят технари без абстрактного мышления, таких инструментов у нас очень мало, и приходится ухищряться, как было доказано в статье.