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.

Key Takeaways

  • There are two ways of dealing with switch or if statements 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:

  1. Carry a static characteristic of the object. These attributes are read-only: they don't change their values throughout the lifetime of an object.
  2. 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.

Having switch or 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:

Daily Energy Requirements formula
Daily Energy Requirements formula

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 CatDiet class:

public class CatDiet
{
    private readonly double _weightInKg;
    private readonly bool _isKitten;
    private readonly CatCondition _condition;

    public CatDiet(double weightInKg, bool isKitten, CatCondition condition)
    {
        _weightInKg = weightInKg;
        _isKitten = isKitten;
        _condition = condition;
    }

    public int DailyEnergyRequirements => (int)(RestingEnergyRequirements * Factor);

    public int DailyMeals
    {
        get
        {
            if (_isKitten)
                return 4;

            return 2;
        }
    }

    private double RestingEnergyRequirements => Math.Pow(_weightInKg, 0.75) * 70;

    private double Factor
    {
        get
        {
            if (_isKitten)
                return 2.5;

            switch (_condition)
            {
                case CatCondition.HealthyIntact:
                    return 1.4;

                case CatCondition.HealthyNeutered:
                    return 1.2;

                case CatCondition.ObeseProne:
                    return 1;

                case CatCondition.Overweight:
                    return 0.8;

                default:
                    throw new InvalidOperationException(
                        $"Unknown cat condition: {_condition}");
            }
        }
    }
}

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, DailyMeals and 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 _condition field. DailyMeals also includes an if statement on the _isKitten field.

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 DailyEnergyRequirements and 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 Factor and DailyMeals as abstract properties, letting subclasses provide their implementation.

public abstract class CatDietBase
{
    private readonly double _weightInKg;

    protected CatDietBase(double weightInKg)
    {
        _weightInKg = weightInKg;
    }

    public int DailyEnergyRequirements => 
        (int)(RestingEnergyRequirements * Factor);

    public abstract int DailyMeals { get; }

    protected abstract double Factor { get; }

    private double RestingEnergyRequirements => Math.Pow(_weightInKg, 0.75) * 70;
}

Now you can start implementing diets. You create the one for kittens first:

public class KittenDiet : CatDietBase
{
    public KittenDiet(double weightInKg) : base(weightInKg) { }

    public override int DailyMeals => 4;

    protected override double Factor => 2.5;
}

As you can see, the KittenDiet class is straightforward. It inherits from CatDietBase, and overrides its values for the DailyMeals and Factor properties. 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:

public abstract class AdultCatDietBase : CatDietBase
{
    protected AdultCatDietBase(double weightInKg) : base(weightInKg) { }

    public override int DailyMeals => 2;
}

You then proceed to create diets for every possible cat condition:

public class IntactAdultCatDiet : AdultCatDietBase
{
    public IntactAdultCatDiet(double weightInKg) : base(weightInKg) { }

    protected override double Factor => 1.4;
}

public class NeuteredAdultCatDiet : AdultCatDietBase
{
    public NeuteredAdultCatDiet(double weightInKg) : base(weightInKg) { }

    protected override double Factor => 1.2;
}

public class ObeseProneAdultCatDiet : AdultCatDietBase
{
    public ObeseProneAdultCatDiet(double weightInKg) : base(weightInKg) { }

    protected override double Factor => 1;
}

public class OverweightAdultCatDiet : AdultCatDietBase
{
    public OverweightAdultCatDiet(double weightInKg) : base(weightInKg) { }

    protected override double Factor => 0.8;
}

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 switch or 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 Factor and 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:

Cat states and transitions between them
Cat states and transitions between them

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:

public class Cat
{
    public enum CatState
    {
        Playful,
        Hungry,
        Sleepy
    }

    private CatState _state = CatState.Playful;

    public string WantsTo
    {
        get
        {
            switch (_state)
            {
                case CatState.Playful:
                    return "Play!";
                case CatState.Sleepy:
                    return "Take a nap";
                case CatState.Hungry:
                    return "Nibble on some food";
            }
            
            throw new InvalidOperationException(
                "This cat doesn't know what it wants.");
        }
    }

    public void Play()
    {
        if (_state == CatState.Playful)
        {
            _state = CatState.Sleepy;
        }
    }

    public void Feed()
    {
        if (_state == CatState.Hungry)
        {
            _state = CatState.Playful;
        }
    }

    public void Nap()
    {
        if (_state == CatState.Sleepy)
        {
            _state = CatState.Hungry;
        }
    }
}

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 Play, Feed, and Nap change the cat's state according to the rules discussed above.

This is how you'd interact with this cat:

 var cat = new Cat();

Console.WriteLine(cat.WantsTo); // Play!

cat.Play();

Console.WriteLine(cat.WantsTo); // Take a nap

cat.Nap();

Console.WriteLine(cat.WantsTo); // Nibble on some food

cat.Feed();

Console.WriteLine(cat.WantsTo); // Play!

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 Cat class.

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:

public interface ICat
{
    string WantsTo { get; }

    ICat Play();
    ICat Feed();
    ICat Nap();
}

public class PlayfulCat : ICat
{
    public string WantsTo => "Play!";

    public ICat Play()
    {
        return new SleepyCat();
    }

    public ICat Feed()
    {
        return this;
    }
    public ICat Nap()
    {
        return this;
    }
}

public class SleepyCat : ICat
{
    public string WantsTo => "Take a nap";

    public ICat Play()
    {
        return this;
    }

    public ICat Feed()
    {
        return this;
    }

    public ICat Nap()
    {
        return new HungryCat();
    }
}

public class HungryCat : ICat
{
    public string WantsTo => "Nibble on some food";

    public ICat Play()
    {
        return this;
    }

    public ICat Feed()
    {
        return new PlayfulCat();
    }

    public ICat Nap()
    {
        return this;
    }
}

The new ICat interface defines the ways to interact with your cat. The PlayfulCat, SleepyCat, and 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.

var playfulCat = new PlayfulCat();

Console.WriteLine(playfulCat.WantsTo); // Play!

var sleepyCat = playfulCat.Play();

Console.WriteLine(sleepyCat.WantsTo); // Take a nap

var hungryCat = sleepyCat.Nap();

Console.WriteLine(hungryCat.WantsTo); // Nibble on some food

var anotherPlayfulCat = hungryCat.Feed();

Console.WriteLine(anotherPlayfulCat.WantsTo); // Play!

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 Cat class:

public class ProperlyStatefulCat
{
    private CatState _state;

    public ProperlyStatefulCat()
    {
        SetState(new PlayfulCatState(this));
    }

    public string WantsTo => _state.WantsTo;

    public void Play()
    {
        _state.Play();
    }

    public void Nap()
    {
        _state.Nap();
    }

    public void Feed()
    {
        _state.Feed();
    }

    internal void SetState(CatState newState)
    {
        _state = newState;
    }
}

You then model each of the possible states in a separate class:

public abstract class CatState
{
    protected readonly ProperlyStatefulCat Context;

    protected CatState(ProperlyStatefulCat context)
    {
        Context = context;
    }

    public abstract string WantsTo { get; }

    public abstract void Play();
    public abstract void Nap();
    public abstract void Feed();    
}

public class PlayfulCatState : CatState
{
    public PlayfulCatState(ProperlyStatefulCat context) : base(context) { }

    public override string WantsTo => "Play!";

    public override void Play()
    {
        Context.SetState(new SleepyCatState(Context));
    }

    public override void Nap() { }

    public override void Feed() { }    
}

public class SleepyCatState : CatState
{
    public SleepyCatState(ProperlyStatefulCat context) : base(context) { }

    public override string WantsTo => "Take a nap";

    public override void Play() { }

    public override void Nap()
    {
        Context.SetState(new HungryCatState(Context));
    }

    public override void Feed() { }
}

public class HungryCatState : CatState
{
    public HungryCatState(ProperlyStatefulCat context) : base(context) { }
    
    public override string WantsTo => "Nibble on some food";

    public override void Play() { }

    public override void Nap() { }

    public override void Feed()
    {
        Context.SetState(new PlayfulCatState(Context));
    }    
}

ProperlyStatefulCat now delegates the calls to its Play, Nap, and 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 Cat class.

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.

Summary

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.

References


  1. Thatcher, C., Hand, M. S., & Remillard, R. (2010). Small animal clinical nutrition: An iterative process. Small Animal Clinical Nutrition, 3–21. ResearchGate
  2. Martin, R. C. (2008). Clean Code: A Handbook of Agile Software Craftsmanship (1st ed.). Pearson. Goodreads
  3. Riel, A. J. (1996). Object-Oriented Design Heuristics (1st ed.). Addison-Wesley Professional. Goodreads
© 2020 Dmitry Khmara