Polymorphism
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!
}
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!
}
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
}
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]
}
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.