☕ Java

Compile-time Polymorphism

Compile-time polymorphism, also called static polymorphism or early binding, is the form of polymorphism where the method to be called is determined by the compiler at compile time based on the method signature and the type of the reference variable. In Java, compile-time polymorphism is achieved through method overloading — defining multiple methods with the same name but different parameter lists in the same class. The compiler resolves which overloaded method to invoke by matching the argument types and count to the available signatures.

What Compile-time Polymorphism Is

Polymorphism means "many forms" — the same name behaves differently depending on context. In compile-time polymorphism, the context that determines behaviour is the argument types provided at the call site. The compiler examines the arguments and selects the most appropriate overload before the program even runs. Once compiled, the bytecode contains a direct reference to the specific method chosen — there is no runtime lookup and no ambiguity. This is described as early binding because the binding between the method call and the method implementation happens early — at compile time — rather than at runtime. The JVM executes the pre-decided call without any polymorphic lookup. This makes overloaded method calls potentially faster than overriding-based polymorphism (which requires a vtable lookup), though the JIT compiler typically optimises both to the same performance in practice. Compile-time polymorphism is more limited than runtime polymorphism — it cannot adapt to the actual type of an object that is not known until the program runs. But it provides a different kind of value: it allows a class to provide a natural, intuitive API with a single conceptual operation under one name, regardless of what types the caller passes. A print() method that accepts int, double, String, and boolean under one name is more intuitive than four methods named printInt(), printDouble(), printString(), and printBoolean(). The caller uses the one name print and the compiler routes to the right implementation — this is compile-time polymorphism serving the goal of clean API design.
Java
// ── Compile-time polymorphism through method overloading: ────────────
public class DataFormatter {

    // Same conceptual operation — format — four different implementations:
    public String format(int value) {
        return String.format("%,d", value);           // 1,234,567
    }

    public String format(double value) {
        return String.format("%.2f", value);          // 3.14
    }

    public String format(boolean value) {
        return value ? "Yes" : "No";                  // Yes / No
    }

    public String format(String value) {
        if (value == null) return "(null)";
        return """ + value.trim() + """;            // "hello"
    }

    public String format(double value, int decimalPlaces) {
        return String.format("%." + decimalPlaces + "f", value);
    }
}

// ── Compiler resolves each call at compile time: ──────────────────────
DataFormatter fmt = new DataFormatter();

String r1 = fmt.format(1234567);       // → format(int)
String r2 = fmt.format(3.14159);       // → format(double)
String r3 = fmt.format(true);          // → format(boolean)
String r4 = fmt.format("  hello  ");   // → format(String)
String r5 = fmt.format(3.14159, 4);    // → format(double, int)

System.out.println(r1);  // 1,234,567
System.out.println(r2);  // 3.14
System.out.println(r3);  // Yes
System.out.println(r4);  // "hello"
System.out.println(r5);  // 3.1416

// ── Resolution happens at compile time — bytecode shows the decision: ──
// The compiled bytecode for fmt.format(1234567) contains a direct
// invokevirtual reference to format(int) — not a generic dispatch.
// The choice is baked into the class file.

Overloading Resolution Rules

The Java compiler follows a precise three-phase process to resolve which overload to call. In the first phase it tries to find a match using exact types or subtype relationships without any type promotion — if an exact match exists, it is chosen without any ambiguity. In the second phase it tries widening primitive conversions — int to long, float to double, and similar promotions that never lose information. In the third phase it tries boxing and unboxing plus widening, and varargs methods. This ordering has important consequences. Widening takes precedence over boxing: if both an int parameter overload and an Integer parameter overload exist, passing an int literal selects the int version, not the Integer version. Widening also takes precedence over varargs: passing a single int to a method with overloads for (int) and (int...) selects the (int) version. The compiler produces an error only when two equally specific overloads would both apply to the given arguments and neither is more specific than the other. The classic example is passing null to overloads that take different reference types — null is assignable to both String and Object, and neither String nor Object is more specific than the other in terms of the argument type, so the compiler reports ambiguity and demands a cast. Understanding overload resolution prevents subtle bugs where the wrong overload is silently selected. When in doubt, use an explicit cast to make the intended overload unambiguous — the cast documents intent and eliminates any possibility of the wrong resolution.
Java
// ── Phase 1: exact match — always wins: ──────────────────────────────
public class Resolver {
    public void process(int n)    { System.out.println("int: "    + n); }
    public void process(long n)   { System.out.println("long: "   + n); }
    public void process(double n) { System.out.println("double: " + n); }
    public void process(String s) { System.out.println("String: " + s); }
    public void process(Object o) { System.out.println("Object: " + o); }
}

Resolver r = new Resolver();
r.process(42);          // exact match → process(int)
r.process(42L);         // exact match → process(long)
r.process(42.0);        // exact match → process(double)
r.process("hello");     // exact match → process(String)

// ── Phase 2: widening primitive conversions: ──────────────────────────
r.process((short) 10);  // short widens to int → process(int)
r.process(3.14f);       // float widens to double → process(double)
// No process(short) or process(float) exists, so widening applies.

// ── Widening takes priority over boxing: ──────────────────────────────
public class BoxingVsWidening {
    public void show(long n)    { System.out.println("long: "    + n); }
    public void show(Integer n) { System.out.println("Integer: " + n); }
}

BoxingVsWidening bvw = new BoxingVsWidening();
bvw.show(42);   // int — widens to long (Phase 2), NOT boxed to Integer (Phase 3)
// Output: long: 42

// ── Phase 3: boxing/unboxing: ─────────────────────────────────────────
public class BoxingExample {
    public void show(int n)     { System.out.println("int: "    + n); }
    public void show(Integer n) { System.out.println("Integer: " + n); }
}

BoxingExample be = new BoxingExample();
be.show(42);             // exact match → show(int)
be.show(Integer.valueOf(42)); // exact match → show(Integer)

// ── Ambiguity — null matches multiple reference types: ────────────────
public class Ambiguous {
    public void call(String s)  { System.out.println("String"); }
    public void call(Integer n) { System.out.println("Integer"); }
}

Ambiguous amb = new Ambiguous();
// amb.call(null);  // COMPILE ERROR — null matches both String and Integer
                    // neither is more specific than the other

amb.call((String)  null);   // ✓ explicit cast resolves ambiguity → String
amb.call((Integer) null);   // ✓ explicit cast resolves ambiguity → Integer

// ── Subtype specificity: ──────────────────────────────────────────────
public class Specificity {
    public void show(Object o)  { System.out.println("Object: " + o); }
    public void show(String s)  { System.out.println("String: " + s); }
}

Specificity sp = new Specificity();
sp.show("hello");   // String is more specific than Object → show(String)
sp.show(42);        // 42 is Integer, boxes to Object → show(Object)

Constructor Overloading as Compile-time Polymorphism

Constructor overloading is a specific and extremely common form of compile-time polymorphism. When a class defines multiple constructors with different parameter lists, the compiler applies the same overload resolution rules to determine which constructor new invokes. Every call to new ClassName(...) is resolved at compile time to a specific constructor based on the argument types — there is no runtime selection among constructors. Constructor overloading is so universal in Java that it is easy to forget it is a form of compile-time polymorphism. But it is governed by exactly the same rules: the compiler finds the most specific matching constructor, applies widening if needed, and embeds the choice in the bytecode. The result is that the same class name in a new expression can invoke completely different initialisation paths depending on the arguments provided. The distinction between constructor overloading and runtime polymorphism is important: the selected constructor is always final at compile time. There is no dynamic dispatch for constructors — you cannot override a constructor, and the JVM will never substitute a subclass constructor when the superclass constructor is invoked. This is why super() always calls the exact superclass constructor, never a subclass constructor.
Java
// ── Constructor overloading — compile-time resolution: ───────────────
public class Connection {
    private final String host;
    private final int    port;
    private final boolean secure;
    private final int    timeoutMs;

    // Four constructors — compiler picks based on argument types:
    public Connection(String host, int port, boolean secure, int timeoutMs) {
        this.host      = host;
        this.port      = port;
        this.secure    = secure;
        this.timeoutMs = timeoutMs;
    }

    public Connection(String host, int port, boolean secure) {
        this(host, port, secure, 30_000);
    }

    public Connection(String host, int port) {
        this(host, port, true);
    }

    public Connection(String host) {
        this(host, 443);
    }

    @Override
    public String toString() {
        return (secure ? "https" : "http") + "://" + host + ":" + port +
               " (timeout=" + timeoutMs + "ms)";
    }
}

// ── Each new call resolved at compile time: ───────────────────────────
Connection c1 = new Connection("api.example.com");
// Compiler resolves: Connection(String) — at compile time
System.out.println(c1);  // https://api.example.com:443 (timeout=30000ms)

Connection c2 = new Connection("api.example.com", 8080);
// Compiler resolves: Connection(String, int) — at compile time
System.out.println(c2);  // https://api.example.com:8080 (timeout=30000ms)

Connection c3 = new Connection("api.example.com", 8080, false, 5000);
// Compiler resolves: Connection(String, int, boolean, int) — at compile time
System.out.println(c3);  // http://api.example.com:8080 (timeout=5000ms)

Overloading vs Overriding — The Critical Distinction

Overloading and overriding are completely different mechanisms that share nothing except that both involve methods with the same name. Confusing them is one of the most common mistakes among Java learners, and the distinction matters deeply in practice. Overloading is resolved at compile time based on the static types of the arguments — the types that the compiler sees in the source code. Overriding is resolved at runtime based on the actual type of the object. This fundamental difference means that overloading can be understood entirely by reading the source code, while overriding requires knowing what objects exist at runtime. The @Override annotation is the clearest way to see the distinction: it is valid only for overriding, and the compiler will reject it on an overloaded method if no method with that exact signature exists in the superclass. Another test is to look at what happens when you change the reference type of the variable: changing a Dog reference to an Animal reference changes which overloaded method is called (because overload resolution uses the reference type) but does not change which overriding method is called (because override resolution uses the object type). This asymmetry has a practical consequence: if you pass a subclass object to an overloaded method, the compiler sees only the declared type of the variable holding the subclass, and selects the overload based on that declared type — not the actual runtime type. This is sometimes described as overloading not being polymorphic, while overriding is genuinely polymorphic.
Java
// ── The key difference in a single example: ──────────────────────────
public class Processor {
    // Overloaded methods — compile-time resolution:
    public void process(Animal a) { System.out.println("Processing Animal"); }
    public void process(Dog d)    { System.out.println("Processing Dog");    }
}

public class Animal {
    public String speak() { return "..."; }          // overridable
}

public class Dog extends Animal {
    @Override
    public String speak() { return "Woof!"; }         // overriding
}

Animal animal = new Dog();   // reference type: Animal, object type: Dog

Processor p = new Processor();

// OVERLOADING — resolved at COMPILE TIME based on REFERENCE type:
p.process(animal);           // "Processing Animal" — compiler sees Animal
// Even though the object is a Dog, the reference type is Animal.
// Compiler picks process(Animal) at compile time.

// OVERRIDING — resolved at RUNTIME based on OBJECT type:
System.out.println(animal.speak());   // "Woof!" — Dog's implementation
// JVM looks at the actual object (Dog) at runtime.
// Dog.speak() overrides Animal.speak() — Dog's version runs.

// ── Summary table: ────────────────────────────────────────────────────
//
//                    OVERLOADING            OVERRIDING
// ─────────────────  ─────────────────────  ─────────────────────
// Also called        Static / compile-time  Dynamic / runtime
// Resolved by        Compiler               JVM at runtime
// Based on           Reference type         Object (actual) type
// Parameter list     Must differ            Must be same
// Return type        Can be different       Same or covariant
// Inheritance        Same class             Across class hierarchy
// @Override          Not applicable         Should always use
// Keyword            none                   @Override (annotation)
// Performance        Slightly faster        vtable lookup (optimised)
// Use case           API convenience        Behaviour specialisation

Related Topics in Object-Oriented Programming

OOP Concepts
Object-Oriented Programming (OOP) is a programming paradigm that organises software around objects — self-contained units that combine data (fields) and behaviour (methods). Java is a class-based, object-oriented language where almost everything is an object. OOP provides four foundational principles: encapsulation, inheritance, polymorphism, and abstraction. Together they produce software that is modular, reusable, maintainable, and easier to reason about as systems grow in complexity.
Classes
A class is the fundamental building block of Java. It is a blueprint that defines the structure and behaviour of objects — what data each object holds (fields) and what operations it can perform (methods). Every Java program is composed of classes. Understanding how to design a class well — choosing the right access modifiers, separating state from behaviour, and writing cohesive single-responsibility classes — is the foundation of object-oriented programming in Java. This entry covers class anatomy, fields, methods, access modifiers, static vs instance members, the this keyword, and class design principles.
Objects
An object is a runtime instance of a class. Where a class is a blueprint that exists in source code, an object is a living entity that exists in memory during program execution — it has its own identity, its own state stored in its fields, and the ability to respond to method calls. Every object has three fundamental properties: identity (a unique memory address), state (the current values of its fields), and behaviour (the methods it responds to). This entry covers object identity vs equality, the Object class hierarchy, object state and mutation, method calls, toString, equals and hashCode, and the object lifecycle.
Object Creation
Object creation in Java is the process of allocating memory, initialising fields, and running constructor logic to bring an object into existence. The new keyword is the primary mechanism, but Java also provides factory methods, builder patterns, copy constructors, and object cloning. Constructors are special methods that set up the initial state — their design determines how easy or difficult the class is to use correctly. This entry covers constructors in depth, constructor overloading and chaining, copy constructors, factory methods, the builder pattern, and the difference between shallow and deep copy.