Dart Object-Oriented Programming

Polymorphism

45 min Lesson 7 of 8

What Is Polymorphism?

Polymorphism means “many forms”. It is the ability of different objects to respond to the same method call in different ways. You call .area on a Circle and get a circle’s area; you call .area on a Rectangle and get a rectangle’s area -- same method name, different behavior. The caller does not need to know which type of shape it is.

Polymorphism is one of the four pillars of OOP and arguably the most powerful. It is the foundation of plugin architectures, dependency injection, and the entire Flutter widget system.

Polymorphism Through Inheritance

The most common form of polymorphism is when a parent class variable holds a child class object. The child’s overridden method runs, not the parent’s:

Basic Polymorphism

class Animal {
  String name;
  Animal(this.name);

  String speak() => '$name makes a sound';
}

class Dog extends Animal {
  Dog(String name) : super(name);

  @override
  String speak() => '$name says: Woof!';
}

class Cat extends Animal {
  Cat(String name) : super(name);

  @override
  String speak() => '$name says: Meow!';
}

class Duck extends Animal {
  Duck(String name) : super(name);

  @override
  String speak() => '$name says: Quack!';
}

void main() {
  // All stored as the PARENT type Animal
  List<Animal> animals = [
    Dog('Rex'),
    Cat('Luna'),
    Duck('Donald'),
    Dog('Buddy'),
    Cat('Whiskers'),
  ];

  // Same method call, DIFFERENT behavior
  for (var animal in animals) {
    print(animal.speak());
  }
  // Rex says: Woof!
  // Luna says: Meow!
  // Donald says: Quack!
  // Buddy says: Woof!
  // Whiskers says: Meow!
}
The Magic: The for loop does not know or care whether each animal is a Dog, Cat, or Duck. It just calls speak() and the correct version runs automatically. This is polymorphism -- write one loop that works with any animal type, even types that don’t exist yet.

Why Polymorphism Is Powerful

Without polymorphism, you would need ugly type-checking code everywhere:

Without vs With Polymorphism

// WITHOUT polymorphism -- fragile, must change for every new type
void makeAnimalSpeak(dynamic animal) {
  if (animal is Dog) {
    print(animal.bark());
  } else if (animal is Cat) {
    print(animal.meow());
  } else if (animal is Duck) {
    print(animal.quack());
  }
  // Add a Parrot? Must edit this function!
  // Add a Snake? Must edit again!
}

// WITH polymorphism -- clean, works with ANY animal forever
void makeAnimalSpeak(Animal animal) {
  print(animal.speak());  // Just works. Always.
  // Add a Parrot that extends Animal? Works automatically!
  // Add a Snake? Works automatically!
}
Open/Closed Principle: Polymorphism enables the Open/Closed Principle -- your code is open for extension (add new animal types) but closed for modification (the function never changes). This is one of the most important principles in software engineering.

Polymorphism with Abstract Classes

Abstract classes are the most common way to set up polymorphism. The abstract class defines the contract, and subclasses provide the implementations:

Abstract Class Polymorphism

abstract class PaymentMethod {
  String get name;
  double get processingFee;

  bool validate();
  Future<bool> processPayment(double amount);

  // Concrete method using abstract methods -- polymorphism in action
  Future<String> pay(double amount) async {
    if (!validate()) return 'Validation failed for $name';

    double total = amount + processingFee;
    bool success = await processPayment(total);
    return success
        ? 'Paid \$${total.toStringAsFixed(2)} via $name'
        : 'Payment failed via $name';
  }
}

class CreditCard extends PaymentMethod {
  final String cardNumber;
  final String expiry;

  CreditCard(this.cardNumber, this.expiry);

  @override
  String get name => 'Credit Card';

  @override
  double get processingFee => 2.50;

  @override
  bool validate() => cardNumber.length == 16 && expiry.isNotEmpty;

  @override
  Future<bool> processPayment(double amount) async {
    print('Charging \$${amount.toStringAsFixed(2)} to card ending ${cardNumber.substring(12)}...');
    return true;
  }
}

class PayPal extends PaymentMethod {
  final String email;

  PayPal(this.email);

  @override
  String get name => 'PayPal';

  @override
  double get processingFee => 1.50;

  @override
  bool validate() => email.contains('@');

  @override
  Future<bool> processPayment(double amount) async {
    print('Sending \$${amount.toStringAsFixed(2)} via PayPal to $email...');
    return true;
  }
}

class BankTransfer extends PaymentMethod {
  final String iban;

  BankTransfer(this.iban);

  @override
  String get name => 'Bank Transfer';

  @override
  double get processingFee => 0;

  @override
  bool validate() => iban.length >= 15;

  @override
  Future<bool> processPayment(double amount) async {
    print('Transferring \$${amount.toStringAsFixed(2)} to IBAN $iban...');
    return true;
  }
}

// This function works with ANY payment method -- now and in the future
Future<void> checkout(double amount, PaymentMethod method) async {
  String result = await method.pay(amount);
  print(result);
}

void main() async {
  var methods = <PaymentMethod>[
    CreditCard('4111111111111111', '12/26'),
    PayPal('ahmed@example.com'),
    BankTransfer('AE070331234567890123456'),
  ];

  for (var method in methods) {
    await checkout(99.99, method);
    print('---');
  }
}

Polymorphism with Interfaces

Interfaces (using implements) provide another form of polymorphism. The implementing class must provide all methods, and code can work with any implementation:

Interface Polymorphism

abstract class Exportable {
  String export();
  String get fileExtension;
}

class JsonExporter implements Exportable {
  final Map<String, dynamic> data;
  JsonExporter(this.data);

  @override
  String export() => '{${data.entries.map((e) => '"${e.key}": "${e.value}"').join(', ')}}';

  @override
  String get fileExtension => '.json';
}

class CsvExporter implements Exportable {
  final List<String> headers;
  final List<List<String>> rows;

  CsvExporter(this.headers, this.rows);

  @override
  String export() {
    String header = headers.join(',');
    String body = rows.map((row) => row.join(',')).join('\n');
    return '$header\n$body';
  }

  @override
  String get fileExtension => '.csv';
}

class XmlExporter implements Exportable {
  final String rootTag;
  final Map<String, String> data;

  XmlExporter(this.rootTag, this.data);

  @override
  String export() {
    String items = data.entries.map((e) => '  <${e.key}>${e.value}</${e.key}>').join('\n');
    return '<$rootTag>\n$items\n</$rootTag>';
  }

  @override
  String get fileExtension => '.xml';
}

// Works with ANY exporter -- polymorphism!
void saveFile(Exportable exporter, String fileName) {
  String content = exporter.export();
  print('Saving $fileName${exporter.fileExtension}:');
  print(content);
  print('');
}

void main() {
  saveFile(
    JsonExporter({'name': 'Ahmed', 'role': 'Developer'}),
    'user',
  );

  saveFile(
    CsvExporter(['Name', 'Age'], [['Ahmed', '25'], ['Sara', '30']]),
    'users',
  );

  saveFile(
    XmlExporter('user', {'name': 'Ahmed', 'role': 'Developer'}),
    'user',
  );
}

Runtime Type vs Compile-Time Type

When you store a child object in a parent-type variable, the variable has two types:

Two Types at Play

class Shape {
  double get area => 0;
  void describe() => print('I am a shape');
}

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

  @override
  double get area => 3.14159 * radius * radius;

  @override
  void describe() => print('I am a circle with radius $radius');

  // Circle-specific method
  double get circumference => 2 * 3.14159 * radius;
}

void main() {
  Shape s = Circle(5);  // Compile-time type: Shape, Runtime type: Circle

  // These work -- defined in Shape (compile-time type)
  print(s.area);      // 78.54 (Circle's override runs!)
  s.describe();       // I am a circle (Circle's override runs!)

  // This does NOT work -- circumference is not in Shape
  // print(s.circumference);  // Compile error!

  // To access Circle-specific members, use type check + cast
  if (s is Circle) {
    print(s.circumference);  // 31.42 (smart cast after is check)
  }

  // Runtime type checking
  print(s.runtimeType);   // Circle
  print(s is Shape);      // true
  print(s is Circle);     // true
}
Key Insight: The compile-time type (Shape) determines what methods you can call. The runtime type (Circle) determines which override actually runs. This is why s.area returns the Circle’s area even though s is declared as Shape.

Polymorphism with Collections

Polymorphism shines when processing collections of mixed types:

Processing Mixed Collections

abstract class Notification {
  String get title;
  String get body;
  String get channel;

  void send() {
    print('[$channel] $title: $body');
  }
}

class EmailNotification extends Notification {
  final String to;
  final String subject;
  final String message;

  EmailNotification({required this.to, required this.subject, required this.message});

  @override
  String get title => subject;
  @override
  String get body => message;
  @override
  String get channel => 'Email';

  @override
  void send() {
    print('Sending email to $to...');
    super.send();
  }
}

class SmsNotification extends Notification {
  final String phone;
  final String text;

  SmsNotification({required this.phone, required this.text});

  @override
  String get title => 'SMS';
  @override
  String get body => text;
  @override
  String get channel => 'SMS';

  @override
  void send() {
    print('Sending SMS to $phone...');
    super.send();
  }
}

class PushNotification extends Notification {
  final String deviceToken;
  final String heading;
  final String content;

  PushNotification({required this.deviceToken, required this.heading, required this.content});

  @override
  String get title => heading;
  @override
  String get body => content;
  @override
  String get channel => 'Push';
}

void main() {
  // Mixed collection -- all treated as Notification
  List<Notification> queue = [
    EmailNotification(to: 'ahmed@test.com', subject: 'Welcome!', message: 'Thanks for joining.'),
    SmsNotification(phone: '+971501234567', text: 'Your code is 1234'),
    PushNotification(deviceToken: 'abc123', heading: 'New Update', content: 'v2.0 is here!'),
    EmailNotification(to: 'sara@test.com', subject: 'Invoice', message: 'Your invoice is ready.'),
  ];

  // One loop handles ALL notification types
  print('Sending ${queue.length} notifications:');
  for (var notification in queue) {
    notification.send();
    print('');
  }

  // Filtering by type
  var emails = queue.whereType<EmailNotification>().toList();
  print('Email count: ${emails.length}');  // 2
}

Polymorphism in Flutter

Flutter is built entirely on polymorphism. Every widget is a Widget, every layout is a Widget, every button is a Widget:

How Flutter Uses Polymorphism

// Simplified Flutter-like example
abstract class Widget {
  void render();
}

class Text extends Widget {
  final String data;
  Text(this.data);

  @override
  void render() => print('Rendering text: "$data"');
}

class Button extends Widget {
  final String label;
  final Function() onPressed;
  Button(this.label, this.onPressed);

  @override
  void render() {
    print('Rendering button: [$label]');
  }
}

class Column extends Widget {
  final List<Widget> children;
  Column(this.children);

  @override
  void render() {
    print('Rendering column with ${children.length} children:');
    for (var child in children) {
      child.render();  // Polymorphism! Each child renders differently
    }
  }
}

void main() {
  // Column contains mixed widget types -- polymorphism everywhere
  var screen = Column([
    Text('Hello, World!'),
    Button('Click Me', () => print('Clicked!')),
    Text('Welcome to Flutter'),
    Column([
      Text('Nested text'),
      Button('Nested button', () {}),
    ]),
  ]);

  screen.render();
  // Rendering column with 4 children:
  // Rendering text: "Hello, World!"
  // Rendering button: [Click Me]
  // Rendering text: "Welcome to Flutter"
  // Rendering column with 2 children:
  //   Rendering text: "Nested text"
  //   Rendering button: [Nested button]
}
Flutter Insight: In real Flutter, Column(children: [Text('Hi'), ElevatedButton(...), Image(...)]) works because they all extend Widget. The Column does not know what specific widgets it contains -- it just calls build() on each one and the correct widget renders. This is polymorphism making Flutter possible.

Practical Example: Report Generator

Polymorphic Report System

abstract class ReportSection {
  String get title;
  String generate();

  @override
  String toString() => '=== $title ===\n${generate()}';
}

class TextSection extends ReportSection {
  @override
  final String title;
  final String content;

  TextSection(this.title, this.content);

  @override
  String generate() => content;
}

class TableSection extends ReportSection {
  @override
  final String title;
  final List<String> headers;
  final List<List<String>> rows;

  TableSection(this.title, this.headers, this.rows);

  @override
  String generate() {
    String header = headers.join(' | ');
    String divider = headers.map((h) => '-' * h.length).join('-+-');
    String body = rows.map((r) => r.join(' | ')).join('\n');
    return '$header\n$divider\n$body';
  }
}

class SummarySection extends ReportSection {
  @override
  final String title;
  final Map<String, dynamic> stats;

  SummarySection(this.title, this.stats);

  @override
  String generate() =>
      stats.entries.map((e) => '${e.key}: ${e.value}').join('\n');
}

class Report {
  final String name;
  final List<ReportSection> sections;

  Report(this.name, this.sections);

  void print_report() {
    print('╔${"═" * (name.length + 4)}╗');
    print('║  $name  ║');
    print('╚${"═" * (name.length + 4)}╝\n');

    for (var section in sections) {
      print(section);  // Polymorphism! Each section generates differently
      print('');
    }
  }
}

void main() {
  var report = Report('Monthly Sales Report', [
    TextSection('Overview', 'Sales performance for March 2024 exceeded targets by 15%.'),
    TableSection('Top Products', ['Product', 'Units', 'Revenue'], [
      ['Widget A', '150', '\$4,500'],
      ['Widget B', '89', '\$2,670'],
      ['Widget C', '210', '\$6,300'],
    ]),
    SummarySection('Key Metrics', {
      'Total Revenue': '\$13,470',
      'Total Units': 449,
      'Average Order': '\$30.00',
      'Growth': '+15%',
    }),
  ]);

  report.print_report();
}

Practice Exercise

Open DartPad and build a shape calculator: (1) Create an abstract class Shape with abstract getters area and perimeter, a name getter, and a concrete method describe() that prints all info. (2) Create Circle, Rectangle, Triangle (using Heron’s formula), and Square extends Rectangle. (3) Create a function printShapeReport(List<Shape> shapes) that prints each shape’s description, finds the largest by area, the smallest by perimeter, and calculates the total area. (4) Create a List<Shape> with at least 6 mixed shapes and pass it to the function. (5) Use whereType<Circle>() to filter only circles and print their count.