Modular, extensible, maintainable, and testable functional strategy pattern - part 1

The Strategy Pattern is likely the most used design pattern. Who said it? Well, I said it. The strategy pattern concept is simple. At a given moment, the execution workflow must take one from different paths depending on the evaluation of the current state, being state a variable or a group of variables.

There are countless situations to apply the strategy pattern; however, we will focus on analysing a single variable to control the execution flow.

We have variable var1 and, depending on the boolean result of different evaluations, var1 is processed differently.

var1 is int -> process it as an int
var1 is long string -> process the long string
var1 is short string -> process the short string

What could be the most extensible, maintainable, modular, and testable way to implement a strategy pattern here? Let's see different options:

1) if-statements are the most easily grasping strategy, likely used by all novice programmers but also advanced ones (when coding for very straightforward cases 😉).

if isinstance(var1, int):
     # do some logic here
elif isinstance(var1, str) and len(var1) > 100:
     # do other logic here
elif isinstance(var1, str) and len(var1) <= 100:
     # do yet another logic here
else:
     # everything before was false
     # do this other thing

The case presented above might be a feasible quick implementation for some simple cases. But here, we imagine all operations are long and complex because we are developing an extensive program ☺️. So, what are the problems of a direct if/elif/else implementation as the one presented above?

  • if-statements will become too lengthy and maybe nested as the number of options grows.
  • Also, having the business logic directly under the if clauses render that code challenging to test. Surely, impossible to test on its own.
  • Adding more options will just make the code less readable, especially if implementations are lengthy.
  • And, the code is not modular because you cannot use those logic blocks anywhere else as they are hardcoded directly under the if-clauses.

How can we improve from this situation?

The first step you can do to improve the code's modularity and testability is to encapsulate each logical process in its separate function:

if isinstance(var1, int):
     process_is_int(var1)
elif isinstance(var1, str) and len(var1) > 100:
     process_is_long_string(var1)
elif isinstance(var1, str) and len(var1) <= 100:
     process_is_short_string(var1)
else:
     do_else_logic(var1)

By doing this, you will be able to test the different functions in their dedicated unit-tests. Also, your code becomes more modular because you can re-use those functions anywhere else; even have them as a public, well-documented library API.

The second step towards improving the quality of your code is to transform your asserting statements into, also, individual functions.

if is_int(var1):
     process_is_int(var1)
elif is_long_string(var1):
     process_is_long_string(var1)
elif is_short_string(var1):
     process_is_short_string(var1)
else:
     do_else_logic(var1)

Notice how you can now use the evaluation functions (is_int, is_long_string, and is_short_string) anywhere else in the code and test them properly. Also, suppose you want to add additional features to those functions in a later stage, for example, new assertions or logging. In that case, you can do it easily without the need to modify the if/elif block.

(side comment) Once you reach this point of understanding, your brain will likely spark the light of using a dictionary to control the flow. Probably you have read about that strategy somewhere. I have used it also. Using dictionaries to control flow is productive in some cases, but its use won't excel in this particular case. So I will discuss using dictionaries to control flow in other posts of my series (keep an eye on them) and won't talk about them here. (end of side comment)

Finally, there is a way that out-stands the previous two, both if-else blocks and that crazy dictionary strategy I told you about but didn't explain 😉.

The implementation design that best suits our example (in my experience and opinion) is using a for loop. Yes, a for-loop. How?

First, we create a list of tuples where each tuple has two values: 1) the evaluation function (boolean logic) and 2) the business logic. The for loop will loop through the list and operate on the first function that evaluates to True.

# note how our logic is getting defined in separate functions
# as we evolve from if-statements to this pipeline-like method
options = [
    # (if_this_is_true, do_this).
    (is_int, process_is_int),
    (is_long_string, process_is_long_string),
    (is_short_string, process_is_short_string),
    (lambda x: True, process_the_else_logic),
    ]

for evaluation, function in options:
    if evaluation(var1):
        function(var1)
        # the loop does not need to continue
        # after a function is executed
        break

In case the else logic does not receive var1 as an argument, you can move it to the else method of the for-loop:

options = [
    (is_int, process_is_int),
    (is_long_string, process_is_long_string),
    (is_short_string, process_is_short_string),
    ]

for evaluation, function in options:
    if evaluation(var1):
        function(var1)
        break
else:
    process_the_else_logic()

Benefits of this last approach? Several:

  1. The for-loop executes the boolean logic only once and only if the previous evaluated to False, so there are no losses against the if-else block.
  2. You can easily change the order of the process by changing the order of the tuples in the list without the need to be moving large pieces of code around.
  3. You can add/remove new functionalities by adding/removing tuples in the list without changing the engine.
  4. It is effortless to read and understand what the code will do. Understanding how the code will do it is another story. For that you would need to read into each function. Congratulations, you have separated the what from the how.

Honestly, I see no flaws in this method. I think it really is extensible, maintainable, modular, and testable. If you think differently or know other possible examples, let me know in the comments. One can apply the strategy pattern to countless situations. This is one of them. Likely, you have heard of the strategy pattern when implementing OOP, but life is not always about OOP despite what they told you 😛

But this is not the whole story. For example, many times, we use if-statements to evaluate the state of multiple variables. And, of course, that is also possible using the for loop strategy presented here. But asserting the state of multiple variables is a more complex case that would require the use of functools.partial, so we leave that discussion for another post.

I hope you enjoyed this quick yet profound discussion, which I am sure will help you design larger and more maintainable software packages. Despite not being skilled in other languages besides Python, I trust you can apply this same (or similar) idea in other realms of programming.

Cheers,

13