Inheritance & the extends Keyword
What Is Inheritance?
Inheritance is one of the four pillars of OOP. It lets you create a new class (called a subclass or child class) that inherits all the properties and methods of an existing class (called a superclass or parent class). The subclass can then add new features or modify inherited behavior. Think of it as saying: “A Dog is a Animal” -- the Dog class inherits everything from Animal and adds dog-specific features.
Inheritance promotes code reuse. Instead of copying the same properties and methods into multiple classes, you put the shared code in a parent class and let children inherit it.
Basic Inheritance with extends
Use the extends keyword to create a subclass. The child inherits all non-private members of the parent.
Your First Inheritance
// Parent class (superclass)
class Animal {
String name;
int age;
Animal(this.name, this.age);
void eat() {
print('$name is eating.');
}
void sleep() {
print('$name is sleeping.');
}
@override
String toString() => 'Animal($name, age: $age)';
}
// Child class (subclass) -- inherits from Animal
class Dog extends Animal {
String breed;
Dog(String name, int age, this.breed) : super(name, age);
// New method specific to Dog
void bark() {
print('$name says: Woof! Woof!');
}
}
// Another child class
class Cat extends Animal {
bool isIndoor;
Cat(String name, int age, {this.isIndoor = true}) : super(name, age);
void meow() {
print('$name says: Meow!');
}
}
void main() {
Dog dog = Dog('Rex', 5, 'German Shepherd');
Cat cat = Cat('Luna', 3, isIndoor: true);
// Inherited methods work
dog.eat(); // Rex is eating.
dog.sleep(); // Rex is sleeping.
dog.bark(); // Rex says: Woof! Woof!
cat.eat(); // Luna is eating.
cat.meow(); // Luna says: Meow!
// Inherited properties work
print(dog.name); // Rex
print(dog.age); // 5
print(dog.breed); // German Shepherd
}
class Dog extends Animal, Pet. For multiple inheritance-like behavior, Dart uses mixins (covered in a later lesson).The super Keyword
The super keyword refers to the parent class. You use it to:
- Call the parent’s constructor:
super(args) - Call the parent’s methods:
super.methodName() - Access the parent’s properties:
super.propertyName
Using super in Constructors
class Vehicle {
String make;
String model;
int year;
Vehicle({required this.make, required this.model, required this.year});
String get info => '$year $make $model';
}
class Car extends Vehicle {
int doors;
String fuelType;
// Call parent constructor with super
Car({
required super.make,
required super.model,
required super.year,
this.doors = 4,
this.fuelType = 'Gasoline',
});
@override
String get info => '${super.info} ($doors-door, $fuelType)';
}
class ElectricCar extends Car {
double batteryCapacity; // kWh
ElectricCar({
required super.make,
required super.model,
required super.year,
super.doors,
required this.batteryCapacity,
}) : super(fuelType: 'Electric');
double get rangeKm => batteryCapacity * 6; // Simplified estimate
@override
String get info => '${super.info} [${batteryCapacity}kWh, ~${rangeKm}km range]';
}
void main() {
var civic = Car(make: 'Honda', model: 'Civic', year: 2024);
print(civic.info); // 2024 Honda Civic (4-door, Gasoline)
var tesla = ElectricCar(
make: 'Tesla', model: 'Model 3', year: 2024,
batteryCapacity: 75,
);
print(tesla.info);
// 2024 Tesla Model 3 (4-door, Electric) [75.0kWh, ~450.0km range]
}
super.paramName syntax in constructors (like required super.make) is a Dart 3 shorthand. It automatically passes the parameter to the parent constructor. Before Dart 3, you had to write: Car({required String make, ...}) : super(make: make, ...).Overriding Methods
Method overriding lets a subclass provide its own implementation of a method defined in the parent class. Use the @override annotation to signal your intent.
Overriding Methods
class Shape {
String color;
Shape(this.color);
double get area => 0;
void describe() {
print('A $color shape with area ${area.toStringAsFixed(2)}');
}
}
class Circle extends Shape {
double radius;
Circle(String color, this.radius) : super(color);
@override
double get area => 3.14159 * radius * radius;
@override
void describe() {
print('A $color circle with radius $radius and area ${area.toStringAsFixed(2)}');
}
}
class Rectangle extends Shape {
double width;
double height;
Rectangle(String color, this.width, this.height) : super(color);
@override
double get area => width * height;
@override
void describe() {
// Call parent method first, then add more info
super.describe();
print(' Dimensions: ${width} x $height');
}
}
void main() {
Shape shape = Shape('red');
Circle circle = Circle('blue', 5);
Rectangle rect = Rectangle('green', 10, 4);
shape.describe();
// A red shape with area 0.00
circle.describe();
// A blue circle with radius 5.0 and area 78.54
rect.describe();
// A green shape with area 40.00
// Dimensions: 10.0 x 4.0
}
@override annotation is technically optional, always include it. It tells Dart (and other developers) that you intentionally override a parent method. If you misspell the method name, the analyzer will warn you that you are not actually overriding anything -- catching a bug early.Calling super Methods
When you override a method, you can still call the parent’s version using super.methodName(). This is useful when you want to extend (not replace) the parent’s behavior.
Extending Parent Behavior
class Logger {
void log(String message) {
print('[LOG] $message');
}
}
class TimestampLogger extends Logger {
@override
void log(String message) {
// Add timestamp, then call parent
String timestamp = DateTime.now().toIso8601String().substring(11, 19);
super.log('[$timestamp] $message');
}
}
class FileLogger extends TimestampLogger {
final List<String> _logHistory = [];
@override
void log(String message) {
_logHistory.add(message); // Save to history
super.log(message); // Call parent (which adds timestamp)
}
List<String> get history => List.unmodifiable(_logHistory);
}
void main() {
var logger = FileLogger();
logger.log('App started');
logger.log('User logged in');
// Output:
// [LOG] [14:30:25] App started
// [LOG] [14:30:25] User logged in
print(logger.history); // [App started, User logged in]
}
Inheritance Chains
Inheritance can go multiple levels deep. Each class inherits from its parent, which inherits from its parent, all the way up to Object (the root of every Dart class).
Multi-Level Inheritance
class LivingThing {
bool isAlive = true;
void breathe() => print('Breathing...');
}
class Animal extends LivingThing {
String name;
Animal(this.name);
void move() => print('$name is moving.');
}
class Pet extends Animal {
String ownerName;
Pet(String name, this.ownerName) : super(name);
void greetOwner() => print('$name greets $ownerName!');
}
class Dog extends Pet {
String breed;
Dog(String name, String ownerName, this.breed) : super(name, ownerName);
void fetch() => print('$name fetches the ball!');
}
void main() {
var dog = Dog('Rex', 'Ahmed', 'Labrador');
// From LivingThing
dog.breathe(); // Breathing...
print(dog.isAlive); // true
// From Animal
dog.move(); // Rex is moving.
// From Pet
dog.greetOwner(); // Rex greets Ahmed!
// From Dog
dog.fetch(); // Rex fetches the ball!
// Dog IS-A Pet IS-A Animal IS-A LivingThing IS-A Object
print(dog is Dog); // true
print(dog is Pet); // true
print(dog is Animal); // true
print(dog is LivingThing); // true
print(dog is Object); // true
}
The is and as Operators
Use is to check if an object is an instance of a class (including parent classes). Use as to cast an object to a specific type.
Type Checking and Casting
class Employee {
String name;
double salary;
Employee(this.name, this.salary);
void work() => print('$name is working.');
}
class Manager extends Employee {
List<Employee> team;
Manager(String name, double salary, {List<Employee>? team})
: team = team ?? [],
super(name, salary);
void conductMeeting() => print('$name is conducting a meeting with ${team.length} people.');
}
class Developer extends Employee {
String language;
Developer(String name, double salary, this.language) : super(name, salary);
void code() => print('$name is coding in $language.');
}
void processEmployee(Employee emp) {
emp.work(); // All employees can work
// Type checking with is
if (emp is Manager) {
emp.conductMeeting(); // Dart auto-casts after is check (smart cast)
} else if (emp is Developer) {
emp.code(); // Smart cast -- no need for (emp as Developer).code()
}
}
void main() {
List<Employee> staff = [
Manager('Sara', 120000),
Developer('Ahmed', 95000, 'Dart'),
Developer('Khalid', 90000, 'Python'),
Manager('Fatima', 115000),
];
for (var emp in staff) {
processEmployee(emp);
print('---');
}
// Sara is working.
// Sara is conducting a meeting with 0 people.
// ---
// Ahmed is working.
// Ahmed is coding in Dart.
// ---
// ...
}
is check, Dart automatically promotes the variable to the checked type within that block. So after if (emp is Developer), you can call emp.code() without explicitly casting. This is called type promotion and eliminates the need for most as casts.Overriding toString and Equality
Overriding toString, == and hashCode
class Coordinate {
final double x;
final double y;
const Coordinate(this.x, this.y);
// Override toString for readable output
@override
String toString() => 'Coordinate($x, $y)';
// Override == for value equality
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is Coordinate && other.x == x && other.y == y;
}
// Override hashCode (must match ==)
@override
int get hashCode => x.hashCode ^ y.hashCode;
}
void main() {
var a = Coordinate(1, 2);
var b = Coordinate(1, 2);
var c = Coordinate(3, 4);
print(a); // Coordinate(1.0, 2.0)
print(a == b); // true (value equality!)
print(a == c); // false
print(identical(a, b)); // false (different objects)
// Works correctly in collections
var set = {a, b, c};
print(set.length); // 2 (a and b are "equal")
}
When to Use Inheritance
Inheritance should model an “is-a” relationship. Ask yourself: “Is [child] a [parent]?” If yes, inheritance makes sense. If not, use composition (having a property of another type) instead.
Is-A vs Has-A
// GOOD: Is-a relationship -- inheritance
class Animal { void eat() {} }
class Dog extends Animal { void bark() {} }
// A Dog IS-A Animal -- correct!
// BAD: Not an is-a relationship
class Engine { void start() {} }
// class Car extends Engine {} // A Car IS-A Engine? NO!
// GOOD: Has-a relationship -- composition
class Car {
Engine engine; // A Car HAS-A Engine -- correct!
Car(this.engine);
void start() => engine.start();
}
void main() {
var engine = Engine();
var car = Car(engine);
car.start();
}
Car should not extend Engine just because it needs engine functionality -- it should contain an Engine.Practical Example: Notification System
Real-World Inheritance Example
class Notification {
final String title;
final String message;
final DateTime createdAt;
bool _isRead = false;
Notification({required this.title, required this.message})
: createdAt = DateTime.now();
bool get isRead => _isRead;
void markAsRead() {
_isRead = true;
}
String get preview => message.length > 50
? '${message.substring(0, 50)}...'
: message;
@override
String toString() => '[${isRead ? "Read" : "New"}] $title: $preview';
}
class EmailNotification extends Notification {
final String senderEmail;
final bool hasAttachment;
EmailNotification({
required super.title,
required super.message,
required this.senderEmail,
this.hasAttachment = false,
});
@override
String toString() {
String attach = hasAttachment ? ' [Attachment]' : '';
return 'Email from $senderEmail$attach - ${super.toString()}';
}
}
class PushNotification extends Notification {
final String appName;
final String? actionUrl;
PushNotification({
required super.title,
required super.message,
required this.appName,
this.actionUrl,
});
bool get hasAction => actionUrl != null;
@override
String toString() => 'Push [$appName] - ${super.toString()}';
}
class SMSNotification extends Notification {
final String phoneNumber;
SMSNotification({
required super.title,
required super.message,
required this.phoneNumber,
});
@override
String toString() => 'SMS from $phoneNumber - ${super.toString()}';
}
void main() {
List<Notification> inbox = [
EmailNotification(
title: 'Meeting Tomorrow',
message: 'Don't forget the team standup at 9 AM',
senderEmail: 'manager@company.com',
),
PushNotification(
title: 'New Update',
message: 'Version 2.0 is now available with exciting new features and improvements',
appName: 'MyApp',
actionUrl: '/update',
),
SMSNotification(
title: 'Verification',
message: 'Your code is 123456',
phoneNumber: '+1234567890',
),
];
// All notifications share the same interface
print('--- Inbox (${inbox.length} notifications) ---');
for (var notif in inbox) {
print(notif);
}
// Mark first as read
inbox[0].markAsRead();
print('\nAfter reading first:');
print(inbox[0]);
// Count unread
int unread = inbox.where((n) => !n.isRead).length;
print('\nUnread: $unread');
// Type-specific behavior
for (var notif in inbox) {
if (notif is PushNotification && notif.hasAction) {
print('Action available: ${notif.actionUrl}');
}
}
}
Practice Exercise
Open DartPad and build a media library system: (1) Create a base class MediaItem with properties: title, creator, year, _rating (double, private, 0-5). Add a setter for rating with validation, a getter ratingStars that returns stars like “★★★☆☆”, and a method describe(). (2) Create Movie extends MediaItem with durationMinutes and genre. Override describe() to include duration. (3) Create Song extends MediaItem with album and durationSeconds. Add a getter formattedDuration that returns “3:45” format. (4) Create Podcast extends MediaItem with episodeNumber and series. (5) Create a List<MediaItem> with mixed types, loop through them, call describe(), and use is checks for type-specific output.