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.
Table of Contents
How to use this article
- Read the task and the expected output.
- Try it for at least 15 minutes before opening the hint.
- Before writing any code, draw the hierarchy: who inherits from whom and which method goes on each floor.
- 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, store0.- An abstract method
String sound()and a methodString introduce()that returnsToby (3 years old) says Woof!. Dog extends Animal, with a breed. Its sound isWoof!and itsintroduce()addsand is a beagleat the end, reusing the parent’s.Cat extends Animal, with aboolean indoor. If it’s an indoor cat it saysMeow; if not,Hiss!.- In
main, anAnimal[]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 abstractdouble area()andString getName().- A
final boolean isBiggerThan(Shape other)method that compares by area. toString()in the formatCircle r=1.5 with area 7.07.- A
static void sort(Shape[] shapes)method that sorts them from smallest to largest area using onlyisBiggerThan(bubble sort). Circle(radius) andTriangle(three sides, area with Heron’s formula).Trianglehas astatic 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). ConstantHOURLY_RATE = 10.0.double weeklyCost()(hours × rate), abstractString getRole()andtoString()in the formatCook 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) andlist().
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:
- **
Animalis abstract** because there are no “generic animals” in the shelter: each one is a dog or a cat. Andsound()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-1and comes out as 0 years old withoutCatdoing anything. - **
introduce()usessound()without knowing which one it’ll be.** When the object is a cat,Cat‘ssound()runs. The dog, on top of that, overridesintroduce()and reuses the parent’s withsuper. - **
(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:
- **
Shapehas no constructor and no fields**, and that’s fine: Java gives it an empty one, and the implicitsuper()inCircleandTrianglefinds it. The children don’t need to writesuper. - **
isBiggerThanisfinal.** If a child could redefine what “bigger” means,sortwould stop being reliable. The children decide how to work out their area, but not how they’re compared. - **
sortisstaticand takes aShape[].** 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 usesShape‘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.
- **
isValidisstaticbecause 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 inWorker. 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 withsuper.weeklyCost(), not by copying it. - **The manager inherits from
Waiter, not fromWorker. A manager is a** waiter (works on the floor and gets tips) with extra responsibility. That way it inheritsaddTipandgetTipswithout writing anything. Itssuper.weeklyCost()callsWaiter‘s, which in turn isWorker‘s: 40 × 10 + 120 = €520. - **
weeklyHoursisprotected final.** The children can read it if they need to, but nobody can change it after hiring.nameisprivatebecause the children don’t need it: they havegetName(). - **
floorTips()usesinstanceof Waiter**, and that includes Tomás. Lucía has €35.50 (the negative tip was ignored) and Tomás €20: €55.50. - **
Staffonly knows aboutWorker.** If tomorrow you hire aDeliveryDriver,totalCost,mostExpensiveandlistwork 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:
- After
new Dog(), the object on the heap is of typeDog, even though the variabletobyis of typeAnimal. The real object doesn’t change depending on how you declare it. - When
toby.speak()runs, it goes intoAnimal‘sspeak()(the dog doesn’t have its own). Notice thatthispoints to theDogobject. - Inside
speak(), the call tosound()looks inDogfirst… and finds nosound(), onlysounds(). So it falls back toAnimal‘s and returns"...". That jump is the bug, live. - With
rex.sounds(), the method does run: it exists, but nothing calls it fromspeak(). Now add@Overrideabovesounds()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’sObject.- Constructors:
super(...)on the first line. If you don’t write it, Java insertssuper()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… | Code | Result |
|---|---|---|
| Inherit | public class Dog extends Animal { … } | Dog has everything Animal has |
| Call the parent’s constructor | super(name, age); (first line) | builds the inherited part |
| Use the parent’s version | super.introduce() | reuses and extends |
| Override | @Override public String sound() { … } | replaces the parent’s |
| Overload | introduce() and introduce(String) | both live side by side |
| Field visible to children | protected double balance; | children yes, others no |
| Class that can’t be instantiated | public abstract class Animal | new Animal() doesn’t compile |
| Method children must write | public abstract String sound(); | no body |
| Method nobody can change | public final boolean isBiggerThan(…) | can’t be overridden |
| Class nobody can inherit from | public final class X | extends X doesn’t compile |
| Store children in the parent type | Animal a = new Dog(…); | polymorphism |
| Array with several types | Animal[] s = { new Dog(…), new Cat(…) }; | one loop for all |
| Check and convert | if (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
| Symptom | Likely cause |
|---|---|
constructor X in class X cannot be applied to given types | The parent has no empty constructor and you didn’t call super(...) |
call to super must be first statement in constructor | There’s code before super(...) |
X has private access in Parent | The field is private: use the getter or make it protected |
method does not override or implement a method from a supertype | Name 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 method | The variable is of the parent type: check with instanceof |
ClassCastException | Downcast without checking the real type |
StackOverflowError in an overridden method | Missing super., so the method calls itself |
| The parent’s version runs instead of yours | You didn’t override: a typo in the name or parameters (no @Override) |
Before you hand it in, ask yourself
- Does every
extendspass the “is a” test? If it’s “has a”, it’s composition, not inheritance. - Do all the children’s constructors call
super(...)with the parent’s data on the first line? - Have I put
@Overrideon everything I override, andsuper.where I extend instead of replace? - Is every downcast protected by an
instanceof? Could it be an overridden method instead?
Did this article help you? Here is how you can support SergioLearns →
