Skip to main content
PProfileMatch

Polymorphism, Abstraction and Interfaces in OOP (Java Examples)

ProgrammingLesson 3 of 47 min read

What you'll learn

  • Explain abstraction as separating what a type promises from how it works
  • Describe how polymorphic calls are resolved at run time, and which parts of Java are not polymorphic
  • Choose between an abstract class and an interface for a given design
  • Compare Java's declared interfaces with Python's duck typing and abstract base classes

On this page

  1. Abstraction: say what, hide how
  2. Polymorphism: the real type decides
  3. Interfaces: capabilities instead of ancestry
  4. What is not polymorphic in Java
  5. The same idea in Python: duck typing and abstract base classes
  6. Common mistakes
  7. How this shows up in real projects and interviews

Look at the loop in the next program. It calls t.fee(...) three times with the same code, and prints three different prices because the garage has three different tariffs. The loop has no if and no instanceof, and does not know which tariff it holds. That small trick, one call site with many possible behaviours, is polymorphism, and it is what lets you add a fourth tariff next month without touching the loop.

Abstraction: say what, hide how

Abstraction means giving callers a simple, stable description of what something does and hiding how it does it. In the garage, callers only need "given a stay length in minutes, return a fee". Whether the fee is hourly, flat or a night discount is a detail. In Java that promise is an abstract class or an interface; the abstract method rawFee below has a signature but no body, and each subclass must supply one.

The class also shows the template method idea. The public fee method is final: it validates the input and applies the daily cap, and it calls the one step that varies. Subclasses cannot accidentally forget the validation, because the part that must never change is locked while the part that should change is abstract.

Tariffs.java
import java.util.List;

public class Tariffs {

    // Abstraction: callers only know "something that can price a stay"
    static abstract class Tariff {
        private final String name;

        protected Tariff(String name) {
            this.name = name;
        }

        // Template method: the algorithm is fixed, one step varies
        final int fee(int minutes) {
            if (minutes < 0) {
                throw new IllegalArgumentException("minutes must be >= 0");
            }
            return Math.min(rawFee(minutes), dailyCap());
        }

        protected abstract int rawFee(int minutes);

        protected int dailyCap() {
            return 400;
        }

        String name() {
            return name;
        }
    }

    static class Hourly extends Tariff {
        private final int perHour;

        Hourly(int perHour) {
            super("Hourly " + perHour + "/h");
            this.perHour = perHour;
        }

        @Override
        protected int rawFee(int minutes) {
            return ((minutes + 59) / 60) * perHour;      // every started hour is billed
        }
    }

    static class FlatEntry extends Tariff {
        FlatEntry() {
            super("Flat entry");
        }

        @Override
        protected int rawFee(int minutes) {
            return 120;
        }

        @Override
        protected int dailyCap() {
            return 120;
        }
    }

    static class NightOwl extends Tariff {
        NightOwl() {
            super("Night owl");
        }

        @Override
        protected int rawFee(int minutes) {
            return minutes <= 30 ? 0 : 20 + (minutes / 60) * 15;
        }
    }

    public static void main(String[] args) {
        List<Tariff> tariffs = List.of(new Hourly(40), new FlatEntry(), new NightOwl());

        for (Tariff t : tariffs) {                        // one loop, three behaviours
            System.out.printf("%-14s  45 min=%3d   4 h=%3d   26 h=%3d%n",
                t.name(), t.fee(45), t.fee(240), t.fee(1560));
        }
    }
}

Output

Hourly 40/h     45 min= 40   4 h=160   26 h=400
Flat entry      45 min=120   4 h=120   26 h=120
Night owl       45 min= 20   4 h= 80   26 h=400

FlatEntry overrides the optional hook dailyCap as well as the required rawFee, while NightOwl inherits the default cap of 400 and hits it at the 26-hour stay. The loop treats all three identically.

Polymorphism: the real type decides

Polymorphism ("many forms") means code written against a general type works with any specific type that fits it. In Java the mechanism for instance methods is dynamic dispatch: the JVM looks at the actual class of the object at the moment of the call and runs that class's version of the method. The variable's declared type only decides which methods you are allowed to call.

Polymorphism is the payoff for the previous two lessons. Encapsulation keeps each tariff's rules inside the tariff, and composition or inheritance gives you many implementations. Polymorphism lets one piece of calling code stay ignorant of all of them, which is what makes new behaviour an addition rather than a modification.

Interfaces: capabilities instead of ancestry

A class can extend only one parent, but it can implement many interfaces. An interface describes a capability ("can be charged", "can print a receipt") without saying anything about what the implementer is. Since Java 8 interfaces can contain default methods with a body, so you can add behaviour to an interface without breaking existing implementers. They cannot hold instance state.

Receipts.java
import java.util.List;

public class Receipts {
    interface Payable {
        int fee();
    }

    interface Printable {
        String receipt();

        default String banner() {                         // default method: shared, overridable
            return "== GARAGE RECEIPT ==";
        }
    }

    // One type, two capabilities. A class may implement many interfaces.
    record Stay(String plate, int fee) implements Payable, Printable {
        @Override
        public String receipt() {
            return plate + " paid " + fee;
        }
    }

    record Fine(String reason, int fee) implements Payable {}

    static int totalDue(List<? extends Payable> items) {  // depends only on the capability
        int sum = 0;
        for (Payable p : items) {
            sum += p.fee();
        }
        return sum;
    }

    public static void main(String[] args) {
        Stay stay = new Stay("MH12-4471", 80);
        Fine fine = new Fine("blocked exit", 500);

        System.out.println(stay.banner());
        System.out.println(stay.receipt());
        System.out.println(totalDue(List.of(stay, fine)));

        Payable waived = () -> 0;                         // an interface with one method accepts a lambda
        System.out.println(totalDue(List.of(waived, stay)));
    }
}

Output

== GARAGE RECEIPT ==
MH12-4471 paid 80
580
80

totalDue accepts a List<? extends Payable>, so it works with stays, fines and anything else that can be charged, even though Stay and Fine share no parent class. The last lines show a bonus: an interface with exactly one abstract method can be implemented with a lambda, which is why Payable waived = () -> 0; compiles.

Abstract classInterface
Instance fields and constructorsYesNo (constants only)
How many a class can useExtend oneImplement many
Best forA shared skeleton with some common code, as in the template methodA capability that unrelated types can offer
Can be a lambda targetNoYes, if it has a single abstract method
Typical riskA rigid hierarchyInterfaces so tiny that the design becomes hard to follow

What is not polymorphic in Java

Newcomers often assume every name lookup is dynamic. It is not. Only instance method calls are dispatched on the real type. Fields, static methods and the choice between overloaded methods are all decided at compile time from the declared type. The program below makes each case visible.

DispatchTraps.java
public class DispatchTraps {
    static class Vehicle {
        String label = "vehicle";

        String kind() {
            return "generic vehicle";
        }

        static String registry() {
            return "Vehicle registry";
        }
    }

    static class Truck extends Vehicle {
        String label = "truck";                          // hides, does not override

        @Override
        String kind() {
            return "truck";
        }

        static String registry() {                       // hides, does not override
            return "Truck registry";
        }
    }

    static String park(Vehicle v) {
        return "parking a vehicle";
    }

    static String park(Truck t) {
        return "parking a truck in the wide bay";
    }

    public static void main(String[] args) {
        Vehicle v = new Truck();                         // declared type Vehicle, real type Truck

        System.out.println(v.kind());                    // decided at run time by the real type
        System.out.println(v.label);                     // decided at compile time by the declared type
        System.out.println(v.registry());                // static: declared type again
        System.out.println(park(v));                     // overload chosen at compile time
        System.out.println(park((Truck) v));
    }
}

Output

truck
vehicle
Vehicle registry
parking a vehicle
parking a truck in the wide bay

Only the first line, truck, is polymorphic. The field label printed vehicle because fields are not overridden, only hidden. v.registry() printed the Vehicle registry because static methods belong to the class, not the object. And park(v) picked the Vehicle overload because overload resolution looks at the declared type, which is why the cast on the last line changes the result.

The same idea in Python: duck typing and abstract base classes

Python has no interface keyword and does not check types before running. If an object has a fee method, it can be used as a tariff: "if it walks like a duck". That makes polymorphism almost free, with a cost: a missing method is discovered when that line executes.

duck_typing.py
class Hourly:
    def __init__(self, per_hour):
        self.per_hour = per_hour

    def fee(self, minutes):
        return -(-minutes // 60) * self.per_hour      # ceiling division


class FlatEntry:
    def fee(self, minutes):
        return 120


class Broken:
    pass                                              # no fee() at all


def total(tariffs, minutes):
    return sum(t.fee(minutes) for t in tariffs)       # works for anything with fee()


print(total([Hourly(40), FlatEntry()], 200))
try:
    total([Hourly(40), Broken()], 200)
except AttributeError as err:
    print("found at run time:", err)

Output

280
found at run time: 'Broken' object has no attribute 'fee'

When you want the safety net back, an abstract base class (abc.ABC) refuses to instantiate a subclass that forgot a required method, and a typing.Protocol lets a type checker verify the shape without any inheritance.

abc_demo.py
from abc import ABC, abstractmethod


class Tariff(ABC):
    @abstractmethod
    def raw_fee(self, minutes): ...

    def fee(self, minutes):
        return min(self.raw_fee(minutes), 400)


class Hourly(Tariff):
    def raw_fee(self, minutes):
        return -(-minutes // 60) * 40


class Forgetful(Tariff):
    pass                                      # did not implement raw_fee


print(Hourly().fee(1560))
try:
    Forgetful()
except TypeError as err:
    print("TypeError:", err)

Output

400
TypeError: Can't instantiate abstract class Forgetful without an implementation for abstract method 'raw_fee'

Common mistakes

  • Type-switching instead of polymorphism. A chain of if (t instanceof Hourly) ... else if (t instanceof FlatEntry) spreads each tariff's rules across callers. Move each branch into the class and call one method.
  • Instantiating an abstract type. new Tariff("x") fails with Tariff is abstract; cannot be instantiated.
  • Not implementing every abstract method. javac reports X is not abstract and does not override abstract method rawFee(int) in Tariff; in Python the equivalent is the TypeError printed above.
  • Expecting fields or statics to behave polymorphically. See DispatchTraps: you get the parent's value and no error.
  • Overloading when you meant overriding. rawFee(long minutes) in a subclass does not override rawFee(int). Add @Override and the compiler rejects it with method does not override or implement a method from a supertype.
  • A fat interface. An interface with ten methods forces every implementer to provide all of them, often as stubs that throw. Split it by capability.
  • Assuming an interface adds state. Interface fields are implicitly public static final constants, so they cannot hold per-object data.

How this shows up in real projects and interviews

Polymorphism is how most real extension points work: a payment gateway interface with one implementation per provider, a storage interface with a local and a cloud version, a pricing or validation rule per product type. When the next provider arrives, you add a class and register it, and the calling code is untouched. When you see a long switch on a type or a status code in a code base, that is usually a missing polymorphic design.

Interviews ask for the difference between overloading and overriding, abstract class versus interface, and why v.label printed the parent's value. The practical answer to "abstract class or interface?" is: interface by default for a capability, abstract class when subclasses really share code and state. The next lesson uses this vocabulary to refactor a tangled class into small pieces: SOLID principles applied to a realistic refactor.

Try it yourself

Add a Subscriber tariff to Tariffs.java that is free for the first 120 minutes and then charges 25 per started hour, capped at 150. Then add a Discountable interface with a default method fee(int minutes, int percentOff), implement it on one tariff only, and write a loop that applies a 10 percent discount to every tariff that supports it using instanceof Discountable once, in main, and nowhere else.

Show a hint

For Subscriber, override both rawFee and dailyCap. A default method in Discountable can call an abstract int baseFee(int minutes) that the implementing class provides, which keeps the discount arithmetic in the interface.

Key takeaways

  • Abstraction exposes what a type promises and hides how it works; an abstract method or interface is the promise.
  • Polymorphism lets one call site work with many implementations, because the real object decides which instance method runs.
  • In Java only instance method calls dispatch on the real type; fields, static methods and overload choice use the declared type.
  • Use interfaces for capabilities that unrelated types can offer and abstract classes for a shared skeleton with common code.
  • Python reaches the same goal with duck typing, ABCs and Protocols, trading compile-time checks for flexibility.

Test yourself

A trick from this lesson's ride: The declared type picks the menu; the real object cooks the dish. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is polymorphism in OOP with a simple example?

Polymorphism means one interface, many behaviours. If a Tariff type has a fee(minutes) method and you loop over a list of hourly, flat and night tariffs calling fee, each object runs its own version of the method even though the calling code is identical.

What is the difference between an abstract class and an interface in Java?

An abstract class can hold fields, constructors and shared code, and a class can extend only one. An interface describes a capability, cannot hold instance state, and a class can implement many. Use an interface for a capability and an abstract class for a shared skeleton.

What is the difference between method overloading and overriding?

Overriding replaces a parent's instance method in a subclass with the same signature and is resolved at run time by the object's real type. Overloading defines several methods with the same name but different parameters and is resolved at compile time from the declared argument types.