Loading learning content...
Method overriding isn't a free-for-all. Programming languages enforce strict rules about what can be overridden and how. These rules exist to preserve the integrity of the type system, ensure that subclasses remain valid substitutes for their parent classes, and prevent subtle bugs that could arise from inconsistent behavior.
Understanding these rules is essential for writing correct inheritance hierarchies. Violating them results in compiler errors (in statically-typed languages) or runtime surprises (in dynamic languages). More importantly, understanding why these rules exist helps you design better class hierarchies from the start.
By the end of this page, you will understand the complete set of rules governing method overriding: signature matching requirements, access modifier constraints, exception handling rules, and the final/sealed keyword. You'll learn not just what the rules are, but why they exist and what happens when you violate them.
The foundational rule of method overriding is signature matching: the overriding method must have the same name and the same parameter list as the method being overridden. This ensures that when client code calls the method, the call is valid regardless of whether it's executing the parent's or child's version.
What constitutes a method signature?
In most languages, a method signature consists of:
Return type is often not considered part of the signature for overloading purposes, but it has specific rules for overriding (covariant returns, covered later).
1234567891011121314151617181920212223242526272829303132333435363738394041424344
class Animal { // Method with specific signature: name="speak", params=(String) public void speak(String message) { System.out.println("Animal says: " + message); } public int calculate(int x, int y) { return x + y; }} class Dog extends Animal { // ✅ VALID OVERRIDE: Same signature (speak, String) @Override public void speak(String message) { System.out.println("Dog barks: " + message); } // ❌ NOT AN OVERRIDE: Different parameter type (String instead of int) // This is OVERLOADING, not overriding! // @Override // Would cause compile error if uncommented public int calculate(String x, String y) { return Integer.parseInt(x) + Integer.parseInt(y); } // ❌ NOT AN OVERRIDE: Different parameter count // @Override // Would cause compile error public void speak(String message, int volume) { System.out.println("Dog barks loudly: " + message); }} class Cat extends Animal { // ❌ COMPILE ERROR: Trying to override with incompatible type // public void speak(int soundCode) { } // This wouldn't override // ✅ VALID OVERRIDE: Exact signature match @Override public void speak(String message) { System.out.println("Cat meows: " + message); }}Always use @Override (Java), override (TypeScript/C#), or type checkers (Python with mypy) to verify your overrides. These tools catch accidental overloading when you intended to override—a common source of bugs where the parent's method continues to be called unexpectedly.
When overriding a method, the overriding method cannot have a more restrictive access modifier than the method it overrides. This rule exists to uphold the Liskov Substitution Principle: if client code can call a method on the parent type, it must also be able to call that method on any subtype.
Consider why this makes sense: if a parent class declares a public method, client code expects to call it on any instance of that class or its subclasses. If a subclass could make that method private, the subclass would no longer be a valid substitute for the parent—a fundamental violation of polymorphism.
| Parent Access | Allowed Override Access | Not Allowed |
|---|---|---|
| public | public only | protected, package-private, private |
| protected | public, protected | package-private, private |
| package-private | public, protected, package-private | private |
| private | N/A (cannot be overridden) | N/A (not inherited) |
The key principle: You can make access wider (more permissive) but not narrower (more restrictive). Moving from protected to public is fine—it just makes the method more accessible. Moving from public to protected would break existing code that depends on public access.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class Vehicle { // Public method - accessible everywhere public void start() { System.out.println("Vehicle starting"); } // Protected method - accessible in subclasses and same package protected void performMaintenance() { System.out.println("Performing maintenance"); } // Package-private method - accessible in same package void diagnose() { System.out.println("Running diagnostics"); } // Private method - NOT inherited, cannot be overridden private void internalCheck() { System.out.println("Internal check"); }} class Car extends Vehicle { // ✅ VALID: Same access (public) @Override public void start() { System.out.println("Car engine starting"); } // ✅ VALID: Widening access (protected → public) @Override public void performMaintenance() { System.out.println("Car maintenance"); } // ✅ VALID: Widening access (package-private → protected) @Override protected void diagnose() { System.out.println("Car diagnostics"); } // This is NOT an override - it's a new method with same name // Private methods are not inherited private void internalCheck() { System.out.println("Car internal check"); }} class Motorcycle extends Vehicle { // ❌ COMPILE ERROR: Cannot reduce visibility // @Override // protected void start() { // Trying to narrow public → protected // System.out.println("Motorcycle starting"); // } // Error: attempting to assign weaker access privileges // ❌ COMPILE ERROR: Cannot reduce visibility // @Override // private void performMaintenance() { // protected → private // System.out.println("Motorcycle maintenance"); // } @Override public void start() { System.out.println("Motorcycle engine starting"); }}Private methods are not visible to subclasses, so they cannot be overridden. If a subclass defines a method with the same name as a parent's private method, it's a completely new method, not an override. This is why @Override on such a method causes a compile error.
In languages with checked exceptions (most notably Java), overriding methods have constraints on what exceptions they can declare. The rule is: an overriding method cannot declare broader checked exceptions than the method it overrides.
Why? Consider client code that catches exceptions from the parent's method. If the subclass could throw new exception types that the parent didn't declare, the client's exception handling would be incomplete—exceptions would escape the catch blocks. This would again violate substitutability.
The exception rules:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
import java.io.*; class DataReader { // Declares it can throw IOException public void read(String path) throws IOException { // ... read file } // Declares multiple exceptions public void parse(String data) throws IOException, ParseException { // ... parse data } // No exceptions declared public void process(String input) { // ... process }} class FileDataReader extends DataReader { // ✅ VALID: Same exception @Override public void read(String path) throws IOException { // ... specific file reading } // ✅ VALID: Subclass of IOException (narrower exception) @Override public void parse(String data) throws FileNotFoundException { // FileNotFoundException extends IOException // This is allowed because any code catching IOException // will also catch FileNotFoundException }} class NetworkDataReader extends DataReader { // ✅ VALID: Fewer exceptions (eliminating one) @Override public void parse(String data) throws IOException { // Removed ParseException - this is allowed } // ✅ VALID: No exceptions at all @Override public void read(String path) { // Eliminated IOException - allowed (doesn't throw) }} class DatabaseDataReader extends DataReader { // ❌ COMPILE ERROR: Adding new checked exception // @Override // public void read(String path) throws IOException, SQLException { // // SQLException is not a subclass of IOException // // Client code catches IOException but not SQLException // } // Error: overridden method does not throw SQLException // ✅ VALID: RuntimeExceptions can always be added @Override public void process(String input) throws IllegalArgumentException { // RuntimeException subclasses are always allowed if (input == null) { throw new IllegalArgumentException("Input cannot be null"); } } @Override public void read(String path) throws IOException { // Valid override }}Python, JavaScript/TypeScript, and C# don't have checked exceptions, so this rule doesn't apply directly. However, the principle of not surprising callers with unexpected exceptions is still good practice—document the exceptions your overrides can throw.
Sometimes a class author wants to explicitly prevent a method from being overridden. This might be because:
Most languages provide a mechanism for this: final (Java), sealed (C#), or @final decorator equivalents.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class BankAccount { private double balance; private final String accountId; public BankAccount(String accountId, double initialBalance) { this.accountId = accountId; this.balance = initialBalance; } // Final method: critical security, cannot be overridden public final String getAccountId() { return accountId; } // Final method: balance calculation is invariant public final double getBalance() { return balance; } // Final method: audit trail must always happen public final void withdraw(double amount) { if (amount > balance) { throw new IllegalArgumentException("Insufficient funds"); } audit("Withdrawal: " + amount); balance -= amount; } // Can be overridden: different account types may have different fees public double calculateFee() { return 0.0; // No fee by default } // Protected helper, can be extended protected void audit(String action) { System.out.println("[AUDIT] " + accountId + ": " + action); }} class PremiumAccount extends BankAccount { public PremiumAccount(String accountId, double initialBalance) { super(accountId, initialBalance); } // ❌ COMPILE ERROR: Cannot override final method // @Override // public double getBalance() { // return super.getBalance() + 100; // Fraudulent inflation! // } // Error: overridden method is final // ✅ VALID: calculateFee is not final @Override public double calculateFee() { return 0.0; // Premium accounts have no fees } // ✅ VALID: extend the audit behavior @Override protected void audit(String action) { super.audit(action); notifyAccountManager(action); } private void notifyAccountManager(String action) { System.out.println("[MANAGER] Premium account activity: " + action); }}Use final for methods where incorrect overriding could cause security vulnerabilities, data corruption, or violated invariants. For general methods, lean toward allowing overrides unless you have specific reasons not to. You can always add final later; removing it is a breaking change.
Different programming languages have different default behaviors for whether methods can be overridden:
Java, Python, JavaScript: Methods are virtual by default. Any instance method can be overridden unless explicitly marked final.
C#, C++: Methods are non-virtual by default. You must explicitly mark methods as virtual to allow overriding, and use override to actually override them.
This distinction has significant design implications:
| Aspect | Virtual by Default (Java/Python) | Non-Virtual by Default (C#/C++) |
|---|---|---|
| Philosophy | Favor flexibility and extensibility | Favor safety and explicitness |
| Override Declaration | Optional (@Override for safety) | Required (override keyword) |
| Preventing Override | Explicit (final keyword) | Implicit (don't add virtual) |
| Runtime Overhead | All methods use vtable dispatch | Only virtual methods use vtable |
| Design Burden | Author must mark non-overridable | Author must mark overridable |
| Error Risk | Accidental override possible | Harder to accidentally override |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
public class BaseLogger{ // Non-virtual: CANNOT be overridden public void LogInfo(string message) { Console.WriteLine($"[INFO] {message}"); } // Virtual: CAN be overridden public virtual void LogWarning(string message) { Console.WriteLine($"[WARNING] {message}"); } // Virtual: CAN be overridden public virtual void LogError(string message) { Console.WriteLine($"[ERROR] {message}"); }} public class FileLogger : BaseLogger{ // ❌ COMPILE ERROR: LogInfo is not virtual // public override void LogInfo(string message) // { // File.AppendAllText("info.log", message); // } // Error: cannot override non-virtual member // This HIDES the base method (different from overriding!) // Generally considered bad practice - use 'new' keyword to be explicit public new void LogInfo(string message) { File.AppendAllText("info.log", message); } // ✅ VALID: LogWarning is virtual public override void LogWarning(string message) { base.LogWarning(message); // Call base implementation File.AppendAllText("warning.log", message); } // ✅ VALID: LogError is virtual public override void LogError(string message) { Console.WriteLine($"[CRITICAL ERROR] {message}"); File.AppendAllText("error.log", message); AlertAdministrator(message); } private void AlertAdministrator(string error) { Console.WriteLine("Administrator alerted!"); }} // Usage demonstrates the differencepublic class Demo{ public static void Main() { BaseLogger logger = new FileLogger(); // Calls BaseLogger.LogInfo (not overridden, no polymorphism) logger.LogInfo("Starting up"); // Uses base version! // Calls FileLogger.LogWarning (virtual, polymorphic dispatch) logger.LogWarning("Low memory"); // Uses override // Calls FileLogger.LogError (virtual, polymorphic dispatch) logger.LogError("Connection failed"); // Uses override }}In C#, using new to hide a base method is different from overriding. Hiding doesn't provide polymorphic behavior—the method called depends on the reference type, not the object type. This is usually a design smell; prefer virtual/override for true polymorphism.
Even experienced developers make mistakes when overriding methods. Understanding common pitfalls helps you write more robust code and catch errors early:
caculate() instead of calculate() creates a new method, not an override. Use @Override annotations to catch this.process(Integer id) doesn't override process(int id) in some languages. Autoboxing doesn't apply to method signatures.super.method().public method protected violates substitutability. The compiler will catch this.equals(), you must also override hashCode() to maintain the consistency contract.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
class User { private String name; private int id; public User(int id, String name) { this.id = id; this.name = name; } // Base equals implementation @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; User user = (User) obj; return id == user.id; }} // ❌ MISTAKE: Overriding equals with wrong signatureclass AdminUser extends User { private String department; public AdminUser(int id, String name, String department) { super(id, name); this.department = department; } // This is OVERLOADING, not overriding! // The parameter is AdminUser, not Object public boolean equals(AdminUser other) { if (other == null) return false; return super.equals(other) && department.equals(other.department); } // Without @Override, no compile error... but wrong behavior // List operations use equals(Object), not equals(AdminUser)!} // ✅ CORRECT: Proper overrideclass ModeratorUser extends User { private String zone; public ModeratorUser(int id, String name, String zone) { super(id, name); this.zone = zone; } @Override // This catches any signature mistakes! public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; if (!super.equals(obj)) return false; // Check parent fields ModeratorUser that = (ModeratorUser) obj; return zone.equals(that.zone); } @Override // Must override hashCode when overriding equals public int hashCode() { return 31 * super.hashCode() + zone.hashCode(); }}Modern IDEs highlight override issues, generate correct override stubs, and warn about missing hashCode when equals is overridden. Use these tools—they catch mistakes that are hard to debug later.
We've covered the comprehensive set of rules that govern method overriding. These rules exist to maintain type safety, ensure substitutability, and prevent subtle bugs in inheritance hierarchies.
virtual@Override, override keywords catch accidental overloading and typosWhat's next:
Now that we understand the rules governing overriding, the next page explores how to call the parent's version of an overridden method using the super keyword. This enables extension rather than replacement—adding behavior while preserving the parent's logic.
You now understand the complete set of rules that govern method overriding, including signature matching, access modifiers, exception handling, and the final keyword. Next, we'll learn how to leverage parent implementations with the super keyword.