Все три вмещают любое значение, включая null; разница только в том, что компилятор позволяет с ним делать. Object? сохраняет статическую проверку, dynamic её отключает, а void запрещает использовать значение вообще.
**Object?**: пока проверка is не сузила тип, доступны только toString, hashCode, ==, runtimeType и noSuchMethod, поэтому опечатка даёт ошибку компиляции
**dynamic**: любой вызов компилируется и ищется во время выполнения, так что x.lenght уезжает в релиз и превращается в NoSuchMethodError. Всё, что прочитано из dynamic, снова оказывается dynamic, и он расползается по коду
**void**: значение можно держать, но нельзя читать: print(f()) для void f() падает с «This expression has a type of 'void' so its value can't be used». Так API говорит «не используйте то, что я возвращаю»
**Object не относится к top-типам**: он исключает null, поэтому null is Object равно false. Object? означает Object плюс Null, как и любой T? означает T или Null
Правило: Object? для значения, которое вы будете проверять, dynamic используйте только на границе декодирования, где тип сразу сужается
FlutterSenior
Система типов Dart в глубину
Верхние и нижние типы, reified-дженерики, вариантность, вывод типов, FutureOr, extension type
Закрыто вопросов
0 / 18
- 0Повторить
- 1Часто спрашивают
Вопросы
Never называют bottom-типом: это подтип любого типа без единого значения, поэтому выражение типа Never никогда не завершается; оно бросает исключение или зацикливается. Объявите хелпер как Never, если он всегда бросает, и тогда flow analysis знает, что код после вызова недостижим.
Продвижение типа сохраняется: после if (s == null) fail('...'); переменная уже String. С void-хелпером вызов считается обычной инструкцией, и s остаётся String?
Нет пропущенного return: default:, который вызывает fail(...), больше не даёт ошибку «body might complete normally»
**throw образует выражение типа Never**: поэтому v ?? (throw ArgumentError()) имеет ненулевой тип v
Async: async-хелпер лучше объявлять как Future<Never>; поток выполнения прерывается только там, где вы его await
Как аргумент типа: Never подходит в любую позицию: <Never>[] годится как валидный пустой List<String>, а Result<int, Never> описывает результат, который не может быть ошибкой
**Null не bottom-тип**: null является значением; Never? означает просто Null
Sound означает, что статический тип никогда не врёт: выражение типа String всегда вычисляется в String, или программа бросает исключение до того, как неверное значение будет использовано. Dart добивается этого проверками при компиляции плюс коротким списком проверок во время выполнения.
Проверяется в рантайме
явные касты as и null-assertion !
неявные даункасты из dynamic (единственный оставшийся неявный каст)
записи через ковариантные дженерики (add в List<Animal>, который на самом деле List<Dog>) и параметры с covariant
чтение late-переменной, которая не была инициализирована (LateInitializationError): это не проверка типа, но тоже выполняется в рантайме
Как Dart к этому пришёл
Dart 2 сделал систему типов sound, но всё ещё разрешал неявные даункасты вроде String s = someObject; со скрытой проверкой
Null safety в Dart 2.12 сделала null отдельным типом и убрала неявные даункасты из всего, кроме dynamic; Dart 3 работает только в sound null safety
Что даёт soundness
Компилятор может доверять типам: компилятор может доверять статическим типам, поэтому AOT убирает лишние проверки на null и на тип и девиртуализирует вызовы
Ошибки сдвигаются к границе: поле String не может содержать null во время выполнения, поэтому защитные проверки исчезают, а плохое значение падает там, где входит в систему (на касте или чтении dynamic), а не тремя слоями глубже
Flow analysis сужает статический тип локальной переменной или параметра после проверки вроде x is String или x != null, чтобы использовать её как более узкий тип без приведения. Продвижение действует, только пока Dart может доказать, что переменная не менялась после проверки.
Что продвигается: локальные переменные и параметры после is, == null / != null или их отрицаний, внутри цепочек && и в ветках ?:
Ранний выход: if (x is! String) return; продвигает всё, что идёт после, как и throw и вызов функции, возвращающей Never
Что отменяет продвижение: присваивание значения более широкого типа или создание замыкания, которое переменную записывает: с этого места анализатор не знает, когда запись выполнится. Замыкания, которые только читают, не мешают
Поля: продвигаются только приватные final-поля (Dart 3.2); во всех остальных случаях сначала скопируйте в локальную переменную, final t = _token;
Каждый дженерик-объект хранит свои аргументы типа во время выполнения, поэтому is List<String> и as Map<String, int> выполняют настоящие проверки, а параметр типа T внутри тела обозначает настоящий тип. По-прежнему нельзя создать экземпляр T или вызвать его статические члены.
Фиксируется при создании: аргумент типа определяется, когда объект создаётся, и никакой каст его не меняет. jsonDecode строит List<dynamic>, поэтому as List<String> падает, даже если каждый элемент имеет тип String
Конвертируйте, а не кастуйте: .cast<String>() возвращает ленивое проверяемое представление того же списка; List<String>.from(...) копирует в собственный список
**x is T работает**: внутри дженерик-функции можно проверять и продвигать до T без передачи токена типа, в отличие от Class<T> в Java
Недоступно: T(), T.fromJson(...) и статические члены через T: передавайте фабрику вроде T Function(Map<String, dynamic>)
Цена во время выполнения: каждый экземпляр несёт свои аргументы типа, а каждый дженерик-as и ковариантная запись означают реальную проверку подтипа. Исключение составляют extension types: они стираются до своего representation-типа
is задаёт вопрос о подтипе и продвигает переменную; runtimeType == T сравнивает точный класс вместе с аргументами типа. Подкласс или другой аргумент типа никогда не совпадут, поэтому используйте is, паттерны или switch по sealed-классу, а runtimeType оставьте для логов.
Только точный класс: Dog().runtimeType == Animal равно false, поэтому реестр Map<Type, Handler> с ключом Animal промахивается мимо всех подклассов
Аргументы типа учитываются: <String>[] is List<Object> равно true, а <String>[].runtimeType == List<Object> равно false
Приватные классы: на VM jsonDecode('{}').runtimeType равно _Map<String, dynamic>, и такой тип даже нельзя записать как ключ
Нет продвижения: после if (a.runtimeType == Dog) переменная остаётся Animal, так что каст всё равно нужен
Имена нестабильны: runtimeType.toString() минифицируется на вебе и при --obfuscate, поэтому никогда не сохраняйте его и не используйте как ключ
Вывод типов достраивает типы, которые вы не написали, в двух направлениях: сверху вниз, из контекстного типа (List<String> a = [];), и снизу вверх, из самих выражений ([1, 2] даёт List<int>). Если ни одно направление ничего не даёт, выбирается dynamic, по умолчанию без предупреждения.
Сверху вниз: аннотация переменной, тип параметра или возвращаемый тип: takesInts([]) создаёт List<int>, а Set<String> s = {} создаёт Set
Снизу вверх: наименьшая верхняя граница элементов: [1, 2.0] даёт List<num>, [1, 'a'] даёт List<Object>, [Dog(), Cat()] даёт List<Animal>, часто не тот тип, который вы имели в виду
Пустые литералы: var xs = []; создаёт List<dynamic>, а {} создаёт Map<dynamic, dynamic>. Аргумент типа реифицирован, так что список принимает любую запись, а ошибка всплывает позже на касте, далеко от строки, которая добавила плохой элемент
Дженерик-методы: если T встречается только в возвращаемом типе, final u = decode(body); делает T равным dynamic, и любая опечатка на u компилируется. Пусть тип несёт аргумент, например T Function(Map<String, dynamic>) fromJson
Сделайте это заметным: strict-inference сообщает о каждом таком откате
Он компилируется, печатает 1, а затем addStray бросает type 'Cat' is not a subtype of type 'Dog' of 'value'. Дженерик-классы в Dart ковариантны, поэтому List<Dog> является подтипом List<Animal>, и рантайм платит за это проверкой типа при каждой записи.
Почему это разрешено: чтение через более широкий тип всегда безопасно, а ковариантность упрощает повседневный код. Запись небезопасна, поэтому Dart во время выполнения проверяет каждый аргумент, в типе которого участвует T класса
Объект не меняется: animals по-прежнему List<Dog>; аннотация изменила только то, что компилятор разрешает вызывать
Почему это больно в продакшене: исключение возникает внутри addStray, возможно, на несколько слоёв ниже строки, расширившей тип, и стек-трейс на эту строку не указывает
Как исправить: принимайте Iterable<Animal>, если только читаете, и тогда add просто нет; для записи создайте настоящий List<Animal>.from(dogs). dogs.cast<Animal>() даёт лишь представление того же List<Dog> и падает на том же add
Нет модификаторов вариантности: в отличие от out / in в Kotlin, в стабильном Dart нельзя объявить параметр типа только для чтения; class Box<out T> по-прежнему требует экспериментального флага variance
covariant позволяет переопределению сузить параметр до подтипа, что обычное правило переопределения запрещает. Компилятор принимает сужение и вместо этого вставляет проверку типа во время выполнения в начало метода.
Правило существует потому, что вызывающий код, держащий супертип, может передать любой Animal, и void handle(Dog a) получил бы Cat. covariant говорит «я знаю, проверь в рантайме», и неверный аргумент бросает TypeError.
Ставьте его в супертипе: тогда любое переопределение может сужать параметр без повторения ключевого слова
Flutter им пользуется: State.didUpdateWidget(covariant T oldWidget) и CustomPainter.shouldRepaint(covariant CustomPainter oldDelegate); это безопасно, потому что фреймворк передаёт только подходящий тип
**Никогда в ==**: List.contains, indexOf и remove вызывают == с произвольными объектами, поэтому covariant Money other превращает contains('x') в TypeError; хеш-коллекции Set и Map попадают на это только при совпадении хешей, так что баг плавающий. Оставьте Object other, проверяйте other is Money внутри и переопределите hashCode в паре
**Параметры с T получают это неявно**: List<T>.add(T value) проверяется так же, поэтому добавление Cat в List<Dog>, видимый как List<Animal>, бросает исключение
У json['id'] статический тип dynamic, а присваивание dynamic переменной int означает неявный даункаст: компилятор вставляет ту же проверку, что дал бы as int. Ключа нет, значение равно null, и эта проверка бросает исключение.
**Так делает только dynamic**: с null safety (Dart 2.12) неявный даункаст из любого другого типа, например из Object в String, вызывает ошибку компиляции. Передача json['name'] как аргумента String даёт второй скрытый каст на следующей строке
Сообщение подсказывает, какой каст: явный as заканчивается на «in type cast», а неявный не заканчивается
**is никогда не бросает, as бросает**: причём TypeError, то есть Error, сигнализирующий о баге, поэтому обёртка парсера в on TypeError прячет дефекты, а не обрабатывает плохие данные
Паттерны не падают: case {'id': final int id} просто не совпадает с неверной формой; бросают только cast-паттерн (final id as int) и null-assert-паттерн (final id!)
Сделайте это видимым: strict-casts: true превращает каждый неявный даункаст из dynamic в ошибку анализатора
register(HttpAuthRepository()) выводит T из аргумента, поэтому сервис сохраняется под ключом HttpAuthRepository. Ключ типа Type совпадает только с точно таким же типом, а не с супертипом, поэтому поиск по AuthRepository промахивается и бросает исключение.
Почему вывод выбирает класс: в вызове нет ни аргумента типа, ни контекстного типа, поэтому T выводится снизу вверх из статического типа аргумента. Интерфейс, который вы имели в виду, в вызове вообще не встречается
**У ключей Type нет подтипов**: HttpAuthRepository == AuthRepository равно false. Map<Type, Object> ищет точное совпадение, в отличие от проверки is
Исправление: указывайте аргумент типа при регистрации: register<AuthRepository>(...). get_it работает так же, поэтому безопасной привычкой будет registerSingleton<AuthRepository>(...). Либо сначала положите аргумент в переменную с типом интерфейса: final AuthRepository repo = HttpAuthRepository(); locator.register(repo);, и тогда статический тип и есть интерфейс
Почему анализатор молчит: вывод типа прошёл успешно, так что даже strict-inference здесь нечего сообщить
Как поймать в следующий раз: в тестах фейки обычно регистрируют с явным типом, поэтому неправильной остаётся только боевая сборка зависимостей (в продакшен-конфигурации). Smoke-тест, который собирает настоящий контейнер и достаёт каждый интерфейс, ловит это в CI
Функциональные типы контравариантны по параметрам и ковариантны по возвращаемому типу: замена может принимать больше, чем обещает слот, и возвращать что-то более узкое.
Смотрите со стороны вызывающего. Тот, кто держит void Function(Dog), вызывает её только с Dog, и функция, принимающая любой Object, с этим справится. Слот void Function(Animal) могут вызвать с Cat, и обработчик только для собак получил бы кошку, поэтому компилятор это запрещает.
**void относится к top-типам**: в слот, возвращающий void, подходит любой возвращаемый тип, поэтому onPressed: () => controller.forward() компилируется
Необязательные параметры расширяют совместимость: void ping([int code = 0]) одновременно и void Function(), и void Function(int)
Где это кусается: колбэки: (Dog d) {...} для параметра void Function(Animal) отклоняется; расширьте параметр и сужайте внутри через if (a is Dog)
В отличие от дженерик-классов: функциональные типы проверяются строго при компиляции, без лазейки covariant и без проверки записи в рантайме
Модель: принимайте шире, возвращайте уже
int реализует лишь Comparable<num>, а Comparable<int> является подтипом Comparable<num>, но не наоборот, поэтому T extends Comparable<T> отвергает int. Для чисел принимайте Comparator<T> или пишите версию для num.
Что даёт F-bound: параметр типа входит в собственную границу, поэтому T обязан сравниваться с себе подобными. Money implements Comparable<Money> подходит, а сравнение Money со String даёт ошибку компиляции
Что теряет «сырая» граница: T extends Comparable означает Comparable<dynamic>, поэтому List<Comparable> с Duration и String компилируется и падает внутри Duration.compareTo
Тихая ловушка: без явного аргумента типа maxOf(ints) компилируется, потому что вывод выбирает T = num. Тело на основе reduce затем бросает type '(num, num) => num' is not a subtype of type '(int, int) => int': замыкание передаётся в List<int>
Что писать: оставьте T extends Comparable<T> для собственных value-типов; для чисел сделайте отдельную версию, как package:collection с отдельными расширениями max для int, double и num; для всего остального принимайте Comparator<T> и оставьте решение вызывающему
Extension type (Dart 3.3) описывает представление существующего типа, на этапе компиляции: extension type UserId(String value) даёт String собственный статический тип и API без объекта-обёртки. Во время выполнения он стирается до representation-типа, так что UserId оказывается просто String.
По сравнению с классом-обёрткой
Нет аллокации: члены разрешаются статически и компилируются в вызовы на String, что важно для id в больших списках и горячих участках кода
Статическая дисциплина: если передать «голый» String туда, где ожидается UserId, будет ошибка компиляции; в этом весь смысл
Ограничения: никаких полей экземпляра, кроме representation, и никаких собственных ==, hashCode или toString: «Extension types can't declare members with the same name as a member declared by 'Object'»
**implements String** открывает члены representation-типа и делает тип присваиваемым в String; без этого наружу ничего не просачивается
Где гарантия исчезает
**is и as проверяют representation**: 'anything' is UserId равно true, 'raw' as UserId проходит, а case UserId u совпадает с любой String
**runtimeType равен String**, а List<UserId> представляет тот же реифицированный тип, что и List<String>
**== сравнивает representation**: UserId('u1') == 'u1' равно true
**dynamic ничего не находит**: вызов isValid через dynamic даёт NoSuchMethodError
Когда нужен класс
когда различие должно пережить проверку в рантайме, сериализатор или поиск в Map<Type, ...>, либо когда нужно собственное равенство
Extension types стираются во время выполнения, поэтому field is Email проверяет лишь is String и даёт true для любой String. Номер телефона попадает в ветку Email, split('@').last возвращает его целиком, и в лог уходит полный номер.
Почему: extension type существует только для компилятора. Во время выполнения Phone('+77011234567') оказывается обычной String, поэтому любая проверка типа видит только тип представления. Любая сырая String, переданная в mask, тоже попадает в ветку Email, так что утечка не ограничена телефонами
Почему это проблема безопасности: mask «ломается открытым»: не та ветка возвращает чувствительное значение вместо ошибки, а строка лога выглядит замаскированной для ревьюера
Анализатор молчит: цепочка if проходит анализ без замечаний; если переписать её на switch, кейс Phone получит хотя бы предупреждение unreachable_switch_case
Исправление: когда код должен различать типы во время выполнения (параметр Object?, логгер, сериализатор, Map<Type, ...>), делайте их классами; sealed class Contact вдобавок даёт исчерпывающий switch. Либо оставьте extension types и дайте каждому свой геттер masked, и тогда выбор делается статически по известному типу
Как поймать в следующий раз: считайте is, as и type-паттерны на extension type поводом присмотреться на ревью и покрывайте тестом каждую ветку: mask(Phone('+77011234567')) должен вернуть ***4567
Он печатает true, String, true. FutureOr<T> представляет собой неразмеченное объединение (untagged union) T и Future<T>, и при T = Object ветви пересекаются: нигде не записано, из какой ветви пришло значение, поэтому await разворачивает future, даже если вы задумывали его как данные.
**Единственный пользовательский union-тип (кроме T?)**: он встроен в правила подтипов: и T, и Future<T> присваиваются ему, а обратно ничего не выходит без проверки
Сужение требует непересекающихся ветвей: if (v is Future<int>) ... else ... продвигает else-ветку до int, потому что int никогда не бывает Future. С Object такой гарантии нет
**FutureOr<Object?> эквивалентен Object?: они взаимные подтипы, это последняя строка: такая аннотация ничего не сообщает читателю
FutureOr<Object> эквивалентен Object**: возвращаемый тип load() не уже Object, поэтому аннотация скрывает, что может вернуться Future
Правило: используйте FutureOr<T> как соглашение о вызове с конкретным T, например для кэша, который может ответить синхронно, сужайте его на границе, а когда нужно настоящее размеченное объединение (tagged union), используйте sealed-класс
Эти падения ловит strict-casts: он запрещает неявные даункасты из dynamic, и String name = json['name']; становится ошибкой анализатора. Два других флага показывают места, где анализатор молча откатился к dynamic.
Все три относятся к языковым режимам в analyzer: language: файла analysis_options.yaml, а не к линтам, поэтому flutter_lints их не включает; very_good_analysis их включает.
**strict-casts**: каждое неявное присваивание, аргумент и возврат из dynamic в T, как обычные ошибки вроде return_of_invalid_type. Это граница с JSON
**strict-raw-types**: дженерик-тип без аргументов там, где их нельзя вывести: void f(List items), Future f. Предупреждение strict_raw_type
**strict-inference**: var x; без инициализатора, final xs = [];, decode(body), у которого T ниоткуда не берётся. Предупреждения вроде inference_failure_on_collection_literal; самый шумный из трёх
Внедрение в легаси-код: сначала strict-casts, потому что он находит реальные падения; затем strict-raw-types, правки в основном механические; strict-inference последним. Понизьте диагностику до info в analyzer: errors:, пока разбираете список, а потом уберите это переопределение
Ограничивайте параметры типа границей Object, чтобы null не значил две вещи, не пускайте dynamic и «сырые» типы в публичные сигнатуры, принимайте Iterable<T> только для чтения и возвращайте копии, а не внутренние коллекции.
**Граница extends Object**: неограниченный V допускает int?, и тогда V? get(key) не отличит «нет значения» от «сохранён null». V extends Object делает nullable-аргументы типа невыразимыми
**Без dynamic и «сырых» типов**: используйте Object? и List<T>; голый List или dynamic в сигнатуре отключает проверку в коде вызывающего
Внутрь берите только для чтения, наружу отдавайте копии: принимайте Iterable<T>, если только читаете, чтобы ковариантность не ударила на записи; возвращайте Map.unmodifiable(...) или копию, а не внутреннюю коллекцию
Новая граница ломает совместимость: сужение T до T extends Object отвергает тех, кто писал Cache<String, int?>, поэтому это мажорная версия