In the previous part, we had an overview of why switch
-case
could be hard to maintain. This part will focus on the simplest scenario: when type-code only affects data, not behavior. We'll do this by modeling a pizzeria.
Initial Solution
In our pizzeria, when customers place an order, they can choose the size and kind of toppings they want. The price of the pizza only depends on its size. For the sake of simplicity (and because our slogan is "You dream it, we make it"), we don't want to limit what kind of toppings customers can choose. Therefore, we'll model the toppings as a list of strings.
So our pizza class will look like the following:
class Pizza {
static final int SIZE_SMALL = 0;
static final int SIZE_NORMAL = 1;
static final int SIZE_LARGE = 2;
List<String> toppings;
int size;
Pizza(List<String> toppings, int size) {
this.toppings = toppings;
this.size = size;
}
int price() {
switch (size) {
case SIZE_SMALL:
return 2;
case SIZE_NORMAL:
return 3;
case SIZE_LARGE:
return 4;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
}
The use of this class is pretty straightforward. Assuming that we want to calculate the price of our new favorite normal-size coconut-catnip pizza1, we would write:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_NORMAL);
int price = pizza.price();
Until this, we always gave five pizzas to our delivery guy at every turn he made because he couldn't carry more large pizzas. But what about if we get an order of ten small pizzas? It would be a shame to deliver it in two rounds. After all, two small pizzas weigh less than a large one.
So to optimize our process, we want to calculate the weight of each pizza. Again, for simplicity's sake, we assume a pizza's weight depends only on size. So we add this method to our class:
double weight() {
switch (size) {
case SIZE_SMALL:
return 0.5;
case SIZE_NORMAL:
return 0.75;
case SIZE_LARGE:
return 1.25;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
It was as easy as copying the previous method and changing the result type, the method name, and the return values.
What would happen if we needed one more method, depending on the size? Let's say a toString()
method. We would make new copies again and again. Those who don't like this much code duplication, raise your hands! Well, if you didn't raise your hand, shame on you! But don't worry; I'll convince you it isn't good.
Extending Size Options
Let's say our pizzeria is so popular we want to present monster-size pizzas. It's pretty simple; we should create a new constant for the new size and a new case
clause for each switch
statement. Our new code:
class Pizza {
static final int SIZE_SMALL = 0;
static final int SIZE_NORMAL = 1;
static final int SIZE_LARGE = 2;
static final int SIZE_MONSTER = 3;
List<String> toppings;
int size;
Pizza(List<String> toppings, int size) {
this.toppings = toppings;
this.size = size;
}
int price() {
switch (size) {
case SIZE_SMALL:
return 2;
case SIZE_NORMAL:
return 3;
case SIZE_LARGE:
return 4;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
double weight() {
switch (size) {
case SIZE_SMALL:
return 0.5;
case SIZE_NORMAL:
return 0.75;
case SIZE_LARGE:
return 1.25;
case SIZE_MONSTER:
return 2;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
}
We can already feel a smell. We had to modify a method to extend its capabilities. That violates the Open/Closed Principle.
Unfortunately, we have bigger issues when we want to use it:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER);
int price = pizza.price();
We get an IllegalStateException
. Why?
Of course, we just forgot to insert the new case
statement into our price()
method. You probably noticed it already, but it's an elementary example. If you had a more complex situation, it's much harder to remember every switch
-case
s to change. I almost don't dare to mention how much harder it is if your switch
-case
s are not in a simple class but scattered all over your codebase2.
Still not convinced? Don't worry; there's more.
Let's forget for a minute that we implemented this class. Let's look at our only constructor's signature:
Pizza(List<String>, int)
What does it tell us? Not so much. And of course, the class doesn't have any JavaDoc. No worries, we will figure out how to use it. Our first try:
Pizza pizza = new Pizza(null, 100);
int price = pizza.price();
Aaaand we get an IllegalStateException
in the second row. Pretty sure it's because we used null as the first argument, right?
Of course, we know that this isn't the problem: we provided an invalid size value. Yes, it would be better if we did some argument checking in the constructor, but that's not the point. If somehow we modified our size field internally and it ended up invalid, the problem would be the same. It doesn't matter how hard we try to avoid these scenarios; the possibility remains.
It would be much better if we resolved the root cause of the problem and our field couldn't have an invalid value at all. More accurately, if the value validation would happen at compile time and not runtime.
What? Validation at compile time? Of course! We call it static typing.
Introducing an Enum
Let's try a new approach. Instead of int
constants, we declare a Size
enum:
class Pizza {
enum Size {
SMALL, NORMAL, LARGE, MONSTER
}
List<String> toppings;
Size size;
Pizza(List<String> toppings, Size size) {
this.toppings = toppings;
this.size = size;
}
int price() {
switch (size) {
case Size.SMALL:
return 2;
case Size.NORMAL:
return 3;
case Size.LARGE:
return 4;
case Size.MONSTER:
return 6;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
double weight() {
switch (size) {
case Size.SMALL:
return 0.5;
case Size.NORMAL:
return 0.75;
case Size.LARGE:
return 1.25;
case Size.MONSTER:
return 2;
default:
throw new IllegalStateException("The field 'size' has an invalid value");
}
}
}
Instantiation:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);
Because of static typing in Java, the size field will always have a valid value (except when we pass null
, but let's leave that problem to another day). But the switch
-case
is still there. Every time we create a new size, all switch
-case
statements we use with size as a condition must be updated. We will probably forget one or two.
Getting Rid of Switch-Case
Let's modify the Size
enum to a class. Classes can have fields, so we don't have to use the switch
-case
statement anymore:
class Pizza {
static class Size {
int price;
double weight;
private Size(int price, double weight) {
this.price = price;
this.weight = weight;
}
int getPrice() {
return price;
}
int getWeight() {
return weight;
}
}
static final Size SIZE_SMALL = new Size(2, 0.5);
static final Size SIZE_NORMAL = new Size(3, 0.75);
static final Size SIZE_LARGE = new Size(4, 1.25);
static final Size SIZE_MONSTER = new Size(6, 2);
List<String> toppings;
Size size;
Pizza(List<String> toppings, Size size) {
this.toppings = toppings;
this.size = size;
}
int price() {
return size.getPrice();
}
double weight() {
return size.getWeight();
}
}
Using it:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.SIZE_MONSTER);
Why is it better? We have multiple reasons:
- We can't pass an invalid
Size
. TheSize
constructor is private, which means it can't be called from outside. Only predefined size constants can be used. - If we want to support a new size, we create a new instance. The compiler will raise an error if we forget to set a required value since the constructor lacks an argument.
- Because of the absence of the
switch
-case
statement, our code is much cleaner. - If we want the pizza to have a new property that depends on the size, we only have to add a new field to the
Size
class, make it mandatory in the constructor and write a new getter. The compiler will mark all errors; we are good to go after fixing them.
So this is the ultimate solution? Well, almost. The enum way was a bit cleaner because it declared all its constants. Let's see how we can return to them.
The Revenge of the Enums
First, let's refactor our code and move the constant declarations into the Size
class:
class Pizza {
static class Size {
static final Size SMALL = new Size(2, 0.5);
static final Size NORMAL = new Size(3, 0.75);
static final Size LARGE = new Size(4, 1.25);
static final Size MONSTER = new Size(6, 2);
int price;
double weight;
private Size(int price, double weight) {
this.price = price;
this.weight = weight;
}
int getPrice() {
return price;
}
int getWeight() {
return weight;
}
}
List<String> toppings;
Size size;
Pizza(List<String> toppings, Size size) {
this.toppings = toppings;
this.size = size;
}
int price() {
return size.getPrice();
}
double weight() {
return size.getWeight();
}
}
Usage:
Pizza pizza = new Pizza(List.of("coconut", "catnip"), Pizza.Size.MONSTER);
But this way, we can create new Size
instances in the Pizza
class too, which can be confusing. We could move the Size
class to its file. Because of the private constructor, it couldn't be called from outside.
There is a different solution, which involves enums again. But we already covered that part; what new can enums possibly provide? Let's examine what enums are and how they work in Java.
Understanding Enums
In C (and most languages) enum is a convenient way of declaring automatically incrementing int
constants. If we wanted, we could set the value of some of the constants manually or even repeat the values:
typedef enum pizza_size { SMALL, NORMAL, LARGE = 3, EXTRA_LARGE, MONSTER = EXTRA_LARGE };
enum pizza_size size = NORMAL;
This way, SMALL
, NORMAL
, LARGE
, and MONSTER
will have the values 0
, 1
, 3
, 4
, and 4
, respectively. But basically, those are just int
constants. Enum variables can still have any value. C++ and C# also use a similar approach.
But not Java. Of course, you could say Java always chooses its path. Don't judge quickly because the Java enum is very cool.
Java enums are similar to the Size
class we just implemented. They are classes (static ones, if declared as inner types) with a private constructor. The only instances they can have are the ones we declared.
There are a couple of advantages of this design:
- We can compare enum variables with
==
, likeint
s. If the contained constants differ, we compare the reference of two different instances, which returns false. If they have the same value, they will be represented by the same object, so the references will be the same. Hence, the comparison will return true. It means convenient, fast, and readable comparison. - Because they are classes, they can have properties, methods, and constructors. The only limitation we have is the visibility of the constructors: they are always private.
- We cannot accidentally instantiate them (because of the private constructor). Instantiation always happens as a new constant declaration.
- They are always ordered, which can be rather valuable sometimes. The order is the declaration order, and we can query the (0-based) ordinal with the implicit
int ordinal()
method. - Similarly, we can access the declared name using the
String name()
method or convert the name to a constant with thestatic T valueOf(String)
method. - Instances are always
public static final
, and constructors areprivate
. We don't have to specify these keywords (in fact, we get an error if we use different visibility). - Because of the private constructor, we cannot extend enums (would it even make sense?).
Applying Smart Enums
Because of the above, we can rewrite the Size
class to an enum:
class Pizza {
enum Size {
SMALL(2, 0.5),
NORMAL(3, 0.75),
LARGE(4, 1.25),
MONSTER(6, 2);
int price;
double weight;
Size(int price, double weight) {
this.price = price;
this.weight = weight;
}
int getPrice() {
return price;
}
int getWeight() {
return weight;
}
}
List<String> toppings;
Size size;
Pizza(List<String> toppings, Size size) {
this.toppings = toppings;
this.size = size;
}
int price() {
return size.getPrice();
}
double weight() {
return size.getWeight();
}
}
It's very similar to our previous example but with less boilerplate code. Sweet.
Dealing With User Input
So far, we have determined values based on size. But that size comes from the user. If we have a web application, it's most probably comes from a dropdown or a radio button:
<select name="size">
<option value="SMALL">Small</option>
<option value="MEDIUM">Medium</option>
<option value="LARGE">Large</option>
<option value="MONSTER">Monster</option>
</select>
And here, sizes are strings. How can we convert those to Size
instances?
In Java, it's pretty straightforward with the enum solution:
String sizeName;
Pizza.Size size = Pizza.Size.valueOf(sizeName);
But what if we need to work with a different language, let's say JavaScript? We could always introduce a switch
-case
, but we know better now.
A better solution is to use lookup tables. Every language has an implementation for it with possibly different names: map, dictionary, table; you name it.
For example, in JavaScript, the simplest solution is to use an object:
const sizes = {
SMALL: Pizza.Size.SMALL,
MEDIUM: Pizza.Size.MEDIUM,
LARGE: Pizza.Size.LARGE,
MONSTER: Pizza.Size.MONSTER,
};
The usage is straightforward:
let sizeName;
let size = sizes[sizeName];
If we want, we can even inline the constant declarations:
const sizes = {
SMALL: new Pizza.Size(2, 0.5),
NORMAL: new Pizza.Size(3, 0.75),
LARGE: new Pizza.Size(4, 1.25),
MONSTER: new Pizza.Size(6, 2),
};
Since lookup tables are more flexible in certain circumstances, this is a good solution, too.
Conclusion
In this post, our type code was the pizza size. We can use the above solutions effectively if:
- A handful of attributes' values depend on the type code
- The methods' behavior is always the same (They don't do different things if the type code changes. Returning different values is a single behavior. Eating a pizza or wearing it as a hat are not.)
We introduced a few possible ways to refactor the code and get rid of switch
-case
:
- Introducing a class with a fixed set of constant instances3
- In Java, using enums to achieve the same
- Using lookup tables
If the behavior of our class also depends on the type code, we have to use different approaches. We will cover them in the following parts.
-
Yummy ↩
-
For a deeper explanation, take a look at the first part of the series ↩
-
This refactoring even has a name: Replace Type Code With Class ↩
Top comments (0)