Strategy pattern

By Martin McBride, 2021-09-25
Tags: behavioural pattern strategy
Categories: design patterns


Strategy pattern is a behavioural design pattern. It allows an object to choose between different strategies in a structured way.

A strategy might be an algorithm - for example, your code might need to apply various search algorithms to a data set. You might need to be able to search a list of words for an exact match, or a matching starting letter, or for words that are anagrams.

Or a strategy might implement a policy - for example, a user login system might need to authenticate user credentials. The system might give users a choice of logging on with a name and password, or a google id, or a GitHub id, etc. Each method would have a different method of authentication.

Motivation

It is quite common to encounter a situation where an object has to choose between multiple algorithms or policies.

The naive approach might be to build all the different strategies into a single class. That class might typically select the required strategy at runtime, using conditional logic (such as a set of if statements). This can lead to complex and unwieldy classes that are difficult to maintain and test.

If new strategies are required, they will need to be added to the class, which makes the problems progressively worse.

Strategy pattern implements each strategy into its own separate class so that each can be developed and tested independently. It provides a mechanism to select the correct strategy class depending on the context.

It is possible to implement the strategy pattern in such a way that new strategies can be added without making changes to the main class, leading to a loosely coupled system.

Example - searching a word list

Here is a simple class that accepts a list of words, and provides methods for searching that list of words in several ways:

class WordList:
    def __init__(self, words):
        self.words = words

    def find_matching(self, target):
        return [word for word in self.words if target==word]

    def find_startswith(self, target):
        return [word for word in self.words if word.startswith(target)]

This code is reasonably simple. The class WordList is initialised with a value words that must be a list of words.

The find_matching method returns a list of all words that match the supplied target word. It will return an empty list if no words match.

This code uses a list comprehension to create the list of matching words.

The find_startswith method returns a list of all words that start with the supplied target string. It will return an empty list if no words match.

Here we exercise the class:

wordlist = WordList(['apple', 'lemon', 'lime', 'pear'])

print(wordlist.find_matching('pear')) # ['pear']
print(wordlist.find_startswith('l'))  # ['lemon', 'lime']

We first create wordlist, initialised with our fruit names.

Calling wordlist.find_matching returns a list containing the matching string 'pear'.

Calling wordlist.find_startswith returns a list containing the matching string 'lemon' and 'lime', the two strings that start with the letter 'l'.

The basic problem with this approach is that all the search methods are stored in the same class:

  • This means that the class could get very large, and contain many diverse and complex search methods.
  • Also, every time you need to add a new method, the class must be updated.

Simple strategy implementation of word searching

In the strategy pattern, we place each algorithm in a separate class. In our example, we will create the following classes:

We have creates a new interface, called IFinder. This is the interface for all the classes that implement a finding algorithm (each different algorithm is a strategy in this pattern). The interface is very simple, it is just an execute method that accepts a list of words and returns a list of matching words.

In the strategy pattern, the strategy interface is usually very simple, containing a single method that runs the algorithm.

Each find method is implemented as a separate class, that derives from IFinder. Here is the code for the interface:

class IFinder:

    def execute(self, words):
        return  []

The execute method accepts a list of words, and return a list of matches. Because IFinder is an interface, the execute method just returns an empty list. The concrete finder classes have functioning execute methods.

Strategy classes

Here are two finder classes. They both implement the IFinder interface:

class MatchingFinder(IFinder):

    def __init__(self, target):
        self.target = target

    def execute(self, words):
        return [word for word in words if self.target==word]

class StartswithFinder(IFinder):

    def __init__(self, target):
        self.target = target

    def execute(self, words):
        return [word for word in words if word.startswith(self.target)]

The MatchingFinder class has an execute method that returns a list of words that exactly match the target. It works in the same way as the find_matching method of the previous WordList class.

Notice that the target value is passed into the constructor of the MatchingFinder class. This means that the execute method does not need to include any parameters. This is useful, as we will see, if you later need extra finder classes that take different parameters.

The StartswithFinder class implements the same algorithm as the previous find_startswith method.

New WordList class

Here we revise the WordList class to work with IFinder objects. We implement a find method that accepts an IFinder object and calls its execute method to find matches:

class WordList:
    def __init__(self, words):
        self.words = words

    def find(self, finder):
        return finder.execute(self.words)

Notice that the WordList now has no knowledge of any algorithms. It delegates the matching to the IFinder class.

Here is the new design in action:

wordlist = WordList(['apple', 'lemon', 'lime', 'pear'])

print(wordlist.find(MatchingFinder('pear')))
print(wordlist.find(StartswithFinder('l')))

We create the WordList as before. To perform a matching search we use the following code:

wordlist.find(MatchingFinder('pear'))

This creates a MatchingFinder with the target 'pear'.

We pass that MatchingFinder into the find method of wordList. That, in turn, calls the execute method of the MatchingFinder to return a list of matches.

Adding a new algorithm

Suppose we wanted to add another algorithm. For example, we might want to find all words whose length is within certain bounds.

With the original code (not using strategy pattern) this would require us to extend the WordList class with an extra method.

With strategy pattern, we don't need to change our existing code at all!

We just create a new class, and WordList will automatically be able to use it:

class LengthFinder(IFinder):

    def __init__(self, minlength, maxlength):
        self.minlength = minlength
        self.maxlength = maxlength

    def execute(self, words):
        return [word for word in words
                 if self.minlength <= len(word) <= self.maxlength]

print(wordlist.find(LengthFinder(2, 4))) # ['lime', 'pear']

The new LengthFinder class has a constructor that accepts a min and max length, and it finds all the words with lengths that are in the range.

Notice the advantage of passing the parameters in via the constructor. LengthFinder requires two integer values, unlike MatchingFinder which requires a single string. But since we pass these in the constructor, both classes have the same execute method.

Summary

We have seen a simple example of how we can use the strategy design pattern in Python to implement a loosely coupled, extensible class for finding matching entries in a list of words.

The solution has several advantages:

  • Each matching method is implemented in a simple, separate class.
  • The overall system is structured and easy to understand.
  • Extra strategies can be added without the need to modify existing code.

See also

If you found this article useful, you might be interested in the book NumPy Recipes or other books by the same author.

Join the PythonInformer Newsletter

Sign up using this form to receive an email when new content is added:

Popular tags

2d arrays abstract data type alignment and angle animation arc array arrays bar chart bar style behavioural pattern bezier curve built-in function callable object chain circle classes clipping close closure cmyk colour combinations comparison operator comprehension context context manager conversion count creational pattern data science data types decorator design pattern device space dictionary drawing duck typing efficiency ellipse else encryption enumerate fill filter font font style for loop formula function function composition function plot functools game development generativepy tutorial generator geometry gif global variable gradient greyscale higher order function hsl html image image processing imagesurface immutable object in operator index inner function input installing iter iterable iterator itertools join l system lambda function latex len lerp line line plot line style linear gradient linspace list list comprehension logical operator lru_cache magic method mandelbrot mandelbrot set map marker style matplotlib monad mutability named parameter numeric python numpy object open operator optimisation optional parameter or pandas partial application path pattern permutations pie chart pil pillow polygon pong positional parameter print product programming paradigms programming techniques pure function python standard library radial gradient range recipes rectangle recursion reduce regular polygon repeat rgb rotation roundrect scaling scatter plot scipy sector segment sequence setup shape singleton slice slicing sound spirograph sprite square str stream string stroke structural pattern subpath symmetric encryption template tex text text metrics tinkerbell fractal transform translation transparency triangle truthy value tuple turtle unpacking user space vectorisation webserver website while loop zip zip_longest