Skip to content

Decorators

Shamik edited this page Oct 16, 2022 · 11 revisions

Decorators, what are they?

It's a function which wraps other functions. This definition might not be clear, however, fret not and read on.

Simple Decorators

import functools

def some_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        # Doing something before
        value = func(*args, **kwargs)
        # Doing something after
        return value
    return wrapper

@some_decorator
def say_hi():
    """Python says hi to you! 😀"""
    print('Hello there! 😀')
    return {'H':0,'e':1,'l':2,'l':3,'o':4}

The name of the decorator is some_decorator and it's decorating/wrapping another function say_hi with @some_decorator above say_hi. This decorator can be used by using @some_decorator. All that @some_decorator is doing is wrapping say_hi and returning what say_hi is supposed to return. This is just for illustration purposes and might seem very useless at first, but if this decorator boilerplate template is used then you can create pretty impressive decorators.

Digging Deeper

Looking inside some_decorator, there is another function wrapper, which takes in any number of args and kwargs, assigns a value to the function it's wrapping, in our case say_hi, and returns the wrapped function and its values. If we don't return the value parameter then it will not return the dictionary object of say_hi. Try it by commenting return value! Finally we are returning the wrapper object without, which our decorator object would be defunct. Try this too by commenting return wrapper!

The func argument in some_decorator is being accessed inside wrapper by value. This is called a closure.

Closure

The main feature of a closure is that it has full access to the variables and names defined in the local namespace where the closure was created, even though the enclosing function has returned and finished executing. E.g. wrapper has access to the func argument in some_decorator even though that function has been executed, this is because a closure causes the inner function to retain state.

The structure of a closure is the same as the structure of some_decorator.

  • There's an inner function i.e. wrapper
  • A variable is being referenced from the enclosing function i.e func is being referenced from some_decorator
  • At the end the inner function is being returned i.e. return wrapper

Examples of static and dynamic closures are here.

Functools as decorator

Importing functools and using it as a decorator is the first use of decorators. @functools.wraps(func) wraps any function passed to some_decorator so that when we introspect func i.e. help(func) or func__name__, it would be pointing to the name and help of func instead of the wrapper function, wrapper, that it's wrapped with. Try removing @functools.wraps(func) and run help(say_hi) and say_hi.__name__.

Standard Decorators

Three main decorators will be looked into:

  1. @property
  2. @classmethod
  3. @staticmethod

The code, which will be used to demonstrate all of them is here and certain sections will be pasted below for illustration.

@property

This decorator is used in the class to instantiate properties of the class. Since the class is a Circle, one of the properties is a radius and the other is an area.

Click to expand the code!
  @property
  def radius(self):
      """Get value of radius"""
      return self._radius

  @radius.setter
  def radius(self, value):
      """Set radius, raise error if negative"""
      if value >= 0:
          self._radius = value
      else:
      raise ValueError("Radius must be positive")

  @property
  def area(self):
      """Calculate area inside circle"""
      return self.pi() * self.radius**2

The radius of a circle must be given to calculate the area, however, one cannot set the area of a circle without the radius. Therefore, the radius is a property of the class circle.

As the radius can have a value, it will be set with a setter method of the property decorator, in our case @radius.setter. More info on the getter, setter, & deleter methods of property here. Note, how the radius method needs to have the same name for setting the @radius.setter. If we were to use the getter & deleter methods for the radius they would have the same method name as setter.

The area doesn't need to be set and therefore doesn't have a setter method like radius.

Since area & radius methods are properties, they can be retrieved as an attribute without (), e.g. Circle.area/Circle.radius.

@classmethod

Classmethods are factory methods, which create a specific instance of the class. The unit_circle is a specific instance of the class circle, and therefore can be instantiated as below.

Click to expand the code!
  @classmethod
  def unit_circle(cls):
      """Factory method creating a circle with radius 1"""
      return cls(1)

unit_circle can access all the methods of Circle and has access to all the other attributes as well.

Another such example and the entire code. In this case there's an existing class to create employee records, however, this class requires first, last and pay arguments in a certain format ('Bambino', 'Jones', 50000) to create a new employee. However, if the format changes i.e. 'John-Doe-70000', we can create a separate @classmethod to process the same. This class method takes a string as input and creates new employee records. The code below does the same.

Click to expand the code!
@classmethod
def from_string(cls, emp_str):
    first, last, pay = emp_str.split('-')
    return cls(first, last, pay)

Another example of class method is when we use to set the raise amount. Refer to the code below.

Click to expand the code!
@classmethod
def set_raise_amount(cls, amount):
    cls.raise_amount = amount

@staticmethod

This is a method which is independent of the Circle class and can be accessed by other methods in the class. This can be called on either an instance or on the class itself.

Click to expand the code!
@staticmethod
def pi():
    """Value of π, could use math.pi instead though"""
    return 3.1415926535

Cool Decorators

Now that we have an idea about some decorators, let's look at some other cool ones. This directory contains the codes.

Timer Decorator

The first one is a timer function, which calculates how much time a function takes to execute.

Click to expand the code!
  import functools
  import time


  def timing_func(func):
  """Prints the runtime of any decorated function."""
  @functools.wraps(func)
  def timer_wrapper(*args, **kwargs):
      # perf_counter is used her to measure time intervals
      start_time = time.perf_counter()
      value = func(*args, **kwargs)
      end_time = time.perf_counter()
      # total run time
      run_time = end_time - start_time
      # printing the repr of the function name and
      # the time in seconds
      print(f"Finished {func.__name__!r} in {run_time:.4f} secs")
      return value
  return timer_wrapper


  @timing_func  # wrapping the function
  def timer_testing(some_num_time):
      """Testing the timer decorator."""
      for _ in range(some_num_time):
          # creating a list of cubed numbers
          # and adding them up
          sum([i**3 for i in range(10000)])

Debug decorator

This decorator is useful for checking which function is being called and the value being returned.

Click to the expand the code!
  import math
  import functools


  def debug(func):
  """Print the function arguments and return value."""

      @functools.wraps(func)
      def debug_wrapper(*args, **kwargs):
          # repr to get a list of the function arguments
          args_repr = [repr(a) for a in args]
          # repr to get a list of keyword arguments and their
          # corresponding values
          kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
          signature = ", ".join(args_repr + kwargs_repr)
          # Printing the function name and its arguments
          print(f"Calling {func.__name__}({signature})")
          value = func(*args, **kwargs)
          # the function and their returned values
          print(f"{func.__name__!r} returned {value!r}")
          return value
      return debug_wrapper


  # initialising the debug wrapper
  # for the math.factorial module.
  math.factorial = debug(math.factorial)

  # initialising the debug module
  # for the exp approximation


  @debug
  def approximate_exp(num_terms=10):
      """Approximating exponential by adding
      the inverse of n factorials."""

      # returning the approximated exponential
      return sum(1/math.factorial(num) for num in range(num_terms))

Repeat decorator

This decorator just repeats the function as many times as one wants.

Click to expand the code!
  import functools


  def repeat_func(_func=None, *, num_times=1):
      """Repeating a function once/any number of times.
      This function works with or without the kwarg num_times.
      """
      def repeat_decorator(func):
          """Wrapper function for repeating the function.
          This will create function object for the repeat_func.
          As we reserve the repeat_func for using it as a decorator,
          and it requires arguments, this function is required."""

          @functools.wraps(func)
          def repeat_wrapper(*args, **kwargs):
              # repeating function n number of times
              # but returning only the function value once
              for _ in range(num_times):
                  value = func(*args, **kwargs)
              return value
          return repeat_wrapper

  # The parameter _func takes two values, either None, when there's
  # num_times provided otherwise the decorator function being
  # passed to _func.
      if _func is None:
          return repeat_decorator
      else:
          return repeat_decorator(_func)


  @repeat_func
  def say_hi_once():
      """Python says hi to you! 😀"""
      print('Hello there! 😀')
      return {'H': 0, 'e': 1, 'l': 2, 'l': 3, 'o': 4}


  @repeat_func(num_times=3)
  def say_hi_thrice():
      """Python says hi to you! 😀"""
      print('Hello there! 😀')
      return {'H': 0, 'e': 1, 'l': 2, 'l': 3, 'o': 4}

Calling the function say_hi_once() and say_hi_thrice() will yield the repeated print statements but return only one time the function return statement. If the return statement were to be returned as many times as the repeated print statements, then the code can be modified accordingly.

Using a cache decorator

This uses the default cache of the Standard Library to store previously calculated results, which can be used to calculate future results. This type of problems can be solved with dynamic programming also, using a data structure such as heap to store the previously calculated results. For instance, to solve a fibonacci series one can perform the following:

Click to expand the code!
  import functools

  # usually the cache size is 128, however, we are setting it to 4 here
  @functools.lru_cache(maxsize=4)
  def fibonacci(num):
      # print(f"Calculating fibonacci({num})")
      if num < 2:
          return num
      return fibonacci(num - 1) + fibonacci(num - 2)