Replacing Type Code With State/Strategy

Replacing Type Code With State/Strategy

Previously, we talked about potential issues switch-case can cause, and what to do when the type affects only data, or behavior, too.

Now that we are switch-case ninjas, we can go even further and see how to tackle when the type code and the behavior change dynamically at runtime.

The Problem

We’ll dive into the realm of DnD. Or something similar.

In our game, we’ll have four rooms. One is the start, the others contain different things: a dragon, treasure, and fireproof armor. We can move between specific rooms:

If we don’t move, we should act depending on the room: pick up the armor, attack the dragon, or open the chest.

Our initial code skeleton is the following:

class Game {
enum Room {
START, DRAGON, CHEST, ARMOR
}

enum GameStatus {
IN_PROGRESS, ENDED
}

enum Action {
MOVE, ACT
}

enum MovementDirection {
NORTH, SOUTH, EAST, WEST
}

Room room = Room.START;
GameStatus gameStatus = GameStatus.IN_PROGRESS;
boolean playerFireproof = false;
boolean dragonLives = true;

void play() {
while (gameStatus == GameStatus.IN_PROGRESS) {
System.out.println(currentRoomDescription());
Action action = nextUserAction();
if (action == Action.ACT) {
act();
} else {
move(movementDirection());
}
}
}

Action nextUserAction() {
// implementation skipped for easier understanding
}

MovementDirection movementDirection() {
// implementation skipped for easier understanding
}

String currentRoomDescription() {
// TODO
}

void act() {
// TODO
}

void move(MovementDirection direction) {
// TODO
}
}

Three things depend on the current room:

The room’s description
If we move to a direction, in which room do we end up
The action we can perform

For simplicity, we won’t show any error messages when the user tries to do anything invalid. For example, when they try to move in a direction where there aren’t any doors.

Let’s see a naive implementation:

class Game {

// rest of the code

String currentRoomDescription() {
switch (room) {
case Room.START:
return “You are in an empty room. You see a door to the North and to the East.”;
case Room.DRAGON:
if (dragonLives) {
return “You see a dragon. You can attack it if you want. Behind it there is a door to the East. You can go back South.”;
}
return “The dragon is dead. You see a door to the South and to the East.”;
case Room.CHEST:
return “There is a chest in the middle of the room. You can open it. You can also go back to the West.”;
case Room.ARMOR:
if (playerFireproof) {
return “You are in an empty room. You see a door to the West.”;
}
return “There is a fireproof armor in the middle of the room. You can pick it up. You can also go back to the West.”;
default:
throw new IllegalStateException();
}
}

void act() {
switch (room) {
case Room.START:
// do nothing – no action is available in the start room
return;
case Room.DRAGON:
if (!dragonLives) {
// we can’t do anything if the dragon is dead
return;
}
if (playerFireproof) {
// the player is fireproof so they can kill the dragon
dragonLives = false;
return;
}
gameStatus = GameStatus.ENDED;
System.out.println(“The dragon burned you alive. You’re dead. Game over.”);
return;
case Room.CHEST:
gameStatus = GameStatus.ENDED;
System.out.println(“You got the chest. Congratulations, you won!”);
return;
case Room.ARMOR:
// player picks up the armor
playerFireproof = true;
return;
default:
throw new IllegalStateException();
}
}

void move(MovementDirection direction) {
switch (room) {
case Room.START:
switch (direction) {
case MovementDirection.NORTH:
room = Room.DRAGON;
return;
case MovementDirection.EAST:
room = Room.ARMOR;
return;
}
break;
case Room.DRAGON:
switch (direction) {
case MovementDirection.SOUTH:
room = Room.START;
return;
case MovementDirection.EAST:
if (!dragonLives) {
room = Room.CHEST;
}
return;
}
break;
case Room.CHEST:
if (direction == MovementDirection.WEST) {
room = Room.DRAGON;
}
break;
case Room.ARMOR:
if (direction == MovementDirection.WEST) {
room = Room.START;
}
break;
default:
throw new IllegalStateException();
}
}
}

Yikes, that’s super ugly. The code already got out of hand when we had only four rooms. We don’t want to imagine how unmaintainable will it get for dozens or hundreds of rooms.

Improving the Code

Fortunately, we already learned that we can create a class hierarchy to deal with different behaviors. The only problem is that we didn’t have to deal with changing states before.

The solution is easy. Keep the room-independent things in the game class and extract the rest.

Before we do that, we need to refactor a few things:

It’s worth to extract the MovementDirection enum because it’s not that tightly coupled to the Game class
The move() method should return the next room since it doesn’t have access to the Game.room field anymore
Similarly, it’s worth to extract the GameStatus enum and make act() return the new status
We should create the Player class to manage the player’s state
We pass the Player to the act() method to use/alter the player’s state

After all the steps above, the code looks like following:

enum MovementDirection {
NORTH, SOUTH, EAST, WEST
}

enum GameStatus {
IN_PROGRESS, ENDED
}

class Player {
boolean fireproof = false;

void pickUpFireproofArmor() {
fireproof = true;
}

boolean isFireproof() {
return fireproof;
}
}

class Game {
enum Room {
START, DRAGON, CHEST, ARMOR
}

enum Action {
MOVE, ACT
}

Room room = Room.START;
Player player = new Player();
GameStatus gameStatus = GameStatus.IN_PROGRESS;

void play() {
while (gameStatus == GameStatus.IN_PROGRESS) {
System.out.println(currentRoomDescription());
Action action = nextUserAction();
if (action == Action.ACT) {
gameStatus = act(player);
} else {
room = move(movementDirection());
}
}
}

Action nextUserAction() {
// implementation skipped for easier understanding
}

MovementDirection movementDirection() {
// implementation skipped for easier understanding
}

String currentRoomDescription() {
// implementation skipped for easier understanding
}

GameStatus act(Player player) {
// implementation skipped for easier understanding
}

Room move(MovementDirection direction) {
// implementation skipped for easier understanding
}
}

The next step is to move the three room-dependent methods to a new interface:

class Game {

// code skipped for easier understanding

void play() {
while (gameStatus == GameStatus.IN_PROGRESS) {
System.out.println(room.description());
Action action = nextUserAction();
if (action == Action.ACT) {
gameStatus = room.act(player);
} else {
room = room.move(movementDirection());
}
}
}

Action nextUserAction() {
// implementation skipped for easier understanding
}

MovementDirection movementDirection() {
// implementation skipped for easier understanding
}
}

interface Room {
String description();

GameStatus act(Player player);

Room move(MovementDirection direction);
}

Now it’s time to move forward and implement the four rooms:

interface Room {
static final Room START = new StartRoom();
static final Room DRAGON = new DragonRoom();
static final Room ARMOR = new ArmorRoom();
static final Room CHEST = new ChestRoom();

String description();

GameStatus act(Player player);

Room move(MovementDirection direction);
}

class StartRoom implements Room {
@Override
String description() {
return “You are in an empty room. You see a door to the North and to the East.”;
}

@Override
GameStatus act(Player player) {
// nothing to do
return GameStatus.IN_PROGRESS;
}

@Override
Room move(MovementDirection direction) {
switch (direction) {
case MovementDirection.NORTH:
return Room.DRAGON;
case MovementDirection.EAST:
return Room.ARMOR;
}
return this;
}
}

class DragonRoom implements Room {
boolean dragonLives = true;

@Override
String description() {
if (dragonLives) {
return “You see a dragon. You can attack it if you want. Behind it there is a door to the East. You can go back South.”;
}
return “The dragon is dead. You see a door to the South and to the East.”;
}

@Override
GameStatus act(Player player) {
if (!dragonLives) {
// we can’t do anything if the dragon is dead
return GameStatus.IN_PROGRESS;
}
if (player.isFireproof()) {
// the player is fireproof so they can kill the dragon
dragonLives = false;
return GameStatus.IN_PROGRESS;
}
System.out.println(“The dragon burned you alive. You’re dead. Game over.”);
return GameStatus.ENDED;
}

@Override
Room move(MovementDirection direction) {
switch (direction) {
case MovementDirection.SOUTH:
return Room.START;
case MovementDirection.EAST:
if (!dragonLives) {
return Room.CHEST;
}
break;
}
return this;
}
}

class ChestRoom implements Room {
@Override
String description() {
return “There is a chest in the middle of the room. You can open it. You can also go back to the West.”;
}

@Override
GameStatus act(Player player) {
System.out.println(“You got the chest. Congratulations, you won!”);
return GameStatus.ENDED;
}

@Override
Room move(MovementDirection direction) {
if (direction == MovementDirection.WEST) {
return Room.DRAGON;
}
return this;
}
}

class ArmorRoom implements Room {
boolean armorPickedUp = false;

@Override
String description() {
if (armorPickedUp) {
return “You are in an empty room. You see a door to the West.”;
}
return “There is a fireproof armor in the middle of the room. You can pick it up. You can also go back to the West.”;
}

@Override
GameStatus act(Player player) {
armorPickedUp = true;
player.pickUpFireproofArmor();
return GameStatus.IN_PROGRESS;
}

@Override
Room move(MovementDirection direction) {
if (direction == MovementDirection.WEST) {
return Room.START;
}
return this;
}
}

Note that we introduced constants in the Room interface so every room will have a single instance. This is useful because every room class handles its state management independently. It improves cohesion and follows the single-responsibility principle.

Also note that every class we have now is much simpler than before1.

In the previous post in the series we already saw the advantages of introducing subclasses. Therefore, we’ll talk about what’s new in this implementation.

Introducing State/Strategy

Compared to our pet example in the previous post, we had shared behavior and state among the classes. For example:

Game status
Flow of the game
Reading user input

Adding the common behavior to a superclass could have been a solution. From an architectural perspective, that would have been violating the SOLID principles; therefore, a suboptimal choice.

Not to mention that instead of managing the game status in multiple places is challenging.

It was a wiser decision to keep the common parts in the Game class and extract the changing parts to the Room interface and its implementations.

This refactoring has a name: replace type code with state/strategy. The state/strategy refers to the State and Strategy design patterns.

Their intent is exactly the same as we implemented above:

Separate the common parts from the changing parts
Introduce a class hierarchy for the changing parts
Make them interchangeable at runtime

Their class diagrams (and code representations) are the same. The differentiator is only philosophical:

If the different implementations do different things, then it’s the State pattern. For example, different rooms have different behaviors.
If they do the same things, but differently, then it’s the Strategy. For example, when we save an image to different file formats, the result is the same: an image file on the disk, but the algorithms that converted the image to a binary representation are different.

Further Patterns

There are many other patterns we could use to replace conditional statements with polymorphism. Each of them has specific conditions under which they work the best.

A non-comprehensive list of such patterns:

Abstract Factory
Command
Visitor
Chain of Responsibility
Decorator
Proxy

It’s worth knowing them because they are very powerful – if we use them wisely.

Do you struggle with any of those? I might write a post about them. Drop me a comment or an email to express your interest.

Conclusion

If we feel that conditional statements are hard to maintain, we have many alternatives. In this series, we saw reasons beyond maintainability. We also understood a few specific refactoring techniques and their limitations.

But the most important thing is to use these techniques wisely. If a pattern is overused, it can decrease maintainability like any other technique. We should always remember that there isn’t a single best solution, a one-size-fits-all technique. Because, in engineering, the best answer to most questions is: “it depends”.

Yes, we still have a few switch-case statements in the Room.move() implementations, but that should be our homework to get rid of those. ↩

Leave a Reply

Your email address will not be published. Required fields are marked *