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

Кто мы и о чем эта статья

Привет! На связи Георгий Саватьков, Development Lead в продуктовой команде бренда Атом, и сегодня я бы хотел поговорить о Patterns и Records , а так же поделиться опытом их использования в работе.

Чтобы не потеряться в тексте, все подзаголовки расставлены в таком порядке:

  1. Record: определение и применение;

  2. Record, первый пример: убираем ненужный класс-обертку;

  3. Record, второй пример: используем «неоднородность»;

  4. Pattern: определение;

  5. Pattern в объявлении или присвоении значения переменной;

  6. Pattern в for-циклах;

  7. Pattern в управляющих конструкциях;

  8. Guard clause;

  9. Exhaustiveness checking;

  10. Где применять Pattern?

  11. Пара слов напоследок

1. Record

Обратившись к официальной документации, мы узнаем определение Record -a:

Records are an anonymous, immutable, aggregate type. Like other collection types, they let you bundle multiple objects into a single object. Unlike other collection types, records are fixed-sized, heterogeneous, and typed.

Переведем:

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

Здесь самые важные слова — это immutable (иммутабельные) и heterogeneous (неоднородные). Запомним эти две (а еще лучше все) характеристики Record -ов, они нам еще пригодятся дальше.

Рассмотрим предлагаемое описание далее:

Records are real values; you can store them in variables, nest them, pass them to and from functions, and store them in data structures such as lists, maps, and sets.

Переведем:

Record -ы ведут себя как обычные значения; их можно хранить в переменных, иметь множественные уровни вложенности, передаваться из и в функции, и храниться в таких структурах данных как списки, мапы (то есть ассоциативные массивы) и сеты.

Тут, вроде бы, и так все понятно, однако сразу можно отметить, как нам предлагается использовать их, в частности pass them to and from functions будет одним из мощных инструментов, которые мы рассмотрим в этой статье.

Итак, попробуем написать свой Record:

final myCoolRecord = (name: 'ATOM', isDartCool: true, 2024);  

Похоже на кортежи (если вы пробовали языки типа Python), не так ли? Однако, кортежи не могут содержать именованных аргументов.

Особенно хорошо на этом примере ощущается характеристика heterogeneous (неоднородные): у нас не просто разные типы данных, но помимо этого еще используются как именованные, так и позиционные аргументы.

Советовать так смешивать типы аргументов — скорее плохой совет; но знать, что это возможно, всегда полезно.

Динамическая типизация, когда она применяется с умом — всегда приятно и хорошо. Но что, если мы хотим явно указать тип?

Мы это можем сделать как с позиционными аргументами:

final (String, bool, int) myCoolRecord = ('ATOM', true, 2024); 

К слову, в такой аннотации можно давать имена аргументам, однако разница будет только визуальная: при инициализации, все еще придется писать как позиционные аргументы:

final (String name, bool isDartCool, int currentYear) myCoolRecord = ('ATOM', true, 2024); 

Так и с именованными:

final ({String name, bool isDartCool, int currentYear}) myCoolRecord = (name: 'ATOM', isDartCool: true, currentYear: 2024); 

Однако, нужно отметить очень важный момент:

The names of named fields in a record type are part of the record’s type definition, or its shape. Two records with named fields with different names have different types.

Переведем:

Именованные аргументы в типе Record-а являются частью его определения. Если два Record-а, оба с именованными аргументами, имеют разные имена этих аргументов, то такие Record-ы имеют разные типы.

Рассмотрим на примере:

final ({String name}) myCoolRecordA = (name: 'ATOM'); 

final ({String title}) myCoolRecordB = (title: 'ATOM'); 

print(myCoolRecordA == myCoolRecordB); // false 

Причем IDE даже подскажет, в чем причина:

The type of the right operand ('({String title})') isn't a subtype or a supertype of the left operand ('({String name})')

Момент не слишком сложный, однако важный для понимания. С позиционными аргументами такой ситуации нет:

final (String,) myCoolRecordA = ('ATOM',);  
final (String,) myCoolRecordB = ('ATOM',);  
final (String name,) myCoolRecordC = ('ATOM',);  

  
print(myCoolRecordA == myCoolRecordB); // true
print(myCoolRecordB == myCoolRecordC); // true

Наличие trailing comma при одном позиционном аргументе регламентируется стандартом 1 и стандартом 2

Record-ы сами определяют hashCode и свое равенство, исходя из своих аргументов.

2. Record, первый пример: убираем ненужный класс-обертку

Рассмотрим несколько примеров использования Record-ов из реальной жизни (то есть чуть-чуть интереснее, чем в официальной документации).

Зачастую, нам приходится работать с Stream-ами и им подобными. Рассмотрим на примере Rx.combileLatest(...):

final nameSubject = BehaviorSubject<String>.seeded('ATOM');  
final isDartCoolSubject = BehaviorSubject<bool>.seeded(true);  
final yearSubject = BehaviorSubject<int>.seeded(2024);

Для упрощения этого примера, используется пакет rxdart

И где-то мы хотим сделать .combineLatest3(...):

final result = Rx.combineLatest3(
    nameSubject,
    isDartCoolSubject,
    yearSubject,
    (name, isDartCool, year) => null,
);

Классически, до Dart 3, мы бы вернули экземпляр какого-то объекта, скажем, MyCoolData:

final class MyCoolData {
  MyCoolData({
    required this.name,
    required this.isDartCool,
    required this.year,
  });

  final String name;
  final bool isDartCool;
  final int year;
}

В результате, у нас получится следующая конструкция:

void main(List<String> arguments) async {
  final BehaviorSubject<String> nameSubject = BehaviorSubject.seeded('ATOM');
  final BehaviorSubject<bool> isDartCoolSubject = BehaviorSubject.seeded(true);
  final BehaviorSubject<int> yearSubject = BehaviorSubject.seeded(2024);

  final result = Rx.combineLatest3(
    nameSubject,
    isDartCoolSubject,
    yearSubject,
    (name, isDartCool, year) => MyCoolData(
      name: name,
      isDartCool: isDartCool,
      year: year,
    ),
  );
  final resultSubscription = result.listen(
    (event) {
      print(
        'Name: ${event.name}\n'
        'Is dart cool: ${event.isDartCool}\n'
        'Year: ${event.year}',
      );
    },
  );
  ...
}

И все бы ничего, но мы создали класс, который совершенно бесполезен за пределами области видимости подписки на этот результирующий Stream.

Можем ли мы использовать Record вместо него? Конечно можем, причем просто удалив символы имени класса в лямбде комбайнера:

...
 final result = Rx.combineLatest3(
    nameSubject,
    isDartCoolSubject,
    yearSubject,
    (name, isDartCool, year) => (
      name: name,
      isDartCool: isDartCool,
      year: year,
    ),
  );
  final resultSubscription = result.listen(
    (event) {
      print(
        'Name: ${event.name}\n'
        'Is dart cool: ${event.isDartCool}\n'
        'Year: ${event.year}',
      );
    },
  );
...

Ввиду того, что мы использовали именованные аргументы для Record-a, мы все еще можем обращаться к ним по, собственно, именам.

Как результат, класс-обертка нам больше не нужен, а читаемости код совсем не потерял.

3. Record, второй пример: используем «неоднородность»

Рассмотрим чуть более интересный пример. Предположим, что у нас есть 3 Future, которые возвращают разные типы:

Future<String> _getName() async {  
	await Future<void>.delayed(const Duration(milliseconds: 300));  
	return 'ATOM';  
}  
  
Future<bool> _getIsDartCool() async {  
	await Future<void>.delayed(const Duration(milliseconds: 210));  
	return true;  
}  
  
Future<int> _getYear() async {  
	await Future<void>.delayed(const Duration(milliseconds: 4500));  
	return 2024;  
}

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

Вспомнив одну из основных характеристик Record-ов - heterogeneous - мы можем написать следующее:

import 'dart:async';

void main(List<String> arguments) async {
  try {
	// В реальности, тут было бы просто `final result`
    final (String, bool, int) result = await (_getName(), _getIsDartCool(), _getYear()).wait;
    var name = result.$1;
    var isDartCool = result.$2;
    var year = result.$3;

    print(
      'Name: $name\n'
      'Is dart cool: $isDartCool\n'
      'Year: $year',
    );
  } on ParallelWaitError<(String?, bool?, int?), (AsyncError?, AsyncError?, AsyncError?)> catch (e) {
    // Мы все еще имеем доступ к успешно завершившимся Future
    print('Name value: ${e.values.$1}');
    print('Is Dart cool value: ${e.values.$2}');
    print('Year value: ${e.values.$3}');

    // И к, собственно, ошибкам
    print('Name error: ${e.errors.$1}');
    print('Is Dart cool error: ${e.errors.$2}');
    print('Year error: ${e.errors.$3}');
  }
}

Класс ParallelWaitError позволяет нам, с помощью двух Record-ов, указать что мы ожидаем на выходе данных (в нашем случае (String?, bool?, int?)) и на выходе ошибок (в нашем случае (AsyncError?, AsyncError?, AsyncError?)). Nullable все аргументы ввиду того, что часть могла завершиться успешно, а часть выдать ошибку – ровно то, что мы хотели!

result.$1, result.$2, result.$3 - это синтаксис деструктуризации Record-а.

Мы положили результат await-а наших фич в Record, как раз таки воспользовавшись той самой его характеристикой – "неоднородностью".

В случае, если какая-то из ожидаемых Future действительно сделает throw:

Future<bool> _getIsDartCool() async {
  throw AsyncError('My cool description', StackTrace.current);
}

то мы получим как данные с успешно завершившихся, так и ошибки с упавших Future:

Name value: ATOM
Is Dart cool value: null
Year value: 2024
Name error: null
Is Dart cool error: My cool description
Year error: null

Нужно отметить, что код выше можно сделать еще понятнее и более читаемым:

...
try {
    (String, bool, int) result = await (_getName(), _getIsDartCool(), _getYear()).wait;
    // От такого
    // var name = result.$1;
    // var isDartCool = result.$2;
    // var year = result.$3;

	// К такому!
    final (name, isDartCool, year) = result;

    print(
       'Name: $name\n'
       'Is dart cool: $isDartCool\n' 
       'Year: $year',
    );
}
...

Но для этого нам нужно познакомиться с еще одним нововведением в Dart 3: Pattern

4. Pattern: определение

Если обратиться к официальному определению Pattern-a, то мы увидим следующее:

A pattern represents the shape of a set of values that it may match against actual values.

Переведем:

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

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

На Pattern возлагается две основные задачи, причем как "и", так и "или":

  1. Matching - это понятие, когда template Pattern-a и предлагаемый ему объект действительно сошлись (то есть Matching был успешен)

  2. Destructuring - это понятие, когда Matching был успешен, и Pattern-у становятся "доступны" данные внутри этого объекта.

5. Pattern в объявлении или присвоении значения переменной

В объявлении переменной, pattern variable declaration сопоставляется с правой частью выражения, и, если успешно, то деструктурирует значения в новые переменные:

var (String name, bool isDartCool, int year) = ('ATOM', true, 2024);

А в чем разница с подобным примером Record-а?

Да, на первый взгляд конструкции почти идентичные. Разница в том, что Record - это тип (который мы присвоили переменной myCoolRecord), а Pattern - это выражение.

В присвоении значения переменной, variable assignment pattern сопоставляется с левой частью выражения, и, если успешно, то деструктурирует значения в уже существующие переменные:

...
// Скопируем с прошлого примера
var (String name, bool isDartCool, int year) = ('ATOM', true, 2024);

(name, _, _) = ('ATOM 2', isDartCool, year);
(name, _, year) = ('Atom', isDartCool, 2025);
(name, isDartCool, year) = ('Atom', true, 2025);

print(
  'Name: $name\n'
  'Is Dart cool: $isDartCool\n'
  'Year: $year',
); 
/** Prints  
* Name: Atom  
* Is Dart cool: true  
* Year: 2025 
**/
...

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

...
(name, isDartCool, year) = ('Atom', true, '2024');
...

Сообщит нам, что:

The matched value of type 'String' isn't assignable to the required type 'int'. 
Try changing the required type of the pattern, or the matched value type.

Обратите внимание, что именем переменной можно пренебречь, как обычно, через использование _.

6. Pattern в for циклах

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

Предположим, к примеру, что у нас есть Map<String, int>, которая содержит результаты спортивного матча. Ключ - имя команды, значение - итоговый счет.

final gameScores = <String, int>{  
	'team a': 85,  
	'team b': 100,  
};  

Давайте применим Pattern так, чтобы максимально красиво вывести результаты этого матча:

for (final MapEntry(key: teamName, value: teamScore) in gameScores.entries) {  
	print('Team $teamName has score of $teamScore');  
}

Разберем что происходит:

  1. Object Pattern проверяет является ли итерируемый элемент gameScores.entries типом MapEntry

  2. Вызывает getterkey, value у экземпляра MapEntry, и кладет их в локальные (в скоупе цикла) переменные teamName и teamScore соответственно

Если имя таких getter-ов (как key и value в данном случае) нас устраивает как имя локальной переменной, мы можем сократить запись, использовав синтаксис :name_of_the_getter:

for (final MapEntry(:key, value: teamScore) in gameScores.entries) {  
	print('Team $key has score of $teamScore');  
}

7. Pattern в управляющих конструкциях

А именно в констркуиях switch-case и if.

Тут стоит начать с того, что в принципе в Dart 3 появился новый способ "взаимодействия" с switch-case: switch expression.

Если коротко, то теперь результат switсh-a можно присваивать к переменной и возвращать из метода, ну в общем взаимодействовать с ним как с обычным expression:

const score = 100;  
final myCoolResult = switch (score) {  
	100 => 'Ok',  
	_ => 'Not ok',  
};  
  
print(myCoolResult); // Ok

_ в данном случае обозначает "в остальных случаях".

const dynamic score = 100;  
final myCoolResult = switch (score) {  
	int() => 'This is an int',  
	double() => 'This is a double',  
	Object() => 'This is an Object',  
	_ => 'This is something else'  
};
print(myCoolResult); // This is an int

Однако и на этом синтаксический сахар не заканчивается! Нужно познакомиться еще с двумя понятиями: Guard clause и Exhaustiveness checking.

8. Guard clause

Помимо самого "условия case-а", можно вводить дополнительные ограничения на него, используя ключевое слово when:

Помимо switch expression, Guard clause так же поддерживается для swicth statement и if case

const dynamic score = 100;  
final myCoolResult = switch (score) {  
  int() when score >= 100 => 'This is an int greater than or equal to 100',  
  int() => 'This is an int less than 100',  
  double() when score % 2 == 0 => 'This is an even double',  
  _ => 'This is something else. Might be a String or a List of objects'  
};  
  
print(myCoolResult); // This is an int greater than or equal to 100

Условия Guard clause-ов можно конкатенировать, как обычно: логическим или: ||, а так же и: &&.

Так же важно отметить, что у нескольких swith statement-ов могут быть под одним Guard clause-ом.

9. Exhaustiveness checking

Внимательный читатель отметит, что в примере выше используется немного странная конструкция const dynamic score..., и сделано это было специально.

Дело в том, что Dart - умный язык, и сам понимает, если значение может "войти" в switch, однако не обработаться там.

В прямом исполнении это избавляет нас от возможности "забыть обработать вариант" скажем, enum-a или sealed class-а, выдавая Compile Time Error; что делает наш код более предсказуемым. Более детально об этом мы обязательно поговорим в рамках другой статьи.

Однако же, это работает и в обратном направлении: если убрать dynamic, то анализатор, совершенно справедливо, скажет Dead code. И это действительно так, ввиду динамической типизации и понимания контекста.

То есть, если рассматривать пример выше, мы совершенно смело можем переписать его так:

const score = 100;  
final myCoolResult = switch (score) {  
  int() when score >= 100 => 'This is an int greater than or equal to 100',  
  int() => 'This is an int less than 100',  
};  
print(myCoolResult); // This is an int greater than or equal to 100

Нам больше не нужны case-ы ни для double (так как тип у нас не int), ни для _ (в остальных случаях, которых тоже нет), так как Dart знает, что таких вариантов не может быть, ввиду того, что понимает тип данных динамически типизированной переменной score.

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

10. Где применять Pattern?

— В сочетании с Record -ами. Это позволяет красиво, в одну строку, создать локальные переменные и присвоить деструктуризированное значение.

— Деструктуризация экземпляров классов.

— Алгебраические типы данных. Ну, то есть красивое название для sealed-классов.

— Валидация данных (в частности, JSON).

11. Пара слов напоследок

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

Серьезных минусов у новых понятий нет. Особенно, если не пытаться переложить на Pattern-ы задачи Regex-a.

Напоследок отметим, что команда Dart уже давно обсуждает введение argument spread-а, который, технически, является частным случаем Record-ов. С его приходом значительно сократится количество «мусорного» кода для написания виджетов. Но об этом я расскажу в другом материале!

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


  1. ChessMax
    12.09.2024 09:52
    +2

    Динамическая типизация, когда она применяется с умом — всегда приятно и хорошо.

    В приведенном примере нет никакой динамической типизации. Рекорды в дарт строго типизированы. Если не указать тип, то компилятор выводит его автоматически. Это называется type inference.

    Rx.combileLatest(...)

    Видимо имелось ввиду Rx.combineLatest(...)

    Однако и на этом синтаксический сахар не заканчивается! Нужно познакомиться еще с двумя понятиями: Guard clause и Exhaustiveness checking.

    Exhaustiveness checking - это не синтаксический сахар. Синтаксический сахар - это когда, что-то можно сделать более удобно, чем раньше, при этом старый способ все так же работает. Exhaustiveness checking можно сделать только с помощью switch, никаких других вариантов нет.

    Однако же, это работает и в обратном направлении: если убрать dynamic, то анализатор, совершенно справедливо, скажет Dead code. И это действительно так, ввиду динамической типизации и понимания контекста.

    Если мы убрали dynamic, то у нас нет никакой динамической типизации, как уже выше рассмотрели.

    — Алгебраические типы данных. Ну, то есть красивое название для sealed-классов.

    Алгебраические типы данных это не только sealed классы.

    Да, на первый взгляд конструкции почти идентичные. Разница в том, что Record - это тип (который мы присвоили переменной myCoolRecord), а Pattern - это выражение.

    Переменной нельзя присвоить тип, только значение. Pattern - это не выражение, так как его нельзя сохранить в переменной.


  1. FantasyOR
    12.09.2024 09:52

    Странный пример, неужели так сложно через точку обратиться к полю (key/value), что нужно впустую тратить ресурсы на создание и уничтожение переменных?

    for (final MapEntry(:key, value: teamScore) in gameScores.entries) { print('Team $key has score of $teamScore'); }