В последние годы растет популярность фреймворка для кроссплатформенной разработки приложений Flutter, а вместе с ним вторую жизнь получил и язык программирования Dart. Dart создавался как потенциальная замена JavaScript и мог бы занять нишу, которая сейчас принадлежит TypeScript, но по ряду причин он так и оставался длительное время «одним из проектов Google», который не был оценен ни для использования в web (после транспиляции в JavaScript), ни для создания серверных или десктопных приложений.

При этом компилятор Dart создает оптимизированный высокопроизводительный код для основных операционных систем и может также использоваться для компиляции в другие аппаратные архитектуры (например, для микроконтроллеров) и, в целом, Dart может использоваться как язык общего назначения для создания серверных приложений (Shelf, Aqueduct, который сейчас поддерживается сообществом в проекте Conduit), устройств умного дома (с целевой платформой ARM и пакетом dart_periphery) и, конечно, десктопных, мобильных и веб-приложений (Flutter).

И все же язык обладает определенным набором особенностей, которые надо учитывать при реализации кода и использованию типовых паттернов проектирования. В этой статье мы рассмотрим примеры реализации самых часто используемых основных и порождающих шаблонов на языке Dart (будем использовать возможности наиболее актуальной версии Dart 2.17), а во второй части статьи будут рассмотрены более сложные структурные и поведенческие паттерны.

Dart является ООП-языком с поддержкой классического механизма наследования и в этом он имеет значительное сходство с C++, C#, Java или Kotlin. Но есть несколько важных отличий:

  • В Dart есть абстрактные классы, но нет интерфейсов. При этом при определении класса можно использовать ключевое слово implements, но оно имеет необычную реализацию — указанный в implements класс (абстрактный или с реализацией) будет использоваться только для перечисления заголовков методов (аналог header-файла), но реализация будет из них исключена и должна быть выполнена в классе, реализующим интерфейс

  • В Dart поддерживаются factory методы, которые могут использоваться вместо конструкторов (возвращают объект того типа, в котором определен factory-метод). Это позволяет реализовать сложные сценария создания объекта (например, использовать пул объектов).

  • Dart поддерживает позиционные и именованные аргументы, при этом они могут быть необязательными и иметь значения по умолчанию (или null), это позволяет избежать необходимости использования шаблона Builder, популярного в Java (для переопределения значений по умолчанию для отдельных полей объекта). В этом Dart похож на Kotlin (там нет разделения на именованные и позиционные параметры, название может быть указано при создании объекта или вызове метода).

  • Для любых классов может быть определено поведение операторов (в том числе, для базовых типов, таких как int), например здесь мы добавим возможность получения цифры в указанном разряде для целого числа:

import 'dart:math';

extension NumExt on int {
  int operator [](int b) => this~/(pow(10,b)) % 10;
}

void main() {
  print(1234[1]);		//отобразит 3
}
  • В Dart нельзя создавать переопределяемые конструкторы (частично это компенсируется необязательными параметрами со значениями по умолчанию), но можно использовать именованные конструкторы. Они похоже по принципу действия на factory-методы, но всегда работают с новым объектом (выполняют необходимую инициализацию), в то время как вызов factory-метода может не приводить к созданию объекта (например, при использовании объекта из пула).

  • Исключения всегда являются непроверяемыми и, в случае отсутствия блока try-catch, оно будет поднято до границы зоны при запуске через runZoned (или до приложения в целом, что приведет к аварийному завершению).

  • Для инициализации переменных в конструкторе можно использовать короткую форму this.<name> (для сохранения значения в этом объекте) или super.<name> (для передачи значения в родительский конструктор).

  • Dart поддерживает статические поля и методы (они размещаются в снимке памяти при компиляции, не используют динамическую память), а также константные объекты (инстанцирование также происходит на этапе компиляции, при этом при использовании нескольких объектов с одинаковыми аргументами будет создан только один объект в snapshot. При этом константным может быть только объект, в котором все поля являются финальными (неизменяемыми) и инициализируются через конструктор.

  • Функции, как и объекты, могут быть переданы в параметрах другой функции или метода класса (и даже в конструктор объекта), а также возвращены как результат функции. Аналогично можно передавать ссылку на метод класса. В последних версиях Dart стало возможным передавать ссылку на конструктор класса (ClassName::new) или factory-метод (ClassName::factoryMethod).

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

  • Функции, как и объекты класса, являются хэшируемыми объектами и, следовательно, могут использоваться как ключ в Map-структурах. Также любые используемые значения (числа, логические типы, строки, структуры данных) являются объектами классов, для которых могут быть созданы расширения (extension). Это позволяет добавить новые методы к существующим классам стандартной библиотеки (работает аналогично механизмам расширений Kotlin или C#, но определяет новый тип на основе существующего, при этом компилятор корректно использует новые методы и свойства над существующими объектами расширяемого класса).

  • В Dart любое свойство класса может быть переопределено через механизм get/set (аналог Kotlin get/set или делегатов), при этом если в родительском классе оно было доступно для записи, в классе-наследнике можно его сделать только для чтения и это можно использовать для ограничения интерфейса родительского класса.

class A {
  int x = 10;
}

class B {
  int get x => 20;
}

void main() {
  final b = B();
  //b.x = 30;				//здесь будет ошибка компиляции
  print(b.x);
}

Более того, если переопределить только get, изменение значения не вызовет ошибку компиляции, но при этом не повлияет на возвращаемое значение (спасибо за дополнение @MiT_73)

class A {
  int x = 10;
}

class B extends A {
  @override
  int get x => 20;
}

void main() {
  final b = B();
  b.x = 30; // здесь не будет ошибки компиляции и присвоения тоже не будет
  print(b.x);
}
  • Для расширения функциональности классов Dart использует механизм примесей (mixin) и в этом похож на Python или C#. Миксины могут добавлять новые свойства и/или методы, при этом метод миксина при переопределении в класс будет доступен через super. Более того, если к классу применить несколько миксинов, каждый из которых будет реализовать один и тот же метод, то они будут доступны последовательно через super (для первого mixin super будет обращаться к родительскому классу). Например, в следующем коде последовательность вызова будет такой: класс-реализация обратится к mixin D (последнему), он к mixin C (первому), он к реализации родительского класса. Вывод будет: A C D B.

class A {
  void log() => print("A");
}

mixin C on A {
  void log() {
    super.log(); print("C");
  }
}

mixin D on A {
  void log() {
    super.log(); print("D");
  }
}

class B extends A with C, D {
  void log() {
    super.log(); print("B");
  }
}

void main() {
  final b = B();
  b.log();
}

Теперь, когда мы рассмотрели особенности реализации ООП в языке Dart, можем перейти к шаблонам проектирования. Традиционно выделяют 4 группы шаблонов (хотя есть и другие классификации): основные (определяющие интерфейсы и классы объектов), порождающие (классы, порождающие объекты других классов), структурные (определяют способы изменения существующих классов через создание классов-посредников), поведенческие (определяют способы взаимодействия между компонентами системы). Мы последовательно разберем основные шаблоны и начнем с основных паттернов:

Основные шаблоны (Fundamental Patterns)

Делегирование (Delegation Pattern)

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

В Dart может быть реализован одним из трех способов:

  • встраивание (агрегация) объекта, который будет реализовывать логику как приватного свойства внутрь нашего класса и чтение его свойств или вызов методов при обращении к свойствам/методам нашего класса. Также такой класс (который в данном случае действует как декоратор) может добавлять новые методы для реализации интерфейса. Как можно видеть, декоратор очень похож на наследование, но может использоваться в нескольких случаях:

    • когда наследование невозможно (например, у родительского класса есть только приватный конструктор и factory-метод для создания экземпляра)

    • когда конструктор декоратора может принимать объекты реализации исходного интерфейса через конструктор и расширять их функциональность (например, так можно с помощью декораторов комбинировать рецепты и получать общую сумму и список ингредиентов)

abstract class Recipe {
  Set<String> get ingredients;
  int get cost;
  void cook();
}

class Salad implements Recipe {
  @override
  Set<String> get ingredients => {'Cabbage', 'Tomatoes', 'Cucumber'};
  
  @override
  int get cost => 100;
  
  void cook() {
    print('Salad is cooked');
  }
}

class SpecialSalad implements Recipe {
  final _salad = Salad();
  
  @override
  Set<String> get ingredients => _salad.ingredients..add('CurrySauce');
  
  @override
  int get cost => _salad.cost + 15;
  
  @override
  void cook() {
    print('Cooking special salad');
    _salad.cook();
  }
  
  void vip() {
    print('Vip method');
  }
}

void debug(Recipe recipe) {
  print('Ingredients: ${recipe.ingredients}');
  print('Cost: ${recipe.cost}');
  recipe.cook();
}

void main() {
  debug(Salad());
  final specialSalad = SpecialSalad();
  debug(specialSalad);
  specialSalad.vip();
}
  • использование расширений класса (extension), это позволит избежать необходимости создания объекта (он будет доступен через this), но для определения альтернативного поведения методов нужно будет изменить их название (при этом у всех рецептов появятся методы specialIngredients и specialCost, которые добавляют соус карри к ингредиентам и 15 к стоимости):

abstract class Recipe {
  Set<String> get ingredients;
  int get cost;
  void cook();
}

class Salad implements Recipe {
  Set<String> get ingredients => {'Cabbage', 'Tomatoes', 'Cucumber'};
  
  int get cost => 100;
  
  void cook() {
    print('Salad is cooked');
  }
}

extension SpecialRecipe on Recipe {
  Set<String> get specialIngredients => this.ingredients..add('CurrySauce');
  
  int get specialCost => this.cost + 15;
  
  void specialCook() {
    print('Cooking special salad');
    this.cook();
  }
  
  void vip() {
    print('Vip method');
  }
}

void debug(Recipe recipe) {
  print('Ingredients: ${recipe.ingredients}');
  print('Cost: ${recipe.cost}');
  recipe.cook();
}

void specialDebug(Recipe recipe) {
  print('Ingredients: ${recipe.specialIngredients}');
  print('Cost: ${recipe.specialCost}');
  recipe.specialCook();
}

void main() {
  final salad = Salad();
  debug(salad);
  specialDebug(salad);
  salad.vip();
}
  • миксины — реализация методов в классе определяется подключенными mixin, в этом случае класс может непосредственно обеспечивать необходимый интерфейс (методы из mixin будут доступны как методы нашего класса, но нужно следить за тем, чтобы сигнатура метода нашего класса совпадала с сигнатурой метода из миксина). Mixin можно комбинировать и создавать более сложные рецепты (последовательно получая ингредиенты из ранее подключенных примесей через вызов super.ingredients).

class Recipe {
  Set<String> get ingredients => {};
  int get cost => 0;
  void cook() {}
}

class SomeRecipe implements Recipe {
  void cook() {
    print("Cooking some recipe");
  }

  int get cost => 50;

  Set<String> get ingredients => {"Something"};
  
}

mixin Salad on Recipe {
  Set<String> get ingredients => super.ingredients..addAll({'Cabbage', 'Tomatoes', 'Cucumber'});
  
  int get cost => super.cost + 100;
  
  void cook() {
    print('Salad is cooked');
  }
}

class SpecialSalad extends Recipe with Salad {
  Set<String> get ingredients => super.ingredients..add('CurrySauce');
  
  int get cost => super.cost + 15;  
  
  void vip() {
    print("VIP Method");
  }
}

void debug(Recipe recipe) {
  print('Ingredients: ${recipe.ingredients}');
  print('Cost: ${recipe.cost}');
  recipe.cook();
}

void main() {
  debug(SomeRecipe());
  final specialSalad = SpecialSalad();
  debug(specialSalad);
  specialSalad.vip();
}

Неизменяемый интерфейс (Immutable Interface)

Здесь ожидается, что созданный объект не может быть изменен. Для создания неизменяемых объектов можно использовать три подхода:

  • Создание константных объектов (при этом аргументы, которые передаются в конструктор тоже должны быть константами, вычисляемыми на этапе компиляции)

class ForeverSame {
  final String name;
  const ForeverSame(this.name);
}

void main() {
  const same1 = ForeverSame('Marty');  // same1 всегда показывает на этот объект
//   same1.name = 'Doc';  // вызовет ошибку, поскольку имя изменять нельзя
  print(same1.name);
}
  • Создание неконстантных объектов с final-полями без методов, которые могут повлиять на состояние объекта (нужно в случае, если значение не может быть вычислено на этапе компиляции):

import 'dart:math';

class SecretToken {
  final String token;
  const SecretToken(this.token);
}

void main() {
  final secret = SecretToken(Random().nextInt(100000).toString());
  // secret.token = '123456';   // вызовет ошибку, нельзя изменять final-поля
  print(secret.token);
}
  • использовать freezed или built_value для создания класса без возможностей изменения, но с поддержкой клонирования в новый объект с изменением одного или нескольких полей (метод copyWith), эти библиотеки используют кодогенерацию для создания необходимых методов. Добавим в pubspec.yaml необходимые зависимости и определим модель данных. Если необходимо создать неизменяемый объект (но с возможность вывода через print структуры объекта), можно дополнительно указать опцию в аннотации @Freezed(copyWith: false).

dependencies:
  flutter:
    sdk: flutter
  freezed_annotation:

dev_dependencies:
  freezed:
  build_runner:
import 'package:freezed_annotation/freezed_annotation.dart';

part 'data.freezed.dart';

@Freezed()
class Person with _$Person {
  @Assert('age>=0')
  const factory Person({
    required final String lastname,
    required final String firstname,
    //The age of the user, positive integer number
    required int age,
    int? salary,
  }) = _Person;
}
import 'package:freezedtest/data.dart';

void main() {
  const person = Person(
    lastname: 'Ivanov',
    firstname: 'Ivan',
    age: 24,
  );
  print(person);
  final afterYearPerson = person.copyWith(age: person.age + 1);
  print(afterYearPerson);
}

Интерфейс и интерфейс-маркер (Interface, Marker Interface)

В Dart отдельного понятия интерфейса нет, но любой абстрактный или конкретный класс может использоваться как интерфейс для перечисления необходимых для реализации методов (отличие от расширения класса в использовании ключевого слово implements). Класс может реализовать несколько интерфейсов (здесь нет проблемы множественного наследования, поскольку в базовых классах игнорируется реализация и остаются только сигнатуры методов). Например, если мы хотим, чтобы класс реализовал метод walk(), но не хотим включать его в базовый класс (например, если это поведение является опциональным), это может быть сделано следующим образом:

abstract class WalkPossibility {
  void walk();
}

abstract class Animal {
  String get name;
}

class Cat extends Animal implements WalkPossibility {
  
  String nick;
  
  Cat(this.nick);
  
  @override
  void walk() {
    print("Cat is walking");
  }
  
  @override
  String get name => 'Cat ${nick}';
}

void main() {
  final cat = Cat('Panther');
  print(cat.name);
  cat.walk();
}

Также для создания интерфейсов могут использоваться миксины (важное отличие, что они могут представлять базовую реализацию). Как и для интерфейсов можно указывать несколько миксинов и, если они дают реализацию для одинаковых сигнатур, вызов осуществляется последовательно через обращение к объекту super (следующий миксин в списке обращается к предыдущему, первый обращается к методу родителя, если он указан в mixin <name> on <parent>). Так предыдущий вариант кода может быть реализован с использованием mixin:

mixin WalkPossibility {
  void walk();
}

abstract class Animal {
  String get name;
}

class Cat extends Animal with WalkPossibility {
  
  String nick;
  
  Cat(this.nick);
  
  @override
  void walk() {
    print("Cat is walking");
  }
  
  @override
  String get name => 'Cat ${nick}';
}

void main() {
  final cat = Cat('Panther');
  print(cat.name);
  cat.walk();
}

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

// mixin Serializable {}		//также можно использовать mixin

abstract class SerializableInterface {}

abstract class Animal {
  String get name;
}

// class Cat extends Animal with Serializable {  //пример с миксином
class Cat extends Animal implements SerializableInterface {
  
  String nick;
  
  Cat(this.nick);
  
  @override
  String get name => 'Cat ${nick}';
}

void serialize(Animal animal) {
  if ((animal is Serializable) || (animal is SerializableInterface)) {
    print("Yes, I can serialize this");
  }
}

void main() {
  final cat = Cat('Panther');
  print(cat.name);
  serialize(cat);
}

Контейнер свойств (Property Container)

Этот паттерн подразумевает возможность динамического определения свойств в объекте класса во время выполнения, их обнаружения и извлечения значений. Технически в dart есть пакет dart:reflect, с помощью которого можно обнаруживать методы и свойства объекта класса, но важно помнить, что во Flutter этот пакет отключен, да и в целом его производительность не очень высокая. Гораздо проще для создания контейнеров свойств использовать возможности структур данных Map и, при необходимости, переопределение операторов (например, для генерации специальных исключений при отсутствии значения). Рассмотрим на простом примере:

class NoPropertyException {}

class InvalidKeyException {}

class AnimalProperties<T> {
  final Map<String,T> _properties = {};
  
  Object? operator [](String key) {
    if (_properties.containsKey(key)) return _properties[key];
    throw NoPropertyException();    
  }
  
  void operator[]=(String key, T value) {
    if (key.startsWith('#')) throw InvalidKeyException();
    _properties[key] = value;
  }
}

void main() {
  final props = AnimalProperties<String>();
  props['Age'] = '5';
//   props['#Test'] = 'Test';    // будет исключение InvalidKeyException
  print(props['Age']);        // 5
//   print(props['Unknown']);    // будет исключение NoPropertyException
}

Здесь мы создали контейнер свойств, который может делать дополнительные проверки и выбрасывает исключения, если ключ не найден или начинается с символа #

Канал событий (Event Channel)

Очень распространенный паттерн для Dart (используется и в Angular Dart и во Flutter), реализуется на Stream. По сути это основной способ для передачи потока событий между разными частями приложения и он часто используется совместно с миксином ChangeNotifier (позволяет подписаться на изменение объекта класса и в нужных местах уведомить подписчиков через вызов notifyListeners). Stream может работать как в с одним получателем, так и быть доступным для неограниченного количества слушателей (во втором случае слушатели, которые подключились к Stream позднее получат только те сообщения, которые были отправлены после подключения).

import 'dart:async';

class Age {
  
  int _age = 0;
  
  final _stream = StreamController<int>();
  
  Stream get subscribe => _stream.stream.asBroadcastStream();
  
  void operator +(int delta) {
    _age += delta;
    _stream.add(_age);
  }
}

void main() {
  Age age = Age();
  age.subscribe.listen((_newAge) {
    print('Age is changed to $_newAge');
  });
  age+1;			// Age is changed to 1
  age+2;		  // Age is changed to 3
}

Порождающие шаблоны (Creational Patterns)

Абстрактная фабрика (Abstract Factory)

Этот паттерн ориентирован на создание объектов классов заданного типа. Например, в нашем случае мы можем сделать перечисление типов животных и фабрику для их генерации (которая будет принимать значение типа и возвращать необходимый объект). Реализация на Dart использует factory-методы для создания нужного объекта:

enum AnimalType {
  Cat, Dog, Cow,
}

class Animal {  
  factory Animal(AnimalType type) {
    switch (type) {
      case AnimalType.Cat:
        return _Cat();
      case AnimalType.Dog:
        return _Dog();
      case AnimalType.Cow:
        return _Cow();
    }
  }
  
  String get type => 'Unknown';  
}

class _Cat implements Animal {
  @override
  String get type => 'Cat';
}

class _Dog implements Animal {
  @override
  String get type => 'Dog';
}

class _Cow implements Animal {
  @override
  String get type => 'Cow';
}

void main() {
  final myCat = Animal(AnimalType.Cat);
  print(myCat.type);
}

Здесь нельзя использовать расширение базового класса (т.е. нельзя написать class _Cat extends Animal), поскольку в этом случае будет необходимо наличие публичного конструктора в базовом классе, а у нас есть только factory-метод.

Строитель (Builder)

Паттерн Builder был особо распространен в Java, поскольку там не поддерживались значения параметров по умолчанию. В целом взаимодействие с Builder основано на последовательности вызовов методов настройки объекта (которые переопределяют значения по умолчанию) и последующий вызов build(), чтобы сконструировать финальный объект для перечисленных ранее свойств. В Dart это может быть реализовано через параметры по умолчанию в конструкторе. Также свойства для объекта могут быть изменены через каскадный оператор, который заменяет собой цепочку вызовов:

class Cat {
  String name;
  
  int age;
  
  bool verbose;
  
  Cat({required this.name, this.age = 1, this.verbose = false});
  
  void describe() {
    print('I\'m a cat with name ${name}, age: ${age} and verbosity: ${verbose}');
  }
}

void main() {
  final cat = Cat(name: 'Panther')..age = 3..verbose = true;
  cat.describe();
}

Фабричный метод (Factory method)

Этот паттерн подразумевает, что базовый класс определяет метод для создания объекта, реализующего некоторый класс или интерфейс, а его реализация определяется в классах-наследниках. Например, мы можем создать класс AnimalCreator, который определит метод create(), возвращающий объект класса Animal (или его подклассов) и CatCreator, который будет создавать котов. Здесь используются обычные механизмы наследования и переопределения реализации методов:

abstract class Animal {
  String get type;
}

class Cat extends Animal {
  @override
  String get type => 'Cat';
}

abstract class Creator {
  Animal create();
}

class CatCreator extends Creator {
  @override
  Animal create() => Cat();
}

void main() {
  print(CatCreator().create().type);
}

Отложенная инициализация (Lazy Initialization)

Этот паттерн подразумевает, что свойство объекта инициализируется только при первом обращении к нему (например, подключение к базе данных устанавливается при первом запросе). Для реализации паттерна в Dart может использовать возможности переопределения get-функций и null значения. Использовать late в этом случае нельзя, поскольку этот модификатор предполагает, что инициализация будет выполнена в процессе выполнения методов жизненного цикла, а в этом паттерне подразумевается, что значение до первого использование не будет изменено. В данном примере кот рождается в тот момент, когда у объекта запрашивается возраст:

class Cat {
  
  Cat() {
    print('I\'m constructed');
  }
  
  int? _age;
  
  int get age {
    if (_age==null) {
      print('I\'m born');
      _age = 0;
    }
    return _age!;
  }
}

void main() {
  final cat = Cat();
  print(cat.age);
}

Альтернативным решением может быть встроенный в язык механизм отложенной инициализации значения при первом обращении к свойству (спасибо@MiT_73):

class Cat {
  
  Cat() {
    print('I\'m constructed');
  }
  
  late final int age = (){
    print('I\'m born');
    return 0;
  }();
}

void main() {
  final cat = Cat();
  print(cat.age);
  print(cat.age);
}

Объектный пул (Object Pool)

Этот паттерн чаще всего используется с разделяемыми ресурсами (например, подключениями к базе данных), которые создаются заранее или регистрируются по мере создания объектов и затем переиспользуются после освобождения объектов. Для реализации объектного пула можно использовать возможности factory-методов и статические поля для хранения объектов, но нужно помнить, что в Dart нет деструкторов и отслеживать жизненный цикл нужно каким-либо статическим методом. Например:

const maxConnections = 2;

class PooledDatabase {
  
  PooledDatabase.connection() {}
  
  bool _isBusy = false;
  
  static List<PooledDatabase>? _pool;
  
  factory PooledDatabase() {
    //первое обращение - создаем объекты
    _pool ??= List<PooledDatabase>.generate(maxConnections, (_) => PooledDatabase.connection()).toList();
    print('Pool is ${_pool!.map((p) => p.hashCode).join(', ')}');
    //ищем свободный объект в пуле
    try {
      final connection = _pool!.firstWhere((db) => !db._isBusy);
      connection._isBusy = true;
      return connection;
    } catch (e) {
      //пул весь заполнен
      throw Exception('Not enough connections');
    }
  }
  
  static release(PooledDatabase connection) {
    connection._isBusy = false;
  }
}

void main() {
  final pool1 = PooledDatabase();
  print(pool1.hashCode);
  final pool2 = PooledDatabase();
  print(pool2.hashCode);
//   final pool3 = PooledDatabase();   //выбросит исключение Not Enough Connections
  PooledDatabase.release(pool2);
  final pool4 = PooledDatabase();
  print(pool4.hashCode);
  assert(pool2==pool4);             //будет true, поскольку второе подключение из пула будет переиспользовано
}

Прототип (Prototype)

Паттерн прототип подразумевает создание нового объекта не через конструктор, а путем клонирования существующего объекта с модификацией свойств. Одной из наиболее простых реализаций в Dart будет использование freezed и кодогенерации для создания метода copyWith или самостоятельная реализация через обычный метод. Например, мы хотим создавать объекты Cat на основе существующих с заменой клички и цвета:

class Cat {
  
  String? color;
  
  String? name;
  
  Cat._({this.color, this.name});
  
  factory Cat() {
    return Cat._();
  }
  
  Cat copyWith({String? color, String? name}) {
    final _newColor = color ?? this.color;
    final _newName = name ?? this.name;
    return Cat._(color: _newColor, name: _newName);
  }
}

void main() {
  final defaultCat = Cat();
  final ourCat = defaultCat.copyWith(color: 'red', name: 'Thunder');
  print(ourCat.name);
  print(ourCat.color);
}

Получение ресурса есть инициализация (Resource Aquisition is Initialization)

Распространенный паттерн для работы с файловыми ресурсами, для которых предполагается последовательность операций после открытия ресурса и закрытие ресурса по окончанию операций. В Dart это может быть реализовано через использование функций расширения, которыми можно дополнить стандартную библиотеку (например, dart:io) и анонимных функций (лямбды) для описания алгоритма действий над ресурсом.

import 'dart:io';

typedef FileCallback = Future<void> Function(File file);

extension TempFile on File {
  temporary(FileCallback action) async {
    await create(recursive: true);
    await action(this);
    await delete(recursive: true);
  }
}

void main() {
  File("c:\\tmp\\temp.data").temporary(
    (file) async => await file.writeAsString('Temp data'),
  );
}

Синглтон (Singleton)

Singleton считается антипаттерном (несмотря на его сходство с пулом объектов) и более правильным решением для создания единственного экземпляра объекта для использования во всем приложении будет применение Service Locator или DI-библиотек (например, getit). Но при необходимости можно создать собственную реализацию Service Locator с использованием статической структуры Map и переопределения factory-методов класса:

Map<Type, Object> services = {};

class DBConnection {
  DBConnection._();
  
  factory DBConnection() {
    if (!services.containsKey(DBConnection)) {
      services[DBConnection] = DBConnection._();
    }
    return services[DBConnection] as DBConnection;
  }
}

void main() {
  final db1 = DBConnection();
  final db2 = DBConnection();
  assert(db1.hashCode == db2.hashCode);    //объекты будут идентичны
}

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


Приходите на открытое занятие по теме «Паттерн проектирования — Декоратор», на котором рассмотрим основной принцип данного паттерна — добавление функциональности к существующему объекту. Поговорим о понятиях «декоратор», «адаптер» и «прокси». Изучим все сходства и различия. Регистрация по ссылке.

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


  1. MiT_73
    26.07.2022 08:14
    +1

    В примере с get/set похоже ошибка и должно быть так:

    class A {
      int x = 10;
    }
    
    class B extends A {
      @override
      int get x => 20;
    }
    
    void main() {
      final b = B();
      b.x = 30; // здесь не будет ошибки компиляции и присвоения тоже не будет
      print(b.x);
    }
    

    Использовать late в этом случае нельзя

    Спорный момент, а что если сделать так?

    class Cat {
      
      Cat() {
        print('I\'m constructed');
      }
      
      late final int age = (){
        print('I\'m born');
        return 0;
      }();
    }
    
    void main() {
      final cat = Cat();
      print(cat.age);
      print(cat.age);
    }
    


    1. dmitriizolotov Автор
      26.07.2022 08:45

      Согласен. С вашего разрешения могу добавить это как примеры в статью?


      1. MiT_73
        26.07.2022 09:22

        Да, конечно