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.

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.
  • Student and Teacher (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 private members.** The id exists inside every Student, but Student‘s code can’t touch it directly. If you write return id; inside Student, the compiler answers id has private access in Person. That’s what getId() 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()

  1. 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.
  2. **If you don’t write it, Java inserts super() with no parameters** for you. That only works if the parent has an empty constructor.
  3. If the parent has no empty constructor, you have to call one yourself. That was my mistake at the start: Person only had Person(String, String), the invisible super() had nothing to call, and out came constructor 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

thissuper
this(...) / super(...)Calls another constructor of this classCalls a constructor of the parent
this.x / super.method()The field or method of this objectThe parent’s version of a method
Where does the constructor call go?First lineFirst 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.

ModifierSame classSame packageSubclass (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 public in Person, it must stay public in Student. Otherwise the error is attempting to assign weaker access privileges; was public.
  • **A final method can’t be overridden.** If getId() were final in Person, trying to redefine it would give overridden 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.

OverridingOverloading
ParametersThe sameDifferent
Where?In a subclassIn the same class or a subclass
Does it replace the original?YesNo, it lives alongside it
Who decides which one runs?The real object, at run timeThe argument types, at compile time
Annotation@OverrideNone

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(...) gives Person is abstract; cannot be instantiated. But it can still have fields, constructors (the children use them through super) 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 Student didn’t write getRole(), the error would be Student 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:

WhereWhat it means
final fieldAssigned once and never changes (you already knew this one)
final methodSubclasses can’t override it
final classNobody 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:

  1. When the Student is created, look at the stack: Student‘s constructor goes in first and immediately jumps to Person‘s with super(name). name is filled in before grade.
  2. The Student object on the heap has both fields: name (inherited) and grade (its own). There’s no separate Person object: it’s just one.
  3. Inside the loop, p is always of type Person, but when introduce() calls role(), it jumps to Student‘s role() on the first pass and to Teacher‘s on the second.
  4. At the cast, notice that s and q point to the same object. The cast doesn’t create or copy anything: it only changes the type you look at it through. That’s why s == q is true.

Summary and quick table

ConceptIn JavaIf you come from Python
Inheritclass Student extends Personclass Student(Person):
Call the parent’s constructorsuper(id, name) (first line)super().__init__(id, name)
Parent’s version of a methodsuper.introduce()super().introduce()
Overridesame method + @Overrideredefine the method
Overloadsame name, other parametersdoesn’t exist (default values instead)
Check the typep instanceof Student sisinstance(p, Student)
Class you can’t instantiateabstract classABC + @abstractmethod
Multiple inheritanceNo (one parent only)Yes

Three ideas to take with you:

  1. Parent first: super(...) on the first line builds the inherited part; if you don’t write it, Java tries super() with no parameters.
  2. Overriding replaces, overloading adds: always @Override, and watch out for equals(Student).
  3. The variable decides what you can call; the object decides what runs. To go down a type, instanceof before 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 →

Sigue aprendiendo

Leave a Reply

Your email address will not be published. Required fields are marked *