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:
-
Violating Open/Closed Principle: We still have to modify the
Pet
enum to introduce new pet types. - One instance per type: We can have only one instance per pet type. For example, we can't have two dogs with different names.
- 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:
- Encapsulation of Behavior: Each subclass defines its unique behavior, making the code more organized and readable.
-
Open/Closed Principle: Our
Pet
class is now open for extension but closed for modification. Adding a new pet type becomes much easier. -
Elimination of
switch
-case
: We've removed the cumbersomeswitch
-case
structures, leading to cleaner and more maintainable code. - 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.
-
The downsides of switch case in the first part of the series ↩
Top comments (0)