MicroPython : Function Decorators

Contents

Introduction

MicroPython is a “slim” version of Python specifically designed with a small footprint to efficiently run on memory constrained microcontrollers.

MicroPython doesn't provide anywhere near the complete complement of Python functionality. However the subset that is implemented is useful, fit-for-purpose and mostly all that is required.

All examples in this article are original and have been tested on a micro:bit for correctness using the Mu Editor.

Other MicroPython articles in this series that the reader might find relevant to this article include:

What are Decorators?

MicroPython decorators wrap a function;, modifying or extending its behavior. The original function is unaltered.

Example 1 looks to improve upon the MicroPython builtin function pow(). When pow() is used to evaluate an odd numbered root (e.g. x1/3, x1/5, x1/7) for a negative number it returns a complex number. As an example, using pow() to evaluate a cube root of a negative number, in this case (∛-125):


pow(-125, 1/3)

MicroPython returns:[1] (2.5+4.330127j)
Numerical answer: -5.0 where -5 x -5 x -5 = -125

          

Steps to follow if the numerical answer is required:

  1. Convert the negative number into its equivalent positive value: -125  →  125
  2. Take the cube root of this positive value: 5.0
  3. Convert the answer to its equivalent negative value -5.0

-(pow(abs(-125), 1/3)) ⇒ -5.0

          
Example 1

# Demonstrate a 'manual' decorator function.

# This program uses the built-in function pow()
# to calculate a cube root. The cube root is
# calculated by using pow(x, 1/3) where x is
# the number the cube root is required of.

# A simple decorator is used to modify the
# behaviour of the pow() function such that
# a complex number is not returned if x is
# negative.

# For example: pow(-125, 1/3) ==> (2.5+4.330127j)
# Instead, using the algorithm above,
# a value of -5 will be returned.

def Decorator(fn):
    # Alters the pow() function such that
    # a cube root of a negative number
    # doesn't return a complex number.
    def wrapper(x):
        if x < 0:
            print('Cube root of',
                  x, '=', -fn(abs(x)))
        else:
            print('Cube root of',
                   x, '=', fn(x))
    return wrapper

def CubeRoot(x):
    # Calculates cube root according to
    # the strict mathematical definition.
    # It will return a complex number
    # when x is negative.
    return(pow(x, 1/3))

# Calling CubeRoot() will now invoke
# the wrapper function.  
CubeRoot = Decorator(CubeRoot)

# Test the 'new' CubeRoot() function.
CubeRoot(73)
CubeRoot(-31)

          
Output:

Cube root of 73 = 4.179339
Cube root of -31 = -3.141381

          

Firstly, Example 1 defines a function CubeRoot() which simply uses the pow() function with no 'fix' for negative numbers.

Then another function Decorator() is defined with an inner function wrapper(). The inner function wrapper() corrects the CubeRoot() function's inability to return the 'correct' answer for negative numbers, using the fix outlined above.

Finally, Decorator(CubeRoot) is called. In effect, the name CubeRoot now points to the wrapper() inner function. So, if CubeRoot() is called the inner function wrapper() will be invoked and the problem with negative numbers is solved.

This leaves the original function CubeRoot() unaltered. It is still indirectly used but with a 'wrapper' to ensure negative numbers are correctly handled.

Defining Decorators with the @ Symbol

The way CubeRoot() in Example 1 was decorated is quite clunky. The name CubeRoot appears three times and the decoration is hidden away as an inner function.

MicroPython provides a much simpler solution that overcomes this cumbersome first approach. Decorators can be used by applying the @ symbol. This is best understood by example. Example 1 has been altered to use the @ symbol:

Example 2

# Defines a decorator function
# using the '@' symbol.

# This program uses the built-in function pow()
# to calculate a cube root. The cube root is
# calculated by using pow(x, 1/3) where x is
# the number the cube root is required of.

# A simple decorator is used to modify the
# behaviour of the pow() function such that
# a complex number is not returned if x is
# negative.

# For example: pow(-125, 1/3) ==> (2.5+4.330127j)
# Instead, using the algorithm above,
# a value of -5 will be returned.

def Decorator(fn):
    # Alters the pow() function such that
    # a cube root of a negative number
    # doesn't return a complex number.
    def wrapper(x):
        if x < 0:
            print('Cube root of',
                  x, '=', -fn(abs(x)))
        else:
            print('Cube root of',
                   x, '=', fn(x))
    return wrapper

# Calling CubeRoot() will now invoke
# the wrapper function.
@Decorator
def CubeRoot(x):
    # Calculates cube root according to
    # the strict mathematical definition.
    # It will return a complex number
    # when x is negative.
    return(pow(x, 1/3))

# Test the 'new' CubeRoot() function.
CubeRoot(73)
CubeRoot(-31)

          
Output:

Cube root of 73 = 4.179339
Cube root of -31 = -3.141381

          

The statement;


CubeRoot = Decorator(CubeRoot)

          

has been replaced with;


@Decorator

          

Note: The decorator function doesn't need to be called Decorator(). It can be given any legal MicroPython name. The following will work fine:



def Foo(fn):
    def wrapper(x):
        if x < 0:
            print('Cube root of',
                  x, '=', -fn(abs(x)))
        else:
            print('Cube root of',
                   x, '=', fn(x))
    return wrapper

@Foo
def CubeRoot(x):
    return(pow(x, 1/3))

          

Returning Values From Decorated Functions

In Example 1 and Example 2 above, the decorated function does the cube root calculation and prints the results. Usually it would be more useful if the function was able to actually return the numerical cube root.

As an example, suppose the surface area of a cube must be calculated and the only known property of the cube is its volume. The relevant equations are:


side = ∛(volume)
area = 6 x side2
          

Example 3 uses these equations to process a list of volumes of cubes, returning their respective surface areas.


# Example of a decorator function
# returning a value.

# Given the volume of a cube, calculate
# the cube's surface area.

# The surface areas are calculated for
# a list of cube volumes.

def Decorator(fn):
    def wrapper(x):
        if x < 0:
            return -fn(abs(x))
        else:
            return fn(x)
    return wrapper

@Decorator
def CubeRoot(x):
    return(pow(x, 1/3))

def CubeSide(volume):
   # Returns the side measurement of
   # a cube given the cube's volume.
    return CubeRoot(volume)
    
def CubeSurfaceArea(volume):
    # Returns a cube's surface area
    # given cube's side measurement.
    side = CubeSide(volume)
    return side * side * 6
 
# Define a list of cube volumes.
volumes = [54, 98.632, 224]

# Calculate the surface areas
# for all cube volumes in the list.
for my_volume in volumes:
    print('Volume =', my_volume,
          ': Surface Area =',
           CubeSurfaceArea(my_volume)) 
          
Output:

Volume = 54 : Surface Area = 85.71966
Volume = 98.632 : Surface Area = 128.0845
Volume = 224 : Surface Area = 221.301
          

The next example is much more generic. A decorator is used to check that all values passed to a mathematical function are numeric.

The function calc() will accept as an argument any legitimate MicroPython function (built-in or user defined). The function passed as the argument will be evaluated if all remaining arguments are numeric.

Example 4

# Decorators are powerful constructs!

# Demonstrate a robust function that accepts
# any mathematical function and will only
# perform the calculation if all arguments
# passed are numerical.

import math

def decorator(fn):
   # Decorator that expects a mathematical
   # function 'fn' as an argument.
    def wrapper(func, *values):
      # The function 'func' is evaluated if
       # all remaining arguments are numerical,
       # otherwise the value None is returned.
        allnum = True
        for item in values:
            isNum = isinstance(item, (int, float))
            allnum = allnum and isNum
        if allnum:
            # All values passed are numerical.
            return func(*values)
        else:
            return None
    return wrapper
    
@decorator
def calc(func, *values):
    # Accepts any valid function
    # and returns the result from
    # calling that function.
    return func(*values)
    
def hypotenuse(side1, side2):
    # Returns the hypotenuse of a right angle
    # triangle using Pythagoras' theorem.
    SqSum = calc(pow, side1, 2) + calc(pow, side2, 2)
    return calc(math.sqrt, SqSum)

# Testing the calc() function.   
print('pow(5, 2) =',
       calc(pow, 5, 2))
print('math.sqrt(5) =',
       calc(math.sqrt, 5))
print('max(3, 4, 5, 9, 2) =',
       calc(max, 3, 4, 5, 9, 2))
print('math.sin("Spam") =',
       calc(math.sin, 'Spam'))
print()

#Calculate the hypotenuse of
# a right angle triangle.
a, b = 3, 4 # Sides of the triangle
print('a =', a, '; b = ', b)
print('hypotenuse = ', hypotenuse(a, b))
          
Output:

pow(5, 2) = 25
math.sqrt(5) = 2.236068
max(3, 4, 5, 9, 2) = 9
math.sin("Spam") = None

a = 3 ; b =  4
hypotenuse =  5.0

          

Conclusion

This article has covered and given examples of decorated functions. It really is worth the time to work through the examples. The use of the @ symbol can produce elegant, succinct and reusable code.

Additionally, the use of decorators allows the modification of functions for increased robustness without necessarily affecting programs that use the underlying functions.

There are many excellent online references such as realpython.com that provide a much deeper dive into decorators. However, be aware that these cover the decorators as applies to the full Python languages and the given examples won't always easily adapt to MicroPython.