Inheritance in Java — theory with students and teachers (and the super() missing from my first extends)
The first time I wrote extends I was really pleased with myself. I had a Person class with an ID and a name, and I wanted Student to reuse it instead of copying the code. I wrote this:
public class Student extends Person {
private double grade;
public Student(String id, String name, double grade) {
this.grade = grade;
}
}
And Java, without even letting me run it:
error: constructor Person in class Person cannot be applied to given types; required: String,String found: no arguments
I hadn’t called any Person constructor. That was exactly the problem: if you don’t call one yourself, Java tries to call one with no parameters, and my Person didn’t have one. Understanding who builds what when one class inherits from another is half of inheritance. The other half is polymorphism, and this article is about both.
We’ll keep the example from classes and objects: at university there are students and teachers, and both are people.
Table of Contents
What inheritance is: “is a”
In the previous article you saw that a Course has students: that’s composition, one object inside another. Inheritance is a different relationship: a Student is a Person. A Teacher is a Person too.
When two classes share data and behaviour, instead of copying the code into both, you write it once in a parent class and the children inherit it:
Person(the superclass or parent class) holds what they have in common: ID, name, introducing themselves.StudentandTeacher(the subclasses or child classes) inherit all that and add their own things: the grade, the department.
To know whether inheritance fits, run the “is a” test. “A student is a person” sounds right: inheritance. “A course is a student” makes no sense: what you have there is “a course has students”, composition.
The three complete classes
Here’s the whole map before we go piece by piece:
public abstract class Person {
protected String name; // subclasses can read it directly
private final String id; // only Person touches it: children use getId()
public Person(String id, String name) {
this.id = id;
this.name = name;
System.out.println("1) Person ready: " + name);
}
public String getId() { return id; }
public String getName() { return name; }
// Every kind of person says what it is: here we don't know how
public abstract String getRole();
public String introduce() {
return "Hi, I'm " + name + " (" + getRole() + ")";
}
@Override
public String toString() {
return getRole() + " " + name + " [" + id + "]";
}
}
public class Student extends Person {
private double grade; // what Person doesn't have
public Student(String id, String name, double grade) {
super(id, name); // first, build the Person part
this.grade = grade;
System.out.println("2) Student ready: grade " + grade);
}
public double getGrade() { return grade; }
@Override
public String getRole() { return "student"; }
@Override
public String introduce() {
return super.introduce() + " and I have a " + grade; // reuses the parent's
}
}
public class Teacher extends Person {
private final String department;
public Teacher(String id, String name, String department) {
super(id, name);
this.department = department;
}
public String getDepartment() { return department; }
@Override
public String getRole() { return "teacher"; }
// Overloading: same name, different parameters (doesn't replace the other one)
public String introduce(String course) {
return introduce() + " and I teach " + course;
}
}
Notice what is not in Student or Teacher: no id, no name, no getName(), no toString(). They inherit them from Person. The children only write what makes them different.
extends: what is inherited and what isn’t
class Student extends Person means: “a Student has everything a Person has, plus whatever I add here”. That’s why this works even though Student doesn’t define getId():
Student ana = new Student("11111111H", "Ana", 7.5);
System.out.println(ana.getId() + " " + ana.getName()); // 11111111H Ana
What is not inherited:
- Constructors. Each class has its own (we’ll see in a moment how they chain).
- **Access to
privatemembers.** Theidexists inside everyStudent, butStudent‘s code can’t touch it directly. If you writereturn id;insideStudent, the compiler answersid has private access in Person. That’s whatgetId()is for.
In Java a class can only have one parent: class A extends B, C doesn’t compile. And if you don’t write extends, your class inherits from Object, the mother of them all. That’s why every object has toString() and equals(), even if you never write them.
Constructors and super()
When you do new Student(...), Java builds the object from the top down: first the Person part, then the Student part. The call super(id, name) is what says “build my parent’s part with this data”.
Student ana = new Student("11111111H", "Ana", 7.5);
Output:
1) Person ready: Ana 2) Student ready: grade 7.5
The two println calls in the constructors prove it: even though we called Student‘s constructor, Person‘s finishes first.
The three rules of super()
- It goes on the first line of the constructor. If you put anything before it, the error is
call to super must be first statement in constructor. It makes sense: you can’t finish the child’s part if the parent’s doesn’t exist yet. - **If you don’t write it, Java inserts
super()with no parameters** for you. That only works if the parent has an empty constructor. - If the parent has no empty constructor, you have to call one yourself. That was my mistake at the start:
Persononly hadPerson(String, String), the invisiblesuper()had nothing to call, and out cameconstructor Person in class Person cannot be applied to given types.
“fixing” that error by adding an empty constructor to Person so it compiles. It compiles, sure, but now you can create students with no ID or name (null). The right fix is to call super(id, name) from the child.
super vs this
this | super | |
|---|---|---|
this(...) / super(...) | Calls another constructor of this class | Calls a constructor of the parent |
this.x / super.method() | The field or method of this object | The parent’s version of a method |
| Where does the constructor call go? | First line | First line |
Since both must be on the first line, a single constructor uses one or the other, never both.
protected: the middle ground
In classes and objects the rule was: fields are private. Inheritance brings one more modifier: protected. A protected member is visible to subclasses (and to classes in the same package), but not to the rest of the world.
In Person we used it on name, which is why Student can write name directly in its methods without going through the getter. The id, on the other hand, stays private.
| Modifier | Same class | Same package | Subclass (other package) | Everyone else |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| (nothing) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
protected opens the door to every subclass, including the ones somebody else writes a year from now. In FP2 the usual approach is to keep fields private and use protected only when the children really need them. When in doubt, private + getter.
Overriding vs overloading
These two words look so alike that they get mixed up in every exam. Let’s take it slowly.
Overriding: same method, another version
Overriding means a child writes the same method as the parent (same name, same parameters) to replace its behaviour. Student overrides introduce():
@Override
public String introduce() {
return super.introduce() + " and I have a " + grade;
}
With super.introduce() we reuse what Person already did (“Hi, I’m Ana (student)”) and add the ending. Without super., we’d be calling our own introduce(): the method would call itself over and over until a StackOverflowError.
System.out.println(ana.introduce());
Hi, I'm Ana (student) and I have a 7.5
always write @Override. It changes nothing if you get it right, but if you misspell the name (introdcue) or get the parameters wrong, the compiler warns you with method does not override or implement a method from a supertype. Without the annotation, Java would quietly create a new method that nobody calls.
Two rules when overriding:
- You can’t reduce visibility. If it’s
publicinPerson, it must staypublicinStudent. Otherwise the error isattempting to assign weaker access privileges; was public. - **A
finalmethod can’t be overridden.** IfgetId()werefinalinPerson, trying to redefine it would giveoverridden method is final.
Overloading: same name, other parameters
Overloading means having several methods with the same name and different parameters. You already did it with Student‘s constructors in the previous article. Teacher has two introduce methods: the inherited one, with no parameters, and a new one that takes the course:
Teacher pepe = new Teacher("22222222J", "Pepe", "Computer Science");
System.out.println(pepe.introduce());
System.out.println(pepe.introduce("FP2"));
Hi, I'm Pepe (teacher) Hi, I'm Pepe (teacher) and I teach FP2
Both live side by side: the new one doesn’t replace the other, it adds to it.
| Overriding | Overloading | |
|---|---|---|
| Parameters | The same | Different |
| Where? | In a subclass | In the same class or a subclass |
| Does it replace the original? | Yes | No, it lives alongside it |
| Who decides which one runs? | The real object, at run time | The argument types, at compile time |
| Annotation | @Override | None |
The equals trap
This is the most expensive mix-up of all. Look at this equals:
public boolean equals(Student other) { // overloading, not overriding!
return id.equals(other.id);
}
It looks right, but Object‘s equals takes an Object, not a Student. Since the parameters are different, this doesn’t override: it overloads. And the result depends on how the variable is declared:
Student a = new Student("111");
Student b = new Student("111");
Object c = b;
System.out.println(a.equals(b)); // picks equals(Student)
System.out.println(a.equals(c)); // picks Object's equals(Object): compares references
true false
The same object b gives two different answers! That’s why in classes and objects we wrote equals(Object o) with @Override: with the annotation, this mistake doesn’t even compile.
Polymorphism: one variable, many forms
Now for the powerful part. Since a Student is a Person, you can store it in a variable of type Person:
Person p = new Student("33333333P", "Luis", 4.0);
And if a Student and a Teacher are both Person, they fit in the same array:
Person[] people = { ana, pepe, new Student("33333333P", "Luis", 4.0) };
for (Person p : people) {
System.out.println(p.introduce());
}
Hi, I'm Ana (student) and I have a 7.5 Hi, I'm Pepe (teacher) Hi, I'm Luis (student) and I have a 4.0
The loop always writes p.introduce(), but each object answers in its own way: the students with their grade, the teacher with Person‘s version. That’s polymorphism: the same message, different answers depending on the object that receives it.
Variable type vs object type
There are two types at play, and each one is in charge of something different:
- The variable’s type (
Person) decides what you can call. The compiler checks it. - The object’s type (
Student) decides which version runs. That’s decided at run time.
That’s why this doesn’t compile, even though the object is a student:
Person p = new Student("33333333P", "Luis", 4.0);
p.getGrade(); // error: cannot find symbol
The compiler only knows that p is a Person, and people don’t have grades. It doesn’t look at which object will be inside at run time.
In Python this doesn’t happen because you don’t declare types: if the object has the method, it gets called. Java checks before running, and that’s exactly what saves you from lots of bugs in big programs.
Casting and instanceof: back to the concrete type
What if inside the loop I need the students’ grades? First I ask what the object is with instanceof, and then I convert it (casting, or downcasting) to the concrete type:
for (Person p : people) {
if (p instanceof Student s) {
System.out.println(s.getName() + ": " + (s.getGrade() >= 5 ? "pass" : "fail"));
} else if (p instanceof Teacher t) {
System.out.println(t.getName() + " is in " + t.getDepartment());
}
}
Ana: pass Pepe is in Computer Science Luis: fail
p instanceof Student s does two things at once: it checks that p is a Student and, if so, hands it to you already converted in the variable s. That’s the modern form (Java 16 or later). In lots of course notes you’ll see the classic form, which does the same in two steps:
if (p instanceof Student) {
Student s = (Student) p; // explicit cast
System.out.println(s.getGrade());
}
Casting without checking. If the object isn’t of that type, it compiles but blows up at run time with ClassCastException. For example, (Student) pepe (with pepe stored in a Person variable) gives: class Teacher cannot be cast to class Student. Before a downcast, always instanceof.
You never need to write an upcast (from Student to Person), and it can’t fail: every student is a person. The dangerous one is the downcast, from Person to Student, because not every person is a student.
If you find yourself writing lots of instanceof checks in a row to do different things depending on the type, it’s usually a sign that the behaviour should be an overridden method. Polymorphism used well gets rid of almost every cast.
Abstract classes and methods
Does new Person(...) make sense? At our university, no: everyone is either a student or a teacher. Person exists to share code, not to create objects. That’s what abstract is for:
public abstract class Person {
...
public abstract String getRole(); // no body: just the signature and ;
}
- An abstract class can’t be instantiated.
new Person(...)givesPerson is abstract; cannot be instantiated. But it can still have fields, constructors (the children use them throughsuper) and normal methods. - An abstract method has no body: it says “every person can tell you their role, but each kind does it their own way”. If a class has an abstract method, the class must be abstract.
- Subclasses must implement it. If
Studentdidn’t writegetRole(), the error would beStudent is not abstract and does not override abstract method getRole() in Person.
The interesting part is that Person uses getRole() in introduce() and toString() even though it doesn’t know what it will return. When the real object is a Student, Student‘s getRole() runs:
System.out.println(ana); // student Ana [11111111H]
The parent leaves the gap and each child fills it in.
final: closing the door
final has three uses, and inheritance brings two new ones:
| Where | What it means |
|---|---|
final field | Assigned once and never changes (you already knew this one) |
final method | Subclasses can’t override it |
final class | Nobody can inherit from it (cannot inherit from final) |
String is a final class: you can’t write class MyString extends String. That way nobody can change how something so basic behaves.
abstract and final are opposites. abstract says “someone has to complete me”; final says “nobody can change me”. That’s why you can’t put them together on the same method or class.
Watch it step by step in Python Tutor
Python Tutor runs code line by line and draws the variables and objects. Choose Java in the drop-down, paste this program and step through it with Next:
public class Tutor {
static abstract class Person {
String name;
Person(String name) { this.name = name; }
abstract String role();
String introduce() { return name + " (" + role() + ")"; }
}
static class Student extends Person {
double grade;
Student(String name, double grade) { super(name); this.grade = grade; }
String role() { return "student"; }
}
static class Teacher extends Person {
Teacher(String name) { super(name); }
String role() { return "teacher"; }
}
public static void main(String[] args) {
Person[] people = { new Student("Ana", 7.5), new Teacher("Pepe") };
for (Person p : people) {
System.out.println(p.introduce());
}
Person q = people[0];
Student s = (Student) q; // q points to a Student: the cast is safe
System.out.println(s.grade + " " + (s == q));
}
}
Output:
Ana (student) Pepe (teacher) 7.5 true
What to watch:
- When the
Studentis created, look at the stack:Student‘s constructor goes in first and immediately jumps toPerson‘s withsuper(name).nameis filled in beforegrade. - The
Studentobject on the heap has both fields:name(inherited) andgrade(its own). There’s no separatePersonobject: it’s just one. - Inside the loop,
pis always of typePerson, but whenintroduce()callsrole(), it jumps toStudent‘srole()on the first pass and toTeacher‘s on the second. - At the cast, notice that
sandqpoint to the same object. The cast doesn’t create or copy anything: it only changes the type you look at it through. That’s whys == qistrue.
Summary and quick table
| Concept | In Java | If you come from Python |
|---|---|---|
| Inherit | class Student extends Person | class Student(Person): |
| Call the parent’s constructor | super(id, name) (first line) | super().__init__(id, name) |
| Parent’s version of a method | super.introduce() | super().introduce() |
| Override | same method + @Override | redefine the method |
| Overload | same name, other parameters | doesn’t exist (default values instead) |
| Check the type | p instanceof Student s | isinstance(p, Student) |
| Class you can’t instantiate | abstract class | ABC + @abstractmethod |
| Multiple inheritance | No (one parent only) | Yes |
Three ideas to take with you:
- Parent first:
super(...)on the first line builds the inherited part; if you don’t write it, Java triessuper()with no parameters. - Overriding replaces, overloading adds: always
@Override, and watch out forequals(Student). - The variable decides what you can call; the object decides what runs. To go down a type,
instanceofbefore the cast.
In the practice article you’ll build three complete programs with inheritance: a payroll with several kinds of employee, some abstract geometric shapes, and savings and current bank accounts.
Next step: practiceInheritance in Java — practice with a payroll, some shapes and two kinds of account (and the balance I made public so it would compile)Did this article help you? Here is how you can support SergioLearns →
