Dart Object-Oriented Programming

Inheritance & the extends Keyword

45 min Lesson 4 of 8

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
}
Note: Dart supports single inheritance only -- a class can extend exactly one parent class. You cannot write 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]
}
Dart 3+ Super Parameters: The 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
}
Always use @override: While the @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.
  // ---
  // ...
}
Smart Cast: After an 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();
}
Common Mistake: Don’t use inheritance just to reuse code. If the relationship isn’t truly “is-a”, prefer composition. Over-using inheritance leads to rigid, fragile hierarchies. A 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.