Java foundations · Lesson 30 About 75 min with practice
Java Polymorphism and Method Overriding
One shared method call can use different implementations. Learn how the declared type makes a call legal, how the runtime receiver selects an override, and how to keep that flexibility within a clear behavioral contract.
Separate compile-time member selection from runtime instance-method dispatch.
Override a compatible inherited method and use @Override to catch mistakes.
Reuse base behavior with super without recursive self-calls.
Process several subclasses through one shared reference type.
Distinguish overriding from overloading, field hiding, and static hiding.
Preserve shared state rules and verify read-only specialized reports.
A reference has a declared type; its object has a runtime type
MemberAccount account = new StudentAccount("Mina", "North") describes two things. The variable is declared as MemberAccount, while the object created at runtime is a StudentAccount. Assignment does not copy or simplify that object, and it does not remove its campus information.
The compiler first checks whether a call such as account.summary() is valid through the declared type. MemberAccount declares summary, so the call is allowed. For this ordinary overridden instance method, runtime dispatch then selects the implementation belonging to the actual object's class.
The result contains campus even though the variable is member-typed. That is the central idea of subtype polymorphism here: a shared type lets one caller use several compatible implementations. It does not mean every field, static call, constructor, or overload is chosen dynamically.
JAVA 21 COMPILER
Main.java · A base reference calls the student implementation
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Mina | borrowed: 0 | campus: North
Trace the first call in four steps
The variable is declared MemberAccount, so summary() must be available on that API.
The receiver object is a StudentAccount created by new.
The student has a compatible override of the selected no-argument instance method.
That override runs and appends campus after calling the base implementation with super.
The base reference did not copy the object or remove its student state.
Override the inherited method, not a similarly named idea
StudentAccount declares public String summary() with the same no-argument signature as the inherited method. @Override asks the compiler to verify that this declaration really overrides an eligible inherited method. A spelling error or an extra parameter is then reported at compile time instead of becoming an unnoticed new method.
Use compatible return types and do not reduce the inherited method's visibility. The examples keep the return type String and public access unchanged. Java also permits covariant reference return types in suitable overrides; that is different from changing a String-returning method into an int-returning one.
The annotation is a safety check, not the mechanism that switches dispatch on. A valid instance override still works without it, but omitting the check makes accidental overloading easier. Keep @Override on intended overrides, and preserve the base method's behavioral promise of a read-only report.
Let one shared caller work with several implementations
The array stores MemberAccount references to a regular member, a student, and a staff account. Each loop iteration uses the same source expression account.summary(). The receiver changes, so the runtime implementation can change without a chain of instanceof checks or casts in the reporting loop.
The base class remains concrete in this lesson, so a regular MemberAccount is also a valid instance. Student and staff add their own metadata while retaining the common name and borrowing behavior. Abstract classes and interfaces will provide more ways to express shared contracts in the next lesson.
This pattern keeps the caller focused on the capability it needs: producing a summary. The caller should not need getCampus or getDesk merely to build a report. If a new compatible account overrides summary correctly, the same loop can handle it without editing a type-based branch.
JAVA 21 COMPILER
Main.java · One loop, three runtime implementations
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Mina | borrowed: 0
Omar | borrowed: 0 | campus: North
Lina | borrowed: 0 | desk: Help
Use super to extend a base implementation without calling yourself
StudentAccount.summary returns super.summary() plus the campus suffix. The super call selects the base summary implementation on this same instance, preserving the member name and current borrowing count before adding student-specific details. It does not allocate or query a separate parent object.
Writing return summary() + ... inside that override would call the current instance method again and recurse without progress. The intended operation is reuse of the superclass implementation, not another virtual call to the method currently being implemented.
A super call chooses a particular superclass implementation for that call. Normal virtual calls made within a base method can still dispatch on the current object, as the next example shows. Treat super as a precise language construct, not a general way to turn all behavior on the object into base-class behavior.
JAVA 21 COMPILER
Main.java · Reuse base details and append specialization
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Mina | borrowed: 1 | campus: North
A call inside an inherited method still has the current receiver
reportLine is implemented only in MemberAccount and builds "Report: " + summary(). Calling it on a StaffAccount runs that inherited method body, but its ordinary summary() call uses the same staff receiver. Runtime dispatch therefore selects StaffAccount.summary and includes the desk.
The file containing the caller does not permanently pin every nested instance call to that class. Ask which method body is executing, then which receiver and call kind the next invocation uses. This two-step trace is more reliable than assuming that code located in the base must always print only base data.
Do not make such virtual calls from constructors as a shortcut for initialization. A subclass method can run before its subclass fields have been assigned. Our constructors validate and store fields directly, and reports are requested only after construction completes normally.
JAVA 21 COMPILER
Main.java · A base method can call an overridden instance method
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Report: Lina | borrowed: 0 | desk: Help
Why does reportLine include the desk?
reportLine is inherited from MemberAccount.
Its receiver is still the StaffAccount instance.
The ordinary summary() call in that body dispatches to StaffAccount.summary().
The result is prefixed with Report: and returned without changing state.
Overloading is selected first; overriding dispatches the selected signature
Labeler has label(Object) and label(String), which are overloads because their parameter lists differ. LoudLabeler overrides only label(Object). For the variable Object value = "Java", the compiler selects label(Object), and runtime dispatch then chooses the child's implementation of that signature.
For the literal "Java", the compiler instead selects label(String). The child did not override that overload, so the inherited base text implementation runs. The runtime fact that the first argument happened to contain a String does not redo overload resolution using a new parameter type.
When debugging, separate the questions: which signature was selected from the declared types, and which overriding body implements that signature for the receiver? Adding a parameter to summary would create an overload, not replace the no-argument report used through MemberAccount.
JAVA 21 COMPILER
Main.java · Resolve the overload before dispatching its implementation
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
child object
base text
Predict the two-stage choice
For the Object-typed variable, select label(Object) first, then use the child override of that signature. For the String literal, select label(String), which the child inherits unchanged. The runtime String stored in an Object variable does not trigger a second overload search.
Fields and static methods follow different rules
In the diagnostic example, view has declared type BaseLabel but refers to a ChildLabel. Reading view.label selects the base field, while view.describe() dispatches to the child's instance override. Field hiding creates separate stored fields; it is not virtual method behavior.
The static kind methods hide rather than override one another. Class-qualified calls make the chosen owner explicit. Java can permit static access through an expression, but its declared type—not the object's runtime class—controls that selection; use the class name to avoid that misleading spelling.
Private methods are not inherited override points, final methods cannot be overridden, and static methods cannot be turned into instance overrides. In our account model, borrow and returnOne are final to keep one shared borrowing implementation, while summary is the deliberate overridable query.
JAVA 21 COMPILER
Main.java · Fields and static members are not virtual instance methods
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Field: base field
Instance: child instance
Base static: base static
Child static: child static
Changing a reference changes the receiver, not the old object's class
The rebind example stores a student reference in a member-typed variable, saves an alias, then assigns a staff reference to the variable. Its next summary call dispatches to the staff implementation because that is now its receiver. The saved original reference still reaches the unchanged student.
Polymorphism does not convert a student object into a staff object. The variable receives another reference value. This distinction matters when several callers hold aliases: rebinding one variable does not rebind the others or erase their objects' existing state.
A base-typed reference still exposes only the base API at compile time. account.getCampus() is not valid through MemberAccount even when its current receiver is a student. Use the common reporting capability here instead of downcasting just to reproduce work that summary already provides.
JAVA 21 COMPILER
Main.java · The same variable can refer to another implementation
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Mina | borrowed: 0 | campus: North
Lina | borrowed: 0 | desk: Help
Original: Mina | borrowed: 0 | campus: North
Different reports must still honor the shared contract
Each override produces text without borrowing, returning, renaming, or changing metadata. A student summary that secretly calls borrow would violate the base query contract even if its return type and access modifier were legal. Compilation checks structure; tests and design reasoning check the behavioral promise.
The counter still permits zero through three loans. A fourth borrow is rejected, returning at zero is rejected, and both leave state untouched. Specialized reporting must preserve those rules and reflect the actual current count, not a second cached or hidden counter.
This is a single-threaded in-memory library-counter simulation, not a book reservation, persistence, or membership authorization service. All report methods return Strings and leave printing to main. Polymorphism changes how a shared operation is implemented, not the integration boundaries of this teaching model.
JAVA 21 COMPILER
Main.java · Different reports, unchanged borrowing rules
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output · identify the selected signature and the runtime receiver
Borrow 1: true
Borrow 2: true
Borrow 3: true
Borrow 4: false
Mina | borrowed: 3 | campus: North
Report: Mina | borrowed: 3 | campus: North
Queries unchanged: true
Test receivers, shared callers, and failure categories separately
Test a base instance and each subclass through MemberAccount references. Verify summary and reportLine at zero, after accepted changes, and at the limit. Repeat queries and confirm count and metadata remain unchanged. An empty account array should simply produce no report lines.
Use deliberate compilation failures to check an incorrect @Override signature, reduced visibility, an incompatible return type, and an attempt to override a final operation. Distinguish these from a runtime NullPointerException when a null receiver is actually called; dispatch requires an existing receiver.
The final lab uses one MemberAccount array and one reporting loop, with no type-switch branches. Finish by changing the array order and adding another valid account. The caller should keep working because it depends on the shared summary contract rather than knowing every subclass-specific detail.
Repair exercise: a report method became an overload
This student report accidentally adds a String parameter, so it overloads summary instead of overriding the no-argument method used by the caller. The starter compiles but loses campus information. Repair the signature and add @Override; do not change main or introduce a cast.
JAVA 21 COMPILER
Main.java · Find the wrong method signature
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
The starter compiles but currently omits the campus:
Mina | borrowed: 0
Reveal the explained repair
The member-typed caller invokes summary(), not summary(String). The accidental overload is therefore not a replacement for the inherited no-argument method. Restoring public String summary() and marking it @Override makes the intended relationship explicit and lets the compiler reject future signature mistakes.
JAVA 21 COMPILER
Main.java · Restore the intended override
Predict the result, edit Main.java, then run your program.
Select Run code to compile and execute this program.
Expected output after repair
Mina | borrowed: 0 | campus: North
Independent lab: one reporting loop, several implementations
Build three compatible account types whose summary implementations differ, then report them through one shared MemberAccount loop. Preserve the common borrowing rules and read-only reporting contract. This program is local teaching data, not connected membership software.
Implement MemberAccount with private final memberName, private borrowedCount initially zero, and MAX_BORROWED = 3. Validate and strip nonblank names, throwing Member name must not be blank. on rejection. Keep construction free of virtual report calls.
Expose getMemberName, getBorrowedCount, final borrow, and final returnOne with the same accepted/rejected rules as the previous lesson. Implement public String summary() returning <name> | borrowed: <count>, and reportLine() returning Report: followed by the virtual summary() result.
Create StudentAccount with private final campus and StaffAccount with private final desk. Call super(memberName) first, validate and strip the extra label, and reject with Campus must not be blank. or Desk must not be blank. Use a protected static requireText helper with a label-specific message; public getters expose only immutable label values.
Override public String summary() in both subclasses with @Override, retaining the no-argument signature. Use super.summary() plus | campus: <campus> or | desk: <desk>. No report or getter may mutate state or print.
Create one MemberAccount[] containing Mina, student Omar/North, and staff Lina/Help. Borrow once for Mina, twice for Omar, and borrow then return for Lina. Save counts, then print all summaries with one enhanced-for loop and the inherited reportLine for Omar.
Borrow Omar up to the limit, reject one beyond it, and return once to restore the earlier count. Print the action results and verify all saved counts match again. Test read-only queries separately; do not use instanceof branches or downcasts to construct the reports.
Prepare borrowing counts one, two, and zero for Mina, Omar, and Lina respectively. Expected output:
Mina | borrowed: 1
Omar | borrowed: 2 | campus: North
Lina | borrowed: 0 | desk: Help
Report: Omar | borrowed: 2 | campus: North
Student borrow to limit: true
Student beyond limit: false
Return to prior count: true
Counts restored: true
JAVA 21 COMPILER
Main.java · Build the shared reporting contract
Predict the result, edit Main.java, then run your program.
publicclassMain{publicstaticvoidmain(String[] args){// TODO: create a MemberAccount array with base, student, and staff instances.// TODO: prepare borrowing counts 1, 2, and 0 through shared operations.// TODO: save counts and print reports using one shared loop.// TODO: test the limit and restore the saved counts.}}classMemberAccount{// TODO: validated state, fixed borrowing operations, and overridable queries.}classStudentAccountextendsMemberAccount{// TODO: campus state and an @Override summary with the same signature.}classStaffAccountextendsMemberAccount{// TODO: desk state and an @Override summary with the same signature.}
Standard input · optional
TERMINAL OUTPUT
Select Run code to compile and execute this program.
Stage 1 hints: define the shared contract
Start with validated names and private count. Keep borrow and returnOne final, summary overridable, and reportLine implemented in terms of summary. Do not call a report from a constructor or add console output to the model.
Stage 2 hints: override without replacing the invariant
Construct each subclass with super first, validate its extra label, and override exactly public String summary(). Preserve the common details with super.summary(). The protected static text helper is a deliberate validation utility, not exposed mutable state.
Stage 3 hints: keep the caller generic
Store all three instances in MemberAccount[]. Use the same enhanced-for call to summary for each element. Compare saved counts after queries and after the temporary borrow/return sequence; test query purity independently as well. No instanceof branch or downcast belongs in the report loop.
Reveal the complete explained solution
JAVA 21 COMPILER
Main.java · Complete polymorphic account reports
Predict the result, edit Main.java, then run your program.
publicclassMain{publicstaticvoidmain(String[] args){MemberAccount[] accounts ={newMemberAccount(" Mina "),newStudentAccount(" Omar "," North "),newStaffAccount(" Lina "," Help ")};
accounts[0].borrow();
accounts[1].borrow();
accounts[1].borrow();
accounts[2].borrow();
accounts[2].returnOne();int[] before =newint[accounts.length];for(int index =0; index < accounts.length; index++){
before[index]= accounts[index].getBorrowedCount();}for(MemberAccount account : accounts){System.out.println(account.summary());}System.out.println(accounts[1].reportLine());System.out.println("Student borrow to limit: "+ accounts[1].borrow());System.out.println("Student beyond limit: "+ accounts[1].borrow());System.out.println("Return to prior count: "+ accounts[1].returnOne());boolean unchanged =true;for(int index =0; index < accounts.length; index++){
unchanged = unchanged && before[index]== accounts[index].getBorrowedCount();}System.out.println("Counts restored: "+ unchanged);}}classMemberAccount{publicstaticfinalintMAX_BORROWED=3;privatefinalString memberName;privateint borrowedCount;publicMemberAccount(String memberName){this.memberName =requireText(memberName,"Member name");}protectedstaticStringrequireText(String value,String label){if(value ==null|| value.isBlank()){thrownewIllegalArgumentException(label +" must not be blank.");}return value.strip();}publicStringgetMemberName(){return memberName;}publicintgetBorrowedCount(){return borrowedCount;}publicfinalbooleanborrow(){if(borrowedCount >=MAX_BORROWED){returnfalse;}
borrowedCount++;returntrue;}publicfinalbooleanreturnOne(){if(borrowedCount ==0){returnfalse;}
borrowedCount--;returntrue;}publicStringsummary(){return memberName +" | borrowed: "+ borrowedCount;}publicStringreportLine(){return"Report: "+summary();}}classStudentAccountextendsMemberAccount{privatefinalString campus;publicStudentAccount(String memberName,String campus){super(memberName);this.campus =requireText(campus,"Campus");}publicStringgetCampus(){return campus;}@OverridepublicStringsummary(){returnsuper.summary()+" | campus: "+ campus;}}classStaffAccountextendsMemberAccount{privatefinalString desk;publicStaffAccount(String memberName,String desk){super(memberName);this.desk =requireText(desk,"Desk");}publicStringgetDesk(){return desk;}@OverridepublicStringsummary(){returnsuper.summary()+" | desk: "+ desk;}}
Standard input · optional
TERMINAL OUTPUT
Select Run code to compile and execute this program.
The loop's declared element type is MemberAccount, but each summary call dispatches on that element's actual object. reportLine runs inherited code that itself calls the student override. The counter operations remain the same final implementation for all three types. Omar reaches three, rejects the fourth request, then returns to two; the saved counts match at the end.
Test changes and boundaries
Predict the selected signature, implementing class, returned text, and remaining state before running variants. Reset restores the supplied example.
Call summary and reportLine through base references for a base member, student, and staff account. Repeat at counts zero and three, and verify both returned text and unchanged state.
Reorder the array, add another student, and try an empty array. Keep the reporting loop unchanged and avoid requiring subclass-only getters in the shared caller.
Reject null/blank names, campuses, and desks with their documented messages. Verify base validation happens first, and constructor bodies never invoke an overridable report.
Test the accidental overload, then add @Override to the wrong signature and observe compilation failure. Separately test reduced access, incompatible return types, final-method overrides, and a child-only call through a base reference.
Contrast Object- versus String-typed arguments in the overload example. Verify field/static hiding separately from instance dispatch, and remember that a null receiver causes a runtime error rather than selecting a fallback base implementation.
Common mistakes and how to diagnose them
Changing the parameter list instead of overriding
summary(String) is not the same signature as summary(). Keep @Override on the intended replacement so the compiler catches spelling and parameter mistakes before the base caller silently keeps using another method.
Assuming the declared type determines the instance override
The declared type validates members and overload selection. For an ordinary overridden instance call, the actual receiver determines the implementing body. Assignment to a base reference does not convert the object into a base instance.
Calling summary() from inside its own override
That is another virtual call to the same override and can recurse without progress. Use super.summary() when the intention is to reuse the base implementation before appending specialized details.
Treating fields and static methods as virtual
Field selection and static method hiding follow declared-type rules rather than runtime override dispatch. Avoid duplicate field names and prefer class-qualified static calls so the owner stays clear.
Breaking the behavioral contract in a legal override
A summary that changes borrowing count can compile but violates the promised read-only operation. Check repeated queries and state, not just the printed suffix or the presence of @Override.
Using casts or type switches to rebuild each report manually
When the shared summary method already represents the needed capability, let dispatch choose its implementation. A base caller should not need to know every child field or introduce a branch for each new compatible subtype.
Frequently asked questions
Does @Override cause runtime dispatch?
No. It asks the compiler to verify an intended override. A valid override dispatches without the annotation too, but keeping it helps catch accidental overloads and spelling mistakes.
Why can a base reference call a child summary but not getCampus?
summary is declared on the base API, so the call is legal and its implementation can dispatch dynamically. getCampus is not declared on MemberAccount, so that separate call is rejected at compile time through a base reference.
Is overloading a runtime choice too?
Overload resolution uses compile-time information to select a signature. An instance override can then dispatch at runtime for that selected signature. The Object/String example deliberately demonstrates both stages.
Can I override a static, private, or final method?
Static methods are hidden rather than overridden; private methods are not inherited override points; final methods cannot be overridden. This lesson uses ordinary public instance summary methods as its extension points.
Does every subclass need a different implementation?
No. A subclass can inherit a suitable method unchanged. Override only when specialized behavior fits the shared contract; use super when retaining the base contribution is useful.
Select a signature, then follow the receiver
The declared type and argument expressions determine which calls and overloads compile. Runtime dispatch selects an ordinary instance override for the receiver. @Override verifies intent, super reuses a base implementation, and fields or static members do not follow the same virtual-dispatch rule.
Before moving on, explain why an extra parameter is not an override, why inherited reportLine can include a student or staff suffix, and why a legal override must still honor read-only behavior. Next: abstract classes and interfaces, where you will express shared capabilities without requiring one concrete base implementation for everything.
KNOWLEDGE CHECK
Can you trace which method actually runs?
Answer all six questions and read the explanations. Answers are temporary on this page and do not save course progress.
References and related learning
The account examples, explanations, and exercises are original SovranCode teaching material. References document the Java 21 selection and overriding rules behind the examples.