The Brighter Side of Switch Statements — Analysis of the Attributes of an Object
In the previous article, I talked about the worst possible case of misusing switch statements and the way to fix it.
This time, I'm going to explore the case that doesn't get much attention — analysis of the attributes of an object.
- There are two ways of dealing with
ifstatements on attributes of an object.
- If an attribute is read-only, you can replace its containing object with a hierarchy of classes.
- If the value of an attribute changes during runtime, you can refactor explicit case analysis of such an attribute using State pattern.
- Both techniques require creating additional classes, which can add extra complexity. Only apply them when the benefits outweigh the costs.
The two types of attributes
Attributes describe objects. An attribute that affects an object's behavior can serve two purposes:
- Carry a static characteristic of the object. These attributes are read-only: they don't change their values throughout the lifetime of an object.
- Represent the state of the object. The values of these attributes may change during runtime.
This distinction is essential as you'd want to handle an explicit case analysis of each of these types of attributes differently. Let's explore both cases in detail.
Switch statements on read-only attributes
The type of a car, the kind of an animal — these are the attributes that affect the behavior of an object but whose values don't change throughout the lifetime of the object.
if statements on these attributes is not as dangerous as performing an explicit case analysis of object types. Yet, refactoring an object that contains such code into an inheritance hierarchy is a cleaner solution.
Let's take a look at an example. Imagine that you want to help feline nutritionists calculate how much and how often a cat should be fed.
The amount of food a cat needs to eat daily is called Daily Energy Requirements (DER), and is calculated using the following formula1:
To calculate DER, you multiply Resting Energy Requirements (RER) by a special Factor. RER is based on the cat's body weight, and the Factor accounts for the cat's life stage and condition.
Also, kittens should be fed four times a day, and adult cats should only eat twice a day.
You program these requirements in the
The class accepts the cat's weight, condition, and an indicator of whether it is a kitten or an adult, and stores them in read-only fields. Two public properties,
DailyEnergyRequirements, calculate the feeding frequency and amount using the above formulas.
There are two instances of explicit case analysis of the values of attributes in this class. The
Factor property contains an
if statement on the
_isKitten field and a
switch statement on the
DailyMeals also includes an
if statement on the
Let's get rid of both cases of ECA by refactoring
CatDiet into a hierarchy of classes.
You start with extracting a base class. As implementation of the
RestingEnergyRequirements properties are invariant, i.e., they are the same for different cats, you pull these properties into the new base class. Then, you declare
DailyMeals as abstract properties, letting subclasses provide their implementation.
Now you can start implementing diets. You create the one for kittens first:
As you can see, the
KittenDiet class is straightforward. It inherits from
CatDietBase, and overrides its values for the
Next, you implement diets for adult cats. You notice that adult cats in any condition should eat twice a day. You decide to create a base class to capture this:
You then proceed to create diets for every possible cat condition:
You have distributed the responsibilities of a class that knows too much between the set of small, specific classes. Each of these classes now knows only what it should — the details of a diet it implements.
You may ask: "Haven't we moved the case analysis somewhere to the outer scope? Someone still has to pick the right diet plan for each cat, right?" Absolutely. This approach requires instantiating the correct class based on specific criteria, which is a responsibility per se. This responsibility, however, doesn't naturally belong to the
CatDiet class. The creation of the right diet should be performed by a factory method of some other class. For example, you can decide to create a Nutritionist or a Vet class to choose a diet for a cat. It is perfectly normal to have
if statements that create an object of the correct type based on some criteria. The only rule here is to have at most one such construct per selection — this is something Robert Martin calls a "One Switch" rule2.
As you may have also noticed, you got rid of two instances of case analysis — in the
DailyMeals properties — by creating one set of polymorphic classes. You may find this design cleaner, and it would also allow you to add new diet plans without changing a one-for-all God object.
Switch statements on the state of an object
The mood of a person, the status of an order — these are the examples of state. Such attributes affect the behavior of an object, and their values may change during the object's lifetime.
Don't turn an object that performs explicit case analysis of its state into a hierarchy of classes.
Why is it okay to do so for read-only attributes, but not when an attribute can change its value? The main reason for this is that the object will have to change its type every time its state changes. The static semantics of inheritance is not well suited to represent the dynamic nature of state3.
To demonstrate this, let's interact with a cat. Your typical cat would normally be in one of three states: playful, sleepy, and hungry.
A cat can jump between these states like this:
A playful cat is rested and full. If you play with it, it becomes sleepy. After a sleepy cat takes a nap, it becomes hungry. A hungry cat is rested but wants to eat. If you feed it, it becomes playful again — the never-ending cycle.
You model such a cat in the following class:
The cat's state is represented by its
_state field, which has a default value of Playful. The value of this field affects what the cat will do next. The
WantsTo property performs analysis of the cat's state and returns a string indicating what the cat wants to do. Methods
Nap change the cat's state according to the rules discussed above.
This is how you'd interact with this cat:
Pretty straightforward. The cat knows what it wants, and you can change its state by calling methods on its instance.
But after reading the previous section about read-only attributes, you decide to get rid of explicit case analysis in the
To do that, you decide to model a cat in each of its states — Playful, Sleepy, and Hungry — as separate classes. You come up with the following design:
ICat interface defines the ways to interact with your cat. The
HungryCat classes all implement that interface. Neither of the classes contains the
_state field; instead, it's the types of the classes that carry the state information. Each of these classes is now responsible for telling us what the cat wants. Each of them also decides when to transition to the next state.
But the issue arises when you try to interact with the new cat object.
Now, each time you play with or feed a cat, or when it sleeps, a new cat instance is created, and the old one is discarded. Abandoning cats like this is not nice. It’s also not obvious to the users of such a class, that each time they call a method on it, it creates a new object of a different type. Users of
Cat will be affected by this refactoring, and you will have to change them.
Therefore, when an object performs an explicit case analysis of its state, it is not the best solution to turn this object into a hierarchy of classes.
So is there an alternative solution to dealing with an object's state? Yes, and it's called — surprise — State pattern. State pattern allows an object to change its behavior when its state changes. This pattern models all possible states of an object as separate classes, without exposing them to the users of the object and changing the object's type. It allows for changing these classes internally.
You apply State pattern to the original
You then model each of the possible states in a separate class:
ProperlyStatefulCat now delegates the calls to its
Feed methods and the
WantsTo property to its internal state objects. Each of these objects isolates a single cat's state, is responsible for the cat's behavior in this state, and can decide which state to transition to from this one. Such objects are easy to understand and to test.
What is also important is that the clients of
ProperlyStatefulCat won't have to change at all. They will be able to treat
ProperlyStatefulCat as the original
Should I always replace my switch statements?
Now, when you know how to deal with an explicit case analysis of both types of object attributes, you may ask: "Should I always try to get rid of switch and if statements?" No. Every refactoring should always be a result of a calculated decision. Sometimes the additional overhead is just not worth it.
Both techniques add to the complexity of the codebase as they force you to create and support a new inheritance hierarchy. You have to create a class to describe each value of a read-only attribute or each possible state.
This additional complexity may not always be worth it, especially when a switch statement is simple and stable. Always weigh the benefits and costs of refactoring before applying it to your code.
Explicit case analysis of the attributes of an object poses less risk than the one that's performed on object's types. Despite this, refactoring such an ECA out of your code often makes it cleaner and easier to maintain.
There are two ways of dealing with such instances of explicit case analysis. If the attribute is read-only, then the containing object can be turned into a hierarchy of classes. If the attribute changes its value throughout the lifetime of an object, the switch or if statements on such an attribute can be modeled with the help of a State pattern.
While both techniques make your code cleaner, they may also add complexity to it as they require creating an additional hierarchy of classes. Always consider this complexity when deciding whether to refactor an ECA out of your code.