В декабре у 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);
}
Это хороший способ, но у него есть несколько проблем:
На момент получения результата
Result
у нас, как у пользователя, нет никакой гарантии, что уResult
лишь три наследника.Анализатор тоже не знает о том, сколько наследников может быть у
Result
.Мы никак не можем обезопасить себя от появления нового наследника
Result
в момент компиляции. Можем проверить только в рантайме, выбросив исключение.В отличие от 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, а также проводим прямые эфиры. Присоединяйтесь!