Всем хорошо известны интерфейсы то есть контракты, которым должны соответствовать классы, однако мало кто слышал про универсалии, которые являются последним словом в дизайне ПО. С точки зрения философии, откуда это понятие исходит, универсалия это свойство, которое присуще двум или более сущностям определенной категории, например свойство "цветной" присуще всем объектам, у которых может быть цвет: если бы мы моделировали 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, поэтому мы можем использовать этот класс как контейнер зависимости, способный делится ею. Для этого нужно улучшить логику универсалий, и сделать так, чтобы любая другая сущность, которая имеет универсалию, передавала бы ее другим экземплярам с той же универсалией.

При создании нового Pass, errorManager автоматически прочитается с PassRunner
При создании нового Pass, errorManager автоматически прочитается с PassRunner

Для этого, просто немного модифицируем 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 лет назад, а не тем, о чем половина программистов и не слышала.

Мы все настолько привыкли, что "дизайн" встроен в ЯП и ограничен интерфейсами и парой паттернов, что не понимаем, что построение красивой, эстетической архитектуры это работа по моделированию, отдельная от написания кода. С наличием правильных инструментов, такая работа может быть очень интересной и продуктивной, однако поскольку ЯП пилят технари без абстрактного мышления, таких инструментов у нас очень мало, и приходится ухищряться, как было доказано в статье.

Комментарии (14)


  1. Akela_wolf
    20.09.2023 02:32
    +3

    Если мы говорим о библиотеках, то их код пишет (условно) один разработчик, а читают тысячи. Поэтому экономить на бойлерплейте в ущерб читаемости, скрывая магию за какими-то методами, вызываемыми через рефлексию - очень такое себе.

    Если же мы говорим о приложении, то даже в этом случае код может читаться значительно больше времени чем было потрачено на написание. Ну и для приложения есть DI-контейнер с аннотацями Inject, Autowired и т.п., которые являются стандартом, то есть не вызовут лишнего WTF при чтении кода.


    1. artdeco Автор
      20.09.2023 02:32

      Зачем читать передачу / получение зависимостей? Нужно ли это? В ваших интерфейсах будет описано, какие зависимости они могут иметь. И кто читает библиотеки? 1% мейнтейнеров? Библиотеки не читают а устанавливают. Еще раз повторюсь, видеть как передается определенный параметр может и нужно, зачем видеть передачу зависимостей и что может быть такого ужасного если ее не прочитают, я не знаю.


      1. Akela_wolf
        20.09.2023 02:32
        +1

        Ну вот я ни разу не ментейнер спринга. Однако спрингового кода (именно фреймворка) прочитал более чем достаточно, потому как не всегда документация дает четкий ответ "как это работает?" и "откуда вылез этот баг?". В идеальном мире, разумеется, есть идеальная документация, но в реальном мире разработчики приложений еще как читают код библиотек и фреймворков.


        1. artdeco Автор
          20.09.2023 02:32

          Хорошо даже если читают, то чем традиционный @Inject лучше? Там ровно тем же образом не будет ничего прописано в коде, никто ничего не сможет прочитать.


  1. aleksandy
    20.09.2023 02:32
    +2

    Какие универсалии? Всё описанное называется примесями (mixins, traits).

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

    public static final HashMap<String, HashMap<Object, Object>> SYMBOLS = new HashMap<String, HashMap<Object, Object>>();

    Т.е. работа в конкурентном окружении вообще не предполагается?


    1. artdeco Автор
      20.09.2023 02:32
      -2

      Интерфейс - понятие дизайна. Трейты - поняти имплементации. Универсалия - это специальный тип интерфейса, имеющий только одно свойство, указывающее на объект зависимости. интерфейсы с дефолтными методами - это способ их реализации. Про публичные методы я и написал в "ограничения". Это не моя проблема - это проблема Java, которая не дает нормально выполнить принцип дизайна ПО, о чем я так же написал. Но всем конечно, пофигу, на такое абстрактное понятие, как дизайн, потому что его никто не понимает, всем нравится писать уродский код.


      1. aleksandy
        20.09.2023 02:32
        +1

        Интерфейс - понятие дизайна. Трейты - поняти имплементации.

        Т.е. с дизайном реализации можно не заморачиваться, я правильно понимаю вашу логику?

        Но всем конечно, пофигу, на такое абстрактное понятие, как дизайн, потому что его никто не понимает, всем нравится писать уродский код.

        Красота - понятие субъективное. Не, оно, конечно, круто добавить в реализацию интерфейс и сразу же получить требуемый функционал без каких-либо трудозатрат. Я тоже такое реализовывал, и оно даже работало. Но потом эта простота выходит боком, т.к. перестают думать надо/не надо: нужен функционал добавили интерфейс. А вот правильно ли вообще в данном месте реализовывать этот функционал?

        Вообще миксины, конечно, круто, но они как @Transactional в спринге: не работает - добавим аннотацию, и оно само. В результате имеем кучу никому ненужного прокси-кода, который только на обогрев дата-центра работает.


        1. artdeco Автор
          20.09.2023 02:32
          -1

          Что значит с дизайном реализации можно не заморачиваться? Я заморочился и сделал самым простым возможным способом, чтобы доказать работоспособность метода. Ты говоришь, что все, что я написал -- это просто трейты, а я говорю что нет, что трейтами реализован принцип дизайна.

          Красота -- это самое объективное понятие в мире. Чем больше развиваешься, тем больше видишь красивые вещи. Говорят что красота субъективна обычно те, кто ничего в этом не понимает.

          Ну и что ты дальше написал? "А вот правильно ли вообще в данном месте реализовывать этот функционал?". Давай еще обсудим "а вот правильно ли вообще было идти программистом?" Почему такое отношение к профессионалам, как к полным дегенератам, которые не умеют думать? Что значит "правильно ли вообще реализовывать функционал" -- ну если мне нужна зависимость, то да, конечно правильно.

          И я предлагаю вместо того, чтобы 40 классам делать одно и тоже свойство, выделить его и интерфейс, вернее даже специальную категорию интерфейсов, и имя им даже подобрал -- универсалии. Это вообще основной DRY. В чем ваша проблема всех? Зачем начинать упираться, приводить какие-то доводы доведенные до экстрима "перестают думать а нужен ли вообще функционал", минусовать комментарии автора, ничего конструктивного не приводя.

          И к тому же, это не просто "добавить аннотацию". @Inject это просто добавить аннотацию. Тут же наоборот у тебя больше контроля к тому, что происходит, когда ты создаешь экземпляры через конструкторы, которым передаешь универсалии, ты уже знаешь, что используешь паттерн.


          1. aleksandy
            20.09.2023 02:32

            Возможно ты прав.


  1. mobi
    20.09.2023 02:32
    +4

    Такое чувство, что вместо гибкого DI опять пришли к Service Locator с глобальным состоянием.


    1. artdeco Автор
      20.09.2023 02:32
      -1

      Я лишь описал принцип, концепт дизайна. То, как его позволяет реализовать Джава - это проблема Джавы. Я не знаю, чем он вам недостаточно гибок, любые конкретные пожелания можно обсудить.


    1. aleksandy
      20.09.2023 02:32

      Вообще, одно другому не мешает. Просто getSymbols() должен будет обращаться в DI-контекст за экземплярами. Это, кстати, поможет избежать таких неуродливых методов, типа init_XXX().


  1. AshBlade
    20.09.2023 02:32
    -2

    В книге Внедрение зависимостей на платформе .NET автор делает буквально то же самое: в начале книги реализует внедрение зависимостей через аргументы конструктора, а потом, понимая, что могут возникнуть циклические зависимости, предложил ввести для каждого метода отдельный класс - тот самый универсалий.

    Только он пошел дальше, и дошел до медиатора


    1. artdeco Автор
      20.09.2023 02:32

      спасибо, что подсказал, посмотрю ) вау люди целые книги пишут по этому поводу...