This lecture discusses some further important topics in Python: Decorators and Classes.

Pyton Decorators

In simple words, Python decorators are functions that can modify (e.g., add to) the functionalities of other functions. As will be described below, decorators are particularly useful in making your code shorter. To understand the workings of decorators, we will have to recall a few properties of functions in Python.

Firstly, since every entity in Python is an object, including functions, almost everything, including functions can be assigned to a variable. For example, the simple function,

def hello(name='Amir'):
    return 'Hello ' + name


can be assigned to a new variable,

greet = hello


which is also a function,

greet
<function __main__.hello>

and more importantly, it is not attached to the original function hello(),

del hello
hello
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-6-b1946ac92492> in <module>()
----> 1 hello

NameError: name 'hello' is not defined
greet
<function __main__.hello>
greet()
'Hello Amir'

Functions inside other functions

Now one thing to keep in mind, is that you can define functions inside functions in Python, just as you can do in almost any other capable language.

def hello(name='Amir'):
    print('This is from inside function hello()')
    
    def greet():
        return '\t This is from inside function greet() inside function hello()'
    
    def welcome():
        return '\t This is from inside function welcome() inside function hello()'
    
    print(greet())
    print(welcome())
    print("This is from inside function hello()")


So now, if you type,

hello()
This is from inside function hello()
    This is from inside function greet() inside function hello()
    This is from inside function welcome() inside function hello()
This is from inside function hello()

Another cool feature to know about, is that you can have functions both as input and return values to and from another function. We have seen this already in previous lectures, where we discussed functions for the first time.

With this in mind, let’s create a function like the following,

def decorateThisFunction(func):

    def wrapInputFunction():
        print("Some decorating code can be executed here, before calling the input function")

        func()

        print("Some decorating code can be executed here, after calling the input function")

    return wrapInputFunction

def needsDecorator():
    print("\t This function needs a Decorator")


So now,

needsDecorator()
This function needs a Decorator

Now, it can happen in your programming that you need to do a specific set of tasks for a function repeatedly, so you may prefer to redefine/reassign your function to your decorated function, such that whenever you call your function by its own name, it is always returned in the modified (decorated) state. For example, see what happens with,

needsDecorator = decorateThisFunction(needsDecorator)   # Reassign needsDecorator to the new decorated state


which upon calling outputs,

needsDecorator()
Some decorating code can be executed here, before calling the input function
    This function needs a Decorator
Some decorating code can be executed here, after calling the input function

What happened above is that we wrapped the function and modified its behavior using a simple decorator. We could have also assigned this new modified (decorated) state of the function to a variable with other name than the function name itself. For example,

test = decorateThisFunction(needsDecorator)
test()
Some decorating code can be executed here, before calling the input function
    This function needs a Decorator
Some decorating code can be executed here, after calling the input function

But, you should have done this before reassigning the function name needsDecorator to its new, decorated, state. If you do this after the reassignment, then this is what you get,

test = decorateThisFunction(needsDecorator)
test()
Some decorating code can be executed here, before calling the input function
Some decorating code can be executed here, before calling the input function
    This function needs a Decorator
Some decorating code can be executed here, after calling the input function
Some decorating code can be executed here, after calling the input function

In other word, you decorate your already-decorated function needsDecorator(), one more time by passing it to decorateThisFunction(). Now, since this functionality is needed frequently in Python, Python has a special syntax for it, the Decorator syntax,

@decorateThisFunction
def needsDecorator():
    print "This function needs a Decorator"


The above statement is an exact equivalent to,

needsDecorator = decorateThisFunction(needsDecorator)   # Reassign needsDecorator to the new decorated state


which we used before to decorate our function.

You may wonder what the use of decorators could be. Decorators can be a handy tool for functionalities that have to be repeated for many functions. For example, Porofiling and timing the performance of functions require the idea of decorators. Most often, decorators are useful and needed in web development with Python.

Pyton classes

The concept of Python class, as in almost any other programming language, relates to packing a set of variables together with a set of functions operating on the data. The goal of writing classes is to achieve more modular code by grouping data and functions into manageable units. One thing to keep in mind for scientific computing is that, classes, and more generally, Object Oriented Programming (OOP), are not necessary, and could be a hinderance to efficient computing if used naively. Nevertheless, classes lead to either more elegant solutions to the programming problem, or a code that is easier to extend and maintain in large scale projects. In the non-mathematical programming world where there are no mathematical concepts and associated algorithms to help structure the programming problem, software development can be very challenging. In those cases, Classes greatly improve the understanding of the problem and simplify the modeling of data. As a consequence, almost all large-scale software systems being developed in the world today are heavily based on classes (but certainly not all scientific projects!).

Programming with classes is offered by most modern programming languages, including Python. Python uses the concept of classes in almost every bit of it. However, most Python users don’t even notice the heavy dependence of Python on classes under the hood, until the actually learn what a class is, just as we have made progress in this class so far, without knowing about classes.

Classes can be used for many purposes in scientific programming and computation. One of the most frequently encountered tasks is to represent mathematical functions that have a set of parameters in addition to one or more independent variables. To expand on this, consider the problem described in the following section.

A common programming challenge in numerical computing

To motivate for the class concept, let’s look at functions with parameters. One example is $y(t) = v_0t-\frac{1}{2}gt^2$. Conceptually, in physics, $y$ is viewed as a function of $t$, but mathematically $y$ also depends on two other parameters, $v_0$ and $g$, although it is not natural to view $y$ as a function of these parameters. One can therefore write $f(t;v_0g)$ to emphasize that $t$ is the independent variable, while $v_0$ and $g$ are parameters. Strictly speaking, $g$ is a fixed parameter (as long as the experiment is run on the surface of the earth), so only $v_0$ and $t$ can be arbitrarily chosen in the formula. It would then be better to write $y(t;v_0). Here is an implementation of this function,

def y(t, v0):
    g = 9.81
    return v0*t - 0.5*g*t**2


This function gives the height of the projectile as a function of time. Now suppose you wanted to differentiate $y$ with respect to $t$ in order to obtain the velocity. You could write the following code to do so,

def diff(f, x, h=1E-5):
    return (f(x+h) - f(x))/h


But, here is the catch with this problem of differentiation. The diff function works with any function f that takes only one argument. In other words, if we want to input y to diff, then we will have to redefine y to take only one argument. You may wonder why not change diff. For this simple problem, this could be a solution. But, with larger problems, you are more likely to use sophisticated routines and modules that have been already developed and many of these routines take a function as input that only has one input variable. This is quite often the case with high-performance integration routines.

One, perhaps bad, solution to the above problem is to use global variables. The requirement is thus to define Python implementations of mathematical functions of one variable with one argument, the independent variable,

def y(t):
    g = 9.81
    return v0*t - 0.5*g*t**2


This function will work only if v0 is a global variable, initialized before one attempts to call the function. Here is an example call where diff differentiates y,

v0 = 3
dy = diff(y, 1)


The use of global variables is in general considered bad programming. Why global variables are problematic in the present case can be illustrated when there is need to work with several versions of a function. Suppose we want to work with two versions of $y(t;v_0)$, one with $v_0=1$ and one with $v_0=5$. Every time we call y, we must remember which version of the function we work with, and set v0 accordingly prior to the call,

v0 = 1; r1 = y(t)
v0 = 5; r2 = y(t)


Another problem is that variables with simple names like v0, may easily be used as global variables in other parts of the program. These parts may change our v0 in a context different from the y function, but the change affects the correctness of the y function. In such a case, we say that changing v0 has side effects, i.e., the change affects other parts of the program in an unintentional way. This is one reason why a golden rule of programming tells us to limit the use of global variables as much as possible.

An alternative solution to the problem of needing two v0 parameters could be to introduce two y functions, each with a distinct v0 parameter,

def y1(t):
    g = 9.81
    return v0_1*t - 0.5*g*t**2
def y2(t):
    g = 9.81
    return v0_2*t - 0.5*g*t**2


Now we need to initialize v0_1 and v0_2 once, and then we can work with y1 and y2. However, if we need $100$ v0 parameters, we need $100$ functions. This is tedious to code, error prone, difficult to administer, and simply a really bad solution to a programming problem.

So, is there a good remedy? The answer is yes: the class concept solves all the problems described above.

Class representation of a function

A class as contains a set of variables (data) and a set of functions, held together as one unit. The variables are visible in all the functions in the class. That is, we can view the variables as “global” in these functions. These characteristics also apply to modules, and modules can be used to obtain many of the same advantages as classes offer (see comments in Sect. 7.1.6). However, classes are technically very different from modules. You can also make many copies of a class, while there can be only one copy of a module. When you master both modules and classes, you will clearly see the similarities and differences. Now we continue with a specific example of a class.

Consider the function $y(t;v_0) = v_0t - \frac{1}{2}gt^2$. We may say that $v_0$ and $g$, represented by the variables v0 and g, constitute the data. A Python function, say value(t), is then needed to compute the value of $y(t;v_0)$ and this function must have access to the data v0 and g, while t is an argument. A programmer experienced with classes will then suggest to collect the data v0 and g, and the function value(t), together as a class. In addition, a class usually has another function, called constructor for initializing the data. The constructor is always named __init__. Every class must have a name, often starting with a capital, so we choose Y as the name since the class represents a mathematical function with name y. The next step is to implement this class in Python. A complete class code Y for our problem here would look as follows in Python:

class Y:
    def __init__(self, v0):
        self.v0 = v0
        self.g = 9.81
    def value(self, t):
        return self.v0*t - 0.5*self.g*t**2


A class creates a new data type, here of name Y, so when we use the class to make objects, those objects are of type Y. All the standard Python objects, such as lists, tuples, strings, floating-point numbers, integers, …, are built-in Python classes, and each time the user creates on these variable types, one instance os these classes is created by the Python interpreter. A user-defined object class (like Y) is usually called an instance. We need such an instance in order to use the data in the class and call the value function. The following statement constructs an instance bound to the variable name y:

y = Y(3)


Seemingly, we call the class Y as if it were a function. Indeed, Y(3) is automatically translated by Python to a call to the constructor __init__ in class Y. The arguments in the call, here only the number 3, are always passed on as arguments to __init__ after the self argument. That is, v0 gets the value 3 and self is just dropped in the call. This may be confusing, but it is a rule that the self argument is never used in calls to functions in classes. With the instance y, we can compute the value of y(t=0.1;v_0=3) by the statement,

v = y.value(0.1)


Note that the self input argument is dropped in the call to value(). To access functions and variables in a class, one must prefix the function and variable names by the name of the instance and a dot: the value function is reached as y.value, and the variables are reached as y.v0 and y.g. One could, for example, print the value of v0 in the instance y by writing,

print y.v0


We have already introduced the term instance for the object of a class. Functions in classes are commonly called methods, and variables (data) in classes are called data attributes. Methods are also known as method attributes. For example, in our sample class Y we have two methods or method attributes, __init__ and value, two data attributes, v0 and g, and four attributes in total (__init__, value, v0, g). Note that the names of attributes can be chosen freely, just as names of ordinary Python functions and variables. However, the constructor must have the name __init__, otherwise it is not automatically called when new instances are created. You can do whatever you want in whatever method, but it is a common convention to use the constructor for initializing the variables in the class.

So far, we have explained a method of writing our function of interest in a class style, which resolves the need to pass a auxiliary variable to a function explicitly. But if you look at the original problem that we had, you will notice that we still cannot use our class Y instance y as an argument to other functions similar to diff(). The final resolution to this problem is to add a __call__ method to our originally defined Y class.

Callable objects

If you recall, computing the value of the mathematical function represented by class Y, with y as the name of the instance, is performed by writing y.value(t). If we could write just y(t), the y instance would look as an ordinary function. Such a syntax is indeed possible and offered by the special method named __call__.

class Y:
    def __init__(self, v0):
        self.v0 = v0
        self.g = 9.81
    def value(self, t):
        return self.v0*t - 0.5*self.g*t**2
    def __call__(self, t):
        return self.v0*t - 0.5*self.g*t**2


then, writing y(t) implies a call like y.__call__(t), which is equivalent to y.value(t). The previous value method is now redundant. A good programming convention is to include a __call__ method in all classes that represent a mathematical function. Instances with __call__ methods are said to be callable objects, just as plain functions are callable objects as well. The call syntax for callable objects is the same, regardless of whether the object is a function or a class instance.

You can always test if an instance is callable or not by callable(),

callable(y)
True



Reference

The book A Primer on Scientific Programming with Python by Hans Petter Langtangen, provides a good starting point on the use of Classes and OOP in Python from a scientific programming perspective. The examples provided in this lecture heavily rely on Langtangen’s notes on Python classes in his textbook in chapter 7. You can download a complete electronic copy of this book for free from Springer website, if you redirect to Springer page from UT Austin library page.



Comments