Understanding the Substitution Principle in Java

Ahmad Wijaya
4 min readJun 16, 2024

--

Photo by Austin Distel on Unsplash

Introduction

The Substitution Principle is a core concept in object-oriented programming and is crucial for understanding polymorphism and inheritance in Java. It asserts that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In Java, this principle is often referred to as the Liskov Substitution Principle (LSP).

The Substitution Principle Explained

At its core, the Substitution Principle allows for objects of derived (subclass) types to be treated as objects of a base (superclass) type. For instance, if Cat is a subclass of Animal, then wherever an Animal is expected, a Cat can be used.

class Animal {
public void makeSound() {
System.out.println("Some sound...");
}
}

class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}

public class Main {
public static void main(String[] args) {
Animal myCat = new Cat();
myCat.makeSound(); // Output: Meow
}
}

In this example, the Cat object myCat is treated as an Animal, yet it correctly calls the makeSound method of Cat.

Issues with Substitution and Generics

Java generics introduce a challenge to the substitution principle because they are invariant. This means that even though Cat is a subtype of Animal, List<Cat> is not a subtype of List<Animal>.

List<Cat> cats = new ArrayList<>();
List<Animal> animals = cats; // Compilation error

This leads us to Java wildcards, which provide a solution for this problem.

Using Java Wildcards

Java wildcards (?) are used to handle cases where generic types need to be more flexible. There are three types of wildcards:

  1. Unbounded Wildcards (?):
  • Used when any type can be accepted.

2. Bounded Wildcards with an Upper Bound (? extends Type):

  • Used when you need to accept a type and its subtypes.

3. Bounded Wildcards with a Lower Bound (? super Type):

  • Used when you need to accept a type and its supertypes.

Unbounded Wildcards

Unbounded wildcards are useful when the code does not depend on the actual type parameter.

public void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem);
}
}

List<Cat> cats = new ArrayList<>();
cats.add(new Cat());
printList(cats); // Works fine

Upper Bounded Wildcards

Upper bounded wildcards are useful when you want to read items from a list and you know that the items are of a certain type or its subtype.

public void makeAnimalsSound(List<? extends Animal> animals) {
for (Animal animal : animals) {
animal.makeSound();
}
}

List<Cat> cats = new ArrayList<>();
cats.add(new Cat());
makeAnimalsSound(cats); // Works fine

In this example, makeAnimalsSound accepts a list of Animal or any subclass of Animal.

Lower Bounded Wildcards

Lower bounded wildcards are useful when you want to add items to a list and you want to ensure that the list can accept a certain type or its supertype.

public void addCat(List<? super Cat> cats) {
cats.add(new Cat());
}

List<Animal> animals = new ArrayList<>();
addCat(animals); // Works fine

Here, addCat can accept a list of Cat or any supertype of Cat.

Comprehensive Example

Let’s combine these concepts into a comprehensive example demonstrating the substitution principle with wildcards.

class Animal {
public void makeSound() {
System.out.println("Some sound...");
}
}

class Cat extends Animal {
@Override
public void makeSound() {
System.out.println("Meow");
}
}

class Dog extends Animal {
@Override
public void makeSound() {
System.out.println("Woof");
}
}

public class WildcardExample {
// Method using an unbounded wildcard
public static void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem);
}
}

// Method using an upper bounded wildcard
public static void makeAnimalsSound(List<? extends Animal> animals) {
for (Animal animal : animals) {
animal.makeSound();
}
}

// Method using a lower bounded wildcard
public static void addCat(List<? super Cat> cats) {
cats.add(new Cat());
}

public static void main(String[] args) {
List<Cat> cats = new ArrayList<>();
cats.add(new Cat());

List<Dog> dogs = new ArrayList<>();
dogs.add(new Dog());

// Unbounded wildcard example
printList(cats);
printList(dogs);

// Upper bounded wildcard example
makeAnimalsSound(cats);
makeAnimalsSound(dogs);

// Lower bounded wildcard example
List<Animal> animals = new ArrayList<>();
addCat(animals);
makeAnimalsSound(animals); // Output includes "Meow"
}
}

Class Diagram

Here is the class hierarchy diagram for the above example:

          +--------+
| Animal |
+--------+
/ \
/ \
/ \
+-----+ +-----+
| Cat | | Dog |
+-----+ +-----+

This diagram represents the class hierarchy in which Animal is the superclass, and Cat and Dog are subclasses inheriting from Animal.

Conclusion

The Substitution Principle is a foundational concept that ensures the flexibility and robustness of object-oriented programs. In Java, wildcards in generics help maintain this principle by allowing more flexible type relationships. By understanding and using unbounded, upper bounded, and lower bounded wildcards, you can create more versatile and type-safe code.

--

--

Ahmad Wijaya

Technology Specialist @ TIMWETECH | Java, Go, Python