Инкапсуляция прячет состояние за публичным API, наследование переиспользует код родителя, полиморфизм позволяет одному вызову выполнять разные реализации, а абстракция показывает, что объект делает, но не как. В Dart им соответствуют приватность через _, extends, переопределение методов и абстрактные классы.
Инкапсуляция: поля _private видны только внутри библиотеки и меняются лишь через методы, которые держат их в корректном состоянии: Cart открывает add(), а не сам список
Наследование: class Dog extends Animal получает поля и методы Animal; суперкласс в Dart может быть только один, а общее поведение для разных иерархий дают миксины
Полиморфизм: List<Shape> хранит круги и квадраты, и shape.area во время выполнения вызывает нужное переопределение (динамическая диспетчеризация)
Абстракция: абстрактный класс вроде AuthRepository описывает контракт; вызывающий код не знает, ходит ли реализация в Firebase или это фейк в тесте
Особенности Dart, которые стоит назвать: всё является объектом (даже int и null), каждый класс одновременно неявный интерфейс, а перегрузки методов нет
FlutterJunior
ООП в Dart
Классы, наследование, интерфейсы, примеси и абстрактные классы
Закрыто вопросов
0 / 22
- 0Повторить
- 5Часто спрашивают
Вопросы
Обычный (generative) конструктор создаёт новый экземпляр; именованный, например Point.origin(), даёт дополнительный способ создать тот же класс с понятным названием. final-поля нужно задать до тела (через this.x, инициализатор поля или список инициализации), потому что в теле они уже доступны только для чтения.
**Сокращение this.x**: Point(this.x, this.y) присваивает поля прямо из параметров
Список инициализации: : x = 0, y = 0 выполняется до конструктора суперкласса и до тела; в нём также могут быть assert(...) и вызов super(...), но обращаться к this нельзя
Именованные конструкторы: перегрузки в Dart нет, поэтому альтернативные способы создать объект получают имена: User.guest(), DateTime.utc(...)
Перенаправляющий конструктор: Point.onXAxis(double x) : this(x, 0); передаёт вызов другому конструктору и не имеет тела
По умолчанию: если конструктор не объявлен, появляется неявный безымянный без параметров; конструкторы не наследуются, поэтому каждый подкласс объявляет свои
factory-конструктор не обязан создавать новый объект: его тело само решает, что вернуть (закешированный экземпляр, синглтон или подтип). Закешированный экземпляр вернуть можно, а null нельзя: с null safety фабрика класса User обязана вернуть non-null User.
**Нет this и списка инициализации**: объекта ещё нет; тело должно сделать return экземпляра этого класса или подтипа
Типичные случаи: fromJson, который проверяет данные до создания объекта, синглтон (factory Db() => _instance;), кеш по аргументу, выбор подтипа во время выполнения
Скрывает конкретный тип: фабрика может быть даже у абстрактного класса, поэтому Logger() может вернуть ConsoleLogger, о котором вызывающий код не знает
Ограничения: подкласс не может вызвать фабрику как свой super(...); const фабрика бывает только перенаправляющей, const factory Foo() = _Foo;, и именно такую генерирует freezed
Когда не нужен: если всегда создаётся новый объект без логики, хватит обычного конструктора; если разбор может не удаться и null считается допустимым результатом, напишите static User? tryParse(...)
super.key является super-параметром: конструктор принимает key и передаёт его в конструктор суперкласса. const Home({super.key}) означает ровно const Home({Key? key}) : super(key: key), только короче, а тип берётся у родителя.
Сопоставление: именованные super-параметры сопоставляются по имени, а позиционные по позиции, поэтому порядок должен совпадать с родительским
Свободно комбинируются: Avatar({super.key, required this.url, this.size = 40})
Частая ошибка: передать один аргумент дважды: super.key вместе с явным super(key: ...) даёт ошибку компиляции, а позиционные super-параметры нельзя сочетать с позиционными аргументами в super(...)
Где не работает: у перенаправляющих конструкторов (Foo() : this.named()) и фабрик нет вызова super; если значение преобразуется, super(key: key ?? ValueKey(id)) остаётся явным инициализатором
Нет: в классе не может быть двух методов или конструкторов с одним именем и разными параметрами. Тот же эффект дают необязательные и именованные параметры, именованные конструкторы или просто разные имена методов.
Необязательные и именованные параметры: один show(String text, {Duration duration = ..., String? actionLabel}) заменяет три перегрузки
Именованные конструкторы: Color.fromARGB(...) и Color.fromRGBO(...) вместо двух перегруженных конструкторов
Разные имена: так делает и сам SDK: add и addAll, parse и tryParse
Более широкий тип параметра: принять sealed-тип и сделать по нему switch; часть проверок уходит в рантайм, поэтому лучше варианты выше
Почему в Dart её нет: одно имя соответствует ровно одному члену класса, поэтому вызовы на dynamic и tear-off вроде list.forEach(print) никогда не бывают неоднозначными
Имя, начинающееся с _, приватно для своей библиотеки (обычно это файл), а не для класса: код в том же файле может им пользоваться, а другие файлы нет. Приватный именованный конструктор вроде Cart._() не даёт другим библиотекам напрямую создавать класс или наследоваться от него.
Один файл даёт полный доступ: MyPage и _MyPageState видят приватные члены друг друга; разнесите их по разным файлам, и доступ пропадёт
**part-файлы**: входят в ту же библиотеку и видят её приватные имена; так сгенерированный user.g.dart объявляет _$UserFromJson
**ClassName._()**: если это единственный конструктор, экземпляры появляются только через factory, статический метод или поле-синглтон, которые вы контролируете
**protected нет**: @protected, @visibleForTesting и @internal из package:meta служат предупреждениями анализатора, а не правилами языка
Обычный паттерн: поле приватное, наружу отдаётся геттер (для коллекций это неизменяемая копия или представление), а меняется оно только через методы с проверкой
У каждого поля уже есть неявный геттер, а если оно не final, то и сеттер; get и set позволяют написать эти методы доступа вручную. Поскольку cart.total читается одинаково, будь total полем или вычисляемым геттером, одно можно заменить другим, не трогая вызывающий код.
Производные значения выносите в геттеры: double get total => ... вычисляется из items и не может устареть, а сохранённый total может
Сеттеры проверяют: set quantity(int v) может отклонить отрицательное значение до того, как оно попадёт в приватное поле
Что всё-таки ломается: код, который присваивал значение полю (если не добавить сеттер), и const-конструктор, который его инициализировал
Цена: геттер выполняется при каждом чтении; тяжёлый геттер внутри build отработает на каждый ребилд, поэтому тяжёлую работу оформляйте методом (computeTotals())
Без лишних церемоний: Effective Dart советует не оборачивать приватное поле в пару геттер/сеттер, которая просто его пробрасывает; публичное поле вполне допустимо, позже его можно превратить в геттер
static-член принадлежит самому классу, а не объекту: на всю программу он существует в одном экземпляре. Делайте член статическим, когда он не зависит от конкретного объекта (константы, общий кеш, хелпер вроде int.parse).
**Нет this: статический метод не может читать поля экземпляра и вызывать его методы
Не наследуются и не переопределяются**: статические члены принадлежат только объявившему их классу: Child.helper() не компилируется, если helper объявлен в родителе, а одноимённый static в подклассе остаётся отдельным членом
Недоступны через экземпляр: obj.staticMethod() в Dart не компилируется, в отличие от Java; всегда пишите ClassName.member
Ленивая инициализация: статическое поле (как и поле верхнего уровня) инициализируется при первом чтении, а не на старте
Частая ошибка: изменяемое статическое состояние представляет собой глобальную переменную под красивым именем: оно протекает между тестами и не подменяется фейком; лучше внедрить зависимость
extends наследует реализацию родителя (родитель только один), implements берёт только контракт и заставляет написать каждый член заново, а with добавляет члены миксина. Абстрактный класс нельзя инстанцировать; его используют либо как базу для extends, либо как контракт для implements.
**extends**: одиночное наследование; вы получаете поля и тела методов, переопределяете нужное и обращаетесь к исходной версии через super
**implements: каждый класс задаёт неявный интерфейс, поэтому реализовать можно любой класс; нужно предоставить все его члены, включая поля (как геттеры и сеттеры), и интерфейсов может быть несколько
with: применяет миксины слева направо; при совпадении имён побеждает самый правый
abstract class**: может сочетать члены без тела и с реализацией; для чистого контракта в коде на Dart 3 пишут abstract interface class
Что когда выбирать: extends для настоящего «является» с общим кодом, implements только для подстановки (фейковый репозиторий в тестах), with для способности, общей у несвязанных классов
Порядок: все три сочетаются так: class X extends Y with A, B implements C
Миксин представляет собой переиспользуемый набор членов, который добавляется в класс через with, даже в несвязанные иерархии. Он нужен для способности, которая есть у нескольких классов, но не выражает отношение «является»; on Base разрешает применять миксин только к подклассам Base, поэтому внутри можно вызывать члены Base и super.
**Объявляется через mixin: может содержать поля, конкретные и абстрактные методы; конструктора у него нет, создать экземпляр нельзя
Несколько сразу**: with A, B применяется слева направо; самый правый миксин ближе всего к классу и побеждает при совпадении имён
**on во Flutter**: SingleTickerProviderStateMixin объявлен on State<T>, поэтому он работает только на State
**mixin и mixin class**: начиная с Dart 3 обычный class нельзя указать после with; mixin class одновременно класс и миксин, поэтому у него не может быть extends, with или on
Когда лучше другое: если поведению нужно своё состояние и жизненный цикл или его нужно подменять, отдельный объект-поле (композиция) тестировать проще, чем миксин
Extension добавляет методы, геттеры, сеттеры или операторы к типу, который вам не принадлежит (String, DateTime, BuildContext), без наследования. По сравнению с StringUtils.capitalize(s) вызов s.capitalize() виден в автодополнении и естественно встраивается в цепочки.
Типичные случаи: сокращения вроде context.theme, форматирование DateTime, хелперы для Iterable (extension firstWhereOrNull из package:collection)
Нет состояния: extension не может объявлять поля экземпляра, только статические
Не переопределяет: если у типа уже есть член с таким именем, всегда побеждает настоящий член
Статическая диспетчеризация: extension выбирается по статическому типу на этапе компиляции: на значении dynamic он не работает, а Dog, лежащий в переменной типа Animal, получит extension для Animal
Видимость и конфликты: работает только там, где импортирована его библиотека; два extension с одинаковым членом дают ошибку неоднозначности, которую решают через hide или явный вызов: StringCase(s).capitalize()
Вы объявляете метод экземпляра с ключевым словом operator, Money operator +(Money other), и дальше a + b вызывает его. Перегрузить можно только фиксированный набор операторов, а составные формы вроде += работают автоматически.
Можно перегрузить: ==, <, >, <=, >=, +, -, *, /, ~/, %, ^, &, |, <<, >>, >>>, [], []=, ~ и унарный минус (operator -() без параметра)
Нельзя перегрузить: =, &&, ||, !, ??, ?: и !=, который всегда равен отрицанию ==
Составные формы: если определён +, есть и +=; если определены [] и []=, работает obj[i] += 1
**== идёт в паре с hashCode**: равные объекты обязаны иметь равные хеш-коды, иначе Set и Map ведут себя неправильно
Когда применять: для типов-значений, которые естественно читаются с операторами (Money, Vector, Duration); не для хитрых сокращений на доменных объектах, где именованный метод понятнее
Не хватает hashCode: Set и Map сначала сравнивают хеш-коды и вызывают == только при совпадении, а у двух отдельных объектов identity-хеши разные. Контракт: из a == b следует a.hashCode == b.hashCode.
Исправление: переопределить оба по одним и тем же полям: int get hashCode => Object.hash(x, y); (Object.hashAll для списка); линт hash_and_equals подсвечивает один без другого
**Детали ==**: параметр имеет тип Object; identical(this, other) даёт дешёвую быструю проверку; поля-списки сравнивайте через listEquals, потому что == у списков сравнивает ссылки; сделайте класс final или сравнивайте runtimeType, чтобы подклассы не ломали симметрию
Ловушка с мутацией: множество сохранило точку под старым хешем; если Point остаётся изменяемым и получает исправление, после p.x = 5 вызов contains(p) вернёт false, remove(p) ничего не сделает, но при итерации точка по-прежнему видна, и ничего не падает
Поэтому: типы-значения должны быть неизменяемыми, изменяемый объект не должен быть ключом Map, а records, equatable или freezed дают корректную пару бесплатно
Класс с методом call можно вызывать как функцию: getUserName('42') выполняет getUserName.call('42'). Такой класс нужен, когда функции требуется состояние или настройка (use case со своим репозиторием, валидатор со своими ограничениями), но читаться она должна как одно действие.
Use case: в коде по clean architecture GetUserName хранит репозиторий, а в месте вызова написано await getUserName(id)
Настроенные функции: const MinLength(8) хранит свой лимит и работает как валидатор
Подходит под функциональные типы: передайте const MinLength(8).call туда, где ожидается String? Function(String?); Dart сделал бы tear-off .call и неявно, но линт implicit_call_tearoffs из flutter_lints просит писать его явно
Тестируемость: фейком служит просто другой класс с той же сигнатурой call или класс, который делает implements настоящего
Когда не нужен: если состояния нет, проще функция верхнего уровня или замыкание; класс на каждый вызов превращается в лишнюю церемонию
Flutter замечает изменение, сравнивая старый объект с новым; если изменить объект на месте, это один и тот же объект, и ValueNotifier, BLoC, updateShouldNotify и select в Riverpod ничего не заметят. Неизменяемая модель плюс copyWith дают новый объект на каждое изменение.
Виджеты: Widget помечен @immutable: это дешёвое описание, которое пересоздаётся при каждом build, а всё изменяемое живёт в State или в отдельном хранилище состояния
Как обеспечить: поля final, const-конструктор, где возможно, и @immutable из package:meta, с которым анализатор предупреждает о любом не-final поле в классе и его подклассах
**copyWith**: возвращает новый экземпляр с заменой части полей: state.copyWith(name: 'Ann'); старый экземпляр остаётся корректным для всех, кто его держит
Ловушка поверхностной неизменяемости: в final List<Item> items всё ещё можно сделать add; сохраняйте в конструкторе List.unmodifiable(items)
Ловушка с null: из-за привычного email ?? this.email вызов copyWith(email: null) не может очистить поле; freezed генерирует copyWith, который это умеет
const-конструктор позволяет создать объект на этапе компиляции; одинаковые const-выражения канонизируются в один общий экземпляр, поэтому const Text('Hi') остаётся одним и тем же объектом при каждом build. Когда перестроенный родитель отдаёт Flutter этот же экземпляр, элемент пропускает перестройку всего поддерева.
Механизм: Element.updateChild видит, что новый виджет и есть старый (равенство у Widget сводится к идентичности), и оставляет существующий элемент, не вызывая build ниже
Канонизация: identical(const Point(1, 2), const Point(1, 2)) равно true; одна аллокация на всю программу вместо одной на каждый build
Как объявить: все поля final, у конструктора нет тела, а у суперкласса есть const-конструктор (у StatelessWidget он есть)
Частая ошибка: объявить конструктор недостаточно: MyWidget() без const всё равно создаёт объект на каждый build; нужно писать const MyWidget() или находиться в константном контексте
Ограничения: один аргумент, известный только в рантайме (Theme.of(context), поле, замыкание, DateTime.now()), лишает константности всё выражение; const-виджет всё равно перестраивается, когда меняется InheritedWidget, который он читает через of(context)
Они ограничивают, что другие библиотеки могут делать с классом: base разрешает только наследование, interface разрешает только реализацию, final не разрешает ни того ни другого, sealed фиксирует набор подтипов, чтобы switch мог быть исчерпывающим, а mixin class позволяет использовать класс ещё и после with.
По умолчанию класс можно наследовать и реализовывать откуда угодно, но нельзя подмешивать.
**base**: каждый подтип наследует вашу реализацию, поэтому приватные инварианты сохраняются, а новый конкретный метод можно добавить, никого не сломав; подтипы тоже должны быть base, final или sealed
**interface: вызывающий код зависит только от сигнатур, поэтому тела методов можно свободно переписывать
final: подтипов вне библиотеки нет; добавление члена никогда не ломает совместимость
sealed: неявно абстрактный, подтипы только в той же библиотеке, и компилятор знает их все
Комбинации**: abstract interface class Repository даёт идиоматичный чистый интерфейс
Ограничения действуют только на границе библиотеки (внутри файла разрешено всё), а добавление модификатора к уже опубликованному классу ломает совместимость.
Наследование привязывает подкласс к внутренностям и порядку вызовов базового класса, поэтому одно изменение в базе может сломать подклассы, которые вы не трогали (проблема хрупкого базового класса). Flutter её избегает: виджеты оборачивают виджеты, а поведение приходит через child, билдеры и контроллеры.
В самом фреймворке: Padding(child: Text(...)) вместо подкласса PaddedText; StatelessWidget требует один метод build, а всё, что меняется, приходит через конструктор
Рефакторинг: разделить BaseScreen по ответственностям: общая разметка становится виджетом, логика загрузки и ошибок уходит в хранилище состояния, а экраны их компонуют вместо переопределения хуков
Сначала композиция: держать объект-помощник и делегировать ему: не нужно помнить про super, нет скрытого порядка вызовов, легко подменить фейком в тестах
Потом миксины: для небольшого поведения, общего у несвязанных типов, с on, который явно говорит, что миксину нужно
Наследование в последнюю очередь: для настоящего отношения «является» с контрактом, которым вы владеете; пометьте класс base, если подтипы обязаны сохранить вашу реализацию
Фабрика кеширует первый экземпляр в статическом поле и молча игнорирует baseUrl при всех следующих вызовах, поэтому тот, кто первым создал ApiClient, фиксирует URL для всего изолята: тесты, флейворы и переключатель на стейджинг получают прод.
Почему: _instance ??= ApiClient._(baseUrl) вызывает конструктор, только пока _instance равен null; дальше фабрика возвращает закешированный объект, а новый аргумент просто теряется, без всякой ошибки
Статическое поле переживает тест: оно живёт столько же, сколько изолят, поэтому клиент, созданный при старте приложения или в предыдущем тесте, утекает в следующий, и чисто сбросить его нельзя
Исправление: сделайте обычный конструктор и создавайте по одному экземпляру на окружение при старте (в main, через get_it или провайдер Riverpod), а затем передавайте его в репозитории; «ровно один экземпляр» становится решением на уровне сборки зависимостей, а не свойством класса
Если кеш действительно нужен: ключуйте его по аргументу, _cache.putIfAbsent(baseUrl, ...), как это делает кеш Logger(name); фабрика-синглтон вообще не должна принимать параметры
Как поймать: на ревью считайте фабрику с параметрами, которая возвращает статический экземпляр, запахом; тест, который создаёт два клиента с разными URL и проверяет endpoint(), сразу упадёт
Он напечатает log(cache(base)). with применяет миксины слева направо, и каждый создаёт новый суперкласс, поэтому самый правый миксин ближе всего к Repo: вызов сначала попадает в Logging.fetch, его super.fetch() уходит в Caching, а оттуда super.fetch() уходит в Base.
Линеаризация: реальная цепочка: Base → Base with Caching → Base with Caching, Logging → Repo; with работает как стек, а не как множественное наследование
После перестановки: with Logging, Caching печатает cache(log(base)); в настоящем репозитории это значит, что логируются только промахи кеша, а не каждый вызов, и никто об этом не предупредит
**super связывается там, где миксин применён**: один и тот же миксин в разных классах вызывает разный super.fetch(), поэтому полностью понять миксин в отрыве от класса нельзя
**on Base**: именно это делает super.fetch() допустимым внутри миксина: он гарантирует суперкласс с методом fetch; это не implements
**Забытый super**: миксин, который не вызывает super.fetch(), молча отрезает всё, что ниже; во Flutter такие методы помечены @mustCallSuper (initState, dispose)
Читайте список with справа налево как «ближайший первым», а super читайте как «следующий вниз».
Когда выполняется тело конструктора Base, Screen собран только наполовину. Dart выполняет инициализаторы полей и список инициализации Screen, затем весь конструктор суперкласса (список инициализации, потом тело) и только после этого тело Screen, поэтому onInit() видит title и upper, но не controller.
Уже заданы, когда выполняется тело базы: поля с инициализатором, параметры this.x и присваивания из списка инициализации
Ещё не заданы: всё, что присваивается в теле подкласса: late-поле бросает LateInitializationError, nullable-поле всё ещё null
**Нет this в списке инициализации: Dart это запрещает, поэтому метод экземпляра там не вызвать; дыра остаётся только в телах конструкторов, где вызовы уже виртуальные
Исправить класс**: задать controller в списке инициализации или объявить late final controller = 'controller for $title'; (такое поле вычисляется лениво при первом чтении)
Исправить дизайн: не вызывать переопределяемые методы из конструктора: конструктор присваивает, а не управляет процессом; настройку выполняйте в методе жизненного цикла, который владелец вызывает после создания
Flutter делает так же: State.initState существует потому, что во время конструктора State ещё не привязаны widget и context
Product.== проверяет other is Product, а этому условию удовлетворяет и любой SaleProduct, поэтому обычный товар равен своей версии со скидкой только в одну сторону. == перестаёт быть симметричным, и contains, indexOf, Set и Map отвечают по-разному в зависимости от порядка аргументов.
Почему: plain == sale вызывает Product.==: sale является Product с тем же id, значит true; sale == plain вызывает SaleProduct.==: plain не SaleProduct, значит false. Отсюда [plain].contains(sale) даёт true, а [sale].contains(plain) даёт false
Контракт хеша тоже нарушен: plain == sale выполняется, но их hashCode различаются, поэтому Set или Map хранят их как две разные записи (и map[sale] не находит запись, сохранённую под plain), хотя объекты "равны"
Исправление: сравнивайте конкретный тип в базовом классе: other.runtimeType == runtimeType; тогда объекты разных классов никогда не равны, и оба направления сходятся. Оставьте после этого other is Product, чтобы other.id проходил проверку типов; equatable делает ту же проверку runtimeType
Альтернатива: убрать сам случай: пометьте value-классы как final (Dart 3), чтобы никто не мог унаследоваться и добавить поля, или используйте композицию: SaleProduct хранит Product и discount
Как поймать: тест, который проверяет, что a == b совпадает с b == a для пар связанных типов; несимметричное равенство не проявляется, пока сравниваются объекты одного класса