С какой проблемой я столкнулся

Довольно часто приходится писать Serializable классы. Например, при реализации ORM, чтобы модели могли преобразовываться в JSON и создаваться из JSON.

Все классы моделей должны иметь джентльменский набор Serializable класса - фабричный конструктор fromJson и метод toJson. Хочется сделать так, чтобы все сущности имели этот функционал. Однако, все не так просто, как кажется на первый взгляд.

Моделирование примера

Возьмем для примера модель UserModel, которая представляет сущность User из базы данных. Данная модель должна содержать функционал сериализации в JSON и генерации из JSON'a (см. Рис. 1).

Рис. 1 - Определение класса модели UserModel
Рис. 1 - Определение класса модели UserModel

C методом toJson все просто - можно создать обычный интерфейс, который обяжет классы, реализующие его, определить в своем теле данный метод (см. Рис. 2).

Рис.2 - Определение интерфейса JsonSerializeInterface
Рис.2 - Определение интерфейса JsonSerializeInterface

Однако, заставить класс UserModel реализовать фабричный конструктор fromJson - задачка посложнее.

Формулирование проблемы

Для облегчения понимания последующих рассуждений, будет введена следующие обозначения:

  1. Класс-интерфейс - это класс-контракт с объявлением функционала.

  2. Интерфейс - это набор правил взаимодействия с объектом.

Статический интерфейс - это интерфейс, которым класс обладает как объект. Т.е. - это генеративные конструкторы, фабричные конструкторы и статические атрибуты (методы и поля) класса.

Важно: cтатический интерфейс класса - это не тот интерфейс, который определяется в классе, для последующей передачи экземплярам данного класса!

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

Классы-интерфейсы создаются для обязывания реализации интерфейса для экземпляров класса, а не для статического интерфейса. Т.е. в классе-интерфейсе нельзя создать контракт, обязывающий класс иметь статические методы или какие-нибудь конструкторы. Но почему так?

Определение причин проблемы

Статический интерфейс не наследуется от родительского класса к дочернему классу.
Если у родительского класса есть фабричные конструкторы, какие-то именованные генеративные конструкторы или статические методы, это вовсе не означает, что все это есть и у его производного класса (см. Рис. 3).

Рис. 3 - Пример отсутствия наследования статического интерфейса
Рис. 3 - Пример отсутствия наследования статического интерфейса

Если же позволить классам-интерфейсам обязывать классы реализовывать статические поля и конструкторы, объявленные в нем, то возникнет проблема:

  1. Есть класс-интерфейс, в котором объявлен статический интерфейс класса.

  2. Какой-то класс реализует класс-интерфейс и определяет все статические поля и конструкторы, объявленные в нем.

  3. От данного класса наследуются производные классы и он становится для них супер-классом.

  4. Поскольку, производные классы не наследуют статический интерфейс родительского класса, абсолютно все дочерние классы должны будут самостоятельно заново реализовывать весь статический интерфейс, который декларирован в классе-интерфейсе, который реализует их родительский класс.
    С точки зрения класса-интерфейса, данный эффект нарушает LSP-принцип (L из SOLID).
    Решением проблемы может стать копипаста, которая нарушит DRY-принцип.

Однако, не все так печально. Есть случаи, когда подобная проблема не является проблемой. Например, наш случай именно такой.

Если рассматривать этот эффект в контексте смоделированного примера, то если у какого-то класса модели появится дочерний класс (подмодель), то она должна будет заново определить статический метод-конструктор fromJson.
Такое поведение является нормальным, поскольку для каждой модели создается свой уникальный конструктор.

Создадим свой структуру данных, которая будет эмулировать работу класса-интерфейса для статических атрибутов.

Создание класса-интерфейса

Определение класса-интерфейса изображено на рисунке 4. (Полный код можно будет посмотреть в моем гитхабе - см. конец статьи).

Рис. 4 - Определение класса-интерфейса
Рис. 4 - Определение класса-интерфейса

JsonDeserializeInterface - это класс-фабрика, которая используется для:
1) Генерации экземпляра класса из JSON'а путем передачи ей JSON'a и типа данных инстанциируемого класса.
2) Создания интерфейса, который обязывает другие классы реализовать в своем теле статические поля.
Данный класс является абстрактным, поскольку его экземпляры не имеют никакого смысла. Он должен использоваться только как интерфейс или как фабрика.

static const Map<Type, Constructor<JsonDeserializeInterface>> _fromJsons = {
    ImplementedSubclass1: ImplementedSubclass1.fromJson,
    ImplementedSubclass2: ImplementedSubclass2.fromJson,
    ImplementedSubclass3: ImplementedSubclass3.fromJson,
  };

Словарь _fromJsons содержит в себе соответствия - класс, который обязан определить статический метод, и статический метод-конструктор данного класса.
Благодаря тому, что обращение к статическому методу происходит явным образом, данные классы обязаны содержать в своем теле определения этих методов.
Таким образом создается контракт, согласно которому, классы, определенный в словаре, обязаны иметь перечисленные в этом же словаре статические методы.

Чтобы создать аналогию с синтаксисом реализации интерфейсов, классы, которые обязаны содержать в себе реализацию статического метода, реализуют данный класс как интерфейс и поэтому становятся его потомками. Таким образом, метод, генерирующий экземпляры данных классов из json'а, создает объекты, которые также неявно являются экземплярами класса интерфейса JsonDeserializeInterface.

Поскольку данный словарь определяет контракты для классов, этот словарь нельзя изменять, поэтому он является compile-time константой и инкапсулируется.

static T fromJson<T extends JsonDeserializeInterface>(Map<String, dynamic> json) =>
      _fromJsons[T]!(json) as T;

Поскольку JsonDeserializeInterface - это класс фабрика, то метод fromJson способен генерировать экземпляры его подклассов из JSON'a.

Для этого данному классу нужно передавать тип данных класса, экземпляр которого будет создан из JSON'a.

Это делается при помощи параметризации класса одним из подклассов данного класса. Возвращаться будет также экземпляр подкласса данного класса.

Если в одном из реализующих классов не определить требуемый статический конструктор, то возникнет ошибка (см. Рис. 5).

Рис. 5 - Пример ошибки
Рис. 5 - Пример ошибки

Методы - окей, а конструкторы?

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

Однако, можно вместо конструкторов использовать все те же статические фабричные методы, которые можно обязать реализовать при помощи этого интерфейса. В случае с асинхронным конструктором, в дарте другого выбора вообще нет.

Если нужен пример с асинхронным конструктором - напишите в телеграме, скину пример.

Реализация интерфейса в примере

Теперь можно реализовать класс-интерфейс JsonDeserializeInterface в классе UserModel.
Для этого нужно сделать следующее:

  1. Добавить пару ключ-значения для UserModel в словарь JsonDeserializeInterface._fromJsons, чтобы создать контракт (см. Рис. 6).

  2. Наследовать класс UserModel от класса-интерфейса JsonDeserializeInterface (см. Рис. 7).

  3. Реализовать в классе UserModel метод fromJson (см. Рис. 7).

Рис. 6 - Добавления соответствия для UserModel в словарь контрактов
Рис. 6 - Добавления соответствия для UserModel в словарь контрактов
Рис. 7 - Измененные класс модели UserModel
Рис. 7 - Измененные класс модели UserModel

Теперь можно инстанциировать класс UserModel при помощи фабричного метода fromJson и при помощи фабричного конструктора JsonDeserializeInterface.fromJson.

Заключение

В данной статье был рассмотрен способ создания классов-интерфейсов для статических атрибутов класса.

Представленные паттерны специфичны и должны использоваться в подходящих случаях, иначе могут принести вред в дизайне архитектуры приложений.

От автора

Это моя первая статья. Надеюсь, не последняя :-)

Репозиторий с примером: https://github.com/I-m-good-man/interface_for_constructor_and_static_methods
Связь со мной: https://t.me/i_m_good_man

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


  1. PackRuble
    04.05.2024 07:26
    +1

    Если нужен пример с асинхронным конструктором - напишите в телеграме, скину пример.

    Ну не надо так :(

    Интересно узнать, какие преимущества и недостатки данного способа по сравнению с использованием классического json_serializable. Очень не хватает минимального рабочего примера "в действии". Я понимаю, что статья не об этом, но не могу найти место для применения, когда это полезно.

    Также:

    • factory  конструкторы могут быть объявлены как const , в то время как статический метод - нет

    • factory конструкторы могут быть именованными, для каждого свой json, как быть?

    • JsonDeserializeInterface  справится с наследованием в моделях?


    1. maratxat Автор
      04.05.2024 07:26

      Я понимаю, что статья не об этом, но не могу найти место для применения, когда это полезно.

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

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