You rarely model a problem with one flat pile of classes. Some classes are special cases of others (a Student is a kind of Person), and some share a promise about what they can do without sharing any code (anything that can speak). Java gives you inheritance for the first and interfaces for the second.
Both ideas appear constantly in real libraries. ArrayList extends AbstractList; Runnable and Comparable are interfaces you implement every day. Understanding when to use which — and how Java decides which method actually runs — is essential for reading any non-trivial codebase. Getting this wrong leads to brittle hierarchies that break when you change one class, or duplicated code because you failed to extract a common contract.
Interfaces — a promise of behaviour
An interface lists methods a class promises to provide, but contains no code for them. A class says implements Speaker and then writes those methods. The payoff: any code that only needs 'something that can speak' can hold a Speaker and not care what exact kind of object it is.
This is how Java achieves flexibility without fragility. If you write a method that accepts Speaker, you can later invent a Robot that also implements Speaker, pass it in, and your existing method works unchanged. The interface name itself becomes a type: you can declare a variable as Speaker, store a Person in it, and later store a Student in the same variable. The compiler only cares that the object satisfies the contract.
flowchart TD SP["Speaker (interface)"] --> P["Person implements Speaker"] P -->|extends| S["Student extends Person"] style SP fill:#c2410c,color:#fff style P fill:#c2410c,color:#fff
public class Main {
interface Speaker {
String sayHello();
}
static class Person implements Speaker {
private String name;
Person(String name) {
this.name = name;
}
@Override
public String sayHello() {
return "Hi, I'm " + name;
}
}
static class Student extends Person {
private String course;
Student(String name, String course) {
super(name);
this.course = course;
}
@Override
public String sayHello() {
return super.sayHello() + " studying " + course;
}
}
public static void main(String[] args) {
Speaker a = new Person("Lia");
Speaker b = new Student("Owen", "Physics");
System.out.println(a.sayHello());
System.out.println(b.sayHello());
}
}
Inheritance and super
extends makes one class build on another: a Student automatically gets every field and method a Person has, and can add more. The subclass's constructor uses super(...) to hand arguments up to the parent's constructor. If you omit it, Java inserts a call to super() with no arguments — which fails if the parent has no such constructor.
When a subclass provides its own version of a method the parent already has, that's overriding — and the @Override annotation tells the compiler 'I mean to replace a parent method; please check that I really am'. Overriding is not the same as overloading: overloading creates a new method with different parameters; overriding replaces the parent's method with the same signature. The @Override annotation catches the mistake of accidentally overloading when you meant to override.
flowchart TD P["Person(name)"] -->|"extends"| S["Student(name, course)"] S -->|"super(name)"| P style P fill:#c2410c,color:#fff style S fill:#c2410c,color:#fff
public class Main {
static class Vehicle {
private String brand;
Vehicle(String brand) {
this.brand = brand;
}
String describe() {
return "A vehicle by " + brand;
}
}
static class Car extends Vehicle {
private int doors;
Car(String brand, int doors) {
super(brand);
this.doors = doors;
}
@Override
String describe() {
return super.describe() + " with " + doors + " doors";
}
}
public static void main(String[] args) {
Car c = new Car("Toyota", 4);
System.out.println(c.describe());
}
}
Polymorphism — many shapes, one interface
Both a and b in our earlier example are declared as Speaker — yet b is actually a Student, so its overridden sayHello runs, not Person's. Java picks the method based on the object's real type at runtime, not the variable's declared type. One interface, many shapes — that's polymorphism.
This is powerful because it lets you write general code. A method that takes Speaker does not need a separate branch for Person, Student, Robot, or anything else you invent later. You add new classes; the existing method handles them automatically. That extensibility is why object-oriented design dominates large systems. The cost is indirection: to know which method runs, you must trace the object's actual class. In a small program this is obvious; in a large one, the new call may be twenty files away from the invocation.
flowchart LR
V["Speaker s = new Student(...)"] --> D{"Object's real type?"}
D -->|Person| P["Person.sayHello()"]
D -->|Student| S["Student.sayHello()"]
style D fill:#c2410c,color:#fff
public class Main {
interface Drawable {
String draw();
}
static class Circle implements Drawable {
@Override
public String draw() {
return "Drawing a circle";
}
}
static class Square implements Drawable {
@Override
public String draw() {
return "Drawing a square";
}
}
public static void main(String[] args) {
Drawable[] shapes = {new Circle(), new Square()};
for (Drawable d : shapes) {
System.out.println(d.draw());
}
}
}
Interface versus abstract class
An interface is a pure contract: no fields, no method bodies (before Java 8). An abstract class is a partial implementation: it can have fields, concrete methods, and abstract ones. Use an interface when unrelated classes need to share a capability. Use an abstract class when several classes are genuinely related and share common code.
Java allows a class to implement many interfaces but extend only one class — so interfaces give you more freedom. If you find yourself forcing an is-a relationship just to share code, consider whether an interface with a helper class would be cleaner.
What does this program print? Read it carefully and type the exact output (two lines).
public class Main {
interface Speaker {
String sayHello();
}
static class Person implements Speaker {
private String name;
Person(String name) {
this.name = name;
}
@Override
public String sayHello() {
return "Hi, I'm " + name;
}
}
static class Student extends Person {
private String course;
Student(String name, String course) {
super(name);
this.course = course;
}
@Override
public String sayHello() {
return super.sayHello() + " studying " + course;
}
}
public static void main(String[] args) {
Speaker a = new Person("Lia");
Speaker b = new Student("Owen", "Physics");
System.out.println(a.sayHello());
System.out.println(b.sayHello());
}
}
ais a plainPerson, sosayHelloreturns"Hi, I'm Lia".bis aStudent: even though the variable is typedSpeaker, the object's real method runs.super.sayHello()returns"Hi, I'm Owen", then" studying Physics"is joined on.
What does the @Override annotation do?
@Override asks the compiler to confirm the method genuinely replaces one from the parent. Catch a typo in the method name and the build fails — instead of silently creating a brand-new method.
What does this program print? Read it carefully and type the exact output (one line).
public class Main {
static class Vehicle {
private String brand;
Vehicle(String brand) {
this.brand = brand;
}
String describe() {
return "A vehicle by " + brand;
}
}
static class Car extends Vehicle {
private int doors;
Car(String brand, int doors) {
super(brand);
this.doors = doors;
}
@Override
String describe() {
return super.describe() + " with " + doors + " doors";
}
}
public static void main(String[] args) {
Car c = new Car("Toyota", 4);
System.out.println(c.describe());
}
}
new Car("Toyota", 4)callssuper("Toyota")to set the brand.describe()is overridden, so the Car's version runs.super.describe()returns"A vehicle by Toyota"; the override appends" with 4 doors".
What does this program print? Read it carefully and type the exact output (two lines).
public class Main {
interface Drawable {
String draw();
}
static class Circle implements Drawable {
@Override
public String draw() {
return "Drawing a circle";
}
}
static class Square implements Drawable {
@Override
public String draw() {
return "Drawing a square";
}
}
public static void main(String[] args) {
Drawable[] shapes = {new Circle(), new Square()};
for (Drawable d : shapes) {
System.out.println(d.draw());
}
}
}
The array holds two different classes, both implementing
Drawable.The loop calls
draw()on each element. Java picks the real object's method at runtime.Even though the variable
dis typedDrawable, the object's owndraw()runs.
A class Student extends Person and does NOT override sayHello(). Person implements Speaker and its sayHello() returns "Hi, I'm " + name. What does new Student("Owen").sayHello() return?
A subclass inherits every public method from its parent. Since Student does not override sayHello(), the call uses Person's implementation, which returns "Hi, I'm Owen".
The root of every class: Object
If you do not write extends, Java silently adds extends Object. This means every class inherits methods like toString(), equals(), and hashCode(). Overriding toString() is one of the most common tasks in Java because it controls how your object looks when printed. The default implementation prints the class name and a memory address, which is rarely useful.
Understanding that every class ultimately extends Object explains why you can store any object in a variable of type Object. It is the universal superclass, and the foundation on which collections, generics, and reflection are built.
Recap
- An interface declares behaviour; a class implements it to promise that behaviour.
- Inheritance (
extends) reuses a parent's code; overriding replaces a parent's method. super(...)calls the parent's constructor; it must be the first statement if you write it.- Polymorphism means Java calls the object's real method at runtime, not the variable's declared type.
- Prefer interfaces for shared capabilities across unrelated classes; prefer abstract classes for related classes with shared code.
Next you will explore Java's collections — lists, maps, and sets — which use exactly these polymorphic interfaces to give you flexible, reusable data structures.