Skip to content
Ameen Abozaid
PublishedBEG-023-L01

One call, many behaviors

  • Lesson 1 of 5
  • 12 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

Picture a bank teller who presses the same "month-end" button on every account card in a pile. Each card type reacts in its own way: a savings card adds interest, a checking card charges a fee, a plain card does nothing. The teller never looks at the card type first. They just press the button.

Java can work the same way. This is polymorphism, from the Greek for "many forms": one method call, different behavior depending on the real object that receives it. In this lesson you will write exactly that: a pile of accounts, one loop, one call, and each account doing its own thing.

Why this matters: backend code is full of "do this to every item" work (charge every order, notify every user, validate every field), where the items are of different kinds. Polymorphism lets that code stay short and stay unchanged when a new kind appears.

This lesson builds directly on the previous module: classes, extends, and overriding a method with @Override. It also uses arrays and the enhanced for loop. We use arrays for collections of objects; the List type comes in a later module, and everything here works the same with it.

Learning Objectives

By the end of this lesson you will be able to:

  • Define polymorphism and say what changes between two runs of the same line of code.
  • Distinguish a variable's reference type from the object type it currently holds.
  • Predict which version of an overridden method runs for a given reference and object.
  • Store objects of different subclasses in one parent-typed array and call a method on each.
  • Explain why the caller does not need to know which subclass it holds.
  • State that this works for overridden instance methods, and explain what happens when a method is not overridden.

Written Explanation

Two types for every object variable

Consider this line:

java
Account a = new SavingsAccount("Ana", 1000.0, 0.02);

Two types are involved:

  • The reference type (also called the declared or static type) is what is written on the left: Account. It never changes while the program runs, and the compiler uses it to decide which calls are allowed.
  • The object type (the real or run-time class) is what was created with new: SavingsAccount. It is fixed when the object is created. A variable can later be pointed at a different object, with a different object type.

The assignment is legal because a SavingsAccount is an Account (the "is-a" relationship from inheritance). A variable of a parent type can refer to an object of any subclass. Converting a child reference to a parent type like this is called upcasting, and Java does it automatically. The next lesson looks at it closely.

The rule

When you call an overridden instance method through a reference, Java runs the version that belongs to the object, not the version that belongs to the reference type.

java
Account a = new SavingsAccount("Ana", 1000.0, 0.02);
a.monthEnd();   // runs SavingsAccount.monthEnd, not Account.monthEnd

The Oracle Java Tutorial describes this as the JVM calling the appropriate method for the object that the variable refers to, rather than the one the variable's declared type suggests, which it names virtual method invocation. The mechanism has a name, dynamic dispatch, and a lesson of its own later in this module.

The decision is made at run time, each time the line executes. That is what makes the same line capable of different behavior:

java
for (Account a : accounts) {
    a.monthEnd();   // one line of source code, three different behaviors
}

Nothing about the line changes between iterations. Only the object behind a changes.

What you need for it to work

Three conditions, all of which you already know from inheritance:

  1. A parent type that declares the method (Account has monthEnd()). The compiler only allows calls the reference type declares.
  2. Subclasses that override it with the same name and parameters (use @Override so the compiler checks that for you).
  3. A call through a parent reference to an instance method.

If a subclass does not override the method, the object simply uses the inherited version. If nothing overrides it, the parent's version is the only one, and all objects behave the same. That is why Account.monthEnd() can have an empty body: it is the default for account types with nothing special to do.

Why callers do not need to know

Look at the loop again. It only knows two things: "these are accounts" and "accounts have monthEnd()". It does not know, and does not need to know, that one of them is savings. The knowledge about how a savings account ends the month lives in SavingsAccount, next to the interest rate it needs. A consequence you will exploit in the lab at the end of this module: if a new BusinessAccount class appears with its own monthEnd(), the loop works with it immediately, without any edit.

A call inside inherited code is also dispatched

Dispatch applies to every call on an object, including calls made from inside the parent class. When an inherited method calls another method on this, the object's real class still decides which version runs. Example 3 below shows a parent method that calls an overridden method of its subclass.

Examples

Example 1: animals that speak (simple)

java
class Animal {
    String speak() { return "..."; }
}
class Dog extends Animal {
    @Override String speak() { return "Woof"; }
}
class Cat extends Animal {
    @Override String speak() { return "Meow"; }
}
public class Ex1 {
    public static void main(String[] args) {
        Animal a = new Dog();
        System.out.println(a.speak());
        a = new Cat();
        System.out.println(a.speak());
        a = new Animal();
        System.out.println(a.speak());
    }
}

Output:

text
Woof
Meow
...

The variable a has reference type Animal the whole time. The same call a.speak() printed three different results because the object behind a changed. The third object is a plain Animal, so the parent's version ran.

Example 2: month-end over mixed accounts (practical)

java
class Account {
    private final String owner;
    private double balance;
    Account(String owner, double balance) { this.owner = owner; this.balance = balance; }
    String getOwner() { return owner; }
    double getBalance() { return balance; }
    void deposit(double amount) { balance += amount; }
    void withdraw(double amount) { balance -= amount; }
    void monthEnd() { }          // default: nothing to do
}
class SavingsAccount extends Account {
    private final double rate;
    SavingsAccount(String owner, double balance, double rate) {
        super(owner, balance);
        this.rate = rate;
    }
    @Override void monthEnd() { deposit(getBalance() * rate); }
}
class CheckingAccount extends Account {
    private final double fee;
    CheckingAccount(String owner, double balance, double fee) {
        super(owner, balance);
        this.fee = fee;
    }
    @Override void monthEnd() { withdraw(fee); }
}
public class Ex2 {
    public static void main(String[] args) {
        Account[] accounts = {
            new SavingsAccount("Ana", 1000.0, 0.02),
            new CheckingAccount("Omar", 500.0, 5.0),
            new Account("Lina", 200.0)
        };
        for (Account a : accounts) {
            a.monthEnd();
            System.out.println(a.getOwner() + ": " + a.getBalance());
        }
    }
}

Output:

text
Ana: 1020.0
Omar: 495.0
Lina: 200.0

One array of type Account[] holds three different kinds of object, and one call produced interest for Ana (1000 plus 2% is 1020), a fee for Omar (500 minus 5 is 495), and nothing for Lina. The loop contains no if, no type check, and no knowledge of the subclasses. (This example uses double for money to keep the focus on polymorphism, as earlier modules did. Real money code needs more care, which comes later in the course.)

Example 3: a parent method that calls an overridden one (realistic)

java
class Account {
    private final String owner;
    Account(String owner) { this.owner = owner; }
    String label() { return "Account"; }
    void printStatement() { System.out.println(label() + " of " + owner); }
}
class SavingsAccount extends Account {
    SavingsAccount(String owner) { super(owner); }
    @Override String label() { return "Savings account"; }
}
public class Ex3 {
    public static void main(String[] args) {
        Account a = new SavingsAccount("Ana");
        a.printStatement();
        new Account("Omar").printStatement();
    }
}

Output:

text
Savings account of Ana
Account of Omar

printStatement() is written only once, in Account, and SavingsAccount does not override it. Yet inside it, the call label() (shorthand for this.label()) printed "Savings account" for Ana. The object is a SavingsAccount, so its label() ran even though the call sits in parent code. The Java Language Specification describes this lookup as starting from the actual run-time class of the target object. Statements, receipts, and reports in real systems are often built this way: shared layout in the parent, details in the children.

Backend Insight

Backend code very often processes a collection of things of different kinds in one pass: a batch of payments (card, bank transfer, wallet), a queue of notifications (email, SMS, push), a list of validation rules. Each kind has its own way of doing the job, but the batch processor should not care.

When polymorphism is used well, the processor is a short loop that calls one method, and every kind-specific rule sits inside its own class. New requirements ("we now support wallet payments") become new classes rather than edits to code that already works and has already been tested. That is why teams value it, and why two later topics build on exactly this idea: the Strategy pattern (swap behavior by swapping the object) and dependency inversion (depend on the parent type, not the concrete class). You will meet both in the next level.

Common Mistakes

Mistake 1: Thinking the reference type picks the method

  • Incorrect: "a is declared as Account, so a.monthEnd() runs Account.monthEnd()."
  • Correct: "a is declared as Account, so the call is allowed because Account has monthEnd(). The version that runs belongs to the real object."

The reference type decides what you may call. The object type decides which version runs.

Mistake 2: Expecting polymorphism without an override

If the subclass method has a different name or different parameters, it does not override anything; it is just another method. Call through the parent reference and the parent's version runs. Using @Override turns this silent problem into a compile error, which is why you always write it.

Mistake 3: Checking the type before every call

java
if (a instanceof SavingsAccount) { ... } else if (a instanceof CheckingAccount) { ... }

This undoes the benefit. If you find yourself writing it to call "the same" action on different kinds, move each branch into an overriding method instead. Lessons 2 and 4 return to this.

Exercises

Try each exercise yourself before opening the solutions. Predict first, then run the code if you want to check.

Level A: Foundation

A1. Given these classes, what does the loop print?

java
class Vehicle { String sound() { return "generic"; } }
class Car extends Vehicle { @Override String sound() { return "vroom"; } }
class Bike extends Vehicle { @Override String sound() { return "ring"; } }
class Truck extends Car { }
// ...
Vehicle[] vs = { new Car(), new Bike(), new Vehicle(), new Truck() };
for (Vehicle v : vs) System.out.println(v.sound());
Show solution A1

A1. It prints vroom, ring, generic, vroom. Truck does not override sound(), so it inherits Car's override, which is the nearest version above it in the class chain.

A2. For the line Vehicle v = new Bike();, name the reference type and the object type. Then say what changes if the next line is v = new Car();.

Show solution A2

A2. Reference type: Vehicle. Object type: Bike. After v = new Car(); the reference type is still Vehicle, but the object type is now Car, so v.sound() returns vroom instead of ring.

A3. In one sentence, explain why an array declared Vehicle[] can hold a Bike.

Show solution A3

A3. A Bike is a Vehicle (it extends it), so a Vehicle-typed slot can refer to it.

Level B: Application

B1. Bird has String move() { return "fly"; }. Write Penguin extends Bird so that a loop over Bird[] birds = { new Bird(), new Penguin() } prints fly and then waddle, without any if.

Show solution B1

B1.

java
class Penguin extends Bird {
    @Override String move() { return "waddle"; }
}

The loop for (Bird b : birds) System.out.println(b.move()); prints fly then waddle. I compiled and ran this.

B2. What does this print, and why does the parent's method show the child's fee?

java
class Parcel {
    String describe() { return "parcel, " + fee(); }
    String fee() { return "fee 5"; }
}
class Express extends Parcel { @Override String fee() { return "fee 12"; } }
// ...
Parcel p = new Express();
System.out.println(p.describe());
Show solution B2

B2. It prints parcel, fee 12. describe() is inherited, but the call to fee() inside it is dispatched on the real object, which is an Express, so Express.fee() runs.

B3. A student writes the following and is surprised that the output is base. Explain the problem and the fix.

java
class Account { String monthEnd() { return "base"; } }
class Savings extends Account { String monthEnd(int days) { return "savings " + days; } }
// ...
Account a = new Savings();
System.out.println(a.monthEnd());
Show solution B3

B3. monthEnd(int days) has different parameters, so it does not override monthEnd(); it is a separate overload. The call a.monthEnd() finds only the parent's no-argument method, and it prints base. Fix: give the subclass the same signature, @Override String monthEnd(). With @Override on the original line, the compiler would have reported the mistake immediately.

Level C: Challenge

C1. A shop has a Ticket class whose int price() returns 10. StudentTicket costs 5 and VipTicket costs 25. Write the three classes and a loop that totals the price of the cart { Ticket, StudentTicket, VipTicket, StudentTicket } with no type checks.

Show solution C1

C1.

java
class Ticket { int price() { return 10; } }
class StudentTicket extends Ticket { @Override int price() { return 5; } }
class VipTicket extends Ticket { @Override int price() { return 25; } }
// in main:
Ticket[] cart = { new Ticket(), new StudentTicket(), new VipTicket(), new StudentTicket() };
int total = 0;
for (Ticket t : cart) total += t.price();
System.out.println(total);   // 45

I compiled and ran it: 10 + 5 + 25 + 5 = 45.

C2. Your loop over Ticket[] works. Now a colleague adds a ChildTicket class. In your own words, what must ChildTicket do for the existing loop to price it correctly, and what in the loop must change?

Show solution C2

C2. ChildTicket must extend Ticket and override price() with the same signature (and @Override). The loop does not change at all: it already calls price() on a Ticket reference, and dispatch will pick the new class's version.

Mini Challenge

Model a small "notification" system. Create a parent class Notification with a method String message() that returns "generic". Create at least three subclasses (for example EmailNotification, SmsNotification, PushNotification), each overriding message() to return a different text. In main, build a Notification[] with at least five objects including one plain Notification, loop over it once, and print each message().

Check your work:

  • The loop contains one call and no if, switch, or instanceof.
  • Every override has @Override.
  • You can say, for each printed line, which class's method produced it.
  • Bonus: add a fourth subclass and confirm that you did not change the loop.

Summary & Next Lesson

Key takeaways

  • Polymorphism means one method call can run different code depending on the real object.
  • Every object variable has a reference type (fixed, written in the code) and an object type (the class created with new).
  • The reference type decides which calls are allowed; the object type decides which overridden version runs.
  • Callers can work with the parent type only, which keeps loops short and unchanged when new subclasses appear.
  • It works for overridden instance methods. A method with a different signature is an overload, not an override.

Next lesson

You can now point a parent reference at a child object. The next lesson, "Upcasting, downcasting, and instanceof", looks at moving between parent and child types: what the parent reference lets you do, how to get back to the child type, and why doing that often is a warning sign.

Self-check

  1. In Account a = new SavingsAccount(...), what are the reference type and the object type?
  2. Which monthEnd runs for a.monthEnd(): the reference type's or the object's?
  3. Can polymorphism work if the subclass never overrides the method? What runs?
  4. Why can the month-end loop avoid any if or instanceof?
  5. A subclass declares monthEnd(int days). Does a call to a.monthEnd() through a parent reference reach it?
Show answers
  1. Reference type Account, object type SavingsAccount.
  2. The object's.
  3. There is no different behavior then: the inherited parent version runs for every object. Polymorphism needs an override to show different behavior.
  4. Each subclass carries its own version of monthEnd(), and the call is dispatched on the real object at run time, so the loop needs no type knowledge.
  5. No. A method with different parameters is an overload, not an override, and the call finds only the parent's no-argument method.

References

  • Oracle, Java Tutorials, Polymorphism. Supports the rule that the JVM calls the method of the actual object, not of the variable's declared type (virtual method invocation).
  • Oracle, Java Language Specification SE 21, 15.12.4.4 Locate Method to Invoke. Supports the statement that dynamic method lookup starts from the run-time class of the target object, including for calls made inside inherited code.
  • Oracle, Java SE 21 API, Override annotation. Supports the claim that @Override makes the compiler report an error when a method does not override a supertype method.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.