Запись представляет собой анонимную неизменяемую группу значений фиксированного размера, например (200, 'ok') или (name: 'Ada', age: 36). Её тип определяется формой: числом полей, их именами и типами, поэтому (int, String) нигде не нужно объявлять.
Позиционные поля: читаются через $1, $2, …; порядок входит в тип, поэтому (int, String) и (String, int) считаются разными типами
Именованные поля: читаются по имени; их порядок не важен, у (x: 1, y: 2) тот же тип, что у (y: 2, x: 1)
Имена в позиционном типе служат только документацией: в (int code, String body) геттер по-прежнему $1, а не .code
Частая ошибка: (1) даёт просто число 1; запись из одного поля требует завершающей запятой: (1,)
FlutterMiddle
Паттерны и записи в Dart 3
Записи, деструктуризация, switch-выражения, if-case, sealed-типы, исчерпывающий разбор
Закрыто вопросов
0 / 19
- 0Повторить
- 4Часто спрашивают
Вопросы
Слева пишется паттерн в форме записи: final (code, body) = fetch(); для позиционных полей, final (:name, :age) = user; для именованных. Те же паттерны работают в присваиваниях, циклах for-in, switch и if-case.
**Сокращение :name**: (:name) значит (name: name): переменная с именем поля; (name: who) позволяет выбрать другое имя
**_ пропускает поле**: final (_, body) = fetch(); связывает только body
Присваивание через паттерн: (a, b) = (b, a); меняет местами две существующие переменные без временной; слева могут стоять только локальные переменные и параметры, а не поля или list[0]
Объявление обязано совпасть всегда: компилятор проверяет форму, поэтому final (int a, String b) = someObject; не скомпилируется; если совпадение может не случиться, нужен if-case или switch
switch-оператор выполняет код подходящей ветки, а switch-выражение вычисляется в значение, поэтому его можно присвоить, вернуть или передать аргументом. Оба сопоставляют паттерны, и оба проверяются на исчерпываемость для enum и sealed-типов.
Синтаксис выражения: паттерн => значение через запятую: без case, без break, а ветку «всё остальное» обозначает _, а не default
Выражение обязано покрыть любой вход: ему нужно вернуть значение, поэтому switch по int или String требует ветки _
Оператор изменился в Dart 3: непустой case больше не требует break и никогда не проваливается дальше; пустые case по-прежнему делят следующее тело
Частая ошибка: switch-выражение нельзя оставить отдельным оператором: switch (x) { 1 => "a", _ => "b" }; разбирается как switch-оператор и не компилируется, поэтому его нужно присвоить или вернуть
Код выведет true, true, false, Hello, null. == и hashCode записи строятся из её полей, поэтому записи с равными полями равны, но лишь настолько, насколько корректен == каждого поля, а List сравнивается по ссылке.
**Второй print: порядок именованных полей не входит в форму, поэтому записи равны
Третий print**: (int, int) и ({int x, int y}) имеют разные формы: они никогда не равны, и это не ошибка компиляции
**cache находит значение**: новый экземпляр записи с равными полями находит запись в мапе, так что записи дают составные ключи Map без ручного hashCode
**byTags промахивается**: два литерала ['a'] создают разные объекты, а [1] == [1] в Dart даёт false, поэтому записи различаются
Как исправить: все поля должны сравниваться по значению: String, число, enum или неизменяемый класс с == и hashCode; и нельзя менять объект, который уже входит в ключ мапы
Запись подходит для небольшой короткоживущей группы значений, обычно двух-трёх результатов приватной функции: она полностью типизирована, как класс, и не требует объявления. Это неправильный выбор, когда у данных появляется имя в предметной области, правила или поведение.
**Против Map: форма видна в сигнатуре, поэтому опечатка в ключе или неверный тип становятся ошибкой анализатора, а не упавшим кастом в рантайме
Против класса**: не нужно объявление, ==, hashCode и toString бесплатно, плюс деструктуризация в месте вызова
Плохо для публичного API: у записи нет имени в документации и сообщениях об ошибках, а новое поле меняет её тип во всех местах вызова
Плохо, когда есть правила или поведение: нет методов (только extension), нет конструктора для проверки входных данных, нет copyWith и fromJson
Плохо в иерархиях: запись не может реализовать интерфейс или быть подтипом sealed-класса
Практическое правило: запись для внутреннего «вернуть два значения»; класс, как только данные пересекают границу модуля или команда начинает называть их по имени
Паттерн описывает форму значения; сопоставление проверяет значение на соответствие и за один шаг связывает названные части с новыми переменными. Оно заменяет цепочки проверок is и кастов, проверки на null с последующим !, switch только по константам и ручное извлечение полей.
Где встречаются паттерны: switch-операторы и выражения, if (x case …), объявления переменных, присваивания и циклы for-in
Виды: константный (404, Status.active), переменная (final n, int n), wildcard _, запись (a, b), список [first, ...], мапа {'id': final id}, объект User(:name), сравнение > 0, логические && / ||, null-check x?, null-assert x! и каст x as T
Опровержимые и неопровержимые: в switch и if-case паттерн может не совпасть, и тогда проверяется следующий вариант; в объявлении он обязан совпадать всегда, и это проверяет компилятор
Голое имя означает константу: case maxRetries: сравнивает через ==; чтобы связать переменную, нужно писать case final n
Что это даёт: меньше кода, а для enum и sealed-типов компилятор доказывает, что switch обрабатывает все варианты
sealed-класс представляет собой неявно абстрактный класс, все прямые подтипы которого объявлены в той же библиотеке, поэтому компилятор знает их полный список. switch по нему, пропустивший подтип, не компилируется: «забыли обработать ошибку» из бага в рантайме становится ошибкой компиляции.
В сравнении с абстрактным классом: абстрактный класс может расширить кто угодно из любого файла, поэтому полноту switch по нему доказать нельзя и нужен default
Моделирование состояния: каждый подтип несёт только свои данные (SignedIn(userName), AuthError(message)), поэтому нет флага isLoading рядом с nullable-полем error
**default нет намеренно**: добавите позже Offline, и каждый switch, который его не обрабатывает, покраснеет, а это готовый список задач
Правила: создать экземпляр sealed-класса нельзя; подтипы обычно объявляют final class, чтобы никто снаружи не расширял их дальше
Тоже исчерпываемы: enum, bool, nullable-типы (им нужна ветка null) и записи из таких типов
Она сопоставляет value с паттерном, связывает названные в нём переменные и проверяет дополнительное булево условие (guard), и всё это в одном условии. Она заменяет схему «проверить на null или тип, потом скастовать или поставить !», а связанная переменная оказывается новой локальной с уже суженным типом.
Область видимости: связанные переменные существуют только в условии when и в then-ветке; в else ничего не совпало, поэтому там их нет
Работает там, где не работает promotion: if (s.token case final token?) даёт non-null локальную переменную даже тогда, когда token является публичным изменяемым полем, которое проверка != null не сузит
Одно чтение: поле читается один раз, поэтому между проверкой и использованием его никто не изменит
**when принимает любое выражение: оно выполняется после сопоставления и может использовать все переменные, связанные паттерном
В switch**: если guard не прошёл, проверяется следующий case; ветки с guard не учитываются при проверке исчерпываемости
Сначала он проверяет value is Cart, затем вызывает геттер total и сопоставляет результат с > 100. Каждое имя перед двоеточием означает вызов геттера, поэтому паттерн платит ровно столько, сколько стоит геттер, а исключение из него вылетает наружу из switch.
Геттеры, а не только поля: работают вычисляемые, унаследованные и даже extension-геттеры; Cart(:final items) служит сокращением для Cart(items: final items)
Только именованные: Point(1, 2) не скомпилируется, потому что у объектных паттернов нет позиционной формы; const Point(1, 2) означает совсем другое: константный паттерн, сравнение через ==
Порядок: сначала проверка типа, затем подпаттерны слева направо до первого несовпадения
**Один вызов на switch**: результат каждого геттера кешируется между ветками одного switch, но каждый новый switch или build вызывает его заново
Геттеры должны быть дешёвыми и чистыми: если total сворачивает длинный список или имеет побочные эффекты, прочитайте его один раз в локальную переменную и делайте switch по ней
Он проверяет, что значение является List, проверяет длину (ровно число элементов без rest-элемента и не меньше этого числа с ним) и читает элементы по индексу с обоих концов. Именованный rest (...final rest) копирует середину в новый список; голый ... ничего не копирует.
Проверка типа: на List<Object?> или на List<int>, если написать <int>[…]; Set или ленивый результат where() никогда не совпадут, поэтому сначала нужен toList()
Длина: [a, b] совпадает только со списком из двух элементов, [a, ...] требует одного и более, а [] подходит только для пустого
Не больше одного rest-элемента: он может стоять где угодно, но два сделали бы разбиение неоднозначным
Стоимость: ...final rest выделяет новый список при каждом совпадении, O(n); в горячем коде используйте голый ... и читайте нужное по индексу
Типичные применения: UI-состояния «пусто / один / много», разбор команд вроде ['add', ...final files], рекурсия «голова и хвост»
Map-паттерн проверяет, что значение является Map, затем ищет каждый перечисленный ключ и сопоставляет его значение с подпаттерном. Лишние ключи игнорируются (он совпадает с подмножеством), а отсутствующий ключ или значение неверного типа просто дают несовпадение, без исключения.
Типы проверяются в подпаттернах: {'age': final int age} не совпадёт, если age окажется строкой "36"; так JSON проверяют без кастов as
Ключи должны быть константами: они сравниваются через ==; если ключ лежит в переменной, сначала достаньте значение, потом сопоставляйте его
Нет пустого паттерна, проверки размера и rest-элемента: {} не компилируется («A map pattern must have at least one entry»); чтобы проверить только тип, пишите Map(); чтобы требовать ровно эти ключи, добавьте when json.length == 2
Отсутствие и null: {'email': final email} совпадает с {'email': null} и связывает null, а {'email': final String email} не совпадает
Молчаливый отказ: отсутствующий ключ и неверный тип неотличимы, поэтому map-паттерн хорош для выбора ветки по форме, но плохо сообщает, какое именно поле неверно; для моделей API оставьте полноценный fromJson
Паттерны сравнения (relational) сравнивают значение с константой через ==, !=, <, <=, > или >=; логические паттерны объединяют паттерны через && и ||. Вместе они выражают диапазоны вроде >= 18 && < 65. Если граница берётся из переменной, а не из константы, нужен guard when.
Операнд обязан быть константой: case > minAge падает с ошибкой «The relational pattern expression must be a constant»; when age > minAge принимает любое выражение
Оператор действительно вызывается: он должен быть определён у сопоставляемого типа: в switch по String нельзя написать > 'a'
**|| делит одно тело**: 'sat' || 'sun' => true; если ветки связывают переменные, каждая обязана связать те же имена с теми же типами, поэтому int n || double n отвергается; пишите num n
**&& связывает сильнее, чем ||**: обычный способ записать диапазон, например >= 18 && < 65
Диапазоны не учитываются при проверке исчерпываемости: < 0 плюс >= 0 всё равно требуют ветки _, потому что проверка не рассуждает о числах
Программа выведет big int, int, num 2.5, empty string, string hi, other. Ветки проверяются сверху вниз, и побеждает первая, у которой совпали и паттерн, и guard; следующие ветки уже не проверяются.
**42**: совпадает с int n, и guard n > 10 выполняется
**7**: guard первой ветки не проходит, поэтому проверка переходит к int(), простой проверке типа
**2.5**: не int, но double является num
**''**: String(isEmpty: true) вызывает геттер isEmpty и сравнивает результат с константой true
**true: остаётся только wildcard
Ловушка порядка**: если поставить num n первым, int() никогда не совпадёт; анализатор предупредит «This case is covered by the previous cases», поэтому ветки упорядочивают от частного к общему, как блоки catch
Единственная ветка Ok содержит guard when, а ветки с guard не учитываются при проверке исчерпываемости. Компилятор видит, что Ok с value <= 0 ни с чем не совпадает, и сообщает: «The type 'Result' isn't exhaustively matched by the switch cases since it doesn't match the pattern 'Ok()'».
Почему guard игнорируется: guard может быть произвольным кодом, поэтому компилятор не может знать, что он отсеет, и считает, что такая ветка не покрывает ничего
Диапазонов тоже недостаточно: на Ok(value: > 0) плюс Ok(value: <= 0) ошибка останется; проверка рассуждает о типах, значениях enum, bool, null и длинах списков, но не о числах
Как исправить: добавить ветку без guard после ветки с guard: Ok(:final value) => 'value $value'
**Не чинить через _**: _ => скомпилируется, но в тот день, когда добавят третий подтип, он молча провалится в эту ветку вместо того, чтобы сделать switch красным
Loading => не проверяет тип: голое имя типа в паттерне задаёт константный паттерн, который сравнивает состояние с объектом Type Loading через ==. Ни один LoadState не равен Type, поэтому загрузка проваливается в _ и показывает текст ошибки.
Почему это компилируется: литерал типа считается допустимой константой, поэтому анализатор только предупреждает: "The matched value type 'LoadState' can never be equal to this constant of type 'Type'"
Исправление: объектный паттерн Loading(), который проверяет state is Loading; подойдёт и Loading _
**Ветка _ скрыла ошибку**: без wildcard компилятор сообщил бы, что switch не покрывает Loading(); перечисляйте все подтипы sealed-класса вместо _, тогда и новый подтип сделает switch красным
Как поймать в следующий раз: считать предупреждения ошибками в CI и включить линт type_literal_in_constant_pattern, который ловит именно это; баг обычно появляется, когда старая привычка писать case Foo: встречается с паттернами Dart 3
Паттерн требует, чтобы price был double, но jsonDecode превращает 12 в int, и только 12.5 становится double. Любой товар с целой ценой не проходит сопоставление, и parseProduct молча возвращает null.
Почему ошибка тихая: map-паттерн никогда не бросает исключение: несовпадение типа во вложенном паттерне просто делает весь if-case ложным, поэтому баг выглядит как плохие данные, а не плохой код
Почему тесты могут его пропустить: в фикстурах часто цены вроде 4.99; а на вебе (dart2js) целое число одновременно int и double, поэтому тот же код там работает, а на мобильных падает
Исправление: сопоставлять с типом, который JSON действительно может дать, num price, и конвертировать через price.toDouble()
Как поймать в следующий раз: держать тест парсера с целым значением ("price": 12) для каждого числового поля и логировать исходный JSON, когда парсинг возвращает null
Dart 3.0 принёс ещё и модификаторы классов вроде sealed и final; в следующих версиях появились extension types (3.3), разделители разрядов (3.6), wildcard-переменные _ (3.7), null-aware элементы коллекций (3.8), dot shorthands (3.10) и primary constructors (3.13). Каждая убирает небольшой, но частый кусок шаблонного кода.
Модификаторы классов (3.0): sealed, final, base, interface и mixin class определяют, кто может расширять или реализовывать класс; именно sealed стоит за исчерпывающим switch
Extension types (3.3): extension type UserId(String value) даёт типизированную обёртку без объекта в рантайме и заменяет классы-обёртки для идентификаторов и единиц измерения
Разделители разрядов (3.6): 1_000_000 вместо подсчёта нулей
Wildcards (3.7): локальная переменная или параметр с именем _ ничего не связывает, поэтому (_, _) => 0 допустимо и трюк (_, __) больше не нужен; чтение _ теперь ошибка
Null-aware элементы (3.8): [?promo] и {'email': ?email} добавляют элемент, только если значение не null, вместо if (x != null) x
Dot shorthands (3.10): .center вместо MainAxisAlignment.center везде, где тип известен из контекста
Primary constructors (3.13): class Point(final int x, final int y); объявляет конструктор и поля прямо в заголовке класса вместо привычного шаблонного кода
Включаются через ограничение SDK: возможности привязаны к версии языка пакета (sdk: ^3.13.0), поэтому повышение нижней границы может вскрыть новые ошибки, например старый код, который читает _
Каждый исчерпывающий switch по этому типу (в вашем коде и в каждом пакете, который от него зависит) перестаёт компилироваться, пока не обработает новый вариант. Это и есть работа фичи, но добавление подтипа становится ломающим изменением: для опубликованного пакета это означает мажорную версию.
Почему ломается
Множество закрыто намеренно: все прямые подтипы лежат в одной библиотеке, поэтому потребители вправе не писать default, и компилятор требует с них полноты
Проверяются обе формы: switch-операторы и выражения, включая nullable-значения, которым нужна ветка null; ветки с guard ничего не покрывают
Лазейки в Dart нет: нет аналога @unknown default из Swift или #[non_exhaustive] из Rust, чтобы сказать «вариантов может стать больше»
Как выкатывать
Внутри одного приложения: добавьте подтип и считайте красные switch списком задач: компилятор нашёл каждое место, где нужно решить, что означает новый вариант
Опубликованный пакет: поднимите мажорную версию, опишите новый подтип в changelog и укажите, что вызывающему коду нужно для него показывать или делать
Монорепозиторий: вливайте подтип и все новые ветки одним изменением, чтобы ни один пакет ни разу не оказался на некомпилирующейся версии
Когда не стоит запечатывать
Варианты должны добавлять другие: плагины, сторонние платёжные провайдеры, события, которые определяет приложение: используйте abstract interface class с методом, который переопределяет каждая реализация; тогда новый вариант приносит своё поведение, и ни один switch не ломается
Вы хотите добавлять варианты свободно: оставьте обычный абстрактный класс и смиритесь с тем, что вызывающим нужен default
Частая ошибка: рефлекторный _ => в основной логике снова превращает «не компилируется» в «молча делает не то в рантайме»
Сопоставление и деструктуризация сводятся к проверкам типов и чтению геттеров и ничего не выделяют; создание записи выделяет объект, как любой вызов конструктора. Настоящие расходы прячутся в двух местах: именованный rest-элемент копирует часть списка, а паттерны вызывают ваши геттеры и операторы.
Что выделяет память
Создание записи: один объект на вызов; const-запись канонизируется и ничего не стоит на кадр
Часто оптимизируется: если запись создаётся и деструктурируется в одной функции и никуда не утекает, оптимизирующий компилятор обычно убирает выделение; в debug-режиме не убирает, поэтому замеры в debug ничего не говорят
**...final rest**: новый список при каждом совпадении, O(n); голый ... ничего не копирует
**for (final MapEntry(:key, :value) in map.entries)**: один MapEntry на шаг; это цена итерации, а не паттерна
Какой ваш код выполняется
Ваш код выполняется внутри сопоставления: вызываются геттеры, length, [], == и операторы сравнения; про кеширование и цену см. вопрос про объектные паттерны
Исключение не означает несовпадения: бросивший геттер вылетает из switch, и следующие ветки не проверяются
Как проверить, а не угадывать
Profile-режим на реальном устройстве: flutter run --profile
DevTools Memory: трассировка выделений для класса и счётчики на кадр
DevTools Performance: сначала проверьте, не вызывается ли build чаще, чем нужно; обычно настоящая проблема в этом, а не в паттерне внутри него