DEV Community

Cover image for Replacing Type Code With State/Strategy
Attila Fejér
Attila Fejér

Posted on • Originally published at rockit.zone on

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:

Dungeon with four 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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".


  1. 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. 

Top comments (0)