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.0Encapsulation
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.0Access modifiers control visibility (narrowest → broadest):
private— this class only- package-private (no keyword) — same package
protected— same package + subclassespublic— 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:
- Interfaces — a pure contract; no implementation (except
defaultmethods added in Java 8). - 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!| Interface | Abstract class | |
|---|---|---|
| Instantiable | No | No |
| Fields | Constants only | Any |
| Implementation | default methods only | Mixed concrete + abstract |
| Multiple inheritance | Yes — a class can implement many | No — a class extends exactly one |
| Use when | Unrelated types share a contract | Related 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 kmKey rules to remember:
- Java allows single inheritance only — a class extends exactly one class.
- Use
super.method()to call the parent's version of an overridden method. - Use
super(...)as the first statement in a constructor to call the parent constructor. - Mark a class
finalto prevent it being extended; mark a methodfinalto prevent it being overridden. - 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 expressionsGeneralization
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: 7Runtime 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);| Overloading | Overriding | |
|---|---|---|
| Also called | Compile-time / static polymorphism | Runtime / dynamic polymorphism |
| Resolved at | Compile time | Runtime |
| Where | Same class (or subclass adding overload) | Subclass overrides parent method |
| Signature | Same name, different parameter types/count | Identical signature |
| Return type | Can differ | Must 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 unaffectedComposition 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.