Inheritance in Java — exercises with animals, shapes and a restaurant’s staff (and the cast that turned my cat into a dog)

In the inheritance practical I had an Animal array with dogs and cats, and I wanted to print the dogs’ breeds. getBreed() only exists in Dog, so I did what seemed logical: turn every animal into a dog.

for (Animal a : shelter) {
    Dog d = (Dog) a;
    System.out.println(d.getBreed());
}

It compiled without a single warning. The first dog printed its breed and, when it got to the cat, the program blew up with a ClassCastException. The compiler didn’t complain because **an Animal could be a Dog**; the problem was that this particular animal wasn’t one. A cast doesn’t transform the object: it just tells Java “trust me”. And I’d given it no reason to.

In these exercises you’ll build complete hierarchies: abstract classes, constructors with super, overridden methods that reuse the parent’s, and polymorphic arrays. Before the solutions, three tricky snippets (the first one is exactly my cast). If you need a refresher first, there’s the theory and the practice for this topic.

How to use this article

  1. Read the task and the expected output.
  2. Try it for at least 15 minutes before opening the hint.
  3. Before writing any code, draw the hierarchy: who inherits from whom and which method goes on each floor.
  4. Compare with the solution and read the reasoning behind each decision.

Each class goes in its own file (Animal.java, Dog.java…) with a Main.java for the tests. Write @Override on everything you override: it’ll save you from more than one snippet in the bugs section.

Level 1 — Basic: the animal shelter

Task. Create a hierarchy for an animal shelter:

  • Animal, abstract, with a name and an age (private final). If the age is negative, store 0.
  • An abstract method String sound() and a method String introduce() that returns Toby (3 years old) says Woof!.
  • Dog extends Animal, with a breed. Its sound is Woof! and its introduce() adds and is a beagle at the end, reusing the parent’s.
  • Cat extends Animal, with a boolean indoor. If it’s an indoor cat it says Meow; if not, Hiss!.
  • In main, an Animal[] array with the four animals from the test: introduce them all, count the dogs and work out the average age.

Tests and expected output:

Animal[] shelter = {
    new Dog("Toby", 3, "beagle"),
    new Cat("Luna", 2, true),
    new Cat("Shadow", -1, false),
    new Dog("Rex", 7, "German shepherd")
};
Toby (3 years old) says Woof! and is a beagle
Luna (2 years old) says Meow
Shadow (0 years old) says Hiss!
Rex (7 years old) says Woof! and is a German shepherd
Dogs: 2 · average age: 3.0

The dog’s introduce() is super.introduce() + " and is a " + breed. For the average age, watch out for integer division: convert the sum to double before dividing.

Level 2 — Intermediate: shapes that compare and sort themselves

Task. This time we’re not looking for the biggest shape, like in the practice article, but sorting them all:

  • Shape, abstract, with abstract double area() and String getName().
  • A final boolean isBiggerThan(Shape other) method that compares by area.
  • toString() in the format Circle r=1.5 with area 7.07.
  • A static void sort(Shape[] shapes) method that sorts them from smallest to largest area using only isBiggerThan (bubble sort).
  • Circle (radius) and Triangle (three sides, area with Heron’s formula).
  • Triangle has a static boolean isValid(double a, double b, double c) method: all three sides positive and each one smaller than the sum of the other two.

Tests and expected output:

System.out.println(Triangle.isValid(3, 4, 5) + " " + Triangle.isValid(1, 2, 10));

Shape[] shapes = {
    new Circle(1.5),
    new Triangle(3, 4, 5),
    new Circle(1),
    new Triangle(5, 5, 6)
};

System.out.println(shapes[0].isBiggerThan(shapes[1]));
Shape.sort(shapes);
for (Shape s : shapes) {
    System.out.println(s);
}
true false
true
Circle r=1.0 with area 3.14
Triangle 3.0-4.0-5.0 with area 6.00
Circle r=1.5 with area 7.07
Triangle 5.0-5.0-6.0 with area 12.00

Heron’s formula: with the semi-perimeter s = (a + b + c) / 2, the area is Math.sqrt(s * (s - a) * (s - b) * (s - c)). In the bubble sort, if shapes[j].isBiggerThan(shapes[j + 1]), swap them using a temporary variable.

Level 3 — Challenge: a restaurant’s staff

Task. Model the staff of the Casa Pepe restaurant. Every hour worked costs €10:

  • Worker, abstract, with a name (private) and weekly hours (protected final, never negative). Constant HOURLY_RATE = 10.0.
  • double weeklyCost() (hours × rate), abstract String getRole() and toString() in the format Cook Ana 500.00 €.
  • Cook: if they work the night shift, their cost is 25 % higher.
  • Waiter: costs the normal amount, but collects tips (addTip, ignoring negative amounts). Tips are not a cost for the restaurant.
  • Manager extends Waiter: costs the same as a waiter plus €120 as a responsibility bonus.
  • Staff, with a fixed-capacity array: hire (fails if it’s full or there’s already someone with that name, ignoring case), totalCost(), mostExpensive(), floorTips() (the tips of every waiter, managers included) and list().

Tests and expected output:

Staff casaPepe = new Staff(4);
Waiter lucia = new Waiter("Lucía", 30);
Manager tomas = new Manager("Tomás", 40);

System.out.println(casaPepe.hire(new Cook("Ana", 40, true)));
casaPepe.hire(lucia);
casaPepe.hire(tomas);
System.out.println(casaPepe.hire(new Cook("ana", 20, false)));   // repeated
casaPepe.hire(new Cook("Raúl", 35, false));
System.out.println(casaPepe.hire(new Waiter("Eva", 20)));       // full

lucia.addTip(35.5);
tomas.addTip(20);
lucia.addTip(-5);                                               // ignored

casaPepe.list();
System.out.printf("Weekly cost: %.2f €%n", casaPepe.totalCost());
System.out.println("Most expensive: " + casaPepe.mostExpensive().getName());
System.out.printf("Floor tips: %.2f €%n", casaPepe.floorTips());
true
false
false
Cook    Ana     500.00 €
Waiter  Lucía   300.00 €
Manager Tomás   520.00 €
Cook    Raúl    350.00 €
Weekly cost: 1670.00 €
Most expensive: Tomás
Floor tips: 55.50 €

The cook’s and the manager’s weeklyCost() both start with super.weeklyCost(). For the floor tips, instanceof Waiter is also true for the manager: you don’t need to ask about both.

What’s wrong here?

Three snippets that compile. Before reading the explanation, try to predict what happens.

Snippet 1 — The trust-me cast

Animal[] shelter = { new Dog("Toby", 3, "beagle"), new Cat("Luna", 2, true) };
for (Animal a : shelter) {
    Dog d = (Dog) a;
    System.out.println(d.getBreed());
}

It prints beagle and then throws ClassCastException: class Cat cannot be cast to class Dog. It’s my story from the start. The compiler accepts the cast because some Animal could be a Dog, but what decides at run time is the real object, and Luna is a cat. The fix is to ask first:

for (Animal a : shelter) {
    if (a instanceof Dog d) {
        System.out.println(d.getBreed());
    }
}

“fixing” the ClassCastException with a try-catch around the cast. You hide the symptom, but the design is still wrong: if you know it might not be a dog, ask with instanceof.

Snippet 2 — The dog that didn’t bark

class Animal {
    public String sound() { return "..."; }
    public String speak() { return "Says " + sound(); }
}

class Dog extends Animal {
    public String sounds() { return "Woof!"; }     // no @Override
}

Animal toby = new Dog();
System.out.println(toby.speak());

It prints Says .... The dog hasn’t overridden sound(): it has created a new method, sounds(), with an extra s. To Java they’re two different methods, so speak() still calls Animal‘s sound(). No compile error, no run-time error: just a wrong result, which is the worst kind.

With @Override above sounds(), the compiler would have caught it straight away: method does not override or implement a method from a supertype. And if sound() were abstract in Animal, as in level 1, it wouldn’t compile either, because Dog wouldn’t implement it.

Snippet 3 — The endless toString

class Animal {
    protected String name = "Toby";

    @Override
    public String toString() { return name; }
}

class Dog extends Animal {
    @Override
    public String toString() {
        return toString() + " (dog)";
    }
}

System.out.println(new Dog());

It throws StackOverflowError. The idea was “whatever Animal prints plus (dog)“, but toString() with nothing in front calls the toString() of this same object, which is… this very method. It calls itself over and over until the stack fills up. The fix is one word:

return super.toString() + " (dog)";     // Toby (dog)

Every time an overridden method needs “what the parent did”, write super. in front. Without it, you’re calling your own version.

Solutions

Level 1 — The animal shelter

public abstract class Animal {

    private final String name;
    private final int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = Math.max(0, age);                  // no negative ages
    }

    public String getName() { return name; }
    public int getAge()     { return age; }

    // Every animal makes a sound, but each one its own
    public abstract String sound();

    public String introduce() {
        return name + " (" + age + " years old) says " + sound();
    }
}
public class Dog extends Animal {

    private final String breed;

    public Dog(String name, int age, String breed) {
        super(name, age);                             // first, the Animal part
        this.breed = breed;
    }

    public String getBreed() { return breed; }

    @Override
    public String sound() { return "Woof!"; }

    @Override
    public String introduce() {
        return super.introduce() + " and is a " + breed;
    }
}
public class Cat extends Animal {

    private final boolean indoor;

    public Cat(String name, int age, boolean indoor) {
        super(name, age);
        this.indoor = indoor;
    }

    public boolean isIndoor() { return indoor; }

    @Override
    public String sound() { return indoor ? "Meow" : "Hiss!"; }
}
public class Main {
    public static void main(String[] args) {
        Animal[] shelter = {
            new Dog("Toby", 3, "beagle"),
            new Cat("Luna", 2, true),
            new Cat("Shadow", -1, false),
            new Dog("Rex", 7, "German shepherd")
        };

        int dogs = 0, totalAge = 0;
        for (Animal a : shelter) {
            System.out.println(a.introduce());
            if (a instanceof Dog) dogs++;
            totalAge += a.getAge();
        }
        System.out.printf("Dogs: %d · average age: %.1f%n", dogs, (double) totalAge / shelter.length);
    }
}

Why it’s done this way:

  • **Animal is abstract** because there are no “generic animals” in the shelter: each one is a dog or a cat. And sound() is abstract because no single sound works for all of them.
  • **The age check lives in Animal.** That way it works for dogs, cats and any future child. Shadow was created with -1 and comes out as 0 years old without Cat doing anything.
  • **introduce() uses sound() without knowing which one it’ll be.** When the object is a cat, Cat‘s sound() runs. The dog, on top of that, overrides introduce() and reuses the parent’s with super.
  • **(double) totalAge / shelter.length:** without the cast, 12 / 4 would be integer division. Here it would give 3 anyway, but with other ages you’d lose the decimals.

Level 2 — Shapes that compare themselves

public abstract class Shape {

    public abstract double area();
    public abstract String getName();

    // final: comparing by area is the same for every shape
    public final boolean isBiggerThan(Shape other) {
        return area() > other.area();
    }

    @Override
    public String toString() {
        return String.format("%s with area %.2f", getName(), area());
    }

    // Sorts from smallest to largest area (bubble sort): only uses isBiggerThan
    public static void sort(Shape[] shapes) {
        for (int i = 0; i < shapes.length - 1; i++) {
            for (int j = 0; j < shapes.length - 1 - i; j++) {
                if (shapes[j].isBiggerThan(shapes[j + 1])) {
                    Shape tmp = shapes[j];            // we swap references,
                    shapes[j] = shapes[j + 1];        // we don't copy objects
                    shapes[j + 1] = tmp;
                }
            }
        }
    }
}
public class Circle extends Shape {

    private final double radius;

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

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

    @Override
    public String getName() { return "Circle r=" + radius; }
}
public class Triangle extends Shape {

    private final double a, b, c;

    public Triangle(double a, double b, double c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    // static: can be checked BEFORE the triangle is created
    public static boolean isValid(double a, double b, double c) {
        return a > 0 && b > 0 && c > 0 && a + b > c && a + c > b && b + c > a;
    }

    @Override
    public double area() {
        double s = (a + b + c) / 2;                   // semi-perimeter
        return Math.sqrt(s * (s - a) * (s - b) * (s - c));   // Heron's formula
    }

    @Override
    public String getName() { return "Triangle " + a + "-" + b + "-" + c; }
}

Why it’s done this way:

  • **Shape has no constructor and no fields**, and that’s fine: Java gives it an empty one, and the implicit super() in Circle and Triangle finds it. The children don’t need to write super.
  • **isBiggerThan is final.** If a child could redefine what “bigger” means, sort would stop being reliable. The children decide how to work out their area, but not how they’re compared.
  • **sort is static and takes a Shape[].** It doesn’t belong to any particular shape, and it works with any mix of circles and triangles (and with the shapes you add tomorrow) because it only uses Shape‘s methods.
  • The swap moves references, not objects. The four objects stay in the same place on the heap; what changes is which array position points to each one.
  • **isValid is static because it’s used before** the triangle exists. Exceptions, which are how you reject data inside the constructor, come in the next topic.

The 3-4-5 triangle is a right triangle, so its area also comes from base × height / 2 = 3 × 4 / 2 = 6. Heron gives exactly the same: a nice way to check you’ve written the formula correctly.

Level 3 — The restaurant’s staff

public abstract class Worker {

    public static final double HOURLY_RATE = 10.0;

    private final String name;
    protected final int weeklyHours;                  // the children use it in their calculations

    public Worker(String name, int weeklyHours) {
        this.name = name;
        this.weeklyHours = Math.max(0, weeklyHours);
    }

    public String getName() { return name; }

    public abstract String getRole();

    // Base cost: hours × rate. The children extend it if needed
    public double weeklyCost() {
        return weeklyHours * HOURLY_RATE;
    }

    @Override
    public String toString() {
        return String.format("%-7s %-6s %7.2f €", getRole(), name, weeklyCost());
    }
}
public class Cook extends Worker {

    private final boolean nightShift;

    public Cook(String name, int weeklyHours, boolean nightShift) {
        super(name, weeklyHours);
        this.nightShift = nightShift;
    }

    @Override
    public String getRole() { return "Cook"; }

    @Override
    public double weeklyCost() {
        double base = super.weeklyCost();             // reuse the parent's calculation
        return nightShift ? base * 1.25 : base;       // +25 % for night work
    }
}
public class Waiter extends Worker {

    private double tips;                              // not a cost: the customers pay it

    public Waiter(String name, int weeklyHours) {
        super(name, weeklyHours);
    }

    public void addTip(double amount) {
        if (amount > 0) tips += amount;
    }

    public double getTips() { return tips; }

    @Override
    public String getRole() { return "Waiter"; }
    // weeklyCost(): the one inherited from Worker works as it is
}
public class Manager extends Waiter {

    private static final double RESPONSIBILITY_BONUS = 120.0;

    public Manager(String name, int weeklyHours) {
        super(name, weeklyHours);
    }

    @Override
    public String getRole() { return "Manager"; }

    @Override
    public double weeklyCost() {
        return super.weeklyCost() + RESPONSIBILITY_BONUS;   // the waiter's cost + the bonus
    }
}
public class Staff {

    private final Worker[] workers;
    private int numWorkers = 0;

    public Staff(int capacity) {
        workers = new Worker[capacity];
    }

    public boolean hire(Worker w) {
        if (w == null || numWorkers == workers.length) return false;
        for (int i = 0; i < numWorkers; i++) {
            if (workers[i].getName().equalsIgnoreCase(w.getName())) return false;
        }
        workers[numWorkers++] = w;
        return true;
    }

    public double totalCost() {
        double total = 0;
        for (int i = 0; i < numWorkers; i++) {
            total += workers[i].weeklyCost();         // each with its own formula
        }
        return total;
    }

    public Worker mostExpensive() {
        if (numWorkers == 0) return null;
        Worker max = workers[0];
        for (int i = 1; i < numWorkers; i++) {
            if (workers[i].weeklyCost() > max.weeklyCost()) max = workers[i];
        }
        return max;
    }

    // Only floor staff (Waiter and its children) get tips
    public double floorTips() {
        double total = 0;
        for (int i = 0; i < numWorkers; i++) {
            if (workers[i] instanceof Waiter w) total += w.getTips();
        }
        return total;
    }

    public void list() {
        for (int i = 0; i < numWorkers; i++) {
            System.out.println(workers[i]);
        }
    }
}

Why it’s done this way:

  • **weeklyCost() isn’t abstract in Worker. There’s a default formula (hours × €10) that works for the waiter as it is. Only the ones with something different change it, and they do it by extending** it with super.weeklyCost(), not by copying it.
  • **The manager inherits from Waiter, not from Worker. A manager is a** waiter (works on the floor and gets tips) with extra responsibility. That way it inherits addTip and getTips without writing anything. Its super.weeklyCost() calls Waiter‘s, which in turn is Worker‘s: 40 × 10 + 120 = €520.
  • **weeklyHours is protected final.** The children can read it if they need to, but nobody can change it after hiring. name is private because the children don’t need it: they have getName().
  • **floorTips() uses instanceof Waiter**, and that includes Tomás. Lucía has €35.50 (the negative tip was ignored) and Tomás €20: €55.50.
  • **Staff only knows about Worker.** If tomorrow you hire a DeliveryDriver, totalCost, mostExpensive and list work without touching them.

Writing the whole cost in Manager, weeklyHours * HOURLY_RATE + 120. Today it gives the same result, but if one day the way a waiter is paid changes, the manager will be stuck with the old formula. With super.weeklyCost() it picks up the change on its own.

Watch it step by step in Python Tutor

Python Tutor runs code line by line and draws the objects and the calls. Let’s watch snippet 2, the dog that didn’t bark, live. Choose Java, paste this and step through it with Next:

public class Tutor {
    static class Animal {
        public String sound() { return "..."; }
        public String speak() { return "Says " + sound(); }
    }
    static class Dog extends Animal {
        public String sounds() { return "Woof!"; }     // no @Override
    }
    public static void main(String[] args) {
        Animal toby = new Dog();
        System.out.println(toby.speak());
        Dog rex = new Dog();
        System.out.println(rex.sounds());
    }
}

Output:

Says ...
Woof!

What to watch:

  1. After new Dog(), the object on the heap is of type Dog, even though the variable toby is of type Animal. The real object doesn’t change depending on how you declare it.
  2. When toby.speak() runs, it goes into Animal‘s speak() (the dog doesn’t have its own). Notice that this points to the Dog object.
  3. Inside speak(), the call to sound() looks in Dog first… and finds no sound(), only sounds(). So it falls back to Animal‘s and returns "...". That jump is the bug, live.
  4. With rex.sounds(), the method does run: it exists, but nothing calls it from speak(). Now add @Override above sounds() in your editor and see the error the compiler gives you.

Cheat sheet: inheritance in Java

The essentials in 30 seconds

  • class Child extends Parent: the child has everything the parent has plus its own things. One parent per class; if you don’t name one, it’s Object.
  • Constructors: super(...) on the first line. If you don’t write it, Java inserts super() with no parameters, and it fails if the parent has no empty constructor.
  • Overriding = same method, another version (always @Override). Overloading = same name, other parameters.
  • Polymorphism: the variable decides what you can call; the object decides which version runs.
  • Downcasting: only after instanceof. abstract = “complete me”; final = “don’t change me”.

Syntax

I want to…CodeResult
Inheritpublic class Dog extends Animal { … }Dog has everything Animal has
Call the parent’s constructorsuper(name, age); (first line)builds the inherited part
Use the parent’s versionsuper.introduce()reuses and extends
Override@Override public String sound() { … }replaces the parent’s
Overloadintroduce() and introduce(String)both live side by side
Field visible to childrenprotected double balance;children yes, others no
Class that can’t be instantiatedpublic abstract class Animalnew Animal() doesn’t compile
Method children must writepublic abstract String sound();no body
Method nobody can changepublic final boolean isBiggerThan(…)can’t be overridden
Class nobody can inherit frompublic final class Xextends X doesn’t compile
Store children in the parent typeAnimal a = new Dog(…);polymorphism
Array with several typesAnimal[] s = { new Dog(…), new Cat(…) };one loop for all
Check and convertif (a instanceof Dog d) { d.getBreed(); }safe cast

Templates from the exercises

Child that extends a parent method (Dog, Cook, Manager):

@Override
public String introduce() {
    return super.introduce() + " and is a " + breed;
}

Toby → Toby (3 years old) says Woof! and is a beagle

Final method that relies on an abstract one (Shape):

public abstract double area();

public final boolean isBiggerThan(Shape other) {
    return area() > other.area();
}

Sorted: 3.14 → 6.00 → 7.07 → 12.00

Polymorphic loop + instanceof (Staff):

for (int i = 0; i < numWorkers; i++) {
    total += workers[i].weeklyCost();                     // each its own formula
    if (workers[i] instanceof Waiter w) tips += w.getTips();
}

Cost → 1670.00 € · floor tips → 55.50 €

The most common mistakes

SymptomLikely cause
constructor X in class X cannot be applied to given typesThe parent has no empty constructor and you didn’t call super(...)
call to super must be first statement in constructorThere’s code before super(...)
X has private access in ParentThe field is private: use the getter or make it protected
method does not override or implement a method from a supertypeName or parameters differ from the parent’s
X is not abstract and does not override abstract method…The child doesn’t implement an abstract method
cannot find symbol when calling a child’s methodThe variable is of the parent type: check with instanceof
ClassCastExceptionDowncast without checking the real type
StackOverflowError in an overridden methodMissing super., so the method calls itself
The parent’s version runs instead of yoursYou didn’t override: a typo in the name or parameters (no @Override)

Before you hand it in, ask yourself

  1. Does every extends pass the “is a” test? If it’s “has a”, it’s composition, not inheritance.
  2. Do all the children’s constructors call super(...) with the parent’s data on the first line?
  3. Have I put @Override on everything I override, and super. where I extend instead of replace?
  4. Is every downcast protected by an instanceof? Could it be an overridden method instead?
Keep goingAll Java (en) articles

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 *