Как стать автором
Обновить
70.31
Surf
Создаём веб- и мобильные приложения

Dart 3 на практике: что принесёт нам мажорное обновление языка

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров19K

В декабре у Flutter Dev Podcast вышел выпуск про синтаксический сахар. Мы обсуждали будущее Dart, новые фичи, которые были в планах. Выпуск оказался пророческим: всё, о чём мы говорили, окажется в мажорной третьей версии Dart. 

Послушать выпуск Flutter Dev Podcast про синтаксический сахар >>

Dart 3 – самое мажорное обновление языка со времён Null Safety: он изменит многое в том, как мы пишем код и какие библиотеки используем. Вы можете послушать подкаст, чтобы понять, что именно меняется в языке.

Меня зовут Марк Абраменко, я Engineering Manager во Flutter-отделе Surf. Расскажу, как новые фичи помогут вам на практике и как от этого изменится способ взаимодействия с языком.

Records, безымянные структуры данных

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

Records – это простая связь нескольких типов в одну структуру данных. При этом объединяющая структура остаётся анонимной — то есть безымянной. Рассмотрим пример, где требуется вернуть сразу два значения из функции mixCutletsAndFlies:

class Cutlet {}

class Fly {}

class CutletAndFly {
  final Cutlet cutlet;
  
  final Fly fly;
  
  const CutletAndFly(this.cutlet, this.fly);
}

CutletAndFly mixCutletsAndFlies() => CutletAndFly(Cutlet(), Fly());

Для обычного возврата двух значений из функции требуется создавать целый отдельный класс, ещё и именовать его. Если всё происходит внутри класса и не выходит за его пределы, именование становится бессмысленным. Тем более, что в названии класса мы просто повторяем название смешанных классов. Если приходится возвращать три или четыре значения, имя класса становится совсем абсурдным: CutletAndFlyAndAppleAndOrange.

Для этого случая есть вполне универсальное решение: пакет tuple от Google. Но теперь в нём не будет никакой необходимости, ведь в Dart 3 можно написать это так: 

(Cutlet, Fly) mixCutletsAndFlies() => (Cutlet(), Fly());

Вытащить значение из такой структуры можно таким способом: 

void main() {
  final cutletsAndFlies = mixCutletsAndFlies();
  
  final cutlet = cutletsAndFlies.$1;
  final fly = cutletsAndFlies.$2;
}

Или более элегантным:

void main() {
  /// Тут инициализируется 2 переменных: cutlet и fly
  final (cutlet, fly) = mixCutletsAndFlies();
}

Способ определения переменных из последнего примера называется деструктуризация. Он есть уже во многих других языках программирования. Например, в JavaScript можно деструктурировать не просто аналог Records, а полноценные именные структуры данных. Аналог также есть в Kotlin. А вот в языке Swift есть уже полноценный аналог Records – tuple (да-да, название точно такое же, как у библиотеки от Google).

Передаём привет коллегам из React Native: в Dart теперь можно реализовать полноценный синтаксический аналог Hook.

(T, void Function(T)) useState<T>(T state) {
 return (state, (T newState) => state = newState);
}

void main() {
 var (state, setState) = useState(0);
 print(state);
 setState(1);
 print(state);
}

Во Flutter есть собственная реализация hook, которая, судя по всему, также перейдёт на новый синтаксис.

Помимо прочего вы также можете указывать именованные параметры в record, и работает это примерно так же, как в конструкторе класса: 

(String, {double age}) getPerson() {
 return ("Mark", age: 24);
}

Небольшое изменение в системе типов

Шансы получить каверзный вопрос на собеседовании увеличились вдвое, ведь теперь Record является супертипом Never и подтипом Object и dynamic. То есть тип Record ведёт себя примерно так же, как Function в системе типов Dart.

Больше ограничений в ООП

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

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

Настоящие контракты

Очень часто в Surf мы используем контракты — их ещё называют интерфейсы или протоколы. Обычно это выглядит как абстрактный класс без базовой реализации, которому мы ставим префикс I — Interface: 

/// Интерфейс WM экрана «Меню куба».
abstract class ICubeMenuWm extends IWidgetModel {
 StateStreamable<AuthStatusState> get authStatusState;

 void onSideTap(int index);

 void onKeyPreesed();
}

Фактически контракты исполняют роль header-файла в C. Это отдельная структура с описанием параметров класса, с которыми можно взаимодействовать «снаружи». Они скрывают реализацию и заставляют пользователя класса смотреть только на те компоненты, которые действительно необходимы.

В других языках мобильной разработки — Kotlin и Swift — есть полноценные контракты. В Swift интерфейс именуется как protocol и вообще никак не связан с обычной структурой классов. Он представляет собой отдельную сущность со своим синтаксисом: 

protocol Person { 
  var name: String { get }
}

Также в Swift есть невероятная композиция протоколов, которую в Dart ожидаемо не завезли. Но не будем о грустном.

В Kotlin для контрактов есть ключевое слово interface

interface MyInterface {
    fun bar()
    fun foo() {
      // optional body
    }
}

А помимо interface в Kotlin есть также абстрактные классы, которые работают примерно аналогично абстрактным классам в Dart.

Итак, в Dart 3 тоже появляется ключевое слово interface, но оно не является самостоятельным. Это ключевое слово используется как модификатор к class. Конечно, глядя на Kotlin и Swift, можно предположить, что interface class теперь является полноценным контрактом. Но это не так:

interface class Contract {
 /// error: 'name' must have a method body because 'Contract' isn't abstract.
 String get name;
}

Пытаясь использовать interface class как абстрактный, получаем ошибку анализатора:  класс Contract не стал абстрактным после добавления interface. Дело в том, что interface является модификатором к классу, который говорит лишь о том, что за пределами текущего файла его можно только реализовать (implement) и нельзя унаследовать (extend).

Более того, диссонанс может вызвать то, что для interface class вы можете сделать конструктор и инициализировать его:

interface class Contract {
 final String name;
 Contract(this.name);
}

void test() {
 final contract = Contract('John Pork');
 print(contract.name); // print: John Pork
}

Трушные контракты действительно появились в Dart 3. Но в отличие от Kotlin и Swift недостаточно просто написать «interface». Дело в том, что модификаторы классов можно смешивать между собой. Смешав модификаторы abstract, запрещающий инстанциирование класса, и interface, запрещающий наследование, мы получаем полноценный трушный контракт. Поэтому контракты в Dart-коде будут выглядеть именно так:

abstract interface class Contract {
 abstract final String name;
 void foo();
}

«Конечная!»: модификатор final

Теперь декларация классов напоминает «лаконичный» public static void main из Java. Интерфейс из предыдущего примера можно теперь ещё сделать и ненаследуемым с помощью модификатора final:

abstract final interface class Contract {
 abstract final String name;
}

Вы можете задать справедливый вопрос: «А каким образом сочетается ненаследуемость final и невозможность инстанциирования abstract?» Дело в том, что модификаторы в целом имеют смысл только за пределами текущей области видимости. Проще говоря, за пределами файла.

Таким образом, вы сможете скомпилировать такой код:

abstract final interface class Contract {
 abstract final String name;
 void foo();
}

abstract final class ContractChild extends Contract {}

Но если попытаться сделать это в другом файле, получите сразу две ошибки: 

The class 'Contract' can't be extended outside of its library because it's an interface 
The class 'Contract' can't be extended outside of its library because it's a final class.

Добавление модификатора final решает проблему «fragile base class» в Dart. Это даёт дизайнерам архитектуры больше инструментов, чтобы обезопасить собственные реализации от непреднамеренных поломок при неправильном наследовании. 

В языках, в которых уже присутствуют подобные ограничения, принято ставить final на все классы по умолчанию и открывать только те, которые действительно необходимо. В Kotlin, например, все классы закрыты от наследования по умолчанию. И открыть их можно, добавив ключевое слово open.

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

Это база: инверсия interface 

Для interface есть «обратная операция» в виде ключевого слова base. Если interface запрещает наследование и разрешает только имплементацию, то base – наоборот.

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

// file lib_based.dart

base class Based {}

// file lib_cringe.dart

/// error: The type 'Cringe' must be 'base', 'final' or 'sealed' because the supertype 'Based' is 'base'.
class Cringe extends Based {}

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

// error: The class 'Based' can't be implemented outside of its library because it's a base class.
final class Cringe implements Based {}

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

Миксины: взболтать, но не смешивать

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

Забавно, но во Flutter существует класс WidgetsBindingObserver, который вполне официально в документации советуют использовать как mixin. Во Flutter 3.10 он превратился в abstract mixin class

abstract mixin class WidgetsBindingObserver {

Собственно, это и есть главное отличие обычного mixin от class mixin. В отличие от обычного, класс можно наследовать и указывать конструктор внутри. Таким образом, mixin class получает главный приз как самый свободный класс в новой системе Dart 3: вы можете сделать с ним всё, что захотите.

Комбинации модификаторов: учим новую таблицу умножения

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

Это не все возможные варианты записи класса, но все варианты, которые имеют смысл. Возможно, с новыми версиями мы увидим правила линтера, которые запрещают использовать несовместимые модификаторы, но пока что это делать можно.

Обратите внимание, что обычный «старый» class стал фактически dynamic — если проводить аналогию с объявлением переменных в Dart. Это значит, что новая система модификаторов будет полностью совместима со старым кодом — за исключением mixin, которые мы разобрали выше. Например, новые abstract interface class можно будет унаследовать от обычных классов.

Долгожданный sealed

Эта фича назревала в Dart годами. Я впервые попробовал Dart в 2019 и сразу подумал о двух вещах. Во-первых, где Null Safety (он пришёл в 2021)? Во-вторых, где sealed-классы и почему enum такой бесполезный?

В сообществе тоже есть достаточно серьёзный спрос на sealed-классы. Библиотека freezed, являясь очень стройной реализацией sealed-классов, которые там называются union, собрала 2700 лайков в pub.dev. Для сравнения — у самой популярной реализации bloc в сообществе 2200 лайков.  

Самое простое объяснение принципа работы sealed-классов: это смесь enum и обычного класса. Наследники sealed-класса так же, как и enum, имеют ограниченное количество значений. При этом значения – это потомки класса. В Dart 3 появилось ключевое слово sealed class.

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

enum Result { success, loading, error }

С помощью такого enum можно вернуть сведения о результате, но нельзя приложить к нему дополнительные данные. Например, вернуть сообщение к success или добавить конкретный код ошибки к error. Тогда вы можете воспользоваться не enum, а обычными классами: 

abstract class Result {}

class Success implements Result {
 final String message;
 Success(this.message);
}

class Loading implements Result {}

class Error implements Result {
 final Exception exception;
 Error(this.exception);
}

Это хороший способ, но у него есть несколько проблем: 

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

  2. Анализатор тоже не знает о том, сколько наследников может быть у Result.

  3. Мы никак не можем обезопасить себя от появления нового наследника Result в момент компиляции. Можем проверить только в рантайме, выбросив исключение.

  4. В отличие от enum у нас нет удобного способа для того, чтобы пройтись по всем значениям. Единственный вариант – if. 

Именно эти проблемы решает появление sealed class:

// Заменяем на sealed
sealed class Result {}

class Success implements Result {
 final String message;
 Success(this.message);
}

class Loading implements Result {}

class Error implements Result {
 final Exception exception;
 Error(this.exception);
}

Теперь за пределами файла, где был объявлен sealed-класс, невозможно будет добавить новых наследников. Именно это даёт явную гарантию, что у Result будет ограниченное число значений. При этом сам Result не может выступать в роли значения, поскольку sealed является неявно абстрактным.

Новый switch и «полнота»

Основное отличие switch по сравнению с обычным if-else – возможность пройтись по всем возможным значениям какого-либо объекта. Например, по всем значениям enum: 

switch (result) {
     case Result.success:
       print('success');
       break;
     case Result.loading:
       print('loading');
       break;
     case Result.error:
       print('error');
       break;
   }

Возможно, для кого-то это будет откровением, но то же самое работает с boolean. 

Это свойство bool-значений и enum называется полнотой, или exhaustiveness. Нововведением в Dart 3 является то, что помимо вышеописанных типов полнотой обладают, например, int или sealed-классы. 

Сразу рассмотрим пример с sealed-классом Result, который я описал выше:

sealed class Result {}

void test(Result result) {
 switch (result) {
   case Success():
     print('Success');
     break;
   case Loading():
     print('Loading');
     break;
   case Error():
     print('Error');
     break;
 }
}

Во-первых, нам не нужно указывать default-значение, чтобы анализатор понял, что мы покрыли все значения. Во-вторых, можно увидеть, что теперь в switch можно работать со типами — раньше это можно было делать только с помощью конструкции if-else. Помимо прочего, в каждом case теперь можно «вытащить» значения класса:

void test(Result result) {
 switch (result) {
   case Success(message: var message):
     print(message);
     break;
   case Loading():
     print('Loading');
     break;
   case Error(exception: var exception):
     print(exception);
     break;
 }
}

Предположим, нам нужно не вывести значение через print, а вернуть его из функции. Как бы мы сделали это раньше:

String getStringResult(Result result) {
 switch (result) {
   case Success(message: var message):
     return message;
   case Loading():
     return 'Loading';
   case Error(exception: var exception):
     return exception.toString();
 }
}

Уже выглядит как достаточно лаконичная запись, но можно сделать её ещё лучше:

String getResultInString(Result result) => switch (result) {
     Success success => success.message,
     Loading _ => 'Loading',
     Error error => error.exception.toString(),
   };

Дело в том, что конструкция switch ранее была statement (утверждением), как, например, if-else или while. Её нельзя было вернуть из функции. Теперь конструкция switch может использоваться как expression (выражение), и её можно целиком записать после return.

Вернёмся к «полноте» sealed-классов и главному отличию их использования по сравнению с abstract. Если мы заменим sealed-класс на использование abstract, то получим ошибку:

// Заменяем на abstract
abstract class Result {}

// error: The type 'Result' is not exhaustively matched by the switch cases since it doesn't match 'Result()'.
String getResultInString(Result result) => switch (result) {
     Success success => success.message,
     Loading _ => 'Loading',
     Error error => error.exception.toString(),
   };

Ошибка говорит о том, что анализатор понимает, что у abstract-класса Result может быть неограниченное число представлений. Поэтому требуется дописать здесь дефолтное значение, которое в новом написании будет выглядеть так:

_ => 'Default',

Паттерны

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

Объекты

В примере выше мы уже сталкивались с паттернами, когда «вытаскивали» значение из объекта в switch:

case Success(message: var message):

Эта запись называется object pattern. Помимо разбора значений из объекта в switch, у него есть ещё одна интересная область применения:

void handlePerson(Person person) {
 var Person(name: name, age: age) = person;
 print(name);
 print(age);
}

До этого мы уже говорили о деструктуризации при использовании records. В этом случае деструктурируем не record, а объект целиком, разделяя его на две разные переменные.

Подобное есть, например, в Kotlin, но похожа деструктуризация больше на то, как в Dart 3 можно работать с record:

val person = Person("Jon Snow", 20)
val (name, age) = person

Логические операторы

Предположим, что нам не важно знать конкретное значение ошибки, а нужно получить информацию о том, была ошибка или нет. Тогда запись Result выше можно записать в таком виде:

bool isError(Result result) => switch (result) {
     Success() || Loading() => false,
     Error _ => true,
   };

Внутри case мы также можем использовать логические операторы. Благодаря полноте анализатор также поймёт, что мы покрыли все значения sealed-класса в данном случае.

Records

Новые records тоже могут использоваться как паттерны. В главе про records я уже показывал, как можно инициализировать переменные с их помощью. Также records можно использовать внутри switch: 

String describeBools(bool b1, bool b2) => switch ((b1, b2)) {
     (true, true) => 'both true',
     (false, false) => 'both false',
     (true, false) || (false, true) => 'one of each',
   };

Как можно заметить, полнота работает и в случае с records. Dart понимает, что тут покрыты все возможные значения.

Вот таким способом можно выбрать нужный цвет для элемента в UI:

final color = switch ((isSelected, isActive)) {
      (true, true) => Colors.white,
      (false, true) => Colors.red,
      _ => Colors.black
  };

Null Safety

Конечно, нашлось место и для паттернов с null safety. ? после деструктурированной переменной обеспечивает то, что в кейс попадёт ненулевое значение.

class Person {
 final String? name;
 final int? age;
 Person(this.name, this.age);
}

(String, int) getPersonInfo(Person person) => switch (person) {
     Person(name: var name?, age: var age?) => (name, age),
     Person(name: var name, age: var age) => (name ?? 'Unkown', age ?? 0),
   };

Конечно, можно и использовать паттерн !, чтобы сделать значение не-null принудительно:

(String, int) getPersonInfo(Person person) => switch (person) {
     Person(name: var name!, age: var age!) => (name, age),
     // warning: Dead code
     // warning: This case is covered by the previous cases.
     Person(name: var name, age: var age) => (name ?? 'Unknown', age ?? 0),
   };

В таком случае Dart сообщит, что нижний case уже не имеет смысла и его можно удалить.

Guard clause

В Dart появился guard clause, но пока что только для switch. Реализуется он с помощью ключевого слова when внутри case:

final isOldEnough = switch (person) {
   Person(age: var age?) when age > 60 => true,
   _ => false,
 };

В этой части кода мы убеждаемся, что возраст в модели Person не null, а также больше 60. В противном случае возвращаем false.

Коллекции

Коллекции можно деструктурировать с помощью паттернов — так же, как всё остальное. Например, вытащить первые три значения из массива:

var list = [1, 2, 3, 4];
var [a, b, c] = list;

Конечно же, это применимо и для Map:

final map = {'a': 1, 'b': 2};
 var {'a': int first, 'b': int second} = map;

Dart 3 не является ломающим обновлением, но серьёзно поменяет то, как мы используем его каждый день

Когда вышел Dart 2, нам пришлось переводить проекты на Null Safety — это заняло много месяцев и отняло огромное количество человекочасов. Dart 2 был крайне важным и ломающим обновлением, но не так сильно поменял то, как мы используем Dart. С Dart 3 всё получается наоборот: он не заставит переписать весь проект, однако значительно изменит повседневную работу с ним. 

И, безусловно, с каждым годом Dart становится всё более сложным языком для «быстрого входа». Ещё четыре года назад Dart был языком для DartPad: изменения очень показательные. На Flutter с каждым годом пишется всё больше серьёзных и больших проектов и изменения языка в сторону ограничений, и усложнения синтаксиса точно пойдут на пользу новым классным проектам.

Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!

Теги:
Хабы:
+19
Комментарии25

Публикации

Информация

Сайт
surf.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия