Replacing Type Code With Subclasses

When the Type-Code Affects Behavior

Replacing Type Code With Subclasses

When the Type-Code Affects Behavior

We’ve previously dived into the complexities and maintenance challenges of switch-case and saw a refactoring technique for simple scenarios.

Today, we’ll raise the bar a bit and focus on situations when our type code affects behavior. We’ll use pets to demonstrate the concepts.

Starting Point

We want to model different pets: dogs, cats, and birds. Initially, we might represent this with a type code:

class Pet {
  static final int DOG = 0;
  static final int CAT = 1;
  static final int BIRD = 2;

  int type;

  Pet(int type) {
    this.type = type;
  }

  String communicate() {
    switch (type) {
      case DOG:
        return "woof";

      case CAT:
        return "meow";

      case BIRD:
        return "chirp";

      default:
        throw new IllegalArgumentException("Unknown pet type");
    }
  }

  String move() {
    switch (type) {
      case DOG:
        return "run";

      case CAT:
        return "climb";

      case BIRD:
        return "fly";

      default:
        throw new IllegalArgumentException("Unknown pet type");
    }
  }
}

And its usage:

Pet buddy = new Pet(Pet.DOG);
System.out.println(buddy.communicate());

We already saw that switch-case, has several downsides1. It’s not only repetitive but also violates the Open/Closed Principle. Adding a new pet type means editing multiple parts of the class.

Refactoring: Using an Enum

As we learned in the previous post2, we can refactor this code into an enum:

enum Pet {
  DOG("woof", "run"),
  CAT("meow", "climb"),
  BIRD("chirp", "fly");

  String sound;
  String movement;

  Pet(String sound, String movement) {
    this.sound = sound;
    this.movement = movement;
  }

  String communicate() {
    return sound;
  }

  String move() {
    return movement;
  }
}

Its usage is even simpler than the previous:

Pet buddy = Pet.DOG;
System.out.println(buddy.communicate());

So far so good. But while pizzas had only data2, pets are more dynamic and they have behavior, too. For example, they can interact with objects, like balls:

class Ball {
  void fetch() {}
  void bat() {}
  void ignore() {}
}

enum Pet {
  DOG("woof", "run"),
  CAT("meow", "climb"),
  BIRD("chirp", "fly");

  String sound;
  String movement;

  Pet(String sound, String movement) {
    this.sound = sound;
    this.movement = movement;
  }

  String communicate() {
    return sound;
  }

  String move() {
    return movement;
  }

  void interact(Ball ball) {
    switch (this) {
      case DOG:
        ball.fetch();
        break;
      case CAT:
        ball.bat();
        break;
      case BIRD:
        ball.ignore();
        break;
    }
  }
}

Despite previous efforts, switch-case have returned to the code. Fortunately, we can get rid of it.

Operation as Data

The first possibility is to treat operations like data. There are programming languages, where functions are first-class-citizens and we can store them in variables. Such languages are C and JavaScript. A possible solution in JS can look like the following:

class Ball {
  fetch() {}
  bat() {}
  ignore() {}
}

class Pet {
  static DOG = new Pet('woof', 'run');
  static CAT = new Pet('meow', 'climb');
  static BIRD = new Pet('chirp', 'fly');

  constructor(sound, movement, ballInteraction) {
    this.sound = sound;
    this.movement = movement;
    this.ballInteraction = ballInteraction;
  }

  communicate() {
    return this.sound;
  }

  move() {
    return this.movement;
  }

  interact(ball) {
    this.ballInteraction(ball);
  }
}

But in Java, we can’t store functions in variables (obviously 😒). But since Java 8, we can use function-like constructs: functional interfaces, lambda expressions (similar to JS arrow functions), and method references (which is a syntactic sugar for lambdas):

enum Pet {
  DOG("woof", "run", (ball) -> ball.fetch()),  // lambda
  CAT("meow", "climb", b -> b.bat()),          // lambda
  BIRD("chirp", "fly", Ball::ignore);          // method reference

  String sound;
  String movement;
  Consumer<Ball> ballInteraction;              // functional interface expecting a single Ball argument

  Pet(String sound, String movement, Consumer<Ball> ballInteraction) {
    this.sound = sound;
    this.movement = movement;
    this.ballInteraction = ballInteraction;
  }

  String communicate() {
    return sound;
  }

  String move() {
    return movement;
  }

  void interact(Ball ball) {
    ballInteraction.accept(ball);
  }
}

Despite that it works, this solution has multiple downsides:

  1. Violating Open/Closed Principle: We still have to modify the Pet enum to introduce new pet types.
  2. One instance per type: We can have only one instance per pet type. For example, we can’t have two dogs with different names.
  3. ****:
  4. Readability: The instantiation starts to become long and unreadable, especially with many arguments and more complex interactions.

Introducing Subclasses

Since Java is an object-oriented language, let’s apply some polymorphism. Refactor our Pet enum to a base class and subclasses for each pet type. This way, we encapsulate the behavior specific to each pet type within its subclass:

abstract class Pet {
    abstract String communicate();
    abstract String move();
    abstract void interact(Ball ball);
}

class Dog extends Pet {
  @Override
  String communicate() {
    return "woof";
  }

  @Override
  String move() {
    return "run";
  }

  @Override
  void interact(Ball ball) {
    ball.fetch();
  }
}

class Cat extends Pet {
  @Override
  String communicate() {
    return "meow";
  }

  @Override
  String move() {
    return "climb";
  }

  @Override
  void interact(Ball ball) {
    ball.bat();
  }
}

class Bird extends Pet {
  @Override
  String communicate() {
    return "chirp";
  }

  @Override
  String move() {
    return "fly";
  }

  @Override
  void interact(Ball ball) {
    ball.ignore();
  }
}

Note: in this example, Pet could have been an interface of a class. This detail doesn’t matter regarding the approach.

And its usage:

Pet buddy = new Dog();
System.out.println(buddy.communicate());

The advantages of this approach:

  1. Encapsulation of Behavior: Each subclass defines its unique behavior, making the code more organized and readable.
  2. Open/Closed Principle: Our Pet class is now open for extension but closed for modification. Adding a new pet type becomes much easier.
  3. Elimination of switch-case: We’ve removed the cumbersome switch-case structures, leading to cleaner and more maintainable code.
  4. Dynamicity: Subclasses can have as many instances as we need. Also, they can have their own fields and behaviors that other pet types don’t.

This refactoring’s name is (not surprisingly) replace type code with subclasses.

Conclusion

When the type code affects behavior, refactoring type codes to subclasses offers numerous benefits in terms of maintainability and scalability. It aligns with the principles of good object-oriented design and results in cleaner, more modular code.

But for simple cases, a good old enum with some function references can be a good choice, too.


  1. The downsides of switch case in the first part of the series ↩︎

  2. Replacing Type Code with Enum ↩︎ ↩︎


See also