Shape Shifting Functions

I wanted to write a quick post about decorators, as I’ve been using them a lot recently and I think they’re a really useful tool to have in your belt. I spent quite a bit of time trying to find a suitable analogy, so apologies if this is a bit of a stretch! I’m a massive sci-fi fan, and one of my favourite films is John Carpenter’s The Thing. If you haven’t seen it, it’s about a shape-shifting alien that assimilates the crew of an Antarctic research station. The alien is able to perfectly mimic the appearance of its victims, and it’s only through a series of tests that the crew are able to identify who is human and who is the alien. I felt like this would be a good analogy for decorators, as they allow you to transform a function into something else, but still retain the original function underneath. Anyway, lets dive in!

What are decorators?

In Python, which we’ll use for our examples, decorators are functions that take another function as an argument and return a new function. The new function can perform some additional functionality before or after the original function is called. This is a bit of a mouthful, so let’s look at an example.

def thing_decorator(func):
    def wrapper():
        print("The Thing, ensuring it's safe to transform.")
        func()
        print("The Thing, acting natural.")
    return wrapper

@thing_decorator
def assimilate():
    print("The Thing assimilating.")

When you run this code, you’ll see the following output:

The Thing, ensuring it's safe to transform.
The Thing assimilating.
The Thing, acting natural.

This as close to a hello world I could get for decorators, but it’s a bit of a contrived example. Let’s look at a more practical example.

We know in the movie if The Thing is detected it must halt it’s transformation and go into a defensive mode. We can use a decorator to simulate this behaviour. For the sake of our example, lets imagine that everytime The Thing made an attempt to assimilate someone, it would need to make a check on it’s environment to ensure noone else was around. It might also need to perform other checks such as making sure the temperature is correct, or that it’s not being observed. We can use a decorator to perform these checks before the assimilation takes place.

def check_environment(func):
    def wrapper():
        print("The Thing, checking the environment.")
        func()
    return wrapper

@check_environment
def assimilate():
    print("The Thing assimilating.")

When we run this code, we’ll see the following output:

The Thing, checking the environment.
The Thing assimilating.

Multiple decorators, whaa?

So far, so good. But what if we wanted to perform multiple checks? We could write a decorator for each check, but that would be a bit cumbersome. Instead, we can chain decorators together. Let’s write a decorator to check the temperature.

def check_temperature(func):
    def wrapper():
        print("The Thing, checking the temperature.")
        func()
    return wrapper

def check_environment(func):
    def wrapper():
        print("The Thing, checking the environment.")
        func()
    return wrapper

@check_environment
@check_temperature
def assimilate():
    print("The Thing assimilating.")

When we run this code, we’ll see the following output:

The Thing, checking the environment.
The Thing, checking the temperature.
The Thing assimilating.

What about after the assimilation has taken place? We can write a decorator to check the environment again.

def check_environment(func):
    def wrapper():
        print("The Thing, checking the environment.")
        func()
        print("The Thing, checking the environment again.")
    return wrapper

@check_environment
@check_temperature
def assimilate():
    print("The Thing assimilating.")

When we run this code, we’ll see the following output:

The Thing, checking the environment.
The Thing, checking the temperature.
The Thing assimilating.
The Thing, checking the environment again.

Passing arguments to decorators

So far, we’ve only looked at decorators that take no arguments. What if we wanted to pass arguments to our decorators? Let’s write a decorator that takes a temperature argument.

def check_temperature(temperature):
    def decorator(func):
        def wrapper():
            print(f"The Thing, checking the temperature is {temperature}.")
            func()
        return wrapper
    return decorator

@check_temperature(10)
def assimilate():
    print("The Thing assimilating.")

When we run this code, we’ll see the following output:

The Thing, checking the temperature is 10.
The Thing assimilating.

Passing arguments to decorated functions

What if we wanted to pass arguments to our decorated function? We can do this by adding *args and **kwargs to our wrapper function. Let’s write a decorator that takes a temperature argument and a victim argument.

def check_temperature(temperature):
    def decorator(func):
        def wrapper(*args, **kwargs):
            print(f"The Thing, checking the temperature is {temperature}.")
            func(*args, **kwargs)
        return wrapper
    return decorator

@check_temperature(10)
def assimilate(victim):
    print(f"The Thing assimilating {victim}.")

assimilate("David")

When we run this code, we’ll see the following output:

The Thing, checking the temperature is 10.
The Thing assimilating David.

As I was writing this article, I thought about the movie and if you recall there’s a scene where Blair is trying to simulate that if The Thing ever makes it off the base, how long it would take to assimilate the world. I think he also tried to estimate the probability that one or more of the team members was already assimilated, but I can’t recall if he tried to work out how long until the base itself was assimilated (I think he did). So with this in mind I got a bit carried away with writing these examples and came up with the following.

import random

def populate_initial_victims(decorated_func):
    def wrapper_func(self, *args, **kwargs):
        decorated_func(self, *args, **kwargs)
        self.populate_room()
    return wrapper_func

def assimilate_victim_check(decorated_func):
    def wrapper_func(self, *args, **kwargs):
        if self.can_assimilate:
            decorated_func(self, *args, **kwargs)
        else:
            print("The Thing couldn't assimilate the victim")
    return wrapper_func

class Victim:
    def __init__(self, name, is_human):
        self.name = name
        self.is_human = is_human

class Environment:
    @populate_initial_victims
    def __init__(self, name, temperature):
        self.name = name
        self.temperature = temperature
        self.victims = []

    def populate_room(self):
        num_victims = random.randint(1, len(victims))
        self.victims = random.sample(victims, num_victims)
        print(f"Room {self.name} populated with {len(self.victims)} victims.")

class TheThing:
    def __init__(self):
        self.environment = random.choice(environments)
        self.environment.populate_room()
        self.attempts = 0
        self.total_time = 0

    def change_environment(self):
        self.environment = random.choice(environments)
        self.environment.populate_room()
        self.attempts += 1
        time_in_room = random.randint(5, 10)
        self.total_time += time_in_room
        print(f"The Thing moved to {self.environment.name} and spent {time_in_room} minutes there")

    # property is a built in decorator that allows us to call this method like an attribute i.e can_assimilate rather than can_assimilate()
    @property
    def can_assimilate(self):
        print(f"The Thing is checking if it can assimilate in {self.environment.name}")
        humans_in_room = sum(victim.is_human for victim in self.environment.victims)
        print(f"Number of humans in the room: {humans_in_room}")
        print(f"Is the temperature above 0? {self.environment.temperature > 0}")
        return humans_in_room == 1 and self.environment.temperature > 0

    @assimilate_victim_check
    def assimilate(self):
        for victim in self.environment.victims:
            if victim.is_human:
                print(f"The Thing is attempting to assimilate {victim.name}")
                victim.is_human = False
                assimilation_time = 3
                self.total_time += assimilation_time
                print(f"{victim.name} has been assimilated!")
                break

def simulation():
    thing = TheThing()

    while any(victim.is_human for victim in victims):
        thing.change_environment()
        thing.assimilate()

    print(f"The Thing assimilated everyone in {thing.attempts} attempts")
    print(f"The total time taken was {thing.total_time} minutes")

    return thing.total_time

def reset_simulation():
    global victims
    victims = [
        Victim("Norris", True),
        Victim("Palmer", True),
        Victim("Childs", True),
        Victim("Clark", True),
        Victim("Garry", True),
        Victim("Nauls", True),
        Victim("Bennings", True),
        Victim("Fuchs", True),
        Victim("Windows", True),
        Victim("Blair", True),
        Victim("Copper", True),
        Victim("MacReady", True),
    ]

    global environments
    environments = [
        Environment("Radio Room", 20),
        Environment("Lab", 22),
        Environment("Greenhouse", 24),
        Environment("Storage", 18),
        Environment("Garage", 15),
        Environment("Generator Room", 23),
        Environment("Kennel", 16),
        Environment("Mess Hall", 21),
        Environment("Recreation Room", 22),
        Environment("Dormitory", 19),
        Environment("Control Room", 24),
        Environment("Observatory", 20),
        Environment("Outside", -30),
    ]

best_time = float('inf')
worst_time = 0
total_time = 0

for i in range(100):
    reset_simulation()
    time_taken = simulation()
    best_time = min(best_time, time_taken)
    worst_time = max(worst_time, time_taken)
    total_time += time_taken

average_time = total_time / 100

print(f"Average time for total assimilation of base: {average_time // 60} hours, {average_time % 60} minutes")

best_time = f"{best_time // 60} hours, {best_time % 60} minutes"
worst_time = f"{worst_time // 60} hours, {worst_time % 60} minutes"

print(f"Best case scenario: {worst_time}")
print(f"Worst case scenario: {best_time}")

I’ve added two decorator methods, as well as made use of a built in decorator called property. The property decorator allows us to call the can_assimilate method like an attribute, rather than a method. Now we can simulate how long until the whole base is assimiilated, I’m sure Blair will be thankful for this, assuming he’s still human 👀.

Decorators are pretty awesome huh? I hope you enjoyed this article and learned something new. Oh, and decorators aren’t something specific to Python, they’re a feature of many programming languages, in fact we refer to this as a design pattern, more specifically the decorator pattern. If you’re interested in learning more about design patterns, I’d recommend checking out the Gang of Four book, it’s a classic. If you’re not familiar with design patterns I recommend you read the book, and I’ll leave you with this quote from the book:

Design patterns are recurring solutions to software design problems you find again and again in real-world application development. Patterns are about reusable designs and interactions of objects. The 23 Gang of Four (GoF) patterns are generally considered the foundation for all other patterns. They are categorized in three groups: Creational, Structural, and Behavioral.

The Decorator Pattern is a Structural Pattern, and is one of my favourites. I hope you enjoyed this article, and I’ll see you in the next one! 👋