Welcome back, future Java master! In our previous chapters, we laid the groundwork for Object-Oriented Programming (OOP) by understanding classes, objects, methods, and constructors. You’ve already started thinking in objects, which is a huge step!

Now, get ready to unlock even more power with Java’s core OOP pillars: Inheritance, Polymorphism, and Abstraction. These concepts are not just fancy words; they are the secret sauce to writing flexible, maintainable, and scalable code that can adapt and grow. By the end of this chapter, you’ll not only understand what these terms mean but also how to wield them to build robust applications.

This chapter builds directly on your understanding of classes and objects. If you ever feel a little shaky on those basics, feel free to hop back to the earlier chapters for a quick refresher! We’ll be using Java Development Kit (JDK) 25, the latest stable release as of December 2025, which offers many exciting features, but the core OOP principles we discuss here have been fundamental to Java for decades.

1. The Power of Inheritance: Building on What’s Already There

Imagine you’re designing a game with different types of characters: a Warrior, a Mage, and an Archer. All these characters have some common traits, right? They all have health, name, and can attack(). Without inheritance, you might find yourself writing the same health variable and attack() method in each character class. That’s a lot of repetitive code!

Inheritance is a fundamental OOP principle that allows a new class (the subclass or child class) to inherit properties (fields) and behaviors (methods) from an existing class (the superclass or parent class). This creates an “IS-A” relationship. For example, a Warrior IS-A Character, a Mage IS-A Character.

1.1 Why Inheritance?

  • Code Reusability: You write common code once in the superclass, and all subclasses automatically get it. Less code means fewer bugs and easier maintenance.
  • Hierarchical Classification: It helps organize classes in a logical, tree-like structure, making your codebase more understandable.
  • Extensibility: You can easily add new types of characters (e.g., a Healer) by simply inheriting from Character and adding specific traits.

1.2 The extends Keyword

In Java, we use the extends keyword to establish an inheritance relationship. A class can only extend one other class directly (Java does not support multiple inheritance of classes).

Let’s start with a basic Vehicle class. Every vehicle has a make, model, and can start() and stop().

// Vehicle.java
class Vehicle {
    String make;
    String model;

    public Vehicle(String make, String model) {
        this.make = make;
        this.model = model;
    }

    public void start() {
        System.out.println(make + " " + model + " is starting...");
    }

    public void stop() {
        System.out.println(make + " " + model + " is stopping.");
    }

    public void displayInfo() {
        System.out.println("Vehicle: " + make + " " + model);
    }
}

Now, let’s create a Car class that extends Vehicle. A Car IS-A Vehicle, but it also has its own unique characteristic: the number of doors.

// Car.java
class Car extends Vehicle {
    int numberOfDoors;

    public Car(String make, String model, int numberOfDoors) {
        // The 'super' keyword calls the constructor of the parent class (Vehicle)
        super(make, model); 
        this.numberOfDoors = numberOfDoors;
    }

    // Car can have its own methods too, or override parent methods
    public void drive() {
        System.out.println(make + " " + model + " is driving with " + numberOfDoors + " doors.");
    }

    // Let's override the displayInfo method to add more details!
    @Override // This annotation is a good practice to indicate method overriding
    public void displayInfo() {
        super.displayInfo(); // Call the parent's displayInfo first
        System.out.println("  Doors: " + numberOfDoors);
    }
}

What’s happening here?

  • class Car extends Vehicle: This line declares that Car is a subclass of Vehicle.
  • super(make, model);: Inside the Car constructor, super() is a special call that invokes the constructor of the Vehicle class. This ensures that the make and model fields (which belong to Vehicle) are properly initialized before Car’s own fields are set. Important: super() must be the very first statement in a subclass constructor.
  • @Override: This is an annotation. It’s not strictly required, but it’s a very good practice! It tells the compiler (and other developers) that you intend for this method to override a method in the parent class. If you make a typo in the method signature, the compiler will catch it, preventing subtle bugs.
  • super.displayInfo(): Inside the Car’s displayInfo method, we’re calling the displayInfo method from its parent class (Vehicle). This allows us to reuse the parent’s logic and then add Car-specific details.

1.3 Mini-Challenge: Extend the Vehicle Hierarchy

Your turn! Create a new class called Motorcycle that also extends Vehicle. A Motorcycle has a hasSidecar boolean property.

Challenge:

  1. Create the Motorcycle class.
  2. Ensure its constructor properly calls the Vehicle constructor using super().
  3. Add a performWheelie() method specific to Motorcycle.
  4. Override the displayInfo() method to include whether it has a sidecar.

Hint: Remember to use the super keyword for the parent constructor call and for calling the parent’s displayInfo() method.

Click for a potential solution!
// Motorcycle.java
class Motorcycle extends Vehicle {
    boolean hasSidecar;

    public Motorcycle(String make, String model, boolean hasSidecar) {
        super(make, model);
        this.hasSidecar = hasSidecar;
    }

    public void performWheelie() {
        System.out.println(make + " " + model + " is performing a wheelie!");
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Has Sidecar: " + hasSidecar);
    }
}

1.4 Testing Our Hierarchy

Let’s see our classes in action! Create a Main class to test Vehicle, Car, and Motorcycle.

// Main.java
public class Main {
    public static void main(String[] args) {
        System.out.println("--- Creating a Vehicle ---");
        Vehicle genericVehicle = new Vehicle("Generic", "ModelX");
        genericVehicle.start();
        genericVehicle.displayInfo();
        genericVehicle.stop();
        System.out.println();

        System.out.println("--- Creating a Car ---");
        Car myCar = new Car("Toyota", "Camry", 4);
        myCar.start();      // Inherited from Vehicle
        myCar.drive();      // Specific to Car
        myCar.displayInfo(); // Overridden in Car
        myCar.stop();       // Inherited from Vehicle
        System.out.println();

        System.out.println("--- Creating a Motorcycle ---");
        Motorcycle myBike = new Motorcycle("Harley-Davidson", "Iron 883", false);
        myBike.start();          // Inherited
        myBike.performWheelie(); // Specific to Motorcycle
        myBike.displayInfo();    // Overridden
        myBike.stop();           // Inherited
    }
}

To run this:

  1. Save the Vehicle, Car, Motorcycle, and Main classes in separate .java files in the same directory.
  2. Open your terminal or command prompt in that directory.
  3. Compile: javac *.java (This compiles all Java files in the current directory).
  4. Run: java Main

You should see output demonstrating that each object can access its own methods as well as the inherited ones, and that overridden methods behave as expected!

2. Polymorphism: One Interface, Many Implementations

“Poly” means many, and “morph” means form. So, Polymorphism literally means “many forms.” In Java, it allows you to treat objects of different classes that are related by inheritance as objects of a common type. This means a single method call can behave differently based on the actual type of the object it’s invoked on.

Think about a remote control. It has a “Power” button. When you press it, your TV turns on, your sound system turns on, or your robot vacuum starts cleaning – each device responds to the same “Power” command in its own specific way. The “Power” button is polymorphic!

2.1 Runtime Polymorphism (Method Overriding)

We already saw a glimpse of this with displayInfo(). When you called myCar.displayInfo(), Java knew to execute the displayInfo() method defined in the Car class, not the Vehicle class. This decision happens at runtime, which is why it’s called runtime polymorphism or dynamic method dispatch.

The key here is that the method being called must be present in both the superclass and the subclass, with the exact same signature (name, number, and type of parameters).

Let’s make this even clearer with our Vehicle example. What if we have a list of Vehicle objects, but some are actually Cars and some are Motorcycles?

// Add this to your Main.java's main method
// after the previous code block

        System.out.println("\n--- Demonstrating Polymorphism ---");
        // Create an array that can hold references to Vehicle objects
        // This array can hold Car, Motorcycle, or any future Vehicle subclass!
        Vehicle[] garage = new Vehicle[3];
        garage[0] = new Vehicle("Honda", "CRV"); // A generic vehicle
        garage[1] = new Car("Tesla", "Model 3", 4); // A Car object
        garage[2] = new Motorcycle("Ducati", "Monster", false); // A Motorcycle object

        for (Vehicle v : garage) {
            v.start();          // Calls Vehicle's start()
            v.displayInfo();    // Polymorphic call! Calls overridden method based on actual object type
            // v.drive(); // ERROR! Vehicle doesn't have a drive() method
            // v.performWheelie(); // ERROR! Vehicle doesn't have performWheelie() method
            System.out.println("-----");
        }

Observe:

  • Vehicle[] garage = new Vehicle[3];: We declare an array of Vehicle references. This array can hold any object that “IS-A” Vehicle.
  • garage[1] = new Car(...): Here, a Car object (subclass) is assigned to a Vehicle reference (superclass). This is called upcasting and is always safe.
  • v.displayInfo();: This is the magic of polymorphism! Even though v is declared as a Vehicle type, when displayInfo() is called, Java looks at the actual object being referenced (Car, Motorcycle, or Vehicle) and executes the appropriate displayInfo() method.

Why are v.drive() and v.performWheelie() commented out as errors? Because the compile-time type of v is Vehicle. The Vehicle class itself does not define drive() or performWheelie(). Even though some of the actual objects in the array might have those methods, the compiler only knows about the methods available in the Vehicle class. You can only call methods that are defined in the reference type or its supertypes.

2.2 Compile-time Polymorphism (Method Overloading)

While runtime polymorphism is about method overriding, compile-time polymorphism is about method overloading. We touched on this in an earlier chapter.

Method Overloading means having multiple methods in the same class with the same name but different parameter lists (different number of parameters, different types of parameters, or different order of parameter types). The compiler decides which overloaded method to call based on the arguments provided at compile time.

Example:

class Calculator {
    public int add(int a, int b) {
        return a + b;
    }

    public double add(double a, double b) { // Overloaded method
        return a + b;
    }

    public int add(int a, int b, int c) { // Another overloaded method
        return a + b + c;
    }
}

// In Main method:
Calculator calc = new Calculator();
System.out.println(calc.add(5, 10));        // Calls add(int, int)
System.out.println(calc.add(5.5, 10.2));    // Calls add(double, double)
System.out.println(calc.add(1, 2, 3));      // Calls add(int, int, int)

The compiler determines which add method to use based on the arguments’ types and count. This is also a form of polymorphism.

3. Abstraction: Focusing on What Matters, Hiding the Details

Abstraction is about hiding complex implementation details and showing only the essential features of an object. It’s like driving a car: you know how to use the steering wheel, accelerator, and brake (the essentials), but you don’t need to know the intricate workings of the engine or transmission to drive it. Those details are abstracted away.

In Java, abstraction is primarily achieved using abstract classes and interfaces.

3.1 Abstract Classes

An abstract class is a class that cannot be instantiated directly (you cannot create an object of an abstract class). It’s designed to be a superclass for other classes that will provide complete implementations.

Key characteristics of abstract classes:

  • Declared using the abstract keyword: public abstract class Shape { ... }
  • Can have abstract methods (methods without a body) AND concrete (regular) methods.
  • If a class has at least one abstract method, the class itself must be declared abstract.
  • Subclasses of an abstract class must either implement all its abstract methods or also be declared abstract.
  • Can have constructors, but they are only called via super() from subclasses.

Let’s refactor our Vehicle example to use an abstract class. What if we decide that a “generic Vehicle” shouldn’t really be created directly? It’s more of a concept. And every Vehicle must have a way to calculate its fuelEfficiency(), but how that’s done differs greatly between a Car and a Motorcycle.

// AbstractVehicle.java
abstract class AbstractVehicle { // Now it's an abstract class
    String make;
    String model;

    public AbstractVehicle(String make, String model) {
        this.make = make;
        this.model = model;
    }

    public void start() {
        System.out.println(make + " " + model + " is starting...");
    }

    public void stop() {
        System.out.println(make + " " + model + " is stopping.");
    }

    public void displayInfo() {
        System.out.println("Abstract Vehicle: " + make + " " + model);
    }

    // This is an abstract method. It has no body.
    // Any concrete subclass MUST implement this method.
    public abstract double calculateFuelEfficiency();
}

Now, our Car and Motorcycle classes need to implement calculateFuelEfficiency().

// Car.java (Modified to extend AbstractVehicle)
class Car extends AbstractVehicle { // Now extends AbstractVehicle
    int numberOfDoors;
    double avgMpg; // New field for fuel efficiency

    public Car(String make, String model, int numberOfDoors, double avgMpg) {
        super(make, model);
        this.numberOfDoors = numberOfDoors;
        this.avgMpg = avgMpg;
    }

    public void drive() {
        System.out.println(make + " " + model + " is driving with " + numberOfDoors + " doors.");
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Doors: " + numberOfDoors);
        System.out.println("  Fuel Efficiency: " + calculateFuelEfficiency() + " MPG");
    }

    @Override
    public double calculateFuelEfficiency() {
        // Simple calculation for demonstration
        return avgMpg;
    }
}
// Motorcycle.java (Modified to extend AbstractVehicle)
class Motorcycle extends AbstractVehicle { // Now extends AbstractVehicle
    boolean hasSidecar;
    double avgMpg; // New field for fuel efficiency

    public Motorcycle(String make, String model, boolean hasSidecar, double avgMpg) {
        super(make, model);
        this.hasSidecar = hasSidecar;
        this.avgMpg = avgMpg;
    }

    public void performWheelie() {
        System.out.println(make + " " + model + " is performing a wheelie!");
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Has Sidecar: " + hasSidecar);
        System.out.println("  Fuel Efficiency: " + calculateFuelEfficiency() + " MPG");
    }

    @Override
    public double calculateFuelEfficiency() {
        // Motorcycles might be more efficient, let's pretend
        return avgMpg;
    }
}

And in your Main class, you’ll now create instances of Car and Motorcycle (you cannot create new AbstractVehicle()).

// Main.java (Modified)
public class Main {
    public static void main(String[] args) {
        // System.out.println("--- Creating an AbstractVehicle ---");
        // AbstractVehicle genericVehicle = new AbstractVehicle("Generic", "ModelX"); // ERROR! Cannot instantiate abstract class
        // System.out.println();

        System.out.println("--- Creating a Car (from AbstractVehicle) ---");
        Car myCar = new Car("Toyota", "Camry", 4, 30.5);
        myCar.start();
        myCar.drive();
        myCar.displayInfo();
        myCar.stop();
        System.out.println();

        System.out.println("--- Creating a Motorcycle (from AbstractVehicle) ---");
        Motorcycle myBike = new Motorcycle("Harley-Davidson", "Iron 883", false, 45.0);
        myBike.start();
        myBike.performWheelie();
        myBike.displayInfo();
        myBike.stop();
        System.out.println();

        System.out.println("--- Demonstrating Polymorphism with AbstractVehicle ---");
        AbstractVehicle[] garage = new AbstractVehicle[2]; // Array of AbstractVehicle references
        garage[0] = new Car("Hyundai", "Elantra", 4, 35.2);
        garage[1] = new Motorcycle("Kawasaki", "Ninja", false, 50.0);

        for (AbstractVehicle v : garage) {
            v.start();
            v.displayInfo(); // Polymorphic call, now includes fuel efficiency
            System.out.println("Calculated Fuel Efficiency: " + v.calculateFuelEfficiency() + " MPG");
            System.out.println("-----");
        }
    }
}

Notice how the AbstractVehicle array still allows us to treat Car and Motorcycle polymorphically! The calculateFuelEfficiency() method is called on each, and because it’s an abstract method, Java knows to call the specific implementation provided by Car or Motorcycle.

3.2 Interfaces

An interface is a blueprint of a class. It can contain method signatures (abstract methods), default methods, static methods, and constant fields. It defines a contract: any class that implements an interface must provide an implementation for all its abstract methods.

Key characteristics of interfaces:

  • Declared using the interface keyword: public interface Drivable { ... }
  • All methods without a body are implicitly public abstract (before Java 8).
  • All fields are implicitly public static final (constants).
  • A class implements an interface using the implements keyword.
  • A class can implement multiple interfaces (achieving “multiple inheritance of type”).
  • Java 8+: Interfaces can have default methods (with a body) and static methods. This allows adding new methods to interfaces without breaking existing implementations.
  • Java 9+: Interfaces can have private methods to help refactor common code between default methods.

Let’s define an interface Drivable for our vehicles. Both Car and Motorcycle are drivable. This interface could specify common behaviors that aren’t necessarily part of the AbstractVehicle hierarchy but are shared across different types of drivable things.

// Drivable.java
interface Drivable {
    // All methods in an interface are public abstract by default (pre-Java 8)
    // No need for 'public abstract' keywords here, but it's good for clarity
    void accelerate();
    void brake();

    // Java 8+ feature: default method
    // Provides a default implementation that can be used by implementing classes
    // or overridden if needed.
    default void honk() {
        System.out.println("Beep! Beep!");
    }

    // Java 8+ feature: static method
    // Belongs to the interface itself, not to implementing objects.
    static void displayDrivingTips() {
        System.out.println("Always wear your seatbelt!");
    }
}

Now, let’s make our Car and Motorcycle classes implement the Drivable interface.

// Car.java (Modified to implement Drivable)
class Car extends AbstractVehicle implements Drivable { // Added 'implements Drivable'
    int numberOfDoors;
    double avgMpg;

    public Car(String make, String model, int numberOfDoors, double avgMpg) {
        super(make, model);
        this.numberOfDoors = numberOfDoors;
        this.avgMpg = avgMpg;
    }

    public void drive() {
        System.out.println(make + " " + model + " is driving with " + numberOfDoors + " doors.");
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Doors: " + numberOfDoors);
        System.out.println("  Fuel Efficiency: " + calculateFuelEfficiency() + " MPG");
    }

    @Override
    public double calculateFuelEfficiency() {
        return avgMpg;
    }

    // Implementing abstract methods from Drivable interface
    @Override
    public void accelerate() {
        System.out.println(make + " " + model + " is accelerating with gas pedal.");
    }

    @Override
    public void brake() {
        System.out.println(make + " " + model + " is braking with foot pedal.");
    }
    
    // We can optionally override the default honk() method if needed
    @Override
    public void honk() {
        System.out.println("Honk! Honk! (from Car)");
    }
}
// Motorcycle.java (Modified to implement Drivable)
class Motorcycle extends AbstractVehicle implements Drivable { // Added 'implements Drivable'
    boolean hasSidecar;
    double avgMpg;

    public Motorcycle(String make, String model, boolean hasSidecar, double avgMpg) {
        super(make, model);
        this.hasSidecar = hasSidecar;
        this.avgMpg = avgMpg;
    }

    public void performWheelie() {
        System.out.println(make + " " + model + " is performing a wheelie!");
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Has Sidecar: " + hasSidecar);
        System.out.println("  Fuel Efficiency: " + calculateFuelEfficiency() + " MPG");
    }

    @Override
    public double calculateFuelEfficiency() {
        return avgMpg;
    }

    // Implementing abstract methods from Drivable interface
    @Override
    public void accelerate() {
        System.out.println(make + " " + model + " is twisting throttle to accelerate.");
    }

    @Override
    public void brake() {
        System.out.println(make + " " + model + " is using hand and foot brakes.");
    }
    // We'll let Motorcycle use the default honk() method
}

Now, let’s update our Main class to test the interface methods:

// Main.java (Modified)
public class Main {
    public static void main(String[] args) {
        System.out.println("--- Testing Drivable Interface ---");
        Car myCar = new Car("Ford", "Focus", 4, 28.1);
        myCar.accelerate();
        myCar.brake();
        myCar.honk(); // Car's overridden honk()
        System.out.println();

        Motorcycle myBike = new Motorcycle("Yamaha", "MT-07", false, 60.0);
        myBike.accelerate();
        myBike.brake();
        myBike.honk(); // Default honk() from interface
        System.out.println();

        // We can also use Drivable as a polymorphic type!
        Drivable[] drivables = new Drivable[2];
        drivables[0] = myCar;
        drivables[1] = myBike;

        for (Drivable item : drivables) {
            item.accelerate();
            item.honk();
        }
        System.out.println();

        // Calling a static method from the interface
        Drivable.displayDrivingTips();
    }
}

3.3 Abstract Class vs. Interface: When to Use Which?

This is a common question!

FeatureAbstract ClassInterface
PurposeDefines a common base for a hierarchy, often providing some default implementation. Represents an “IS-A” relationship.Defines a contract for behavior. Represents a “CAN-DO” relationship.
InheritanceA class can extend only one abstract class.A class can implement multiple interfaces.
MethodsCan have abstract and concrete methods.Can have abstract, default (Java 8+), static (Java 8+), and private (Java 9+) methods.
FieldsCan have any type of field (instance, static, final).Only public static final fields (constants).
ConstructorsCan have constructors.Cannot have constructors.
InstantiationCannot be instantiated directly.Cannot be instantiated directly.
Access ModifiersMethods/fields can have any access modifier.All abstract methods are implicitly public. Default/static/private methods have specific rules.

Rule of thumb:

  • Use an abstract class when you want to provide a common base implementation for related classes, and you want to ensure that all subclasses share some common state (fields) or partially implemented behavior. It’s for objects that are truly a type of the abstract class.
  • Use an interface when you want to define a contract for behavior that multiple unrelated classes might share. It’s for objects that can do something, regardless of their position in an inheritance hierarchy.

4. Mini-Challenge: Expanding Our Vehicle Ecosystem

Let’s combine what you’ve learned!

Challenge:

  1. Create a new interface called ElectricVehicle that defines two abstract methods: charge() and getBatteryLevel().
  2. Modify the Car class to also implement ElectricVehicle. (A car can be both AbstractVehicle and Drivable and ElectricVehicle!)
  3. Add necessary fields (e.g., batteryCapacity, currentBatteryLevel) and implement the charge() and getBatteryLevel() methods in the Car class. Provide a simple implementation for charging (e.g., print a message) and returning the battery level.
  4. Update your Main class to create an ElectricCar object and demonstrate its new ElectricVehicle capabilities.

Hint:

  • Remember a class can implement multiple interfaces: class MyClass extends MyAbstractClass implements InterfaceA, InterfaceB { ... }
  • You’ll need to add a couple of new fields to Car for battery management.
Click for a potential solution!
// ElectricVehicle.java
interface ElectricVehicle {
    void charge();
    int getBatteryLevel(); // Returns percentage
}
// Car.java (Further Modified)
class Car extends AbstractVehicle implements Drivable, ElectricVehicle { // Now implements ElectricVehicle
    int numberOfDoors;
    double avgMpg;
    // New fields for ElectricVehicle
    int batteryCapacityKwh;
    int currentBatteryLevel; // As a percentage

    public Car(String make, String model, int numberOfDoors, double avgMpg, int batteryCapacityKwh, int currentBatteryLevel) {
        super(make, model);
        this.numberOfDoors = numberOfDoors;
        this.avgMpg = avgMpg;
        this.batteryCapacityKwh = batteryCapacityKwh;
        this.currentBatteryLevel = currentBatteryLevel;
    }

    public void drive() {
        System.out.println(make + " " + model + " is driving with " + numberOfDoors + " doors.");
        // Simulate battery drain
        if (currentBatteryLevel > 0) {
            currentBatteryLevel -= 5; // Simple drain
            if (currentBatteryLevel < 0) currentBatteryLevel = 0;
            System.out.println("Battery level after drive: " + currentBatteryLevel + "%");
        } else {
            System.out.println("Battery is empty! Cannot drive.");
        }
    }

    @Override
    public void displayInfo() {
        super.displayInfo();
        System.out.println("  Doors: " + numberOfDoors);
        System.out.println("  Fuel Efficiency: " + calculateFuelEfficiency() + " MPG");
        System.out.println("  Battery Capacity: " + batteryCapacityKwh + " kWh");
        System.out.println("  Current Battery: " + currentBatteryLevel + "%");
    }

    @Override
    public double calculateFuelEfficiency() {
        return avgMpg;
    }

    @Override
    public void accelerate() {
        System.out.println(make + " " + model + " is accelerating silently (electric).");
    }

    @Override
    public void brake() {
        System.out.println(make + " " + model + " is braking with regenerative braking.");
    }
    
    @Override
    public void honk() {
        System.out.println("Honk! Honk! (from Electric Car)");
    }

    // Implementing methods from ElectricVehicle interface
    @Override
    public void charge() {
        System.out.println(make + " " + model + " is charging up its " + batteryCapacityKwh + " kWh battery.");
        this.currentBatteryLevel = 100; // Fully charge for simplicity
        System.out.println("Battery fully charged to " + currentBatteryLevel + "%.");
    }

    @Override
    public int getBatteryLevel() {
        return currentBatteryLevel;
    }
}
// Main.java (Further Modified)
public class Main {
    public static void main(String[] args) {
        // ... (previous code) ...

        System.out.println("\n--- Testing Electric Car ---");
        Car electricCar = new Car("Tesla", "Model Y", 4, 120.0, 75, 50); // Make, Model, Doors, MPGe, KWh, Current%
        electricCar.displayInfo();
        electricCar.drive();
        electricCar.charge();
        electricCar.drive();
        System.out.println("Current battery level: " + electricCar.getBatteryLevel() + "%");

        System.out.println("\n--- Polymorphism with ElectricVehicle ---");
        // We can treat it as an ElectricVehicle
        ElectricVehicle ev = electricCar; // Upcasting to ElectricVehicle interface
        ev.charge();
        System.out.println("EV battery after charge: " + ev.getBatteryLevel() + "%");
        // ev.drive(); // ERROR! ElectricVehicle interface doesn't have a drive() method
    }
}

5. Common Pitfalls & Troubleshooting

  1. Forgetting to Implement Abstract Methods: If you extend an abstract class or implement an interface, and your subclass/implementing class is not declared abstract, you must implement all of its abstract methods.

    • Symptom: Compile-time error: “The type MyConcreteClass must implement the inherited abstract method AbstractClass.abstractMethod()” or “MyConcreteClass is not abstract and does not override abstract method interfaceMethod() in MyInterface.”
    • Fix: Either implement the missing methods or declare your class as abstract if it’s meant to be a partial implementation.
  2. Trying to Instantiate an Abstract Class or Interface:

    • Symptom: Compile-time error: “Cannot instantiate the type AbstractClass” or “Cannot instantiate the type Interface.”
    • Fix: Remember that abstract classes and interfaces are blueprints, not concrete objects. You can only create instances of concrete classes that extend an abstract class or implement an interface.
  3. Incorrect super() Call in Constructor:

    • Symptom: Compile-time error: “Call to super() must be first statement in constructor.”
    • Fix: Ensure that super(...) is the very first line inside your subclass’s constructor. If you don’t explicitly call super(), Java tries to insert a default super() (no arguments). If the parent class only has a parameterized constructor, you must explicitly call super() with the correct arguments.
  4. ClassCastException during Downcasting:

    • Symptom: Runtime error: java.lang.ClassCastException: class MySuperClass cannot be cast to class MySubClass. This happens when you try to cast a superclass reference to a subclass type, but the actual object it refers to is not an instance of that subclass (or one of its subclasses).
    • Example:
      AbstractVehicle v = new Motorcycle("Honda", "CBR", false, 50.0);
      // Car c = (Car) v; // This would cause a ClassCastException at runtime
      
    • Fix: Always use the instanceof operator before downcasting to check if the object is truly an instance of the target type.
      AbstractVehicle v = new Motorcycle("Honda", "CBR", false, 50.0);
      if (v instanceof Car) {
          Car c = (Car) v; // This would now be safe if 'v' was actually a Car
          c.drive();
      } else {
          System.out.println("Not a Car!");
      }
      

6. Summary: Your OOP Superpowers Unlocked!

Phew! You’ve covered a lot of ground, and these are truly powerful concepts. Let’s recap the key takeaways:

  • Inheritance (extends): Allows classes to inherit fields and methods from a parent class, promoting code reuse and establishing “IS-A” relationships.
  • Polymorphism: Enables objects of different classes to be treated as objects of a common type, allowing a single method call to perform different actions based on the actual object’s type (runtime polymorphism via method overriding) or method signature (compile-time polymorphism via method overloading).
  • Abstraction: Focuses on essential features while hiding implementation details, achieved through:
    • Abstract Classes (abstract class): Provide a partial implementation, can have both abstract and concrete methods, and cannot be instantiated directly. Subclasses must implement abstract methods.
    • Interfaces (interface): Define a contract for behavior, contain abstract methods (implicitly public abstract), can have default/static/private methods (Java 8/9+), and allow a class to implement multiple interfaces (multiple inheritance of type).

You now have a solid understanding of how to structure your Java code for flexibility, reusability, and maintainability using these core OOP principles. These aren’t just theoretical ideas; they are the bedrock of writing professional, scalable Java applications!

What’s Next? In the next chapter, we’ll shift gears slightly and dive into Collections and Generics. You’ll learn how to store and manage groups of objects efficiently, and how Generics help you write type-safe, reusable code for these collections, preventing many common programming errors. Get ready to organize your data like a pro!