Enums in Java: the key to cleaner, safer code

Introduction

Ah, enumerations! If you've ever dabbled in programming, you've probably come across them. These little marvels allow you to represent a limited set of named values, and believe me, they make your code much clearer and more secure.

In some languages, such as C or C++, they hide behind simple integers. In C#, they look like tidy structures. But what about Java? Hold on to your hats: enums are much more than just fixed lists... they're real objects, with superpowers! 🚀

Why is this important? 🤔

At first glance, this difference may seem trivial... but think again, it changes everything! In Java, enums aren't just pretty lists of values, they offer real advantages:

✅ Security - No more invalid values lying around in the code! With enums, you can be sure you're only manipulating what's intended.

👀 Readability - Clearer, more understandable code... and therefore easier to maintain. Who doesn't like that?

🚀 Extensibility - Need to add behaviour to an enumeration? Don't worry, in Java, an enum can embed methods and attributes to suit your needs.

A concrete example 🎯

Let's imagine a Java game where you can choose a difficulty level before starting a game. A first approach might look like this:

public class Game { 
    private int difficulty; 

    public Game(int difficulty){ 
        this.difficulty = difficulty; 
    } 

    public void start(){ 
        System.out.println("Start game in ” + difficulty ); 
    } 
}

And to start a game :

public class Main { 
    private static final int EASY = 0; 
    private static final int MEDIUM = 1; 
    private static final int HARD = 2; 

    public static void main(String[] args) { 
        Game game = new Game(Main.MEDIUM); 
        game.start(); 
    } 
}

At first glance, everything looks right. The EASY, MEDIUM and HARD constants make the code more readable and avoid the use of simple arbitrary numbers.

But... 🛑 Problem!

What happens if someone decides to start a game with an unrelated value, like 74? There's nothing to stop them... and it's a safe bet that this will cause problems at runtime!

Let's see how enums can save the day. 🎮

Rather than using simple integers, we'll create an enumeration dedicated to levels of difficulty:

public enum Difficulty {
  EASY, MEDIUM, HARD;
}

Next, we update our Game class to use it:

public class Game {
  private Difficulty difficulty;

  public Game(Difficulty difficulty){
    this.difficulty = difficulty;
  }

  public void start(){
    System.out.println("Démarrage du jeu en mode " + difficulty);
  }
}

And of course, we adapt the Main to launch a game:

public class Main {
  public static void main(String[] args) {
    Game game = new Game(Difficulty.MEDIUM);
    game.start();
  }
}

✅ Mission accomplished! Thanks to Java, it's impossible to start a game with anything other than a pre-defined difficulty!

The fun begins 😎

But wait, that's not all! Java offers many more features to its enumerations, making them particularly interesting. As we've seen, an enumeration in Java is not just a type, but a class in its own right.

You can add attributes and methods to it, and even have it implement interfaces, just like any other object. And that's precisely the power of Java enums!

Let's take our example a step further 🌱

First, a small improvement to make the display more pleasant. Currently, the start() method displays the difficulty exactly as it is defined in the enumeration, i.e. in uppercase. To make this more user-friendly, we can add a description attribute and override the toString() method as we would for any object.

Here's how to do it:

public enum Difficulty { 
    EASY(“easy, for beginners”),
    MEDIUM(“medium, a balanced challenge”),
    HARD(“difficult, for experts”); 

    private final String description; 

    Difficulty(String description){ 
        this.description = description; 
    } 

    @Override 
    public String toString(){ 
        return description; 
    } 
}

From now on, the start() method of the Game class :

public void start(){
    System.out.println("Start game in ” + difficulty mode);
}

We will display a much more user-friendly and readable text, without having to touch the Game class itself!

Why stop there?

Let's continue improving our game! Let's add a method to our Game class to calculate the final score.

Here's what it might look like:

public class Game { 
    private Difficulty difficulty; 
    private int totalScore; 

    public Game(Difficulty difficulty){ t
        his.difficulty = difficulty; 
        this.totalScore = 0; 
    } 

    public void addScore(int score){ 
        switch(difficulty){ 
            case EASY: 
                score \= 1; 
                break; 
            case MEDIUM: 
                score \= 2; 
                break; 
            case HARD: 
                score *= 3; 
                break; 
            default: 
                score = score; 
        } 

        this.totalScore += score; 
    } 

    public void start(){ 
        System.out.println("Start game in ” + difficulty mode); 
    } 
 }

Here, we've added the addScore method, which increments the score according to difficulty. If the difficulty is EASY, the score is multiplied by 1, if it's MEDIUM by 2, and so on. This code is simple and fairly common.

But... what if we added a new difficulty, for example VERY_HARD? 😬 The problem with this approach is that if we forget to add the new difficulty in the switch, it could cause an error.

So how can we avoid this?

Let's evolve our code to make it more robust! 🦋

Step 1️⃣: Add an attribute to the enumeration

We're going to add a scalingFactor attribute to our enumeration to define the multiplication coefficient specific to each difficulty. Here's how our enumeration is updated:

public enum Difficulty {
  EASY("facile, pour les débutant", 1), 
  MEDIUM("moyen, un défi équilibré", 2), 
  HARD("difficile, pour les experts", 3),
  VERY_HARD("très difficile, pour les dieux seulement", 4);

  private final String description;
  private final int scalingFactor;

  Difficulty(String description, int scalingFactor){
    this.description = description;
    this.scalingFactor = scalingFactor;
  }

  @Override
  public String toString(){
    return description;
  }

  public int getScalingFactor(){
    return scalingFactor;
  }
}

Step 2️⃣: Simplifying the addScore method

Now we can simplify the addScore method in Game by directly using the scalingFactor of the enumeration:

public void addScore(int score){
    this.totalScore += score * difficulty.getScalingFactor();
}

By adding the scalingFactor directly to the enumeration, the code becomes much more readable and, above all, more secure. It's no longer possible to forget to add a new difficulty to a switch! 👏

The grand finale 🎉

One last little problem persists in our example... Let's be a little self-critical! What if we added a VERY_EASY difficulty, and, oh woe, our beloved Product Owner decides that this time we won't apply a coefficient, but simply subtract 10 points? 🥲

Well, the example is a bit far-fetched, but let's see how we can solve this intelligently!

Step 1️⃣: Create an interface

We start by defining an interface that will be used to calculate the score.

interface Scorable {
  int calculateScore(int baseScore);
}

Step 2️⃣: Implementing the interface in the enumeration

Now we can modify our Difficulty enumeration so that it implements the Scorable interface and each difficulty has its own scoring logic. Here's how it looks:

public enum Difficulty implements Scorable {
  VERY_EASY("très facile, pour les enfants"){
    @Override
    public int calculateScore(int baseScore){
      return baseScore - 10;
    }
  },
  EASY("facile, pour les débutant"){
    @Override
    public int calculateScore(int baseScore){
      return baseScore * 1;
    }
  }, 
  MEDIUM("moyen, un défi équilibré"){
    @Override
    public int calculateScore(int baseScore){
      return baseScore * 2;
    }
  }, 
  HARD("difficile, pour les experts"){
    @Override
    public int calculateScore(int baseScore){
      return baseScore * 3;
    }
  },
  VERY_HARD("très difficile, pour les dieux seulement"){
    @Override
    public int calculateScore(int baseScore){
      return baseScore * 4;
    }
  };

  private final String description;

  Difficulty(String description){
    this.description = description;
  }

  @Override
  public String toString(){
    return description;
  }
}

Step 3️⃣: Modifying the addScore method

That's it! We can now modify the addScore method in the Game class to use this calculation logic in the enumeration:

public void addScore(int score){
    this.totalScore += difficulty.calculateScore(score);
}

Tadam! 🎩✨

It's simple, but essentially, the responsibility for the calculation is now transferred to the enumeration itself. That's the power of Java: enumerations that aren't just types, but real objects capable of handling complex logic! 💪

Special thanks

Many thanks to Joshua Bloch for his book Effective Java (third edition), and particularly chapter 6, which inspired me to explore enumerations in Java. His way of presenting good practices and subtleties of the language helped me deepen and structure this article. 🙏

If you liked this article, please consider supporting me and
buy me a coffee