Cornerstones of Object-Oriented Programming

Classes and objects

A class is a blueprint that defines the state (fields) and behaviour (methods) of a concept. An object is a concrete instance of that class allocated in memory. Every object has its own copy of the class's instance fields, but shares the class's methods.

The four cornerstones — encapsulation, abstraction, inheritance, and polymorphism — are all built on top of this foundation.

public class BankAccount {
    private String owner;
    private double balance;

    public BankAccount(String owner, double initialBalance) {
        this.owner   = owner;
        this.balance = initialBalance;
    }

    public String getOwner()   { return owner; }
    public double getBalance() { return balance; }
}

// Each object has its own independent state
BankAccount alice = new BankAccount("Alice", 1000.0);
BankAccount bob   = new BankAccount("Bob",    500.0);

System.out.println(alice.getBalance()); // 1000.0
System.out.println(bob.getBalance());   // 500.0

Encapsulation

Encapsulation is the bundling of data (fields) and the methods that operate on that data into a single unit (the class), while restricting direct access to the internals. It protects an object's state from unintended modification and lets you change the internal representation later without breaking callers.

The Java idiom: declare fields private, expose controlled access through public methods that can enforce invariants.

public class BankAccount {
    private double balance; // hidden — callers cannot set it directly

    public BankAccount(double initialBalance) {
        if (initialBalance < 0) throw new IllegalArgumentException("Balance cannot be negative");
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("Deposit must be positive");
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount > balance) throw new IllegalStateException("Insufficient funds");
        balance -= amount;
    }

    public double getBalance() { return balance; } // controlled read
}

// Callers never touch balance directly — the class enforces invariants
BankAccount account = new BankAccount(500.0);
account.deposit(200.0);
account.withdraw(100.0);
System.out.println(account.getBalance()); // 600.0

Access modifiers control visibility (narrowest → broadest):

  1. private — this class only
  2. package-private (no keyword) — same package
  3. protected — same package + subclasses
  4. public — everywhere

Abstraction

Abstraction is the act of exposing only the what (the interface) and hiding the how (the implementation). Callers work with a simplified model and are insulated from complexity beneath. The goal is to reduce cognitive load and create stable, swappable boundaries.

Java offers two tools for abstraction:

  1. Interfaces — a pure contract; no implementation (except default methods added in Java 8).
  2. Abstract classes — partial implementation; can mix concrete and abstract methods.
// Interface — defines the contract, nothing about how
public interface Shape {
    double area();
    double perimeter();
}

// Concrete classes hide the HOW behind the interface
public class Circle implements Shape {
    private final double radius;

    public Circle(double radius) { this.radius = radius; }

    @Override public double area()      { return Math.PI * radius * radius; }
    @Override public double perimeter() { return 2 * Math.PI * radius; }
}

public class Rectangle implements Shape {
    private final double width, height;

    public Rectangle(double width, double height) {
        this.width = width; this.height = height;
    }

    @Override public double area()      { return width * height; }
    @Override public double perimeter() { return 2 * (width + height); }
}

// Caller works only with Shape — doesn't care about internals
Shape s = new Circle(5);
System.out.println(s.area()); // 78.53...

Abstract classes are the right choice when related classes share code and you still want to enforce that subclasses implement certain methods:

public abstract class Animal {
    private final String name;

    public Animal(String name) { this.name = name; }

    public String getName() { return name; }

    public abstract String speak(); // subclasses must provide this

    public void introduce() {       // shared concrete behaviour
        System.out.println("I am " + name + " and I say: " + speak());
    }
}

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

new Dog("Rex").introduce(); // I am Rex and I say: Woof!
InterfaceAbstract class
InstantiableNoNo
FieldsConstants onlyAny
Implementationdefault methods onlyMixed concrete + abstract
Multiple inheritanceYes — a class can implement manyNo — a class extends exactly one
Use whenUnrelated types share a contractRelated types share code

Inheritance

Inheritance models an IS-A relationship: a Dog IS-A Animal. A subclass uses extends to inherit all non-private fields and methods from its superclass. It can add new behaviour or override inherited methods to specialize them.

public class Vehicle {
    protected String brand;
    protected int year;

    public Vehicle(String brand, int year) {
        this.brand = brand;
        this.year  = year;
    }

    public String describe() {
        return brand + " (" + year + ")";
    }
}

public class ElectricCar extends Vehicle {
    private int rangeKm;

    public ElectricCar(String brand, int year, int rangeKm) {
        super(brand, year); // delegate to parent constructor
        this.rangeKm = rangeKm;
    }

    @Override
    public String describe() {
        return super.describe() + " — Electric, range: " + rangeKm + " km";
    }

    public void charge() {
        System.out.println(brand + " is charging...");
    }
}

Vehicle v = new ElectricCar("Tesla", 2024, 600);
System.out.println(v.describe());
// Tesla (2024) — Electric, range: 600 km

Key rules to remember:

  1. Java allows single inheritance only — a class extends exactly one class.
  2. Use super.method() to call the parent's version of an overridden method.
  3. Use super(...) as the first statement in a constructor to call the parent constructor.
  4. Mark a class final to prevent it being extended; mark a method final to prevent it being overridden.
  5. Always annotate overrides with @Override — the compiler will catch typos.
// instanceof check before casting
Animal a = new Dog("Rex");
if (a instanceof Dog d) {          // pattern matching (Java 16+)
    System.out.println(d.speak()); // Woof!
}

// sealed classes (Java 17+) restrict which classes may extend
public sealed class Shape permits Circle, Rectangle, Triangle { }
public final class Circle    extends Shape { }
public final class Rectangle extends Shape { }
public final class Triangle  extends Shape { }
// Compiler can now exhaustively check all subtypes in switch expressions

Generalization

Generalization is the bottom-up counterpart of inheritance. Instead of starting with a parent and specializing downward, you start with concrete classes and extract their shared traits upward into a more general superclass or interface. It is the design act of asking: "What do all these things have in common?"

It is the thought process behind the DRY principle applied to class hierarchies — identify duplicate logic across classes, then lift it into a shared abstraction.

// You start with two concrete classes that share logic:
public class Cat {
    private String name;
    public Cat(String name)  { this.name = name; }
    public String getName()  { return name; }
    public void eat()        { System.out.println(name + " is eating"); }
    public String speak()    { return "Meow"; }
}

public class Dog {
    private String name;
    public Dog(String name)  { this.name = name; }
    public String getName()  { return name; }
    public void eat()        { System.out.println(name + " is eating"); } // duplicate!
    public String speak()    { return "Woof"; }
}

// You notice the duplication → generalize upward:
public abstract class Animal {
    private final String name;

    public Animal(String name)  { this.name = name; }

    public String getName()     { return name; }

    public void eat() {                           // shared concrete behaviour — written once
        System.out.println(name + " is eating");
    }

    public abstract String speak();               // specialized per subclass
}

public class Cat extends Animal {
    public Cat(String name)          { super(name); }
    @Override public String speak()  { return "Meow"; }
}

public class Dog extends Animal {
    public Dog(String name)          { super(name); }
    @Override public String speak()  { return "Woof"; }
}

// Now callers can work at the Animal level
List<Animal> animals = List.of(new Dog("Rex"), new Cat("Whiskers"), new Dog("Buddy"));
animals.forEach(a -> System.out.println(a.getName() + ": " + a.speak()));

Generalization produces the stable abstraction that polymorphism then exploits. The two concepts work in tandem: generalization creates the common type; polymorphism dispatches to the right subtype at runtime.

Polymorphism

Polymorphism means "many forms" — the same operation produces different behaviour depending on the object receiving it. Java has two kinds.

Compile-time polymorphism — method overloading

The compiler picks the correct version of a method based on the number and types of arguments. The decision is made at compile time, so there is no runtime overhead.

public class Printer {
    public void print(int n)    { System.out.println("int: " + n); }
    public void print(double d) { System.out.println("double: " + d); }
    public void print(String s) { System.out.println("String: " + s); }
    public void print(int a, int b) { System.out.println("sum: " + (a + b)); }
}

Printer p = new Printer();
p.print(42);        // int: 42
p.print(3.14);      // double: 3.14
p.print("hello");   // String: hello
p.print(3, 4);      // sum: 7

Runtime polymorphism — method overriding

A supertype reference can hold a subtype object. When a method is called, the JVM looks up the actual runtime type and dispatches to that type's implementation via the vtable. This is resolved at runtime, not compile time.

public abstract class Shape {
    public abstract double area();
}

public class Circle extends Shape {
    private final double radius;
    public Circle(double radius) { this.radius = radius; }
    @Override public double area() { return Math.PI * radius * radius; }
}

public class Rectangle extends Shape {
    private final double w, h;
    public Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override public double area() { return w * h; }
}

// One method works for any Shape — open to extension, closed to modification
public static void printArea(Shape shape) {
    System.out.printf("Area: %.2f%n", shape.area());
}

printArea(new Circle(5));        // Area: 78.54
printArea(new Rectangle(4, 6)); // Area: 24.00

// Polymorphism shines with collections of mixed subtypes
List<Shape> shapes = List.of(new Circle(3), new Rectangle(2, 5), new Circle(7));
double total = shapes.stream().mapToDouble(Shape::area).sum();
System.out.printf("Total: %.2f%n", total);
OverloadingOverriding
Also calledCompile-time / static polymorphismRuntime / dynamic polymorphism
Resolved atCompile timeRuntime
WhereSame class (or subclass adding overload)Subclass overrides parent method
SignatureSame name, different parameter types/countIdentical signature
Return typeCan differMust be same or covariant

Coupling and cohesion

These two metrics measure the quality of a class design. They are not OOP mechanics per se, but they are the direct consequence of applying (or ignoring) the four cornerstones well.

Cohesion measures how focused a class is on a single purpose. A highly cohesive class does one thing and does it completely. A low-cohesion class handles unrelated concerns, making it harder to understand and change.

Coupling measures how much one class depends on another. Low coupling means that a change in one class does not ripple into others. High coupling creates fragile, interconnected systems where changing anything breaks everything.

The goal is always high cohesion, low coupling.

// HIGH COUPLING, LOW COHESION — bad
// Report does three unrelated jobs; changing any one affects the others
public class Report {
    public void generate()    { /* query database */ }
    public void sendByEmail() { /* SMTP logic */      }
    public void exportPdf()   { /* PDF rendering */   }
}

// LOW COUPLING, HIGH COHESION — each class has one job
public class ReportGenerator {
    public Report generate() { /* query database, return data */ return new Report(); }
}

public class EmailSender {
    public void send(String to, String body) { /* SMTP */ }
}

public class PdfExporter {
    public byte[] export(Report report) { /* PDF rendering */ return new byte[0]; }
}

// Changes to email logic only touch EmailSender; the others are unaffected

Composition over inheritance

When the relationship between two classes is HAS-A rather than IS-A, prefer composition: hold a reference to a collaborator rather than extending it. This avoids the fragile base class problem and keeps your types flexible.

// Inheritance — fragile: you expose every ArrayList method even ones that make no sense
public class LoggedList extends ArrayList<String> {
    @Override
    public boolean add(String s) {
        System.out.println("Adding: " + s);
        return super.add(s);
    }
    // But callers can still call addAll, removeIf, etc. without logging — leaky
}

// Composition — LoggedList is not an ArrayList, it HAS one
public class LoggedList {
    private final List<String> inner = new ArrayList<>();

    public void add(String s) {
        System.out.println("Adding: " + s);
        inner.add(s);
    }

    public String get(int i) { return inner.get(i); }
    public int size()         { return inner.size(); }
    // Only expose exactly what callers should use
}

Composition also enables runtime flexibility — you can swap the implementation behind the interface without changing callers. This is the foundation of the Strategy pattern.

// Strategy via composition: swap sorting algorithm at runtime
public interface SortStrategy {
    void sort(int[] data);
}

public class BubbleSort implements SortStrategy {
    @Override public void sort(int[] data) { /* bubble sort */ }
}

public class QuickSort implements SortStrategy {
    @Override public void sort(int[] data) { /* quicksort */ }
}

public class Sorter {
    private SortStrategy strategy;

    public Sorter(SortStrategy strategy) { this.strategy = strategy; }

    public void setStrategy(SortStrategy strategy) { this.strategy = strategy; }

    public void sort(int[] data) { strategy.sort(data); }
}

Sorter sorter = new Sorter(new QuickSort());
sorter.sort(data);

sorter.setStrategy(new BubbleSort()); // swap at runtime, no inheritance needed
sorter.sort(data);

Prefer inheritance when the IS-A relationship is genuine and the subtype truly substitutes for the supertype (Liskov Substitution Principle). Prefer composition everywhere else.