DEV Community

Ge Ji
Ge Ji

Posted on

Dart Lesson 8: Object-Oriented (Part 2) — Inheritance and Polymorphism

Today, we'll dive deeper into two other core features of object-oriented programming — inheritance and polymorphism — along with the use of abstract classes and interfaces. These features help us build more flexible and scalable code structures.

I. Inheritance (extends): A Powerful Tool for Code Reuse

Inheritance allows one class (child class) to inherit properties and methods from another class (parent class), while adding new properties/methods or modifying parent class methods. This greatly improves code reusability.

1. Basic Syntax and Usage

Use the extends keyword to implement inheritance:

// Parent class (base class)
class Animal {
  String name;
  int age;

  // Parent class constructor
  Animal(this.name, this.age);

  // Parent class methods
  void eat() {
    print("$name is eating");
  }

  void sleep() {
    print("$name is sleeping");
  }
}

// Child class (derived class): inherits from Animal
class Dog extends Animal {
  // New property in child class
  String breed;

  // Child class constructor: must call parent class constructor (via super)
  Dog(String name, int age, this.breed) : super(name, age);

  // New method in child class
  void bark() {
    print("$name ($breed) is barking");
  }
}

void main() {
  // Create child class object
  Dog dog = Dog("Wangcai", 3, "Golden Retriever");

  // Access inherited properties and methods
  print("Name: ${dog.name}, Age: ${dog.age}"); // Output: Name: Wangcai, Age: 3
  dog.eat(); // Output: Wangcai is eating
  dog.sleep(); // Output: Wangcai is sleeping

  // Call child class's own method
  dog.bark(); // Output: Wangcai (Golden Retriever) is barking
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Animal is the parent class, defining common features (name, age) and behaviors (eat, sleep) of all animals.
  • Dog is the child class, which inherits properties and methods from Animal via extends Animal, while adding its own breed property and bark method.

2. Method Overriding (@override): Customizing Parent Class Behavior

Child classes can override parent class methods to implement their own unique logic. Use the @override annotation to mark overridden methods (optional but recommended for readability).

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

  void makeSound() {
    print("$name makes a sound");
  }
}

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

  // Override parent class's makeSound method
  @override
  void makeSound() {
    print("$name is meowing"); // Override parent class implementation
  }
}

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

  // Override parent class's makeSound method
  @override
  void makeSound() {
    print("$name is chirping");
  }
}

void main() {
  Cat cat = Cat("Mimi");
  cat.makeSound(); // Output: Mimi is meowing

  Bird bird = Bird("Jiujiu");
  bird.makeSound(); // Output: Jiujiu is chirping
}
Enter fullscreen mode Exit fullscreen mode

The core of method overriding is: child classes replace parent class methods with their own implementations, allowing the same method name to exhibit different behaviors — this is the foundation of polymorphism.

3. The super Keyword: Accessing Parent Class Members

The super keyword is used in child classes to access parent class properties, methods, or constructors:

  • super.methodName(): Call a parent class method
  • super.propertyName: Access a parent class property (rarely used, usually accessed directly)
  • In constructors: super(parameters): Call the parent class constructor
class Parent {
  String name;
  Parent(this.name);

  void greet() {
    print("Hello, I'm $name");
  }
}

class Child extends Parent {
  Child(String name) : super(name); // Call parent class constructor

  @override
  void greet() {
    super.greet(); // Call parent class's greet method
    print("I'm a child"); // Add child class logic
  }
}

void main() {
  Child child = Child("Xiaoming");
  child.greet();
  // Output:
  // Hello, I'm Xiaoming
  // I'm a child
}
Enter fullscreen mode Exit fullscreen mode

Note: Child class constructors must first call the parent class constructor (super(...) must be first in the initializer list) to ensure parent class members are initialized first.


II. Polymorphism: Different Behaviors of the Same Action

Polymorphism means a parent class reference can point to a child class object, and method calls will exhibit the child class's specific implementation. Simply put, it's "one interface, multiple implementations".

// Parent class
class Shape {
  // Parent class method (polymorphic "interface")
  double getArea() {
    return 0.0;
  }
}

// Child class 1: Circle
class Circle extends Shape {
  double radius;
  Circle(this.radius);

  @override
  double getArea() {
    return 3.14 * radius * radius; // Area formula for circle
  }
}

// Child class 2: Rectangle
class Rectangle extends Shape {
  double width;
  double height;
  Rectangle(this.width, this.height);

  @override
  double getArea() {
    return width * height; // Area formula for rectangle
  }
}

void main() {
  // Parent class reference points to child class object (core of polymorphism)
  Shape shape1 = Circle(5); // Circle with radius 5
  Shape shape2 = Rectangle(4, 6); // Rectangle with width 4, height 6

  // Calling the same method exhibits different behaviors
  print("Circle area: ${shape1.getArea()}"); // Output: Circle area: 78.5
  print("Rectangle area: ${shape2.getArea()}"); // Output: Rectangle area: 24.0
}
Enter fullscreen mode Exit fullscreen mode

The advantage of polymorphism is: we can write generic code that operates on parent classes without caring about specific child class types. When adding new child classes (like triangles), the generic code remains unchanged, following the "open-closed principle".


III. Abstract Classes (abstract): Templates for Defining Specifications

Abstract classes are classes that cannot be instantiated. They're typically used to define a set of methods (abstract methods) that must be implemented by child classes, and can contain their own implemented methods. Defined with the abstract keyword.

1. Basic Usage

// Abstract class
abstract class Vehicle {
  // Abstract method (declaration only, no implementation; child classes must override)
  void run();

  // Regular method (with implementation)
  void stop() {
    print("Vehicle stopped");
  }
}

// Child class 1: Car
class Car extends Vehicle {
  @override
  void run() {
    print("Car is driving on the road");
  }
}

// Child class 2: Bicycle
class Bicycle extends Vehicle {
  @override
  void run() {
    print("Bicycle is riding on the bike lane");
  }
}

void main() {
  // Abstract classes cannot be instantiated (compilation error)
  // Vehicle v = Vehicle();

  Vehicle car = Car();
  car.run(); // Output: Car is driving on the road
  car.stop(); // Output: Vehicle stopped

  Vehicle bike = Bicycle();
  bike.run(); // Output: Bicycle is riding on the bike lane
}
Enter fullscreen mode Exit fullscreen mode

The core role of abstract classes is to define "specifications": abstract methods enforce required functionality in child classes, while regular methods provide common logic, achieving "separation of specification and implementation".


IV. Interfaces (implements): Multi-Dimensional Specification Constraints

Dart has no dedicated interface keyword — any class can serve as an interface. A class "implements" an interface using the implements keyword, and must override all methods and properties in the interface (whether abstract or not).

Difference between interfaces and inheritance:

  • Inheritance (extends): Child class and parent class have an "is-a" relationship (e.g., Dog is an Animal), for code reuse.
  • Interface implementation (implements): Class and interface have a "has-a" relationship (e.g., Bird implements Flyable), indicating possession of a capability.

1. Basic Usage

// Define interface (regular class)
class Flyable {
  // Method in interface (implicitly requires implementation by implementing class)
  void fly() {
    // Interface implementations are usually empty, serving only as specifications
  }
}

class Swimmable {
  void swim() {}
}

// Implement one interface
class Bird implements Flyable {
  @override
  void fly() {
    print("Bird is flying in the sky");
  }
}

// Implement multiple interfaces (separated by commas)
class Duck implements Flyable, Swimmable {
  @override
  void fly() {
    print("Duck is flying low");
  }

  @override
  void swim() {
    print("Duck is swimming in water");
  }
}

void main() {
  Bird bird = Bird();
  bird.fly(); // Output: Bird is flying in the sky

  Duck duck = Duck();
  duck.fly(); // Output: Duck is flying low
  duck.swim(); // Output: Duck is swimming in water
}
Enter fullscreen mode Exit fullscreen mode

2. Using Abstract Classes as Interfaces

It's better to use abstract classes as interfaces because abstract methods clearly indicate "must implement":

// Abstract class as interface
abstract class Runnable {
  void run(); // Abstract method, no implementation
}

class Person implements Runnable {
  @override
  void run() {
    print("Person is running");
  }
}

class Robot implements Runnable {
  @override
  void run() {
    print("Robot is moving");
  }
}
Enter fullscreen mode Exit fullscreen mode

V. Comprehensive Example: Inheritance and Interfaces

Let's use a "game character" example to integrate inheritance and interfaces:

// Parent class: Role
class Role {
  String name;
  int hp;

  Role(this.name, this.hp);

  void takeDamage(int damage) {
    hp -= damage;
    print("$name took $damage damage, remaining HP: $hp");
  }
}

// Interface: Attack capability
abstract class Attackable {
  void attack(Role target);
}

// Interface: Healing capability
abstract class Healable {
  void heal(Role target, int amount);
}

// Warrior: inherits Role, implements Attackable
class Warrior extends Role implements Attackable {
  Warrior(String name, int hp) : super(name, hp);

  @override
  void attack(Role target) {
    print("$name attacked ${target.name} with a sword");
    target.takeDamage(20);
  }
}

// Priest: inherits Role, implements Attackable and Healable
class Priest extends Role implements Attackable, Healable {
  Priest(String name, int hp) : super(name, hp);

  @override
  void attack(Role target) {
    print("$name attacked ${target.name} with a wand");
    target.takeDamage(10);
  }

  @override
  void heal(Role target, int amount) {
    target.hp += amount;
    print(
      "$name healed ${target.name}, restored $amount HP, current HP: ${target.hp}",
    );
  }
}

void main() {
  Warrior warrior = Warrior("Warrior", 100);
  Priest priest = Priest("Priest", 80);

  // Warrior attacks Priest
  warrior.attack(priest);
  // Output: Warrior attacked Priest with a sword; Priest took 20 damage, remaining HP: 60

  // Priest heals self
  priest.heal(priest, 15);
  // Output: Priest healed Priest, restored 15 HP, current HP: 75

  // Priest attacks Warrior
  priest.attack(warrior);
  // Output: Priest attacked Warrior with a wand; Warrior took 10 damage, remaining HP: 90
}
Enter fullscreen mode Exit fullscreen mode

In this example:

  • Role as the parent class encapsulates common properties (name, hp) and methods (takeDamage) of all roles.
  • Attackable and Healable as interfaces define specifications for "attack" and "heal" capabilities.
  • Warrior and Priest form different role characteristics through inheritance and interface implementation, demonstrating inheritance's reusability and interfaces' flexibility.

Top comments (0)