Замечательная статья от Марьи Белэнджер о новых функциональных возможностях в dart 3 с ретроспективой на прежние ооп-возможности. Имейте ввиду, не взирая на метку "читается за 10 мин", это средней сложности материал с кучей новых dart-овских терминов. И если вы не знакомы с новыми штучками в Dart 3, такими как pattern matching, switch expressions, sealed class, то перенесите чтение в благоприятную обстановку с разделением экрана на две части (IDE|dartpad || статья) для проверки собственных гипотез ;)

Приятного чтения!


Marya Belanger

Marya (mar- like mars, -yuh like yuck :) ) — технический писатель Dart, чертовски заинтересованный в совершенствовании документации на dart.dev.

Сопоставление шаблонов (pattern matching) и исчерпывающие переключатели (exhaustive switches) объединяются для создания функциональных моделей данных, которые легко сочетаются с объектно-ориентированным ядром Dart.

Разница в рефакторинге Dart 3 с использованием функциональных стилей во внутренней кодовой базе Dart
Разница в рефакторинге Dart 3 с использованием функциональных стилей во внутренней кодовой базе Dart

Сегодня (Aug 16, 2023) мы выпускаем Dart 3.1, наш первый стабильный релиз с момента выхода основного релиза Dart 3.0 в мае. Dart 3.1 содержит несколько незначительных обновлений и несколько корректировок API для дальнейшего использования модификаторов классов, представленных в 3.0 (подробнее о которых вы можете прочитать в журнале изменений). В основном мы тратим время на новые пункты роадмэпа, которые, как мы надеемся, достигнут бета-версии и стабильного состояния в ближайших релизах. Оставайтесь с нами, чтобы узнать больше об этом в будущем!

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


Как вы моделируете данные?

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

Но вопрос "как мне смоделировать эти данные?" обычно не вызывает у нас особых раздумий, когда мы начинаем новый проект. Мы склонны по умолчанию выбирать ту парадигму моделирования данных, которая характерна для используемого языка, в отличие от обратного — выбора языка на основе модели, которая имеет наибольший смысл для наших данных.

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

Упрощенное сравнение объектно-ориентированной модели иерархии классов и функциональной алгебраической модели типов данных
Упрощенное сравнение объектно-ориентированной модели иерархии классов и функциональной алгебраической модели типов данных

Dart — объектно-ориентированный язык, но с течением времени в него постоянно добавляются функциональные возможности, что позволяет применять более мультипарадигменный подход к моделированию данных. Совсем недавно в Dart 3 были добавлены сопоставление шаблонов, новая функциональность для switch и sealed типы. Эти новшества позволяют реализовать в Dart алгебраические типы данных, что потенциально дает писать код в функциональном стиле и при этом максимально использовать возможности объектно-ориентированного ядра Dart.

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

Объектно-ориентированный подход

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

Возьмем этот (высокоуровневый псевдокод) пример моделирования рецептов. Имеет смысл иметь объекты рецепта, связанные ингредиенты и шаги вместе с рецептом. Базовый класс рецепта, вероятно, будет содержать некоторые функции для методов приготовления, которые каждый рецепт переопределяет в соответствии со своими уникальными требованиями:

abstract class Recipe {
  final int time;
  final int temp;
  final ingredients = [];
  
  void bake();
}

class Cake extends Recipe {
  Cake() : super({
    time: 40,
    temp: 325,
    ingredients: [Flour, Eggs, Milk];
  });

  @override  
  void bake() => time * temp;
}

class Cookies extends Recipe {
  Cookies() : super({
    time: 25,
    temp: 350,
    ingredients: [Flour, Butter, Sugar];
  });

  @override
  void bake() {
    (time / 2) * temp;
    rotate();
    (time / 2) * (temp - 15);
  }
}

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

Функциональный подход (алгебраические типы данных)

Архитектуру функционального стиля можно рассматривать как обратную сторону архитектуры OO. Вместо того чтобы хранить весь код для одного типа в одном месте (методы экземпляра OO в объявлениях подклассов), вы храните весь код для одной операции в одном месте (функциональное switch-сопоставление типов для определения поведения).

В связи с этим возникает вопрос, когда имеет смысл знать, как каждый подтип в иерархии определяет операцию? Это может быть вызвано несколькими причинами:

  • Добавлять, поддерживать и понимать варианты поведения одной и той же операции в разных типах проще, когда все они находятся рядом в коде.

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

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

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

abstract class Recipe {
  // only fields
}

class Cake extends Recipe {
  // only fields
}

class Cookies extends Recipe {
  // only fields
}

void bake(Recipe recipe) {
  time: recipe.time;
  temp: recipe.temp;
  
  if recipe is Cake: {
    time * temp;
  }
  if recipe is Cookies: {
    (time / 2) * temp;
    rotate();
    (time / 2) * (temp - 15);
  }
}

В этом примере структура программы сосредоточена на операции выпечки bake. Какими бы типами ни оперировал bake, это просто различные возможные результаты одной и той же функции; bake не зависит от типов, с которыми работает.

Это алгебраическая модель типов данных (названная "алгебраической" в честь математической теории множеств). Такая модель является основной организационной моделью функциональных языков, подобно тому, как иерархии классов являются основой ОО-языков. Алгебраические типы данных отделяют поведение от данных, группируя поведение для всех типов по операциям.

И теперь в Dart 3 можно реализовать алгебраические типы данных!

Моделирование объектно-ориентированных алгебраических типов данных

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

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

  • Сначала мы объясним, как сгруппировать варианты операции на основе типов, переключившись на шаблоны объектов.

  • Затем мы сделаем шаг назад и рассмотрим, как спроектировать сами подклассы с помощью нового модификатора sealed, чтобы убедиться, что switch определяет поведение для всех возможных подтипов объекта.

Группировка поведения по типам

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

class SomeLanguageElement extends LanguageElement {
  // every annotation in the language
  // every parser operation in the language
  // every formatter operation in the language
  // etc...
}

// Repeat x1000000 for everything that makes up a programming language!

По этой причине внутренний код Dart уже естественным образом склоняется к функциональному подходу, отделяя функции от определений типов. Возьмите библиотеку annotation_verifier в анализаторе Dart. Она содержит функции, определяющие поведение аннотаций (например, @overrideили @deprecated) в зависимости от того, к какой части кода прикреплена аннотация (например, как @override влияет на класс, а не на поле).

Но распределить поведение по типам не так просто, как принять решение о выделении поведения в отдельный тип. Стандартный способ определения поведения по типу использует цепочку операторов if-else, которую вы часто видите в верификаторе аннотаций. Возьмите следующую функцию проверки, написанную без использования каких-либо возможностей Dart 3. Она проверяет поведение недавно созданной аннотации @visibleOutsideTemplate , которая отказывается от каскадных эффектов другой аннотации, @visibleForTemplate:

void _checkVisibleOutsideTemplate(Annotation node) {
    var grandparent = node.parent.parent;
    if (grandparent is ClassDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    } else if (grandparent is EnumDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    } else if (grandparent is MixinDeclaration &&
        grandparent.declaredElement != null) {
      for (final annotation in grandparent.declaredElement!.metadata) {
        if (annotation.isVisibleForTemplate) return;
      }
    }
    // ...
  }

Функция использует сложные цепочки if-else, проверяя, является ли родительская аннотация декларацией определенного типа (ClassDeclaration, EnumDeclaration или MixinDeclaration), а затем определяя ее поведение в зависимости от типа.

В Dart 3 вы можете использовать объектные шаблоны в switch cases, чтобы значительно реорганизовать эту структуру в более декларативный стиль, делая ее короче и легче для чтения. И оригинальный автор именно так и поступил! 16 строк цепочек операторов if-else сводятся к 7 строкам оператора switch:

void _checkVisibleOutsideTemplate(Annotation node) {
    var grandparent = node.parent.parent;
    switch (grandparent) {
      case ClassDeclaration(declaredElement: InterfaceElement(:var metadata)):
      case EnumDeclaration(declaredElement: InterfaceElement(:var metadata)):
      case MixinDeclaration(declaredElement: InterfaceElement(:var metadata)):
        for (final annotation in metadata) {
          if (annotation.isVisibleForTemplate) return;
        }
    }

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

if (object is Type && object.property != null),

в каждом случае проверяется, соответствует ли шаблон объекта шаблону Type(propertyOfType). Кроме того, когда объект соответствует шаблону объекта, он неявно требует, чтобы он не был null, поэтому нет необходимости в явной проверке на null!

Шаблоны объектов также могут содержать вложенные шаблоны переменных, которые позволяют извлекать (или деструктурировать) значения свойств из объекта в той же строке кода, с которой выполняется сопоставление. Синтаксис (:var metadata) просто означает "сопоставить и объявить новую переменную с тем же именем, что и этот геттер". Так переменная metadata  попадает в область действия последнего цикла for. Довольно лаконично!

Обратите внимание, что цикл for теперь общий для каждого случая. Свойство declaredElement каждого типа на самом деле является подтипом другого типа, InterfaceElement (либо classElement, enumElement, либо mixinElement). Так, в предшествующем Dart 3 цепочке if-else нашаmetadata в каждом предложении if итерировалась отдельно, чтобы гарантировать, что final annotation будет безопасна для каждого из возможных типов metadata.

Теперь реорганизованная структура использует глубоко вложенные объектные шаблоны для каждого случая, чтобы передать metadata своему супертипу, InterfaceElement. Это делает один общий цикл for, итерирующий типы metadata, безопасным для всех случаев.

Аннотированное изображение синтаксиса для глубоко вложенных объектов и шаблонов переменных
Аннотированное изображение синтаксиса для глубоко вложенных объектов и шаблонов переменных

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

  • Объект является одним из типов ClassDeclaration, EnumDeclaration или MixinDeclaration.

  • У объекта есть свойство declaredElement.

  • declaredElement имеет свойство metadata.

  • metadataимеет тип InterfaceElement.

  • Ни один из объектов или свойств, о которых идет речь, не является null.

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

Проверки типов над объектными шаблонами отлично подходят для отделения поведения от типов. Но в нем отсутствует одна особенность OO-подтипов, когда компилятор сообщает вам, если вы объявляете новый подтип, но не определяете поведение для одного из абстрактных методов его супертипа. Как алгебраическая модель типов данных Dart может реализовать те же гарантии безопасности, если мы больше не имеем дело с методами экземпляра в объявлениях типов? Ответ — проверка полноты.

Проверка полноты

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

Это называется проверкой полноты (также я называю это "проверкой на исчерпываемость" — прим. пер.). Технически это всегда существовало в Dart для перечисляемых типов, таких как перечисления (enums) и логические значения (bool). У этих типов есть набор возможных значений, которые не могут меняться, и если вы переключаетесь между ними, компилятор знает, когда вы пропустили одно из них, и предупреждает вас об этом. Использование default — это еще один вид псевдо-полноты. Поскольку значение default соответствует всем случаям, не учтенным явно, оно заставляет компилятор считать переключатель исчерпывающим, не зная, все ли потенциальные типы учтены на самом деле.

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

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

Вот пример фактического рефакторинга, который был внесен в Dart SDK в рамках релиза 3.1: "запечатывание" FileSystemEvent (ссылка), чтобы его подтипы могли быть исчерпывающими. Приготовьтесь, рефакторинг — это ведь сложно…

- final class FileSystemEvent {
+ sealed class FileSystemEvent {

Шучу, это было совсем не сложно! Однако следует отметить, что запечатывание (sealing) существующей иерархии классов — это "разрушающее" изменение (breaking change). Код, ориентированный на старые версии Dart, не сможет реализовать или расширить этот класс, поэтому всегда проверяйте зависимости и предупреждайте пользователей, которые могут использовать подтипы ваших классов в других местах.

Запечатывание FileSystemEvent позволяет исчерпывающе переключать события, производимые FileSystemEntity.watch, которые соответствуют подтипам FileSystemEvent. Типичным является прослушивание этого потока событий и использование цепочки операторов if-else для определения действий в зависимости от типа происходящих событий.

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

(bool contentChanged, bool removed) _fileListener(FileSystemEntity directory) async {

  await for (final event in directory.watch()) {
    return switch (event) {
      FileSystemModifyEvent(contentChanged: final changed) => (changed, false),
      FileSystemCreateEvent() => (false, false),
      FileSystemDeleteEvent() => (false, true),
      FileSystemMoveEvent() => (false, false),
    };
  }
}

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

The type 'FileSystemEvent' is not exhaustively matched by the switch cases since it doesn't match 'FileSystemSyncEvent'

Сочетание sealed-классов и switch через шаблоны объектов позволяет реализовать в Dart полноценный объектно-ориентированный алгебраический тип данных в архитектуре программ.

Бонусные функциональные возможности

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

Обратите внимание, что switch находится справа от оператора возврата return функции _fileListener — это новое switch-выражение в Dart 3. Общий акцент на выражениях и функциях — ключевой элемент функциональных языков. В Dart 3 появились выражения-переключатели (switch expression), которые могут выдавать значение и переходить в любое место, где это выражение допустимо.

И вообще, что возвращает _fileListener в предыдущем примере? Это record (также называемая Запись — прим. пер.), еще одна новая функция Dart 3, также связанная с функциональным программированием. Записи позволяют возвращать несколько разнородных значений из функции, расширяя возможности функций в Dart и уходя от зависимости от пользовательских классов (которые были бы единственным способом вернуть несколько значений разных типов без потери их типов в процессе).

Заключение

Вы можете моделировать алгебраические типы данных в Dart следующим образом:

  • Написать функцию, которая включает экземпляр sealed-класса и его подтипы,

  • И определить отклонение в поведении каждого подтипа в каждом из switсh-случае.

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

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

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

Мы надеемся, что это введение пробудит у вас интерес к функциональному программированию и опробованию новых функций Dart 3. Кто знает, возможно, скоро мы увидим первую "полнофункциональную" Dart-программу от одного из вас!

Материалы

Чтобы узнать больше о функциональном программировании в Dart и за его пределами, посетите эти ресурсы:


Материал переведён Ruble — автором небольшого постжика, создателем приложенек и пакетов, комментатором на SO и просто болеющим за Flutter.

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


  1. 1nvader
    22.03.2024 12:13

    Классная статья, спасибо!


  1. Cryvage
    22.03.2024 12:13

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

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