Abstract Classes & Polymorphism

Polymorphism is often described as the most powerful pillar of OOP. It is the ability to write code that works with a family of types through a single shared interface — code that automatically adapts to each type without needing to know which one it’s dealing with. Abstract classes are the mechanism that makes this safe, enforced, and compiler-guaranteed. By the end of this article, you’ll understand why experienced developers reach for abstract classes first when designing a system.

1. The four pillars — where we are

We’ve covered two pillars. Article 4 tackles the third — and arguably most impactful — one. Polymorphism does not require new syntax to understand; it requires a shift in how you think about writing code that works with objects.

2. The fragile base class problem

At the end of Article 3, our Message base class looks like this:

message.dart (Article 3 version)

class Message {
final String id;
final String senderName;
final DateTime timestamp;
bool _isRead;

Message({ required this.id, required this.senderName,
required this.timestamp, bool isRead = false })
: _isRead = isRead;

// This is fragile — nothing forces subclasses to override it
String preview() => 'Message';

void markAsRead() => _isRead = true;
bool get isRead => _isRead;
}

This design has two quiet problems:

Problem 1 — Direct instantiation. Nothing stops a developer from writing final m = Message(id: '1', senderName: 'Alice', timestamp: DateTime.now()). But a bare Message object has no content — no text, no image, no file. It is a meaningless object that should never exist on its own. The design should make it impossible to create one.

Problem 2 — Forgettable override. If a new developer adds a ReactionMessage subclass and forgets to override preview(), the chat list will silently display the useless string "Message" for every reaction. The compiler won’t say a word. The bug only appears at runtime, in production, when a user notices something looks wrong.

❌ Fragile — silent failures

// Both of these compile fine:

// Problem 1: meaningless object
final m = Message(
id: 'x',
senderName: 'Alice',
timestamp: DateTime.now(),
);

// Problem 2: forgot to override
class ReactionMessage
extends Message {
String emoji;
// No preview() override...
// shows "Message" at runtime
}

✅ Abstract — compiler enforced

// Now the compiler catches both:

// Problem 1: compile error!
// Cannot instantiate abstract class
final m = Message(...); // ❌

// Problem 2: compile error!
// Missing concrete impl of preview()
class ReactionMessage
    extends Message {
  String emoji;
  // ❌ must implement preview()
}

The solution to both problems is one keyword: abstract.

3. The abstract keyword

Marking a class abstract does exactly two things:

First, it prevents the class from being directly instantiated. Message() becomes a compile error. The class can only be used as a base that other classes extend.

Second, it allows (but does not require) the class to declare abstract methods — method signatures with no body, which subclasses are then required to implement.

dart

// Adding the abstract keyword to the class declaration
abstract class Message {
  // ... fields and concrete methods are unchanged
}

void main() {
  // ❌ Compile error: Cannot instantiate abstract class 'Message'
  // final m = Message(id: '1', senderName: 'Alice', timestamp: ...)

  // ✅ This is fine — TextMessage is concrete
  final m = TextMessage(id: '1', senderName: 'Alice',
      timestamp: DateTime.now(), text: 'Hello!');
}

Intent matters

The abstract keyword is also a communication tool. When another developer reads abstract class Message, they immediately understand: this class exists to be extended, not instantiated. The design is self-documenting.

4. Abstract methods — mandatory contracts

An abstract method is a method declared inside an abstract class with no body — just the signature. It is a contract: any non-abstract subclass must provide a concrete implementation, or it won’t compile.

abstract class Shape {
  // Abstract method — no body, no braces, just a semicolon
  double area();
  double perimeter();

  // Concrete method — has a body, inherited as-is
  void describe() {
    print('Area: ${area().toStringAsFixed(2)}, '
          'Perimeter: ${perimeter().toStringAsFixed(2)}');
  }
}

class Circle extends Shape {
  final double radius;
  Circle(this.radius);

  @override
  double area() => 3.14159 * radius * radius;

  @override
  double perimeter() => 2 * 3.14159 * radius;
}

class Rectangle extends Shape {
  final double width, height;
  Rectangle(this.width, this.height);

  @override
  double area() => width * height;

  @override
  double perimeter() => 2 * (width + height);
}

// ❌ This class won't compile — it's missing area() and perimeter()
// class Triangle extends Shape {
//   final double base, height;
//   Triangle(this.base, this.height);
// }

void main() {
  final shapes = [Circle(5), Rectangle(4, 6)];
  for (final s in shapes) {
    s.describe(); // inherited concrete method calls overridden area/perimeter
  }
  // Area: 78.54, Perimeter: 31.42
  // Area: 24.00, Perimeter: 20.00
}

Notice the abstract methods: double area(); and double perimeter();. No braces, no body — just a semicolon. Every concrete subclass must implement them. The compiler verifies this at compile time, not runtime.

Abstract classes cannot have abstract constructors

You can define constructors in an abstract class — they are used by subclasses via super(). But you cannot mark a constructor itself as abstract. The constructor of an abstract class exists purely to be called by child constructors.

5. Mixing abstract and concrete members

One of the most useful aspects of abstract classes is that they can contain both abstract and concrete members in any combination. This gives you fine-grained control over what subclasses must implement versus what they inherit for free.

abstract class Report {
  final String title;
  final DateTime generatedAt;

  Report(this.title) : generatedAt = DateTime.now();

  // ── Abstract: subclasses MUST implement these ──────
  String generateBody();    // the actual report content
  String getFormat();       // 'PDF', 'CSV', 'HTML', etc.

  // ── Concrete: shared by all subclasses ─────────────
  String generateHeader() {
    return '=== $title ===\nGenerated: $generatedAt\n';
  }

  // Calls the abstract method — works for all subclasses
  String generate() {
    return '${generateHeader()}${generateBody()}';
  }

  @override
  String toString() => 'Report($title, format: ${getFormat()})';
}

class SalesReport extends Report {
  final double totalSales;
  SalesReport(this.totalSales) : super('Sales Report');

  @override
  String generateBody() => 'Total Sales: \$$totalSales\n';

  @override
  String getFormat() => 'PDF';
}

class UserReport extends Report {
  final int activeUsers;
  UserReport(this.activeUsers) : super('User Activity Report');

  @override
  String generateBody() => 'Active Users: $activeUsers\n';

  @override
  String getFormat() => 'CSV';
}

void main() {
  final reports = [SalesReport(48500.0), UserReport(1024)];
  for (final r in reports) {
    print(r.generate()); // header is shared; body is unique
  }
}

The generate() method is defined once in the abstract class and calls generateBody() — which each subclass implements differently. The abstract class provides the template; the subclasses fill in the blanks. This pattern is so useful it has a name: the Template Method pattern, which we’ll revisit formally in Article 7.

6. Polymorphism — the main event

Polymorphism comes from Greek: poly (many) + morphe (form). In OOP it means the ability to treat objects of different types uniformly through a shared type — while each object responds in its own specific way.

The most striking demonstration is a single loop that works correctly across an entire family of types:

void main() {
  // A list typed as List<Shape> — holds any concrete subclass
  final List<Shape> shapes = [
    Circle(5),
    Rectangle(4, 6),
    Circle(3),
    Rectangle(10, 2),
  ];

  // One loop — but each shape knows its own area calculation
  for (final shape in shapes) {
    shape.describe();
  }

  // We can even aggregate across all types uniformly
  final totalArea = shapes.fold(0.0, (sum, s) => sum + s.area());
  print('Total area: ${totalArea.toStringAsFixed(2)}');
}

The loop doesn’t know — and doesn’t care — whether it’s dealing with a Circle or a Rectangle. It just calls describe() on a Shape. The correct version of area() runs automatically for each object. That is polymorphism.

The architectural payoff

Code that is written in terms of an abstract type — ShapeMessageReport — automatically works with every type that will ever extend it, including types that don’t exist yet. Add a Triangle class six months from now, implement area() and perimeter(), and the loop above handles it perfectly without a single change. This is what makes polymorphism so transformative: it lets the future extend the present without breaking it.

7. Dynamic dispatch — how it works under the hood

When you call shape.area() on a variable typed as Shape, Dart has to figure out which area() to call at runtime — the Circle version or the Rectangle version. This mechanism is called dynamic dispatch (also called runtime polymorphism).

Dart resolves method calls based on the actual type of the object at runtime, not the declared type of the variable. A variable declared as Shape might hold a Circle at runtime — and Dart will always call Circle‘s area().

dart

void main() {
  // Variable type: Shape. Actual object: Circle.
  Shape shape = Circle(5);

  // Dart looks at the ACTUAL object (Circle), not the variable type (Shape)
  print(shape.area()); // 78.539... — Circle's area() is called

  // Reassign to a Rectangle — same variable, different object
  shape = Rectangle(4, 6);
  print(shape.area()); // 24.0 — Rectangle's area() is called

  // The decision happens at RUNTIME, not compile time
}

Under the hood, each object carries a reference to its class’s method table (a vtable). When Dart calls a method, it looks up the actual runtime type’s method table and calls the correct implementation. This lookup is extremely fast — effectively a single pointer dereference.

Compile-time vs. runtime

The Dart compiler guarantees at compile time that any method you call on a Shape exists in Shape. But the decision of which version of that method to run is made at runtime, based on the actual object. This is the essence of dynamic dispatch.

8. Polymorphism in function signatures

Polymorphism isn’t just about loops. It appears anywhere you write a function that accepts a base type — that function automatically works with every subtype.

// This function accepts any Shape — Circle, Rectangle, future types
void printShapeInfo(Shape shape) {
  print('Type: ${shape.runtimeType}');
  print('Area: ${shape.area().toStringAsFixed(2)}');
  print('Perimeter: ${shape.perimeter().toStringAsFixed(2)}');
}

// This function works on a whole collection of any Shape
Shape findLargest(List<Shape> shapes) {
  return shapes.reduce((a, b) => a.area() > b.area() ? a : b);
}

void main() {
  printShapeInfo(Circle(7));       // works
  printShapeInfo(Rectangle(3, 8)); // works

  final shapes = [Circle(2), Rectangle(10, 5), Circle(8)];
  final largest = findLargest(shapes);
  print('Largest: ${largest.runtimeType} with area ${largest.area().toStringAsFixed(2)}');
  // Largest: Circle with area 201.06
}

printShapeInfo and findLargest are completely agnostic to which concrete type they receive. They work today, and they’ll work tomorrow when someone adds a Triangle or Hexagon — without any modification.

Design principle

Write functions and methods in terms of the most general type that satisfies your needs. If your function only needs to call area(), accept Shape — not Circle. This makes your code reusable across the entire hierarchy, present and future.

9. Building our chat app: the notification system

Now let’s apply everything to our chat app. We’ll do two things: first, upgrade Message to a proper abstract class with enforced abstract methods. Second, build a polymorphic NotificationService that handles all message types without knowing their specifics.

Step 1 — Upgrade Message to an abstract class

// message.dart — now abstract

abstract class Message {
// ── Shared fields ─────────────────────────────────
final String id;
final String senderName;
final DateTime timestamp;
bool _isRead;

// ── Constructor (called by subclasses via super) ───
Message({
required this.id,
required this.senderName,
required this.timestamp,
bool isRead = false,
}) : _isRead = isRead;

// ── Abstract methods — every subclass MUST implement ─

/// A short string for chat list previews, e.g. "Photo"
String preview();

/// The content type, e.g. "text", "image", "voice", "file"
String contentType();

// ── Concrete methods — shared by all subclasses ────
bool get isRead => _isRead;

void markAsRead() => _isRead = true;

String getFormattedTime() {
final h = timestamp.hour.toString().padLeft(2, '0');
final m = timestamp.minute.toString().padLeft(2, '0');
return '$h:$m';
}

// toString() calls the abstract preview() — works for all types
@override
String toString() =>
'[${getFormattedTime()}] $senderName: ${preview()}';

@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is Message && other.id == id && other.runtimeType == runtimeType);

@override
int get hashCode => Object.hash(id, runtimeType);
}

Step 2 — Update subclasses with contentType()

Each subclass now needs to implement both preview() (already done in Article 3) and the new contentType(). Here is the update pattern — shown once for TextMessage, the same applies to the others:

text_message.dart (updated)

class TextMessage extends Message {
  final String text;

  TextMessage({ required String id, required String senderName,
    required DateTime timestamp, required this.text, bool isRead = false })
      : super(id: id, senderName: senderName, timestamp: timestamp, isRead: isRead);

  @override
  String preview() => text.length > 40 ? '${text.substring(0, 40)}...' : text;

  @override
  String contentType() => 'text';
}

// ImageMessage, VoiceMessage, FileMessage follow the same pattern:
// contentType() returns 'image', 'voice', 'file' respectively

Step 3 — The abstract NotificationService

Now for the centrepiece of this article’s chat app section. A notification service needs to know how to display a message — but it shouldn’t care which type of message it is. That’s polymorphism doing the work.

// notification_service.dart

abstract class NotificationService {
  // ── Abstract: each service decides HOW to notify ──
  void sendNotification(String title, String body);

  // ── Concrete: the WHAT to notify is always the same ─
  /// Notify for a single message — works for ANY message type
  void notify(Message message) {
    final title = message.senderName;
    final body  = message.preview(); // ← polymorphic call!
    sendNotification(title, body);
  }

  /// Notify for a batch — summarise instead of spamming
  void notifyBatch(List<Message> messages) {
    if (messages.isEmpty) return;
    if (messages.length == 1) {
      notify(messages.first);
      return;
    }
    final senders = messages.map((m) => m.senderName).toSet().join(', ');
    sendNotification(
      '${messages.length} new messages',
      'From: $senders',
    );
  }

  /// Group messages by content type for analytics
  Map<String, int> getTypeBreakdown(List<Message> messages) {
    final counts = <String, int>{};
    for (final msg in messages) {
      // contentType() is abstract — each type answers differently
      final type = msg.contentType();
      counts[type] = (counts[type] ?? 0) + 1;
    }
    return counts;
  }
}

Step 4 — Two concrete notification services

// push_notification_service.dart

// Simulates sending a push notification to a device
class PushNotificationService extends NotificationService {
final String deviceToken;

PushNotificationService(this.deviceToken);

@override
void sendNotification(String title, String body) {
// In a real app: call Firebase, APNs, etc.
print('PUSH → [$deviceToken]');
print(' Title: $title');
print(' Body: $body');
}
}

// Simulates showing an in-app banner
class InAppNotificationService extends NotificationService {
final List<String> _log = [];

@override
void sendNotification(String title, String body) {
final entry = 'BANNER | $title: $body';
_log.add(entry);
print(entry);
}

List<String> get log => List.unmodifiable(_log);
}

Step 5 — Putting it all together

void main() {
  final now = DateTime.now();

  // A mixed conversation — four different types
  final List<Message> incoming = [
    TextMessage(id: '1', senderName: 'Alice', timestamp: now,
        text: 'Are you coming to the meeting?'),
    ImageMessage(id: '2', senderName: 'Bob', timestamp: now,
        imageUrl: 'https://img.example.com/map.png',
        width: 800, height: 600, caption: 'Here is the venue'),
    VoiceMessage(id: '3', senderName: 'Alice', timestamp: now,
        audioUrl: 'https://audio.example.com/msg.m4a', durationSeconds: 18),
    FileMessage(id: '4', senderName: 'Bob', timestamp: now,
        fileName: 'agenda.pdf', fileUrl: 'https://files.example.com/agenda.pdf',
        fileSizeBytes: 512000),
  ];

  // ── Push notifications ─────────────────────────────
  final push = PushNotificationService('device_abc123');

  print('── Individual notifications ──');
  for (final msg in incoming) {
    // notify() calls preview() — which each type implements differently
    push.notify(msg);
  }

  print('\n── Batch notification ──');
  push.notifyBatch(incoming);

  // ── In-app banners ─────────────────────────────────
  print('\n── In-app banners ──');
  final inApp = InAppNotificationService();
  for (final msg in incoming) {
    inApp.notify(msg);
  }

  // ── Analytics: type breakdown ──────────────────────
  print('\n── Content type breakdown ──');
  final breakdown = push.getTypeBreakdown(incoming);
  breakdown.forEach((type, count) =>
      print('  $type: $count'));
  // text: 1
  // image: 1
  // voice: 1
  // file: 1

  // ── The polymorphism proof ──────────────────────────
  // NotificationService accepts any Message subtype.
  // When we add ReactionMessage next month, this code needs no changes.
  print('\n── Unread messages preview ──');
  incoming
      .where((m) => !m.isRead)
      .forEach((m) => print('  ${m.toString()}'));
}

10. The full abstract class diagram

Two parallel abstract hierarchies. NotificationService.notify() accepts any Message and calls preview() — polymorphically, without knowing the subtype.

11. Exercises

Exercise 1 — Abstract shapes

Create an abstract class Shape with two abstract methods: double area() and double perimeter(), plus a concrete method String describe() that returns a formatted string using both. Implement three concrete subclasses: CircleRectangle, and Triangle (use Heron’s formula for the triangle’s area). Create a List<Shape> with several of each type, then write a function Shape largest(List<Shape> shapes) that finds and returns the one with the greatest area — using only the Shape type, no is-checks.

Exercise 2 — Add a ReactionMessage

Add a fifth message type to the chat app: ReactionMessage. It should extend the abstract Message and add an emoji field (String) and a reactedToMessageId field (String). Implement both required abstract methods — preview() should return something like 'Reacted with :)' and contentType() should return 'reaction'. Then add a ReactionMessage to the conversation list in main() and verify that all notification services handle it correctly without any modification to NotificationService.

Exercise 3 — A log-based NotificationService

Create a third concrete NotificationService called SilentLogService. Instead of sending any real notification, it writes every notification to an internal List<Map<String, String>> log (storing the title and body). Add a method printSummary() that prints how many notifications were logged grouped by the first word of the title (the sender name). Then write a helper function void notifyAll(List<NotificationService> services, Message msg) that dispatches a single message to multiple services — demonstrating polymorphism at both the Message and NotificationService levels simultaneously.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *

Lên đầu trang