Encapsulation hides state behind a public API, inheritance reuses a parent's code, polymorphism lets one call run different implementations, and abstraction exposes what an object does, not how. In Dart they map to _ privacy, extends, overriding and abstract classes.
Encapsulation: fields are _private to the library and change only through methods that keep them valid: a Cart exposes add(), not its raw list
Inheritance: class Dog extends Animal gets Animal's fields and methods; Dart allows one superclass, and mixins cover behaviour shared across hierarchies
Polymorphism: a List<Shape> holds circles and squares, and shape.area runs the right override at runtime (dynamic dispatch)
Abstraction: an abstract class such as AuthRepository declares the contract; the caller does not know whether it talks to Firebase or to a fake in a test
Dart specifics worth naming: everything is an object (even int and null), every class is also an implicit interface, and there is no method overloading
FlutterJunior
OOP in Dart
Classes, inheritance, interfaces, mixins, and abstract classes
Questions closed
0 / 22
- 0Review
- 5Asked often
Questions
A generative constructor creates a new instance; a named one like Point.origin() is an extra, descriptively named way to build the same class. final fields must be set before the body (via this.x, a field initializer or the initializer list) because in the body they are already read-only.
**this.x shorthand**: Point(this.x, this.y) assigns fields straight from the parameters
Initializer list: : x = 0, y = 0 runs before the superclass constructor and the body; it may also hold assert(...) and the super(...) call, but it cannot use this
Named constructors: Dart has no overloading, so alternative ways to build an object get names: User.guest(), DateTime.utc(...)
Redirecting: Point.onXAxis(double x) : this(x, 0); forwards to another constructor and has no body
Defaults: with no constructor declared you get an implicit unnamed one without parameters; constructors are not inherited, so each subclass declares its own
A factory constructor does not have to create a new object: its body decides what to return (a cached instance, a singleton or a subtype). A cached instance is fine, null is not: under null safety a factory of User must return a non-null User.
**No this, no initializer list**: there is no object yet; the body must return an instance of the class or of a subtype
Typical uses: fromJson that validates before building, a singleton (factory Db() => _instance;), a cache keyed by an argument, choosing a subtype at runtime
Hides the concrete type: even an abstract class can have a factory, so Logger() can return a ConsoleLogger that callers never name
Limits: a subclass cannot call a factory as its super(...); a factory can be const only as a redirecting one, const factory Foo() = _Foo;, the form freezed generates
When not to use: if it always builds a fresh object with no logic, a generative constructor is enough; if parsing may fail and null is a valid result, write static User? tryParse(...)
super.key is a super parameter: the constructor takes key and forwards it to the superclass constructor. const Home({super.key}) means exactly const Home({Key? key}) : super(key: key), only shorter, and the type comes from the parent.
Matching: named super parameters match by name, positional ones by position, so they must keep the parent's order
Mixes freely: Avatar({super.key, required this.url, this.size = 40})
Common mistake: passing the same argument twice: super.key plus an explicit super(key: ...) is a compile error, and positional super parameters cannot be combined with positional arguments in super(...)
Where it does not work: redirecting constructors (Foo() : this.named()) and factories have no super call; when the value is transformed, super(key: key ?? ValueKey(id)) must stay an explicit initializer
No: a class cannot have two methods or constructors with the same name and different parameters. You get the same effect with optional and named parameters, named constructors, or simply different method names.
Optional and named parameters: one show(String text, {Duration duration = ..., String? actionLabel}) replaces three overloads
Named constructors: Color.fromARGB(...) and Color.fromRGBO(...) instead of two overloaded constructors
Different names: the SDK itself does this: add and addAll, parse and tryParse
A wider parameter type: accept a sealed type and switch on it; this moves some checks to runtime, so prefer the options above
Why Dart has none: a name maps to exactly one member, so calls on dynamic and tear-offs like list.forEach(print) are never ambiguous
A name starting with _ is private to its library (usually the file), not to the class: code in the same file can still use it, other files cannot. A private named constructor such as Cart._() stops other libraries from creating or extending the class directly.
Same file, full access: MyPage and _MyPageState reach each other's private members; split them into two files and that access is gone
**part files**: belong to the same library and see its private names; that is how a generated user.g.dart defines _$UserFromJson
**ClassName._()**: when it is the only constructor, instances come only through a factory, a static method or a singleton field you control
**No protected**: @protected, @visibleForTesting and @internal from package:meta are analyzer warnings, not language rules
The usual pattern: keep the field private, expose a getter (an unmodifiable copy or view for collections), and change it only through methods that validate
Every field already has an implicit getter, plus a setter if it is not final; get and set let you write those accessors yourself. Because cart.total reads the same whether total is a field or a computed getter, you can swap one for the other without touching callers.
Derived values belong in getters: double get total => ... is computed from items and cannot go stale; a stored total can
Setters validate: set quantity(int v) can reject a negative value before it reaches the private field
What does break: code that assigned to the field, unless you add a setter, and a const constructor that initialised it
Cost: a getter runs on every read; an expensive one used inside build runs on every rebuild, so name heavy work as a method (computeTotals())
No ceremony: Effective Dart says not to wrap a private field in a getter/setter pair that only forwards; a public field is fine and can become a getter later
A static member belongs to the class itself, not to any object: there is one copy for the whole program. Make a member static when it does not depend on a particular instance (constants, a shared cache, a helper like int.parse).
**No this: a static method cannot read instance fields or call instance methods
Not inherited, not overridden**: static members belong to the declaring class only: Child.helper() does not compile when helper is declared on the parent, and a same-named static in the subclass is a separate member
Not reachable through an instance: obj.staticMethod() does not compile in Dart, unlike Java; always write ClassName.member
Lazy: a static field (like a top-level one) is initialised on first read, not at startup
Common mistake: mutable static state is a global variable with a nicer name: it leaks between tests and cannot be swapped for a fake; inject a dependency instead
extends inherits a parent's implementation (one parent only), implements takes only its contract and makes you write every member, and with adds a mixin's members. An abstract class cannot be instantiated and is used either as a base to extend or as a contract to implement.
**extends**: single inheritance; you get fields and method bodies, override what you need and reach the original through super
**implements: every class defines an implicit interface, so you can implement any class; you must provide all its members, fields included (as getters and setters), and you may implement several
with: applies mixins left to right; the rightmost one wins a name clash
abstract class**: may mix bodyless and concrete members; for a pure contract Dart 3 code writes abstract interface class
When to use which: extends for a real is-a with shared code, implements for substitution only (a fake repository in tests), with for a capability shared by unrelated classes
Order: all three combine as class X extends Y with A, B implements C
A mixin is a reusable set of members you add to a class with with, even across unrelated hierarchies. Use it for a capability several classes need that is not an is-a relationship; on Base restricts the mixin to subclasses of Base, so it may call Base members and super.
**Declared with mixin: may hold fields, concrete and abstract methods; it has no constructor and cannot be instantiated
Several at once**: with A, B is applied left to right; the rightmost mixin sits closest to the class and wins a name clash
**on in Flutter**: SingleTickerProviderStateMixin is declared on State<T>, which is why it only works on a State
**mixin vs mixin class**: since Dart 3 a plain class cannot follow with; mixin class is both a class and a mixin, and therefore cannot have extends, with or on
When to prefer something else: if the behaviour needs its own state and lifecycle, or callers should swap it, a separate object you hold (composition) is easier to test than a mixin
An extension adds methods, getters, setters or operators to a type you do not own (String, DateTime, BuildContext) without subclassing it. Compared to StringUtils.capitalize(s), s.capitalize() shows up in autocomplete and chains naturally.
Typical uses: context.theme shortcuts, formatting on DateTime, helpers on Iterable (firstWhereOrNull is an extension from package:collection)
No state: an extension cannot declare instance fields, only static ones
Cannot override: if the type already has a member with that name, the real member always wins
Static dispatch: the extension is picked by the static type at compile time: it does nothing on a dynamic value, and a Dog stored in an Animal variable gets the Animal extension
Visibility and conflicts: active only where its library is imported; two extensions with the same member are an ambiguity error, fixed with hide or an explicit call: StringCase(s).capitalize()
You declare an instance method with the operator keyword, Money operator +(Money other), and from then on a + b calls it. Only a fixed set of operators can be overloaded, and compound forms like += follow automatically.
Overloadable: ==, <, >, <=, >=, +, -, *, /, ~/, %, ^, &, |, <<, >>, >>>, [], []=, ~ and unary minus (operator -() with no parameter)
Not overloadable: =, &&, ||, !, ??, ?: and !=, which is always the negation of ==
Free extras: define + and you get +=; define [] and []= and you get obj[i] += 1
**== comes with hashCode**: equal objects must have equal hash codes, or Set and Map misbehave
When to use: value types that read naturally with operators (Money, Vector, Duration); not for clever shortcuts on domain objects, where a named method says more
hashCode is missing: a Set or Map compares hash codes first and calls == only on a match, and two separate objects get different identity hashes. The contract is that a == b implies a.hashCode == b.hashCode.
Fix: override both over the same fields: int get hashCode => Object.hash(x, y); (Object.hashAll for a list); the hash_and_equals lint flags one without the other
**== details**: the parameter is Object; identical(this, other) is a cheap fast path; compare List fields with listEquals, because == on lists is identity; make the class final or compare runtimeType so that subclasses do not break symmetry
Mutation trap: the set stored the point under its old hash; if Point stays mutable and gets the fix, after p.x = 5, contains(p) returns false and remove(p) does nothing, yet iteration still shows it, and nothing throws
So: value types should be immutable, a mutable object should never be a Map key, and records, equatable or freezed give you a correct pair for free
A class that defines a call method can be invoked like a function: getUserName('42') runs getUserName.call('42'). Write one when a function needs state or configuration (a use case holding its repository, a validator holding its limits) but should read as a single action.
Use cases: in clean-architecture code GetUserName holds the repository, and the call site reads await getUserName(id)
Configured functions: const MinLength(8) keeps its limit and works as a validator
Fits function types: pass const MinLength(8).call where a String? Function(String?) is expected; Dart would also tear off .call implicitly, but the implicit_call_tearoffs lint in flutter_lints asks for it to be explicit
Testable: a fake is just another class with the same call signature, or implements the real one
When not to: with no state, a top-level function or a closure is simpler; one class per call is ceremony
Flutter detects change by comparing the old object with the new one; mutate in place and they are the same object, so ValueNotifier, BLoC, updateShouldNotify and Riverpod's select see nothing. An immutable model plus copyWith gives a new object per change.
Widgets: Widget is @immutable: it is a cheap description recreated on every build, and whatever changes lives in State or in a state holder
Enforcing it: final fields, a const constructor where possible, and @immutable from package:meta, which makes the analyzer warn about any non-final field in the class or its subclasses
**copyWith**: returns a new instance with some fields replaced: state.copyWith(name: 'Ann'); the old instance stays valid for whoever still holds it
Shallow trap: a final List<Item> items can still be added to; store List.unmodifiable(items) in the constructor
Nullable trap: the usual email ?? this.email means copyWith(email: null) cannot clear a field; freezed generates a copyWith that can
A const constructor lets an object be created at compile time; identical const expressions are canonicalized into one shared instance, so const Text('Hi') is the same object on every build. When a rebuilt parent hands Flutter that same instance, the element skips rebuilding that subtree.
The mechanism: Element.updateChild sees that the new widget is the old one (Widget equality is identity) and keeps the existing element without calling build below it
Canonicalization: identical(const Point(1, 2), const Point(1, 2)) is true; one allocation for the whole program instead of one per build
To declare one: all fields final, no constructor body, and a superclass with a const constructor (StatelessWidget has one)
Common mistake: declaring the constructor is not enough: MyWidget() without const still allocates on every build; write const MyWidget() or be inside a const context
Limits: one runtime argument (Theme.of(context), a field, a closure, DateTime.now()) disqualifies the whole expression; a const widget still rebuilds when an InheritedWidget it reads through of(context) changes
They restrict what other libraries may do with a class: base allows only extending, interface only implementing, final neither, sealed fixes the set of subtypes so a switch can be exhaustive, and mixin class lets a class also be used after with.
By default a class can be extended and implemented from anywhere, but not mixed in.
**base**: every subtype inherits your implementation, so private invariants hold and you can add a concrete method later without breaking anyone; subtypes must be base, final or sealed too
**interface: callers depend only on signatures, so you may rewrite the bodies freely
final: no subtypes outside the library; adding a member is never a breaking change
sealed: implicitly abstract, subtypes only in the same library, and the compiler knows them all
Combinations**: abstract interface class Repository is the idiomatic pure interface
The restrictions stop at the library boundary (inside the file everything is allowed), and adding a modifier to a published class is a breaking change.
Inheritance ties a subclass to the base's internals and call order, so one change in the base can break subclasses you never touched (the fragile base class problem). Flutter avoids it: widgets wrap widgets, and behaviour comes in as children, builders and controllers.
In the framework: Padding(child: Text(...)) instead of a PaddedText subclass; StatelessWidget asks for one build method, and everything that varies comes in through the constructor
The refactor: split BaseScreen by responsibility: shared layout becomes a widget, loading and error logic moves to a state holder, and the screens compose them instead of overriding hooks
Composition first: hold a collaborator and delegate to it: no super to remember, no hidden call order, easy to swap for a fake in tests
Mixins second: for small behaviour shared by unrelated types, with on stating what the mixin needs
Inheritance last: for a real is-a relationship with a contract you own; mark the class base if subtypes must keep your implementation
The factory caches the first instance in a static field and silently ignores baseUrl on every later call, so whoever creates ApiClient first fixes the URL for the whole isolate: tests, flavors and a staging switch all get production.
Why: _instance ??= ApiClient._(baseUrl) runs the constructor only while _instance is null; after that the factory returns the cached object, and the new argument goes nowhere without any error
The static outlives the test: it lives as long as the isolate, so a client created by app setup or an earlier test leaks into the next one, and there is no clean way to reset it
Fix: make it a plain constructor and create one instance per environment at startup (in main, get_it or a Riverpod provider), then pass it to repositories; "only one instance" becomes a wiring decision, not a property of the class
If you really need a cache: key it by the argument, _cache.putIfAbsent(baseUrl, ...), as a Logger(name) cache does; a singleton factory should take no parameters at all
Catch it: in review, treat a factory with parameters that returns a static instance as a smell; a test that builds two clients with different URLs and checks endpoint() fails at once
It prints log(cache(base)). with applies mixins left to right, each one creating a new superclass, so the rightmost mixin sits closest to Repo: the call reaches Logging.fetch first, its super.fetch() goes to Caching, and that one's super.fetch() goes to Base.
Linearization: the real chain is Base → Base with Caching → Base with Caching, Logging → Repo; with is a stack, not multiple inheritance
Swapped: with Logging, Caching prints cache(log(base)); in a real repository that means logging only cache misses instead of every call, and nothing warns you
**super is bound where the mixin is applied**: the same mixin calls a different super.fetch() in different classes, so it cannot be fully understood in isolation
**on Base**: is what makes super.fetch() legal inside the mixin: it promises a superclass that has fetch; it is not implements
**Forgetting super**: a mixin that does not call super.fetch() silently cuts off everything below it; Flutter marks such methods @mustCallSuper (initState, dispose)
Read the with list right to left as "nearest first", and super as "the next one down".
When Base's constructor body runs, Screen is only half built. Dart runs Screen's field initializers and initializer list, then the whole superclass constructor (initializer list, then body), and only then Screen's body, so onInit() sees title and upper, but not controller.
Already set when the base body runs: fields with an initializer, this.x parameters and initializer-list assignments
Not set yet: anything assigned in the subclass body: a late field throws LateInitializationError, a nullable field is still null
**No this in an initializer list: Dart forbids it, so an instance method cannot run there; the hole is open only in constructor bodies, which are virtual-call territory
Fix the class**: set controller in the initializer list, or declare late final controller = 'controller for $title';, which is computed lazily on first read
Fix the design: do not call overridable methods from a constructor: constructors assign, they do not orchestrate; run setup in a lifecycle method the owner calls after construction
Flutter follows this: State.initState exists because widget and context are not attached while the State constructor runs
Product.== checks other is Product, which also accepts every SaleProduct, so a plain product equals its discounted version one way but not the other. == stops being symmetric, and contains, indexOf, Set and Map answer differently depending on argument order.
Why: plain == sale runs Product.==: sale is a Product with the same id, so true; sale == plain runs SaleProduct.==: plain is not a SaleProduct, so false. Hence [plain].contains(sale) is true and [sale].contains(plain) is false
The hash contract breaks too: plain == sale holds, yet their hashCodes differ, so a Set or Map stores them as two separate entries (and map[sale] misses the entry stored under plain) instead of treating them as the same key
Fix: compare the concrete type in the base class: other.runtimeType == runtimeType; objects of different classes are then never equal and both directions agree. Keep other is Product after it so other.id type-checks; equatable does the same runtimeType check
Alternative: remove the case: mark value classes final (Dart 3) so nobody can subclass them and add fields, or use composition: SaleProduct holds a Product and a discount
Catch it: a test that asserts a == b equals b == a for pairs of related types; asymmetric equality never shows up when you only compare objects of one class