How to Leverage Polymorphism at Runtime
One of the benefits of object-oriented design is the ability for objects to share some behaviors while simultaneously differing in others. Typically, this is achieved through inheritance — when many subclasses inherit properties from a parent class but can optionally override certain behaviors as needed. This is a very useful and common design pattern; however, there are situations when polymorphism through inheritance is inappropriate. Consider, for example, when you only need a single behavior to change but otherwise want an object to stay the same. Or, when you want an object to shift its behavior at runtime based on some external (but unpredictable) factor. In such cases, an inheritance scheme is likely to cause unnecessarily bloated code that is difficult to maintain (particularly as the number of subtypes increases). However, there is a better approach: the strategy pattern.
Polymorphism through Strategies
The strategy pattern, also known as the policy pattern, is a behavioral design pattern that lets an object execute some algorithm (strategy) based on external context provided at runtime. This pattern is particularly useful when you have an object that needs to be able to execute a single behavior in different ways at different times. By using the strategy pattern, you can define a set of algorithms that can be dynamically provided to a particular object if/when they are needed. This pattern has a number of benefits, including: encapsulation of particular algorithms in their own classes; isolation of knowledge about how algorithms are implemented; and, code that is more flexible, mobile, and maintainable. To the last point, you may note that these are the same attributes that result from code that follows the Open/Closed Principle (OCP) and indeed, the strategy pattern is an excellent way to write OCP-adherent code.
When implementing the strategy pattern, you need three main elements:
- A client, which is aware of the existence of some abstract strategy but may not know what that strategy does or how it does it.
- A set of strategies that the client can use if/when provided with one of them. These may come in the form of first-class functions, objects, classes, or some other data structure.
- Optional context that the client can provide to its current strategy to use in execution.
The classic way to implement strategies is with interfaces. In this case, a client has an internal pointer to some abstract strategy interface, which is then pointed to a concrete strategy implementation via dependency injection (that is, during construction or with a setter at runtime). Thereafter, the client may use the provided strategy to carry out some work, all without knowing (or caring) what the strategy actually does.
Although interfaces are the classic way to implement the strategy pattern, a similar effect may be achieved in languages that do not have interfaces. What is important is that the client is aware of some abstract strategy and is able to execute that strategy without knowledge of its inner workings.
Polymorphism through Pure Inheritance
Before we look at how to use the strategy pattern, let’s look at a few examples that use other approaches to polymorphism. Consider the following snippet, which uses pure inheritance to define different types of runners in a race.
Here we have a Runner parent class and three subclasses that inherit from it: Jogger; Sprinter; and, Marathoner. Each subclass overrides the parent class’ run method with its own implementation. Subsequently, when we instantiate runners of each type and pass them to a new Race object, we can see that each uses its own run behavior when the race starts.
The above snippet works, but it has a few issues. First, it’s a bit bloated in that we have created many subclasses for the sole purpose of changing a single behavior. If the runners varied in more ways this might be worthwhile; however, in this simple program these classes are probably unnecessary. Another, perhaps more noteworthy, problem is that our runners are set permanently to a particular subclass. If, for example, alice_ruby wanted to run like a Marathoner, there is no good way to help her do that without completely changing her class.
If the ability to change behavior dynamically is desirable, then let’s look at one possible solution.
Naive Strategies and Control Flow
In an attempt to improve upon our earlier implementation of the runner program, below we have a refactored version that does not make use of inheritance.
In this version, we have a single Runner class with a constructor that accepts a new argument: a strategy. In this case, our strategy is just a symbol that we then use in a refactored run method. The new run method contains a case statement that checks on a given instance’s strategy attribute and executes some bit of code accordingly. Indeed, when we start our race this time we get the same output as before.
In some ways, this version of the program is an improvement over our earlier version, though in others it is a step back. On the upside, we may now change a given runner’s naive strategy by using a setter to assign it a new symbol, as in alice_ruby.strategy = :marathon. In this fashion, we’re able to effectively change the behavior of a particular object without changing its class. However, the long case statement in the Runner#run method is problematic. Control flow of this sort is a clear violation of the OCP because we can’t extend the run method without opening it for modification. So what do we do if we want the ability to dynamically change strategies while still adhering to the OCP?
The Strategy Pattern in Action
In our final version of this program we’re finally going to use the strategy pattern. In this case, we define a set of strategies in their own classes and then provide those classes to our runners via dependency injection.
As in our second snippet, our Runner class accepts a strategy argument at construction and also has a setter to change that strategy if desired. However, instead of passing a simple symbol to Runner to use in a control structure, we instead pass it one of several strategy classes defined in the RunStrategies module. Each of these strategies has a run method, meaning that our client objects can execute any of them with the same code. Since Ruby doesn’t have formal interfaces, we provide our own simple error checking mechanism by having each strategy inherit from a RunStrategyInterface class that raises an error if its run class method is called. (If a strategy fails to implement a version of this method on its own, then the RunStrategyInterface run class method would execute and raise an error, which we could then test for prior to deployment.)
When this program runs, each runner is provided with the desired strategy at instantiation. During program execution, the runners are then able to use these strategies as needed, passing their own name as context to the strategy. And if we wanted to update a particular runner’s strategy mid-way through the program, we could easily do so with a setter method, as in alice_ruby.strategy = RunStrategies::Marathon.
By using the strategy pattern, we have given our program the ability to dynamically change algorithms at runtime based on context. Further, our Runner#run method is OCP-consistent because we can create new behaviors by simply implementing new strategies (rather than changing a control structure in the run method.)
The strategy pattern is a behavioral design pattern used to dynamically choose and execute algorithms at runtime. This pattern is particularly useful when a given class needs to execute the same behavior in different ways at different times. The strategy pattern allows a program to use polymorphism without a bloated class inheritance structure and still remain consistent with the Open/Closed Principle. Classically, the strategy pattern is implemented using interface abstractions, which let us create multiple strategy implementations to be passed to client objects as needed. However, it is possible to use the strategy pattern in languages without formal interfaces by following a set of conventions and implementing custom error checking.
That’s all for our discussion of the strategy pattern! Stay tuned for future blog posts on other design patterns.