1 The Real-World Scenario
Imagine you are building a payroll system for a university. The university has four kinds of staff members:
All these staff members share some common data (name, address, phone). But each type is paid differently. How can we store them all together and process their payments with a single loop? The answer: Polymorphism!
2 The Class Hierarchy
We design an inheritance hierarchy where StaffMember is the superclass for all staff types:
- StaffMember — the root. Holds shared data and defines
pay()returning 0. - Employee extends StaffMember — adds SSN and payRate. Overrides
pay(). - Volunteer extends StaffMember — overrides
pay()returning 0 (no salary). - Executive extends Employee — adds bonus. Overrides
pay()= salary + bonus. - Hourly extends Employee — adds hoursWorked. Overrides
pay()= hours × rate.
Every class in this hierarchy is connected by IS-A:
3 The Java Classes
Here are all five classes. Notice how each one overrides pay()
in its own way:
4 Building the Polymorphic Array
We declare the array as StaffMember[] — the superclass type.
This lets us store any subclass object inside it, because of the IS-A rule.
📊 What Does the Array Look Like in Memory?
| Slot | Declared Type | Actual Object Stored | Name | |
|---|---|---|---|---|
| staff[0] | StaffMember ref | ➡ | Executive object | Dr. Khalid salary: 5000 + bonus 2000 |
| staff[1] | StaffMember ref | ➡ | Employee object | Faisal salary: 3000 |
| staff[2] | StaffMember ref | ➡ | Hourly object | Layla 80 hrs × 50 SAR/hr |
| staff[3] | StaffMember ref | ➡ | Volunteer object | Maha no payment |
| staff[4] | StaffMember ref | ➡ | Hourly object | Ziad 60 hrs × 45 SAR/hr |
When we write staff[0] = new Executive(...),
Java automatically upcasts the Executive reference to a StaffMember reference.
This is safe because Executive IS-A StaffMember. No cast keyword is needed — Java does it silently.
5 Polymorphic Payroll — One Loop, All Types
Now comes the power of polymorphism.
One loop iterates over all staff members and calls
pay() on each.
Java's dynamic binding picks the correct version of
pay() at runtime
based on the actual object type — not the declared type.
Dynamic Binding Which pay() Gets Called?
The JVM looks at the actual object (not the declared type) to decide which
pay() to execute at runtime:
| Variable | Declared type | Actual object | pay() executed | Result |
|---|---|---|---|---|
| staff[0] | StaffMember | Executive | Executive.pay() | SAR 7,000 |
| staff[1] | StaffMember | Employee | Employee.pay() | SAR 3,000 |
| staff[2] | StaffMember | Hourly | Hourly.pay() | SAR 4,000 |
| staff[3] | StaffMember | Volunteer | Volunteer.pay() | SAR 0 |
| staff[4] | StaffMember | Hourly | Hourly.pay() | SAR 2,700 |
The compiler only knows each variable is a StaffMember.
But at runtime, the JVM inspects the actual object and calls the right
pay(). Five different implementations —
one line of code: s.pay().
6 Casting — Accessing Specific Methods
What if we want to log more hours for our Hourly workers?
The method addHours() exists only in Hourly
— it is NOT in StaffMember.
The polymorphic reference s (declared as StaffMember)
cannot see it directly.
// ERROR! StaffMember has no addHours()
s.addHours(40); // ← compile error!
}
The compiler checks the declared type (StaffMember) and
finds no addHours() there. Refused!
if (s instanceof Hourly) {
Hourly h = (Hourly) s;
h.addHours(40); // ✓ works!
}
}
First check with instanceof,
then downcast to Hourly.
Now the compiler sees the full Hourly interface.
A StaffMember reference can only see methods
defined in StaffMember.
To access subclass-specific methods (like addHours() or
awardBonus()),
you must first check with instanceof,
then downcast the reference.
Remember the setup lines at the top of our program?
((Hourly) staff[2]).addHours(80); // downcast to reach addHours()
((Hourly) staff[4]).addHours(60); // downcast to reach addHours()
Both awardBonus() and addHours()
are not in StaffMember, so the polymorphic array cannot call them directly.
Casting is the only way.
7 Design Discussion — The Volunteer Problem
Now let's think critically about this design.
Our polymorphic loop calls s.pay() for every staff member.
This works — but is it a good design?
Look at Volunteer.pay() — it returns 0.0.
This method exists only because StaffMember declares it.
In the real world, a volunteer is simply not payable — they shouldn't have a
pay() method at all.
But we forced it onto them by putting pay() in the superclass!
↳ Employee { pay() → salary }
↳ Executive { pay() → salary+bonus }
↳ Hourly { pay() → hrs×rate }
↳ Volunteer { pay() → 0 } ← forced!
Volunteer is not payable in real life.
Forcing pay() onto it is a design compromise.
↳ Employee implements Payable
↳ Executive (inherits Payable)
↳ Hourly (inherits Payable)
↳ Volunteer ← no pay() at all! ✓
With an interface, only paid staff implement Payable.
Volunteer is cleanly excluded.
🏋︎ Class Discussion
Volunteer.pay() always returns zero,
why is it even there? Can't we just remove it?
pay()
in StaffMember so that the polymorphic loop can call
s.pay() on every element.
If pay() was not in StaffMember,
the compiler would refuse — because s
is declared as StaffMember.
So yes — we forced Volunteer to have a method it conceptually does not need. This is a real design problem. It is not good design. But at this level, we accept it because we have no better tool yet.
pay() method.
Only Employee (and its subclasses Executive and Hourly) would
implement Payable. Volunteer would not implement it —
so it would have no pay() at all.
The payroll loop would then iterate over Payable[] (not StaffMember[]) and Volunteer would simply not appear in the payroll. We will study this in the Interfaces chapter — stay tuned!
This example shows that polymorphism with inheritance has limits. When a method in the superclass does not make conceptual sense for every subclass, it is a design smell. Interfaces give us a cleaner way to say: "only these specific classes are Payable" — without forcing the method onto everyone else.
8 Degrees of Polymorphism — In Our Staff System
Now that we have seen the payroll system in action, let's ask a critical question: how polymorphic is our design really? Polymorphism is not always 100% — the degree depends on how many subclasses genuinely and naturally share each behavior.
Only add methods to the superclass that are naturally and reasonably shared by all (or most) subclasses. Forcing
addHours() into StaffMember
just to avoid casting would be bad design — it would give every Executive,
Employee, and Volunteer an hours counter that makes no sense for their role.
It is perfectly fine to apply polymorphism at 75% or 80%. That is still excellent design. What matters is that the shared methods are conceptually meaningful for every subclass that inherits them.
🏋︎ Class Discussion
pay()
is not truly 100% polymorphic in our system?
• Executive — salary + bonus → pay() is real and meaningful ✓
• Employee — monthly salary → pay() is real ✓
• Hourly — hours × rate → pay() is real ✓
• Volunteer — returns 0.0 → pay() is forced and artificial ✗
So
pay()
is natural for 3 out of 4 staff types.
We accepted that trade-off to keep the payroll loop simple and uniform.
Here, treating a Volunteer's pay as zero is understandable in a payroll report. But the cleaner solution is the
Payable
interface we discussed in Section 7 — where only genuinely payable staff
implement the interface, and Volunteer is simply excluded from the payroll loop entirely.
That design achieves 100% conceptual purity.
We will build it in the Interfaces chapter!
•
toString()
in StaffMember? Yes — every staff member has an identity. Put it there. ✓•
pay()
in StaffMember? Mostly yes, but Volunteer is an exception. Acceptable compromise. ⚠•
addHours()
in StaffMember? No — only Hourly staff have hours. Keep it out! ✗This reasoning is how experienced engineers decide what belongs in the superclass.