ข้ามไปยังเนื้อหา

Classes & Mixins

constructor ของ Dart รวยกว่าภาษาส่วนใหญ่ และ Flutter ก็ใช้ครบทุกอย่าง พื้นฐานที่ต้องรู้:

class Point {
final double x, y;
// Default constructor with `this.` shorthand — assigns fields directly.
const Point(this.x, this.y);
// Named constructor — a second way to build one.
const Point.origin() : x = 0, y = 0;
// Factory constructor — can return a cached/subtype instance, not just a fresh one.
factory Point.fromMap(Map<String, double> m) => Point(m['x']!, m['y']!);
}
const p = Point(1, 2); // const construction — canonicalized
final o = Point.origin();

สามอย่างที่ควรจำให้ขึ้นใจ:

  • const constructor (ทำได้เมื่อ field ทุกตัวเป็น final) ให้ผู้เรียก build instance ที่ถูก canonicalize — พื้นฐานของ const widget
  • named constructor (Point.origin) ให้หลายวิธีสร้างที่ชัดเจน แทนการ overload
  • factory constructor ไม่จำเป็นต้องคืน instance ใหม่ — คืนตัวที่ cache ไว้หรือ subtype ก็ได้ factory คือวิธีที่หลาย class ของ Flutter/Dart implement singleton หรือ parsing

ทั้งสามข้อพิสูจน์ไว้ข้างล่างด้วย identical() เช็คจริงและ test singleton จริง

Dart มี single inheritance แต่ mixin ให้คุณ compose behavior แบบแนวนอน — เพิ่มชุด method/field เข้า class โดยไม่ต้องมีความสัมพันธ์แบบ subclass นี่คือ pattern ของ Flutter สำหรับ State class

mixin Logger {
void log(String msg) => print('[log] $msg');
}
mixin Validator {
bool isValid(String s) => s.isNotEmpty;
}
// Compose several capabilities with `with`:
class Form with Logger, Validator {
void submit(String value) {
if (isValid(value)) log('submitting $value');
}
}
flowchart TB
  base["class State"] -->|with| result["State class ของคุณ"]
  m1["mixin
SingleTickerProviderStateMixin"] -->|with| result
  m2["mixin
WidgetsBindingObserver"] -->|with| result
mixin เพิ่มความสามารถโดยไม่ต้อง inheritance

เวลาคุณเขียน class _MyState extends State<MyWidget> with SingleTickerProviderStateMixin คุณกำลัง mix ความสามารถในการเป็น vsync provider สำหรับ animation เข้ามา — reuse แบบแนวนอนล้วน ๆ

with A, B ไม่ได้ผสาน A กับ B แบบเท่าเทียมกัน — Dart สร้าง superclass chain จริง ๆ โดย apply mixin จากซ้ายไปขวา ตัวที่อยู่ท้ายสุดจะอยู่ใกล้ที่สุดกับ class ของคุณ แปลว่า override ของ mixin ตัวสุดท้ายจะชนะ และเมื่อมันเรียก super จะไปถึง mixin ที่อยู่ก่อนหน้ามันเอง (ไม่ใช่ base class ดั้งเดิม)

lib/mixin_linearization.dart
// Mixin linearization: with A, B means B is applied LAST, so B sits closer
// to the subclass in the superclass chain — B's override of a shared method
// wins, and B's `super.greet()` call reaches A (not the base class).
class Base {
String greet() => 'Base';
}
mixin A on Base {
@override
String greet() => 'A->${super.greet()}';
}
mixin B on Base {
@override
String greet() => 'B->${super.greet()}';
}
class Combined extends Base with A, B {}
class Reversed extends Base with B, A {}
test/mixin_linearization_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/dart-foundations__classes-and-mixins/mixin_linearization.dart';
void main() {
test('with A, B: the LAST mixin (B) wins and its super reaches the one before it (A)', () {
expect(Combined().greet(), 'B->A->Base');
});
test('reversing the order (with B, A) reverses which mixin wins', () {
expect(Reversed().greet(), 'A->B->Base');
});
}
✓ with A, B: the LAST mixin (B) wins and its super reaches the one before it (A)
✓ reversing the order (with B, A) reverses which mixin wins
00:00 +2: All tests passed!

นี่คือกลไกเดียวกับ class _MyState extends State<T> with SingleTickerProviderStateMixin, WidgetsBindingObserver เป๊ะ — ลำดับไม่ใช่แค่เรื่องความสวยงาม ถ้า mixin สองตัวเขียน override method lifecycle เดียวกันและทั้งคู่เรียก super ตัวที่อยู่ท้ายสุดจะรันก่อนและตัดสินว่าตัวก่อนหน้าจะเห็นอะไร

extension method เพิ่ม method ให้ type ที่คุณไม่ได้เป็นเจ้าของ — พบบ่อยสำหรับ helper สั้น ๆ:

extension StringX on String {
String get capitalized => isEmpty ? this : this[0].toUpperCase() + substring(1);
}
'flutter'.capitalized; // 'Flutter'

enhanced enum พก field และ method ได้ ไม่ใช่แค่ชื่อ — เหมาะกับค่าคงที่ที่มี type และอธิบายตัวเองได้:

enum Status {
active('Active', true),
archived('Archived', false);
const Status(this.label, this.isVisible);
final String label;
final bool isVisible;
}
Status.active.label; // 'Active'

Dart 3.0 เพิ่ม modifier ห้าตัวที่จำกัดว่า class จะถูกใช้นอก library (ไฟล์ .dart) ที่ประกาศมันได้อย่างไร — แต่ละตัวให้ flutter analyze error จริงที่ต่างกันเมื่อถูกละเมิด ตัวอย่างข้างล่างทุกอันมีสองไฟล์: ที่ประกาศใน library หนึ่ง การละเมิดในอีก library หนึ่ง

lib/load_state_sealed.dart
sealed class LoadState {}
final class Loading extends LoadState {}
final class Loaded extends LoadState {
Loaded(this.data);
final String data;
}
// @expect-error real invalid_use_of_type_outside_library diagnostic, captured below
import 'load_state_sealed.dart';
// A sealed class's subtypes must all live in the SAME library — this file
// is a different one, so adding a new variant here is a violation.
class TimedOut extends LoadState {}
error • The class 'LoadState' can't be extended, implemented, or mixed in outside of its library
because it's a sealed class • invalid_use_of_type_outside_library

นี่คือสิ่งที่ทำให้ exhaustiveness checking ของ sealed + switch (พิสูจน์ในบทก่อนหน้า) ปลอดภัยจริง: analyzer นับ subtype ได้ครบเพราะไม่มี library อื่นเพิ่มตัวใหม่ได้

lib/vehicle_base.dart
base class Vehicle {
Vehicle(this.wheels);
final int wheels;
}
// @expect-error real invalid_use_of_type_outside_library diagnostic, captured below
import 'vehicle_base.dart';
class Car implements Vehicle {
@override
int get wheels => 4;
}
error • The class 'Vehicle' can't be implemented outside of its library because it's a base class •
invalid_use_of_type_outside_library

base รับประกันว่าทุก instance เป็น Vehicle จริง ๆ (ผ่าน extends ที่ inherit implementation จริง) — ไม่ใช่ของปลอมที่สร้างด้วย implements ซึ่งแค่ match รูปร่าง interface

lib/repository_interface.dart
interface class Repository {
const Repository();
List<String> fetchAll() => const [];
}
// @expect-error real invalid_use_of_type_outside_library diagnostic, captured below
import 'repository_interface.dart';
class CachedRepository extends Repository {
@override
List<String> fetchAll() => ['cached'];
}
error • The class 'Repository' can't be extended outside of its library because it's an interface
class • invalid_use_of_type_outside_library

วิธีแก้คือสิ่งที่ interface ถูกสร้างมาให้ทำพอดี: implement แทนที่จะ extend — compile ผ่านสะอาด flutter analyze รายงานศูนย์ปัญหา:

lib/repository_interface_ok.dart
import 'repository_interface.dart';
// Implementing (not extending) an `interface` class from outside its
// library is exactly what the modifier is for — this compiles fine.
class CachedRepository implements Repository {
@override
List<String> fetchAll() => ['cached'];
}
lib/currency_final.dart
final class Currency {
const Currency(this.code);
final String code;
}
// @expect-error real invalid_use_of_type_outside_library diagnostic, captured below
import 'currency_final.dart';
class Usd extends Currency {
Usd() : super('USD');
}
error • The class 'Currency' can't be extended outside of its library because it's a final class •
invalid_use_of_type_outside_library

final เข้มงวดที่สุดในสี่ตัวนี้ (รองจาก sealed ที่เพิ่มการบังคับ exhaustiveness ด้วย): นอก library ของมัน Currency subtype ไม่ได้เลยไม่ว่าด้วยวิธีไหน — ใช้สร้างและใช้งานได้อย่างเดียว

modifier ตัวที่ห้าเป็นข้อจำกัดคนละแบบ — ไม่เกี่ยวกับ library boundary แต่เกี่ยวกับสิ่งที่ class ที่ใช้ได้ทั้งแบบ class ปกติและ mixin (class Foo และ with Foo) ประกาศได้ เพราะ field ของ mixin จะถูกต่อเข้ากับ class ที่ apply มัน และ constructor ก็ไม่มีความหมายตรงนั้น:

// @expect-error real mixin_class_declares_non_trivial_generative_constructor diagnostic, captured below
mixin class Logger {
Logger(this.tag); // a mixin class cannot declare a generative constructor
final String tag;
void log(String msg) => print('[$tag] $msg');
}
error • The mixin class 'Logger' can't declare a non-trivial generative constructor •
mixin_class_declares_non_trivial_generative_constructor

abstract class ประกาศ member โดยไม่ต้องมี implementation; implements รับแค่ interface (signature ของ method/getter) โดยไม่มี implementation ที่ inherit มาเลย ในขณะที่ extends inherit code จริง:

abstract class Shape {
double area();
}
// `implements` takes only the interface (member signatures) — nothing is
// inherited, so every member must be reimplemented from scratch.
class UnitSquare implements Shape {
@override
double area() => 1.0;
}
// `extends` inherits real implementation — abstract members must still be
// filled in, but concrete ones (like describe()) come for free.
abstract class ShapeBase {
double area();
String describe() => 'area=${area()}';
}
class UnitCircle extends ShapeBase {
@override
double area() => 3.141592653589793;
}

พิสูจน์แล้วข้างล่าง: UnitCircle ได้ describe() มาฟรีผ่าน extends ส่วน UnitSquare จะต้อง reimplement ทุกอย่างใหม่หมดถ้า Shape ประกาศมันไว้ เพราะ implements ไม่ inherit implementation เลย

const canonicalization, factory singleton, equality และ covariant — พิสูจน์พร้อมกันทั้งหมด

หัวข้อที่มีชื่อว่า “const canonicalization, factory singleton, equality และ covariant — พิสูจน์พร้อมกันทั้งหมด”
lib/const_and_factory.dart
class Point {
const Point(this.x, this.y); // all fields final -> const constructor allowed
final double x, y;
// Object equality: two Points with equal fields should compare equal and
// hash equal, or Sets/Maps/tests silently misbehave.
@override
bool operator ==(Object other) =>
other is Point && other.x == x && other.y == y;
@override
int get hashCode => Object.hash(x, y);
}
class Logger {
Logger._internal(); // private named constructor — only this file can call it
static final Logger _instance = Logger._internal();
// factory: returns the cached singleton instead of a fresh instance.
factory Logger() => _instance;
}
abstract class Shape {
double area();
}
class UnitSquare implements Shape {
@override
double area() => 1.0;
}
abstract class ShapeBase {
double area();
String describe() => 'area=${area()}';
}
class UnitCircle extends ShapeBase {
@override
double area() => 3.141592653589793;
}
class Box {
void addAll(covariant List<num> items) {} // widens the accepted param type
}
class IntBox extends Box {
// Without `covariant` on the base, overriding with a NARROWER param type
// (List<int> instead of List<num>) would be a compile error.
@override
void addAll(List<int> items) {}
}
test/const_and_factory_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:probe/lessons/dart-foundations__classes-and-mixins/const_and_factory.dart';
void main() {
test('const constructors canonicalize: two equal const values are the SAME instance', () {
const a = Point(1, 2);
const b = Point(1, 2);
expect(identical(a, b), isTrue);
final c = Point(1, 2); // built with `final`, not `const` — a fresh instance
expect(identical(a, c), isFalse);
expect(a == c, isTrue); // == still holds, via the overridden operator
});
test('a factory constructor can return a cached instance instead of a fresh one', () {
final l1 = Logger();
final l2 = Logger();
expect(identical(l1, l2), isTrue); // same singleton both times
});
test('overriding == and hashCode is what makes value types work in a Set', () {
final points = {Point(1, 2), Point(1, 2), Point(3, 4)};
expect(points.length, 2); // the two equal Points dedupe
});
test('extends ShapeBase inherits describe() for free; implements Shape does not', () {
expect(UnitCircle().describe(), 'area=3.141592653589793');
expect(UnitSquare().area(), 1.0);
});
test('covariant widens the accepted param type on a subclass override', () {
final box = IntBox();
box.addAll([1, 2, 3]); // compiles: IntBox narrows to List<int>
});
}
✓ const constructors canonicalize: two equal const values are the SAME instance
✓ a factory constructor can return a cached instance instead of a fresh one
✓ overriding == and hashCode is what makes value types work in a Set
✓ extends ShapeBase inherits describe() for free; implements Shape does not
✓ covariant widens the accepted param type on a subclass override
00:00 +5: All tests passed!

สองข้อนี้ควรดูใกล้ ๆ อีกรอบ:

covariant — error จริงถ้าไม่มีมัน Box.addAll รับ List<num>; IntBox override ด้วย List<int> ที่แคบกว่า ถ้าไม่ mark parameter ของ base เป็น covariant นี่คือ compile error จริงแบบไม่แก้ไข — กฎ default ของ Dart คือ type ของ parameter ใน override ต้องเหมือนเดิมหรือกว้างกว่า ไม่แคบกว่าเด็ดขาด เพราะไม่งั้นการเรียก box.addAll(['not an int']) ผ่าน type Box ฐานจะ unsound:

error • 'IntBox.addAll' ('void Function(List<int>)') isn't a valid override of 'Box.addAll'
('void Function(List<num>)') • invalid_override

covariant บน parameter ของ base คือการ opt-out เช็ค soundness นั้นอย่างชัดเจน — คุณกำลังบอก compiler ว่า “ฉันยอมรับความเสี่ยงตอน runtime เอง จะบังคับ type ที่แคบกว่านี้ด้วยตัวเองถ้าจำเป็น”

Equality: Equatable ทำงานแบบเดียวกันด้วย code น้อยกว่า การเขียน ==/hashCode เองแบบข้างบนคือสิ่งที่ Equatable ที่ใช้ในบท BLoC generate ให้คุณจาก list props — สัญญา value-equality เดียวกัน แต่ boilerplate น้อยกว่าเมื่อ class มี field เกินสองสามตัว

Dart class ที่ AI สร้างมักจะได้รูปร่างถูกต้อง แต่พลาดสองเรื่องที่จะกัดก็ต่อเมื่อ code ข้ามไฟล์/package boundary หรือเข้าไปอยู่ใน Set/Map: เลือก class modifier ที่ตรงกับข้อจำกัดที่ตั้งใจไว้จริง ๆ และจำ ==/hashCode บนสิ่งที่เทียบด้วยค่า

// AI-generated: "a User model that's safe to use as an interface for mocking in tests"
class User {
User(this.id, this.name);
final String id;
final String name;
}
// elsewhere, in a test file:
class MockUser implements User {
@override
String get id => 'mock-id';
@override
String get name => 'Mock User';
}

code นี้ compile ผ่านวันนี้ — แต่ “ปลอดภัยที่จะใช้เป็น interface สำหรับ mock ใน test” คือสิ่งที่ interface class User { ... } ควรประกาศไว้ชัดเจน ถ้าไม่ประกาศ ไม่มีอะไรหยุดเพื่อนร่วมทีมจากการเขียน class RealUser extends User ใน production code เดือนหน้า โดยพึ่งพา field layout และ constructor จริงของ User แบบที่ผู้เขียนไม่เคยตั้งใจให้ทำได้

ก่อนเชื่อ code แบบนี้ ให้เช็ค:

  • class นี้ตั้งใจให้เป็นชุด variant ปิด (sealed), รับประกันว่าเป็น implementation จริงเสมอ (final/base) หรือเป็น interface ชัดเจนสำหรับ mocking/adapter (interface)? ถ้าไม่ประกาศอะไรเลย แปลว่าใครก็ได้ที่ไหนก็ได้ extends/implements/with มันได้ — default คือเปิดกว้างที่สุด
  • ทุก class ที่จะอยู่ใน Set, เป็น Map key หรือเทียบด้วย == (รวมถึงใน test ที่ expect(actual, expectedInstance)) ต้องมี ==/hashCode — หรือ Equatable — ไม่งั้น instance ที่มีโครงสร้างเหมือนกันสองตัวจะ != และการ dedup/เทียบจะพังเงียบ ๆ พิสูจน์แล้วข้างบนด้วย test ของ Set
  • mixin class ที่ generator เพิ่ม constructor เข้าไป (พบบ่อยเมื่อ template code-gen สมมติว่า “ทุก class ต้องมี constructor”) — พิสูจน์แล้วข้างบนว่านี่คือ flutter analyze error จริงทันที ไม่ใช่ runtime bug แอบแฝง อย่างน้อยก็ fail แบบเสียงดัง

🐛 หา bug ในโค้ดนี้

sealed class ConnectionState {}
final class Connected extends ConnectionState {
Connected(this.latencyMs);
final int latencyMs;
}
final class Disconnected extends ConnectionState {}
// lib/reconnect_policy.dart — a different file from the one above
class FlakyConnectionState extends ConnectionState {}
เฉลย

โค้ดนี้ compile ไม่ผ่าน: ConnectionState เป็น sealed และ FlakyConnectionState ถูกประกาศในlibrary ที่ต่างกัน (ไฟล์ .dart คนละไฟล์) จาก Connected/Disconnected error จริงจาก analyzer คือ invalid_use_of_type_outside_library: “The class ‘ConnectionState’ can’t be extended, implemented, or mixed in outside of its library because it’s a sealed class” เป็น diagnostic เดียวกับที่พิสูจน์ไปแล้วก่อนหน้านี้ในบทนี้สำหรับ LoadState/TimedOut วิธีแก้คือย้าย FlakyConnectionState เข้าไปอยู่ไฟล์เดียวกับ subtype อื่น ๆ ของ ConnectionState — ซึ่งคือประเด็นทั้งหมดของ sealed: ทุก variant ต้องมองเห็นได้ในที่เดียว เพื่อให้ switch ทุกอันบนมันตรวจ exhaustiveness ได้

class modifier คือคำสัญญาเกี่ยวกับรูปร่างของ subtype tree ที่ compiler บังคับใช้แทนที่จะเป็นแค่ comment ใน code review sealed สัญญาว่า “ฉันนับ subtype ได้ครบทุกตัว” (ซึ่งคือสิ่งที่ทำให้ exhaustive switch ปลอดภัย) final สัญญาว่า “ทุก instance รัน code ของฉันจริง ๆ ไม่มีของสลับเข้ามา” interface สัญญาว่า “พึ่งพารูปร่างของฉัน ไม่ใช่ implementation ของฉัน” (ประตูสำหรับ mocking/adapter อย่างชัดเจน) base อยู่ระหว่าง final กับแบบเปิด: “extend ฉันเพื่อ inheritance จริงได้ แต่ปลอมเป็นฉันผ่าน implements ไม่ได้” mixin linearization ก็อยู่ในจิตวิญญาณ “ชัดเจนดีกว่านัย” แบบเดียวกัน: with A, B ไม่ใช่การผสาน แต่เป็น chain จริงที่มีลำดับ ที่คุณให้เหตุผลได้เหมือน single inheritance เป๊ะ แค่มี link เยอะกว่า

ทางเลือกประโยชน์ต้นทุน
sealedตรวจ exhaustive switch ได้ทุกที่ พิสูจน์แล้วในบทก่อนหน้าvariant ใหม่ทุกตัวต้องอยู่ไฟล์/library เดียวกัน — กระจาย subtype ข้าม package ไม่ได้
finalรับประกันแข็งแกร่งที่สุด: subtype นอก library ไม่ได้เลยไม่มีจุดขยายให้ผู้ใช้เลย — กำแพงที่ตั้งใจ ไม่ใช่ความผิดพลาด
interfaceseam สะอาดสำหรับ mocking/testing/adapter พิสูจน์แล้วข้างบนว่า implements compile ผ่านใครก็ implement ได้ด้วย internal representation ที่ต่างไปเลย — ไม่มี code ร่วมกันเลย
baseรับประกันว่า inheritance จริงผ่าน extends เท่านั้น บล็อกของปลอมจาก implementsยังเปิดให้ subclass ถูกต้องนอก library ได้ ต่างจาก final
==/hashCode เขียนเอง เทียบกับ Equatableไม่ต้องเพิ่ม dependency ควบคุมได้เต็มที่boilerplate ต่อ field เยอะขึ้น และลืม update ทั้งคู่เมื่อเพิ่ม field ได้ง่าย
covariantให้ subclass แคบ parameter type ได้จริงเมื่อคุณรู้ว่าปลอดภัยopt-out เช็ค soundness จริง — compiler ไม่ป้องกันการเรียกที่ unsound ให้อีกต่อไป
  • “sealed, final, base, และ interface แต่ละตัวมี error code เฉพาะของตัวเอง” พิสูจน์แล้วว่าผิดข้างบน: ทั้งสี่ตัวใช้ invalid_use_of_type_outside_library ร่วมกัน — ต่างกันแค่ข้อความตาม modifier และตามว่าเป็นการละเมิด extends หรือ implements
  • “with A, B แค่ผสมทั้งคู่เข้าด้วยกันแบบเท่าเทียม ลำดับไม่สำคัญ” พิสูจน์แล้วว่าผิดข้างบนด้วย test จริง: with A, B กับ with B, A ให้ผลลัพธ์ greet() ต่างกัน ('B->A->Base' กับ 'A->B->Base') เพราะ linearization สร้าง chain ที่มีลำดับจริง
  • “mixin class ก็แค่ mixin ที่ใช้ keyword ต่างกัน” ใกล้เคียง แต่พิสูจน์แล้วข้างบนว่า mixin class ประกาศ generative constructor แบบไม่ trivial ไม่ได้เฉพาะ (mixin_class_declares_non_trivial_generative_constructor) — ข้อจำกัดที่ class ธรรมดาไม่มี เพราะ declaration เดียวกันต้องใช้ได้ตอน mix เข้าด้วย with ด้วย
  • “implements ให้ code reuse แบบเดียวกับ extends แค่ keyword ต่าง” พิสูจน์แล้วว่าผิดข้างบน: UnitSquare implements Shape ต้อง reimplement ทุกอย่างใหม่หมด ส่วน UnitCircle extends ShapeBase ได้ describe() มาฟรี — implements inherit แค่ interface ไม่เคย inherit implementation
  • “object สองตัวที่มี field value เหมือนกันเป๊ะจะ == กันเสมอใน Dart” ผิดถ้าไม่ override ชัดเจน — พิสูจน์แล้วข้างบน == ของ Point ต้องเขียนเอง (หรือ generate ผ่าน Equatable); Object.== default คือเทียบด้วย identity ดังนั้น instance ที่โครงสร้างเท่ากันสองตัวจะ != จนกว่า class จะ opt-in
factory constructor ทำอะไรได้ที่ normal constructor ทำไม่ได้?
mixin ใน Dart/Flutter ใช้ทำอะไร?
ใน class Combined extends Base with A, B พิสูจน์แล้วข้างบนด้วย test จริง override ของใครชนะ และ super ของมันไปถึงไหน?
sealed, final, base, และ interface class ทั้งสี่แชร์ flutter analyze error จริงตัวไหนเมื่อถูกละเมิดข้าม library boundary ตามที่บทนี้พิสูจน์?
ทำไม Point ต้องเขียน == และ hashCode เองถึงจะทำงานถูกต้องใน Set ตามที่พิสูจน์ข้างบน?
ทำไมการ override Box.addAll(List<num>) ด้วย IntBox.addAll(List<int>) ต้องใช้ keyword covariant พิสูจน์แล้วข้างบนด้วย error จริง?