В декабре у Flutter Dev Podcast вышел выпуск про синтаксический сахар. Мы обсуждали будущее Dart, новые фичи, которые были в планах. Выпуск оказался пророческим: всё, о чём мы говорили, окажется в мажорной третьей версии Dart.  Послушать выпуск Flutter Dev Podcast про синтаксический сахар >> Dart 3 – самое мажорное обновление языка со времён Null Safety: он изменит многое в том, как мы пишем код и какие библиотеки используем. Вы можете послушать подкаст, чтобы понять, что именно меняется в языке. Меня зовут Марк Абраменко, я Engineering Manager во Flutter-отделе Surf. Расскажу, как новые фичи помогут вам на практике и как от этого изменится способ взаимодействия с языком. Как часто вам приходилось объединять две непохожие структуры в один отдельный класс с именем вроде UserAndCar или CutletWithFlies только для того, чтобы вернуть их вместе из одной функции? Records пришла, чтобы исправить эту ситуацию.  Records – это простая связь нескольких типов в одну структуру данных. При этом объединяющая структура остаётся анонимной — то есть безымянной. Рассмотрим пример, где требуется вернуть сразу два значения из функции mixCutletsAndFlies: Для обычного возврата двух значений из функции требуется создавать целый отдельный класс, ещё и именовать его. Если всё происходит внутри класса и не выходит за его пределы, именование становится бессмысленным. Тем более, что в названии класса мы просто повторяем название смешанных классов. Если приходится возвращать три или четыре значения, имя класса становится совсем абсурдным: CutletAndFlyAndAppleAndOrange. Для этого случая есть вполне универсальное решение: пакет tuple от Google. Но теперь в нём не будет никакой необходимости, ведь в Dart 3 можно написать это так:  Вытащить значение из такой структуры можно таким способом:  Или более элегантным: Способ определения переменных из последнего примера называется деструктуризация. Он есть уже во многих других языках программирования. Например, в JavaScript можно деструктурировать не просто аналог Records, а полноценные именные структуры данных. Аналог также есть в Kotlin. А вот в языке Swift есть уже полноценный аналог Records – tuple (да-да, название точно такое же, как у библиотеки от Google). Передаём привет коллегам из React Native: в Dart теперь можно реализовать полноценный синтаксический аналог Hook. Во Flutter есть собственная реализация hook, которая, судя по всему, также перейдёт на новый синтаксис. Помимо прочего вы также можете указывать именованные параметры в record, и работает это примерно так же, как в конструкторе класса:  Небольшое изменение в системе типов Шансы получить каверзный вопрос на собеседовании увеличились вдвое, ведь теперь Record является супертипом Never и подтипом Object и dynamic. То есть тип Record ведёт себя примерно так же, как Function в системе типов Dart. Dart очень либеральный язык с точки зрения ограничений в использовании ООП. Многих, кто переходит на Dart с таких языков, как Kotlin и Swift, может удивить, например, отсутствие расширенных параметров доступа (private, public, protected) или то, что абсолютно любой класс можно превратить в интерфейс — это буквально стало визитной карточкой Dart. Всё это говорит не столько о гибкости Dart, сколько о вседозволенности, которая часто несовместима со «взрослой» продакшн-разработкой или разработкой крупных библиотек. Чтобы исправить это, в Dart добавили несколько нововведений для работы с классами. И там всё не так уж и просто, как может показаться на первый взгляд. Очень часто в Surf мы используем контракты — их ещё называют интерфейсы или протоколы. Обычно это выглядит как абстрактный класс без базовой реализации, которому мы ставим префикс I — Interface:  Фактически контракты исполняют роль header-файла в C. Это отдельная структура с описанием параметров класса, с которыми можно взаимодействовать «снаружи». Они скрывают реализацию и заставляют пользователя класса смотреть только на те компоненты, которые действительно необходимы. В других языках мобильной разработки — Kotlin и Swift — есть полноценные контракты. В Swift интерфейс именуется как protocol и вообще никак не связан с обычной структурой классов. Он представляет собой отдельную сущность со своим синтаксисом:  Также в Swift есть невероятная композиция протоколов, которую в Dart ожидаемо не завезли. Но не будем о грустном. В Kotlin для контрактов есть ключевое слово interface:  А помимо interface в Kotlin есть также абстрактные классы, которые работают примерно аналогично абстрактным классам в Dart. Итак, в Dart 3 тоже появляется ключевое слово interface, но оно не является самостоятельным. Это ключевое слово используется как модификатор к class. Конечно, глядя на Kotlin и Swift, можно предположить, что interface class теперь является полноценным контрактом. Но это не так: Пытаясь использовать interface class как абстрактный, получаем ошибку анализатора:  класс Contract не стал абстрактным после добавления interface. Дело в том, что interface является модификатором к классу, который говорит лишь о том, что за пределами текущего файла его можно только реализовать (implement) и нельзя унаследовать (extend). Более того, диссонанс может вызвать то, что для interface class вы можете сделать конструктор и инициализировать его: Трушные контракты действительно появились в Dart 3. Но в отличие от Kotlin и Swift недостаточно просто написать «interface». Дело в том, что модификаторы классов можно смешивать между собой. Смешав модификаторы abstract, запрещающий инстанциирование класса, и interface, запрещающий наследование, мы получаем полноценный трушный контракт. Поэтому контракты в Dart-коде будут выглядеть именно так: Теперь декларация классов напоминает «лаконичный» public static void main из Java. Интерфейс из предыдущего примера можно теперь ещё сделать и ненаследуемым с помощью модификатора final: Вы можете задать справедливый вопрос: «А каким образом сочетается ненаследуемость final и невозможность инстанциирования abstract?» Дело в том, что модификаторы в целом имеют смысл только за пределами текущей области видимости. Проще говоря, за пределами файла. Таким образом, вы сможете скомпилировать такой код:  Но если попытаться сделать это в другом файле, получите сразу две ошибки:  Добавление модификатора final решает проблему «fragile base class» в Dart. Это даёт дизайнерам архитектуры больше инструментов, чтобы обезопасить собственные реализации от непреднамеренных поломок при неправильном наследовании.  В языках, в которых уже присутствуют подобные ограничения, принято ставить final на все классы по умолчанию и открывать только те, которые действительно необходимо. В Kotlin, например, все классы закрыты от наследования по умолчанию. И открыть их можно, добавив ключевое слово open. В Dart за этим будет крайне тяжело следить, поскольку едва ли возможно создать такое правило линтера, которое будет регулярно напоминать о том, что всем классам требуется выставлять модификатор final. Для interface есть «обратная операция» в виде ключевого слова base. Если interface запрещает наследование и разрешает только имплементацию, то base – наоборот. Во-первых, не получится создать класс без модификатора, если наследоваться от base-класса: Во-вторых, за пределами файла не получится имплементить базовый класс: Можно официально объявить, что Dart перестал быть языком, в котором любой класс можно представить как абстрактный.  Изменения не обошли стороной и миксины. Если вы достаточно долго пишете на Dart, то знаете, что в официальном линтере от команды есть правило линтера, которое запрещает использовать обычные классы как mixin. И тут мы встречаем первое ломающее изменение в Dart 3. Обычные классы теперь нельзя примешивать! Забавно, но во Flutter существует класс WidgetsBindingObserver, который вполне официально в документации советуют использовать как mixin. Во Flutter 3.10 он превратился в abstract mixin class:  Собственно, это и есть главное отличие обычного mixin от class mixin. В отличие от обычного, класс можно наследовать и указывать конструктор внутри. Таким образом, mixin class получает главный приз как самый свободный класс в новой системе Dart 3: вы можете сделать с ним всё, что захотите. Сколько же всего возможных комбинаций модификаторов? Судя по таблице авторов самой фичи, — 15. Это не все возможные варианты записи класса, но все варианты, которые имеют смысл. Возможно, с новыми версиями мы увидим правила линтера, которые запрещают использовать несовместимые модификаторы, но пока что это делать можно. Обратите внимание, что обычный «старый» class стал фактически dynamic — если проводить аналогию с объявлением переменных в Dart. Это значит, что новая система модификаторов будет полностью совместима со старым кодом — за исключением mixin, которые мы разобрали выше. Например, новые abstract interface class можно будет унаследовать от обычных классов. Эта фича назревала в 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 можно вернуть сведения о результате, но нельзя приложить к нему дополнительные данные. Например, вернуть сообщение к success или добавить конкретный код ошибки к error. Тогда вы можете воспользоваться не enum, а обычными классами:  Это хороший способ, но у него есть несколько проблем:  На момент получения результата Result у нас, как у пользователя, нет никакой гарантии, что у Result лишь три наследника.  Анализатор тоже не знает о том, сколько наследников может быть у Result. Мы никак не можем обезопасить себя от появления нового наследника Result в момент компиляции. Можем проверить только в рантайме, выбросив исключение. В отличие от enum у нас нет удобного способа для того, чтобы пройтись по всем значениям. Единственный вариант – if.  Именно эти проблемы решает появление sealed class: Теперь за пределами файла, где был объявлен sealed-класс, невозможно будет добавить новых наследников. Именно это даёт явную гарантию, что у Result будет ограниченное число значений. При этом сам Result не может выступать в роли значения, поскольку sealed является неявно абстрактным.  Основное отличие switch по сравнению с обычным if-else – возможность пройтись по всем возможным значениям какого-либо объекта. Например, по всем значениям enum:  Возможно, для кого-то это будет откровением, но то же самое работает с boolean.  Это свойство bool-значений и enum называется полнотой, или exhaustiveness. Нововведением в Dart 3 является то, что помимо вышеописанных типов полнотой обладают, например, int или sealed-классы.  Сразу рассмотрим пример с sealed-классом Result, который я описал выше:  Во-первых, нам не нужно указывать default-значение, чтобы анализатор понял, что мы покрыли все значения. Во-вторых, можно увидеть, что теперь в switch можно работать со типами — раньше это можно было делать только с помощью конструкции if-else. Помимо прочего, в каждом case теперь можно «вытащить» значения класса: Предположим, нам нужно не вывести значение через print, а вернуть его из функции. Как бы мы сделали это раньше: Уже выглядит как достаточно лаконичная запись, но можно сделать её ещё лучше: Дело в том, что конструкция switch ранее была statement (утверждением), как, например, if-else или while. Её нельзя было вернуть из функции. Теперь конструкция switch может использоваться как expression (выражение), и её можно целиком записать после return. Вернёмся к «полноте» sealed-классов и главному отличию их использования по сравнению с abstract. Если мы заменим sealed-класс на использование abstract, то получим ошибку: Ошибка говорит о том, что анализатор понимает, что у abstract-класса Result может быть неограниченное число представлений. Поэтому требуется дописать здесь дефолтное значение, которое в новом написании будет выглядеть так:  Нет, речь не о паттернах проектирования, а о ещё одном расширенном синтаксисе, который касается не только switch, но и многих других аспектов языка. Паттерны, пожалуй, самое сложное для изучения нововведение Dart 3. В примере выше мы уже сталкивались с паттернами, когда «вытаскивали» значение из объекта в switch: Эта запись называется object pattern. Помимо разбора значений из объекта в switch, у него есть ещё одна интересная область применения: До этого мы уже говорили о деструктуризации при использовании records. В этом случае деструктурируем не record, а объект целиком, разделяя его на две разные переменные. Подобное есть, например, в Kotlin, но похожа деструктуризация больше на то, как в Dart 3 можно работать с record:  Предположим, что нам не важно знать конкретное значение ошибки, а нужно получить информацию о том, была ошибка или нет. Тогда запись Result выше можно записать в таком виде:  Внутри case мы также можем использовать логические операторы. Благодаря полноте анализатор также поймёт, что мы покрыли все значения sealed-класса в данном случае. Новые records тоже могут использоваться как паттерны. В главе про records я уже показывал, как можно инициализировать переменные с их помощью. Также records можно использовать внутри switch:  Как можно заметить, полнота работает и в случае с records. Dart понимает, что тут покрыты все возможные значения. Вот таким способом можно выбрать нужный цвет для элемента в UI: Конечно, нашлось место и для паттернов с null safety. ? после деструктурированной переменной обеспечивает то, что в кейс попадёт ненулевое значение. Конечно, можно и использовать паттерн !, чтобы сделать значение не-null принудительно: В таком случае Dart сообщит, что нижний case уже не имеет смысла и его можно удалить. В Dart появился guard clause, но пока что только для switch. Реализуется он с помощью ключевого слова when внутри case: В этой части кода мы убеждаемся, что возраст в модели Person не null, а также больше 60. В противном случае возвращаем false. Коллекции можно деструктурировать с помощью паттернов — так же, как всё остальное. Например, вытащить первые три значения из массива: Конечно же, это применимо и для Map: Когда вышел Dart 2, нам пришлось переводить проекты на Null Safety — это заняло много месяцев и отняло огромное количество человекочасов. Dart 2 был крайне важным и ломающим обновлением, но не так сильно поменял то, как мы используем Dart. С Dart 3 всё получается наоборот: он не заставит переписать весь проект, однако значительно изменит повседневную работу с ним.  И, безусловно, с каждым годом Dart становится всё более сложным языком для «быстрого входа». Ещё четыре года назад Dart был языком для DartPad: изменения очень показательные. На Flutter с каждым годом пишется всё больше серьёзных и больших проектов и изменения языка в сторону ограничений, и усложнения синтаксиса точно пойдут на пользу новым классным проектам. Больше полезного про Flutter — в телеграм-канале Surf Flutter Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf, а также проводим прямые эфиры. Присоединяйтесь!