Classes & Objects
By the end of this lesson you will be able to design a class with fields, methods, and every kind of Dart constructor; explain exactly what lives on the heap when you create an object; trace dynamic dispatch when a method is overridden; choose correctly between extends, implements and with; and avoid the single most common object bug in real codebases — overriding == without hashCode.
1. Classes, fields, objects & this
In Dart, a class bundles together fields (the data an object holds — like the house's bedroom count) and methods (the actions it can perform — like "open the front door"). You create an object with ClassName(...), which is called instantiation.
this is a special reference every method can use to mean "the exact object I was called on". Inside haveBirthday, this.age and plain age refer to the exact same field — Dart lets you drop this. whenever there's no naming clash, but it's always there implicitly.
rex in var rex = Dog('Rex', 3); — does not hold the object's bytes directly; it holds a reference (an address) pointing at the object on the heap. Copying that variable (e.g. passing it to a function) copies the small reference, not the whole object — exactly like D07's pass-by-value-of-a-reference rule for List.The same program as runnable Dart (the // => lines are its exact output). Notice alias = rex copies the reference: still one object.
late, nullable, or assigned by the constructor — Dart's sound null safety (D05) will not compile a class that could leave a non-nullable field unset.this always refers to the specific object a method is running on.Input size → what is feasible: creating n = 106 small objects is fine (each costs tens of bytes, so tens of MB); n = 108 would need GBs — for that many plain numbers use a typed list (D16) instead of one object each.
2. Constructors — every kind
The shorthand this.x in a parameter list assigns the argument straight into the field of the same name — Point(this.x, this.y); is exactly equivalent to writing the field assignments out by hand in an initializer list:
A class can have any number of named constructors (Point.origin(), Point.onXAxis(5)) alongside its main one — useful for offering several sensible ways to build the same kind of object. The part after : and before { is the initializer list: it sets final fields and runs before the constructor body, left to right in the exact order the initializers are written.
this yet (the object isn't fully built), so it can only use the constructor's own parameters and static members — not call instance methods on the object being constructed. Only the constructor body (after {) can safely use this.A redirecting constructor forwards entirely to another constructor of the same class instead of having its own initializer list or body:
A const constructor builds a compile-time constant object when every argument is itself a compile-time constant. Dart canonicalizes these: two separate const expressions with identical arguments produce the exact same object in memory, not two equal-looking copies.
A factory constructor is written factory ClassName(...) and is different from every constructor above in one crucial way: it does not have to create a brand-new instance. It can return a cached existing instance, or even an instance of a different subtype:
Finally, super parameters (Dart 2.17+) let a subclass constructor forward a parameter straight to its superclass constructor without re-typing its type or re-listing it in an initializer list — super.name means "take this parameter and pass it on to super(...) under the same name":
const constructors canonicalize identical calls into one shared instance. factory constructors can skip building a new object entirely — cache one, or hand back a different subtype. super.x forwards a parameter to the superclass constructor.Input size → what is feasible: a constructor costs O(1) plus whatever its body does; const objects are built once for the whole program; a factory cache lookup is O(1) average, so 106 AppLogger(tag) calls ≈ 106 steps.
3. Getters, setters, privacy & static members
A name starting with an underscore, like _celsius, is private — but Dart's privacy is per library (normally: per .dart file), not per class. Any code in the same file can see it; code that only imports the file cannot.
From a different file the same read fails to compile (undefined_getter) — checked with the real analyzer in verify/d09.dart.
Static fields and methods belong to the class itself, not to any instance — there is exactly one copy no matter how many objects you create:
t.celsius = value LOOKS like plain field assignment from the outside — that's the point of a setter — but real validation logic runs underneath. Forgetting this and assigning fields directly elsewhere in a class (bypassing the setter) is a common way validation gets silently skipped.static members are shared across every instance, with exactly one copy.Input size → what is feasible: a getter/setter call is O(1) like a field access: 107 calls ≈ 107 steps. A computed getter costs as much as its body (a getter that loops is O(loop) on EVERY read).
4. Inheritance, override & polymorphism
An abstract class (marked abstract) can declare methods with no body — a promise that every concrete subclass must fulfil — and cannot be instantiated directly (Pet() would be a compile error).
@override is not required by the compiler, but it is required by good practice: it makes the analyzer check that you really are overriding something, catching typos in method names that would otherwise silently create a brand-new, unrelated method.
pet.speak() on a variable declared as the abstract type Pet, Dart doesn't look at the declared type Pet to decide which code to run — it looks at the object's actual runtime type (like a lookup table, sometimes called a "vtable", attached to every object, pointing at the correct method for that exact class). This is called dynamic dispatch, and it's what makes a single line like for (final p in pets) print(p.describe()); correctly print a different sound for every kind of pet in the list.extends creates an IS-A relationship and inherits real code. super.method() calls the parent's version. Which override actually runs is decided by the object's runtime type, not its declared type.Input size → what is feasible: dynamic dispatch adds one table lookup per call: 107 calls ≈ 107 steps — never a reason to avoid polymorphism; deep hierarchies (> 3–4 levels) are a design smell long before they are a speed problem.
5. Interfaces & mixins
extends is like inheriting your parent's entire recipe collection — you get every recipe already written out, and can tweak individual ones. implements is like signing a contract that only lists dish names you promise to be able to cook — you get zero recipes for free, you must write every single one yourself. A mixin (with) is like stapling a few ready-made recipe cards into several different recipe collections at once — reusable behaviour, without needing a single shared parent collection.Every Dart class automatically defines an implicit interface containing all its public members. implements SomeClass means "I promise to have every member SomeClass has" — but you inherit none of its code, only its shape:
A mixin (declared with mixin) packages reusable methods/fields that get "mixed into" a class with with — without being that class's single parent. A class can mix in several mixins at once:
When two mixed-in mixins define the same method, Dart resolves it by linearization: picture the mixins stacked in the order written, with the rightmost one placed closest to the class itself — so it "wins" any name clash, exactly like the last CSS rule to be applied overriding earlier ones.
mixin ... on SomeClass restricts a mixin to only classes that are (or extend) SomeClass, and — crucially — lets the mixin call super.method() to reach the next class down the linearized chain, not just a single fixed parent:
Reversing the mixin order reverses the chain — same two mixins, a different answer:
extends: inherit code, one parent only. implements: adopt a shape only, zero inherited code, can implement many. with: mix in reusable code from one or more mixins; rightmost mixin wins name clashes, and each mixin's super.x reaches the next class down the linearized chain.Input size → what is feasible: mixins cost nothing extra per call (the chain is fixed at compile time); a hierarchy of up to ~10 mixins is still O(1) per method call.
6. Extension methods & extension types
int) — from the outside it now seems to know a new trick, but the manual (the actual int value in memory) hasn't changed shape at all. An extension type (Dart 3.3+) is more like a special name tag you clip onto a value that only the compiler can see — at runtime there is no tag object at all, just the original value, but the compiler uses the name tag to stop you from mixing up, say, metres and seconds by accident.An extension method adds methods/getters to an existing type (even one you don't own, like int) without subclassing it or wrapping it in anything:
Try it — type any whole number and watch the divisor check run:
speak() was resolved by DYNAMIC dispatch — the object's runtime type decided which override ran. Extension methods work the OPPOSITE way: which extension applies is decided at compile time, purely from the variable's declared (static) type — this is static dispatch, and it means an extension method is never "overridden" the way an instance method is. For example, given extension Greeter on Robot { String hello() => 'Robot hello'; } and extension GreeterSub on ChattyRobot { String hello() => 'ChattyRobot hello'; }: Robot r = ChattyRobot(); r.hello(); calls the Robot extension (because r is DECLARED as Robot, even though the real object is a ChattyRobot) — while ChattyRobot cr = ChattyRobot(); cr.hello(); calls the ChattyRobot extension instead, purely because cr's declared type is different. Verified in verify/d09.dart.An extension type (new in Dart 3.3) looks similar but means something very different: it defines a brand-new static type that wraps an existing "representation type", purely for the compiler's benefit. At runtime there is no wrapper object at all — this is what "zero-cost" means.
toString/==/hashCode (those belong to Object, and there is no separate object to attach different ones to) and is/as checks against it behave like checks against the underlying type. Its entire value is: catching mistakes at compile time (e.g. refusing to add a Meters and a Seconds together) with zero runtime overhead — no allocation, no indirection.runtimeType.Input size → what is feasible: isPrime by trial division does about √n steps: n ≤ 1012 → 106 steps fits; n up to 1018 would need 109 steps — too slow, switch to Miller–Rabin. Extension types add zero cost at any size.
7. Enhanced enums
Planet.earth, Planet.mars. An enhanced enum (Dart 2.17+) is the same wall of buttons, except each button now also carries a small data card and can do a bit of work when pressed — Planet.earth can carry its own gravity value and compute your weight on it, without needing a separate lookup table somewhere else in the code.const (enum values are compile-time constants, one singleton instance per named value), and an enum can never be extended or instantiated with new — Planet.values is the complete, fixed, exhaustive list, which is exactly why a switch over an enum can be checked for completeness by the analyzer.Input size → what is feasible: an enum has a fixed, small number of values (tens, not millions); values.byName is a linear scan of them = O(#values).
8. Class modifiers
base), and some say "this exact design is final — nobody outside my own office may extend OR copy its shape" (final).These restrictions only apply to code outside the declaring library (in practice: a different .dart file that only imports this one). Inside the same file, none of these modifiers get in your way. This was verified directly with real multi-file Dart programs while authoring this lesson (one file declaring each modifier, a second file — a separate library — attempting every operation) using dart analyze:
| Modifier | Extend outside library? | Implement outside library? | Instantiate directly? | Use for… |
|---|---|---|---|---|
(none) | ✅ yes | ✅ yes | ✅ yes | ordinary, fully open classes |
abstract | ✅ yes | ✅ yes | ❌ no | a contract with no complete instance of its own |
base | ✅ yes (subtype must also be base/final/sealed) | ❌ no | ✅ yes | guarantee every instance really IS this class's own code (blocks look-alike fakes via implements) |
interface | ❌ no | ✅ yes | ✅ yes | publish a shape for others to implement, but stop them silently inheriting/overriding your internals via extends |
final | ❌ no | ❌ no | ✅ yes | completely closed — nobody outside can extend OR implement it at all |
sealed | ❌ no (outside) | ❌ no (outside) | ❌ no (implicitly abstract) | a known, closed, EXHAUSTIVE set of subtypes for pattern-matching switch |
mixin class | ✅ yes | ✅ yes | ✅ yes | a class usable BOTH as a normal class (extends/new) and as a mixin (with) |
What you can run in one file: inside the declaring library every modifier is permissive, so this panel shows each type being used legally. The table's "outside library" columns are checked separately, by running dart analyze on real multi-file programs inside verify/d09.dart (extending a final/interface/sealed class and implementing a base/final/sealed class are all rejected with invalid_use_of_type_outside_library; instantiating an abstract or sealed class is instantiate_abstract_class).
base class works, but extending an interface class or a final class fails with invalid_use_of_type_outside_library; implementing a base class or a final class fails the same way, while implementing an interface class succeeds; and extending a sealed class from outside its file fails outright, because Dart requires every direct subtype of a sealed class to be declared in that one file.final to close a class completely, base to force real inheritance instead of fake look-alikes, interface to publish a contract without exposing internals to subclassing, and sealed for an exhaustively-known family of subtypes.Input size → what is feasible: modifiers are compile-time rules with zero run-time cost at any n; a switch over a sealed hierarchy is O(#cases) per call.
9. Operator overloading
a + b for two vectors is just a friendlier spelling of a method call, the way "a ÷ b" on a calculator is a button that calls a routine. In Dart an operator is an ordinary method whose name is a symbol: you declare it with the keyword operator, and a + b runs the operator + method of a's class with b as its argument (you cannot write the call out as a.+(b) — only the operator form exists).You may define + - * / ~/ % < > <= >= == [] []= ~ unary- and a few more (<< >> & | ^). You may not redefine &&, ||, !, =, ?: or .. Defining == gives you != for free, and a compound assignment like s += b simply uses your +. The operator is looked up on the left operand, so v * 2 calls Vec.*, while 2 * v would call int.* (and not compile): an extension cannot hijack a member the left type already has.
a + b returns a NEW Vec) is still just a method call: summing n = 106 vectors in a loop allocates 106 short-lived objects — fine. But if you override == you must override hashCode too (next section).operator +(Vec o) => ... declares an operator; a + b calls it on the left operand. Define == together with hashCode; != comes free; &&, ||, ! cannot be overloaded.Input size → what is feasible: each operator call is O(1) plus its body; a chain of n = 106 additions allocates n objects (≈ 106 steps) — use a mutable accumulator if n reaches 108.
10. == , hashCode, toString & composition over inheritance
== (how to compare in detail) but forget hashCode (which bin to check first) — this connects directly back to D08's hash tables.The contract as code, and how Object.hash behaves (more on Object.hash, hashAll, identityHashCode, runtimeType, toString and noSuchMethod in D29 · DateTime, Duration, Uri & Object):
a == b is true, then a.hashCode == b.hashCode MUST also be true. Violating this doesn't throw an error — it just silently corrupts Set/Map behaviour: "equal" objects can be treated as different keys/elements, exactly like BadPoint above. Always override == and hashCode together, never one alone.One more rule from D08: the fields ==/hashCode read must never change while the object is a Set element or Map key.
Finally, composition over inheritance: prefer giving an object a field that provides a capability (HAS-A) over forcing an inheritance relationship (IS-A) just to reuse code. A Car is not an Engine — it has one, and swapping engines needs no class hierarchy at all:
== and hashCode together — never just one — and add toString for readable debugging. Prefer composition (a field providing a capability) over inheritance when you only want to reuse behaviour, not model a true IS-A relationship.Input size → what is feasible: a Set/Map of n = 106 objects needs hashCode in O(1) and well spread: Object.hash(x, y) does that; a hashCode that returns a constant would make every lookup O(n) = 106 steps each.
Quiz
Interview questions
Cheat sheet
| Concept | Syntax / rule |
|---|---|
| Define a class | class Name { fields; methods; }; create with Name(...) |
this | refers to the exact object a method/constructor is running on |
| Constructor shorthand | Name(this.x, this.y); assigns params straight into fields |
| Named constructor | Name.label(...) — as many as you like |
| Initializer list | Name(...) : field = expr, ... { body } — runs left-to-right BEFORE the body; can't use this |
| Redirecting constructor | Name.label(...) : this(...) — forwards to another constructor of the same class |
const constructor | identical const calls are canonicalized into ONE shared instance |
factory constructor | may return a cached instance or a different subtype — not always a new object |
| Super parameters | Sub(super.x) forwards a parameter straight to the superclass constructor |
| Getter / setter | Type get name => ...; / set name(Type v) { ... } — look like fields, can validate/compute |
| Privacy | leading _ = visible only within the same LIBRARY (file), not just the class |
static | one copy shared by the whole class, not per instance |
extends / @override / super.x() | inherit code from ONE parent; override a method; call the parent's version |
| Dynamic dispatch | which override runs is decided by the object's RUNTIME type, not the variable's declared type |
implements | adopt a class's shape only — zero inherited code; can implement several |
with (mixin) | mix in reusable code; rightmost mixin in the list wins name clashes |
mixin ... on Base | restricts the mixin to subtypes of Base; super.x() reaches the next class down the chain |
| Extension method | extension Name on Type { ... } — adds members, no wrapper object; resolved by STATIC dispatch (declared type), not overridable like instance methods |
| Extension type (3.3+) | extension type Name(Repr value) { ... } — compile-time-only label; runtimeType is the representation type |
| Enhanced enum | enum E { a(1), b(2); const E(this.n); final int n; } — fields/const constructor/methods allowed |
| Class modifiers | base blocks outside implements; interface blocks outside extends; final blocks both; sealed requires every subtype in the same file; mixin class works both ways |
== / hashCode | ALWAYS override together — a == b implies a.hashCode == b.hashCode |
| Composition over inheritance | prefer a field providing a capability (HAS-A) over an inheritance hierarchy built only for code reuse |