George Silva
28 Sep 2021
•
6 min read
Hello, my friends! This is the second post in the series about Design Patterns in Python
.
So, first things first: what are design patterns? Design patterns are abstractions. The abstractions or concepts encompass a particular implementation that solves a known, frequent problem while developing software.
Compiling these abstractions and giving them proper names, makes it easier for us, as developers to communicate, express intent, and convey complex interactions between our code with a single name. Knowing these patterns will allow you to express yourself better with your fellow developers 💪.
Remember: design patterns are recipes. They are general and should serve as a guide.
Today we are going to talk about the Strategy Pattern.
Like we did in our first post of the series, let's start with a scenario. A concrete scenario helps us visualize how these patterns are useful.
I will reuse our first scenario - the bookstore
. Our first problem was that we had to convert our objects - pure python objects to JSON representations, so we could integration our Awesome Bookstore
to a big e-commerce player.
Let's recall our POPO (Plain Old Python Objects) - modeled as dataclasses
for brevity.
# models.py
from dataclasses import dataclass
from typing import List
@dataclass
class Customer:
name: str
status: str # can be `normal` or `VIP
# for brevity we are considering address to be the state where the customer lives
# possible values: state-1, state-2 and state-3
address: str
@dataclass
class Author:
name: str
lives_in: str
@dataclass
class Publisher:
name: str
company_number: str
@dataclass
class Book:
title: str
author: List[Author]
genre: str
weight: int # in grams
# this of course is a simplification because prices are usually handled in Decimal
price: float
Our problem today is that we need to calculate the final price for our customers, once they finalize the checkout in our store.
Depending on their status (customer status) - we want to give them discounts and apply different shipping fees.
Consider the following rules:
normal
and VIP.
VIP` customers are indeed very important for us because they buy a lot from us. This type of customer gets 50% off in their shipping fees# this per 1000 grams
STATE_SHIPPING_PRICES = {
"state-1": 10,
"state-2": 12,
"state-3": 25
}
To kick things off, we can do it the simplest way possible. Basically, check if the customer is VIP and calculate their shipping price differently. Should be straightforward.
Imagine we have some code, that given a cart
, returns the total price of the shopping cart.
def get_cart():
# dummy function that retrieves a static shopping cart with some books
george = Customer(name="George", status="normal", address="state-1")
daniel = Author(name="Daniel Higginbotham", lives_in="USA")
russ = Author(name="Russ Olsen", lives_in="USA")
book1 = Book(
name="Clojure for the Brave and True",
author=[daniel],
genre="Computer Science",
weight=200,
price=19.99)
book2 = Book(
name="Getting Clojure",
author=[russ],
genre="Computer Science",
weight=200,
price=19.99)
return {"books": [book1, book2], "customer": george}
Using the function above, we can construct a list of books to be purchased and who is our customer. In this case - the customer, me, is a non-VIP customer living in state-1
.
This means that the shipping fee is 10 moneys
per kilogram. If I'm purchasing 2 books of 200g each, it means that we have 400g total weight. Doing quick math here we should charge me 4 moneys
in shipping fees.
Let's implement that in the easiest way possible:
def calculate_total(books, customer):
total_book_price = sum([book.price for book in books])
total_weight = sum([book.weight for book in books])
total_shipping_price = total_weight * STATE_SHIPPING_PRICES[customer.address]
if customer.is_vip:
total_shipping_price = total_shipping_price * 0.5 # 50% discount
return total_book_price + total_shipping_price
Python is really expressive, so it seems easy, huh?
The problem starts when there are a lot of variations for price calculations, which makes this hard to calculate and maintain. See the if
clause? There is where our headache begins, when the product department decides to implement discounts at book prices for other types of customers, or if they introduce a new type of super-vip
customer, that can get 100% discount at shipping prices.
All of this starts to get messy fast.
That's where the strategy pattern is useful - implementing variations of the same algorithm with different objects and contexts (keep in mind that Python has functions as first-class citiziens, meaning you can pass functions around, simplifying how to implement the strategy pattern - I'll show both ways).
A strategy is an object that has a single execute
method and performs an algorithm - a series of steps, that might return or not return anything.
The only real constraint is that the parameters needed by this algorithm are common among different objects.
So - let's imagine that we first refactor our code to split the logic between calculating totals and shipping fees in two functions:
def calculate_shipping_total(books, customer):
total_weight = sum([book.weight for book in books])
total_shipping_price = total_weight * STATE_SHIPPING_PRICES[customer.address]
if customer.is_vip:
total_shipping_price = total_shipping_price * 0.5 # 50% discount
return total_shipping_price
def calculate_total(books, customer):
total_book_price = sum([book.price for book in books])
total_weight = sum([book.weight for book in books])
total_shipping_price = calculate_shipping_total(books, customer)
return total_book_price + total_shipping_price
As you can see, a common signature between the functions starts to emerge. To accurately calculate prices we need both the list of books being purchased and the customer, right?
In order to make this more flexible - let's implement a strategy, instead of having a hardcoded algorithm, like the one shown in calculate_shipping_total
.
class ShippingStrategy:
"""
Object responsible to calculate the total price of shipping,
given a customer and a list of books and their weights.
"""
def execute(self, books, customer):
raise NotImplementedError
This is our first strategy. The skeleton. Let's say that we have a default strategy, with no discounts and our discounted one.
class DefaultShippingStrategy:
"""
Object responsible to calculate the total price of shipping,
given a customer and a list of books and their weights.
"""
def execute(self, books, customer):
total_weight = sum([book.weight for book in books])
total_shipping_price = total_weight * STATE_SHIPPING_PRICES[customer.address]
return total_shipping_price
class DiscountedShippingStrategy:
def execute(self, books, customer):
total_weight = sum([book.weight for book in books])
total_shipping_price = total_weight * STATE_SHIPPING_PRICES[customer.address]
return total_shipping_price * 0.5
Ok, cool, we have two strategies. But how can we effectively use it? We need a way to figure out which strategy to apply dynamically. And that is basically it.
def which_strategy(customer):
if customer.is_vip:
return DiscountedShippingStrategy()
return DefaultShippingStrategy()
def calculate_total(books, customer):
shipping_strategy = which_strategy(customer)
total_book_price = sum([book.price for book in books])
total_weight = sum([book.weight for book in books])
total_shipping_price = shipping_strategy.execute(books, customer)
return total_book_price + total_shipping_price
See that instead of using multiple, complicated if
statements in calculate_total
function, we delegated all the shipping calculation responsibility to other objects, that are self-contained and execute only one thing.
The catch here is to abstract our different algorithms in different objects and using a method or a function to determine the strategy for us, given a set of parameters.
Separating the execution of the strategy from its choice allows us to have methods with single responsibilities, that are easier to test, easier to extend and maintain.
With Strategy
we can have several different forms of calculating shipping - without creating a monster function that has all the possible shipping variations you will find around.
In Python, we have functions as first class citizens. That means we can pass around functions to functions. That means we don't need an extra object and an extra concept to wrap our shipping fee logic.
Instead, we can basically return functions from other functions and directly use them as needed. The same constraint applies: their signatures should be the same.
def discounted_strategy(books, customer):
# implement our code
# ...
return total_shipping * 0.5
def default_strategy(books, customer);
# implement our code
# ...
return total_shipping
def which_strategy(customer):
# this returns different functions based on our customers input
if customer.is_vip:
return discounted_strategy
return default_strategy
def calculate_total(books, customer):
shipping_strategy = which_strategy(customer)
total_book_price = sum([book.price for book in books])
total_weight = sum([book.weight for book in books])
# directly execute the function return to us from `which_strategy`
total_shipping_price = shipping_strategy(books, customer)
return total_book_price + total_shipping_price
George Silva
See other articles by George
Ground Floor, Verse Building, 18 Brunswick Place, London, N1 6DZ
108 E 16th Street, New York, NY 10003
Join over 111,000 others and get access to exclusive content, job opportunities and more!