Using decorator&namespace to implement function reload in python

Posted on 2022-05-30  33 Views


Function Reload

Function overloading is the ability to have multiple functions with the same name but with different signatures/implementations. When an overloaded function fn is called, the runtime first evaluates the arguments/parameters passed to the function call and judging by this invokes the corresponding implementation.

In C++, in one scope, it allows to declare the fuctions with same name and similar functionality. However, the number or type of parameters of these functions must be distinct.
Function reload example in C++:

#include <iostream>
using namespace std;

class printData
{
   public:
      void print(int i) {
        cout << "integer: " << i << endl;
      }

      void print(double  f) {
        cout << "float: " << f << endl;
      }

      void print(char c[]) {
        cout << "string: " << c << endl;
      }
};

Why no Function Overloading in Python?

Python does officially not support function overloading. When we declare multiple functions with the same name, the later one always overrides the previous one and thus, in the namespace, there will always be a single entry against each function name. We see what exists in Python namespaces by invoking functions locals() and globals(), which returns local and global namespace respectively.

def area(radius):
  return 3.14 * radius ** 2

>>> locals()
{
  ...
  'area': <function area at 0x7f32b34bbd90>,
  ...
}

Calling the function locals() after defining a function we see that it returns a dictionary of all variables defined in the local namespace. The key of the dictionary is the name of the function and value is the reference/value of that variable. When the runtime encounters another function with the same name it updates the entry in the local namespace and thus removes the possibility of two functions co-existing. Hence python does not support Function overloading. It was the design decision made while creating language but this does not stop us from implementing it, so let's overload some functions.

Implementing Function Overloading in Python

Wrapping the Function

Create a class called Function that wraps any function and makes it callable through an overridden call method and also exposes a method called key that returns a tuple which makes this function unique in entire codebase. key1 is for the instance of the function.
example
overload.py

from inspect import get_annotations, getfullargspec
from inspect import signature

class Function(object):
  """Function is a wrap over standard python function
  An instance of this Function class is also callable
  just like the python function that it wrapped.
  When the instance is "called" like a function it fetches
  the function to be invoked from the virtual namespace and then
  invokes the same.
  """
  def __init__(self, fn):
    self.fn = fn

  def __call__(self, *args, **kwargs):
    """Overriding the __call__ function which makes the
    instance callable.
    """
    # fetching the function to be invoked from the virtual namespace
    # through the arguments.
    fn = Namespace.get_instance().get(self.fn, *args)
    if not fn:
      raise Exception("no matching function found.")
    # invoking the wrapped function and returning the value.
    return fn(*args, **kwargs)

  def key(self, args=None):
    """Returns the key that will uniquely identifies
    a function (even when it is overloaded).
    """
    if args is None:
      # args = getfullargspec(self.fn).args
      args = get_annotations(self.fn)
    return tuple([
      self.fn.__module__,
      self.fn.__class__,
      self.fn.__name__,
      len(args or []),
      list(args.values())[0]
    ])
  def key1(self, args=None):
        """Returns the key that will uniquely identifies
        a function (even when it is overloaded).
        """
        if args is None:
            args = getfullargspec(self.fn).args
            # args = get_annotations(self.fn)
        #print("old: ", getfullargspec(self.fn).args)
        return tuple([
        self.fn.__module__,
        self.fn.__class__,
        self.fn.__name__,
        # len(args or [])
        len(args or []),
        type(args[0])
    ])

class Namespace(object):
  """Namespace is the singleton class that is responsible
  for holding all the functions.
  """
  __instance = None

  def __init__(self):
    if self.__instance is None:
      self.function_map = dict()
      Namespace.__instance = self
    else:
      raise Exception("cannot instantiate Namespace again.")

  @staticmethod
  def get_instance():
    if Namespace.__instance is None:
      Namespace()
    return Namespace.__instance

  def register(self, fn):
    """registers the function in the virtual namespace and returns
    an instance of callable Function that wraps the function fn.
    """
    func = Function(fn)
    specs = getfullargspec(fn)
    self.function_map[func.key()] = fn
    return func

  def get(self, fn, *args):
    """get returns the matching function from the virtual namespace.
    return None if it did not fund any matching function.
    """
    func = Function(fn)
    #print("keys: ", list(self.function_map.keys()))
    return self.function_map.get(func.key1(args=args))

def overload(fn):
  """overload is the decorator that wraps the function
  and returns a callable object of type Function.
  """
  return Namespace.get_instance().register(fn)

In the snippet above, the key function returns a tuple that uniquely identifies the function in the codebase and holds:
+ the module of the function
+ class to which the function belongs
+ name of the function
+ number of arguments the function accepts or/and type of the parameters

The overridden call method invokes the wrapped function and returns the computed value (nothing fancy here right now). This makes the instance callable just like the function and it behaves exactly like the wrapped function.

    @overload
    def add(StepBegin: InitializedStep, self)->None:
        self.begin_step_queue.append(StepBegin)

    @overload
    def add(Step: CompletedStep, self)->None:
        self.step_queue.append(Step)

>>> keys:  [('__main__', <class 'function'>, 'add', 2, <class 'Step.InitializedStep'>), ('__main__', <class 'function'>, 'add', 2, <class 'Step.CompletedStep'>)]

DelayQueue.py

from overload import overload
from Step import *

@overload
def area(l: int):
    return l

@overload
def area(l: list):
    return l[0]

@overload
def area(r: int, l: int):
    return r * l

@overload
def area(r: int, l: int, h: int):
    return r * l * h


class DelayQueue:
    def __init__(self) -> None:
        self.begin_step_queue = []
        self.step_queue = []

    @overload
    def add(StepBegin: InitializedStep, self)->None:
        self.begin_step_queue.append(StepBegin)

    @overload
    def add(Step: CompletedStep, self)->None:
        self.step_queue.append(Step)

    def get_step(self)->list:
        return self.step_queue

    def get_begin_step(self):
        return self.begin_step_queue

    def clear_begin_step(self)->None:
        self.begin_step_queue = []

    def clear_completed_step(self)->None:
        self.step_queue = []

cover: Moji, ID: 96091394