Python Lesson 6

Lesson outline

  1. Lambda functions

  2. Errors and exception handling

  3. Composing functions

  4. Recursive functions

  5. Iterators and Generators

  6. Function decorators

  7. Currying arguments

  8. Exercises

Lambda functions

Python encourages a functional programming approach, it treats the functions as objects and facilitates the use of functions as arguments. If you have three different functions

def f1(x):
    return x**2
def f2(x):
    import numpy as np
    return np.log10(x)
def f3(x):
    return x**2/(x**2 + 1.0)
def ffunc(x, fa = f1, fb = f2, fc = f3):
    return fc(fb(fa(x)))
######
######
print(ffunc(2.0))
print(ffunc(2.0, fa=f3, fb=f1, fc=f2))
##

In this case we can introduce the so called Python anonymous or lambda functions, to avoid defining functions f1, f2, and f3. These are oneliners consisting of a single statement whose result is the value returned. They are defined using the lambda keyword that implies the definition of an anonymous function. The syntax is lambda /argumentlist/~: expression. These are called anonymous functions because, lacking the def keyword, they have no name. We can use the previous example and introduce as arguments anonymous functions

print(ffunc(2.0, fa=lambda z: z**2/(z**2 + 1.0) , fb= lambda z: z**2, fc=lambda z: np.log10(z)))
print(ffunc(2.0, fa=lambda z: z**2/(z**2 + 1.0) , fb= lambda z: z**4, fc=lambda z: np.log(z)))
##

The use of lambda functions is most often done together with the map or filter functions (Beware that in most, if not all cases, these constructions can be replaced by a list comprehension). Let's see how to combine anonymous functions with the map function.

The map function syntax is map( ffunction , sequence ), and it applies ffunction to each one of the sequence elements. The output of this function is an iterator (however, before Python 3, a list was returned). We can illustrate the use of map using the function that converts from degrees Farenheit to Kelvin defined in the previous lesson.

def Fahren_2_Kelvin(Temp):
    ''' 
    Function to transform from degrees Fahrenheit to degrees Kelvin.

    Input: 

	  Temp   ::   Temperature expressed in degrees Fahrenheit.
    '''     
    return ((Temp - 32.) * (5./9.)) + 273.15 # Notice that 5/9 and 5./9. are not necessarily equal... (Python 2.7)
####
temperatures_in_F = [32, 22, 100, 23, 231, 86]
temperatures_in_K = list(map(Fahren_2_Kelvin, temperatures_in_F))
print(temperatures_in_F)
print(temperatures_in_K)

The use of a lambda function makes unnecessary to define the Fahren_2_Kelvin function

temperatures_in_F = [32, 22, 100, 23, 231, 86]
temperatures_in_K = list(map(lambda Temp: ((Temp - 32.) * (5./9.)) + 273.15, temperatures_in_F))
print(temperatures_in_F)
print(temperatures_in_K)

The lambda function can have several arguments and this can be combined with the possibility of applying map to several lists.

list_A = [10,11,12,13]
iter_B = range(4)
list_C = [0,2,4,6,8,10]
list(map(lambda x, y, z: x - 10 + y + z, list_A, iter_B, list_C))

Note that if the lists have unequal lengths, the resulting iterator ends at the final point of the shortest list.

The filter function allows for a simple and elegant way of selecting which elements in a sequence evaluate to True under a given conditional statement. The syntax is filter( function , ~ /sequence/ ~). In the following example we filter the even values in the 20-th row of the Pascal triangle

pt20 = [1,
 19,
 171,
 969,
 3876,
 11628,
 27132,
 50388,
 75582,
 92378,
 92378,
 75582,
 50388,
 27132,
 11628,
 3876,
 969,
 171,
 19,
 1]
list(filter(lambda x: (x+1)%2, pt20))

The result of filter is also an iterator.

Errors and exception handling

A possible tool to avoid errors in your programs, making them more foolproof, is assert. The syntax of this command is assert (condition), "Warning message string". When the condition evaluates to True the program continues, however if it is False the warning message is print and the program stops with and AssertionError message. The following line of code check whether the time variable is positive or zero before the program continues running

assert (time >= 0), "Negative time value. Not allowed."

This allows for an easy check of your program input to test if the values are sound.

Sometimes, specially when user input is involved, the input may be not what the program expects and you can make the program digest the input and not die miserably. Imagine you expect the user to provide a float or integer as time value. Notice that when you read with input a value it is recorded as a string.

time_string = input(prompt="time parameter value = ")
time = float(time_string)
print("time = {0}".format(time))

If the user provides a non numerical value the code crashes with a ValueError: could not convert string to float. If we want to avoid this we can use a try/except block

def try_float(string_value):
    try:
	return float(string_value)
    except:
	return string_value
#
time_string = input(prompt="time parameter value = ")
#
time = try_float(time_string)
print("time = {0}".format(time))

You can specify wich kind of exception are you trapping with the syntax except ValueError. You can also trap several exception types including them in a tuple.

You can make use of this to keep asking for a value until it is of the correct type.

while (1):
    #
    time_string = input(prompt="time parameter value = ")
    #
    try:
	time = float(time_string)
	break
    except:
	print("Not a number. Try again.")
#
print("time = {0}".format(time))

You can have also some code block run independently of the success or failure of the try block using finally or code that runs if the try block is successful using else.

trials = 0
while (1):
    #
    time_string = input(prompt="time parameter value = ")
    #
    try:
	time = float(time_string)
	break
    except:
	print("Not a number. Try again.")
    else:
	print("Okay. That was a valid number.")
    finally:
	trials += 1
#
print("time = {0}. You needed {1} trials.".format(time, trials))

If needed, you can stop your Python script raising an exception e.g. ~Exception("Argument ", x, " is not a float.")

def Try_Float(value):
    #
    try: 
	return float(value)
    except:
	raise Exception("Argument ", value, " is not a positiver integer.")

A Python script can also be forced to end using the sys library

def Try_Float(value):
    import sys
    try: 
	return float(value)
    except:
	print("Error. Not a valid input.")
	sys.exit(1)

Composing functions in Python

We can define the composition of two functions of a single argument as follows

def compose(g, f):
    def h(x):
	return g(f(x))
    return h

In this way, we can combine the Fahren_2_Kelvin and Try_Float functions

h = compose(Fahren_2_Kelvin, Try_Float)
#
print(1, h("10"))
print(2, h(10))
print(3, h("10.0e2"))
print(4, h("10a"))

If the function f has several variables, this can be coped with using tuple references

def compose(g, f):
    def h(*args, **kwargs):
	return g(f(*args, **kwargs))
    return h
##

If both functions f and g have several variables, then

def compose(g, f):
    def h(*args, **kwargs):
	return g(*f(*args, **kwargs))
    return h
##

For example, the following function computes the parametric dumbell curve

def Dumbell_Curve_Parametric(a,t):
    x = t
    y = a*t**2*np.sqrt(1-t**2)
    return x,y
t_vals = np.linspace(0,1,1000)
x, y = Dumbell_Curve_Parametric(1.2, t_vals)
plt.plot(x,y)
plt.plot(x,-y)

The dist function computes the distance to the origin of a point in the plane

def dist(x,y):
    return np.sqrt(x**2 + y**2)

Therefore, we can compute the distance to the origin of the points in the dumbell curve

h = compose(dist, Campbell_Curve_Parametric)
##
t_vals = np.linspace(0,1,1000)
plt.plot(t_vals,h(1.2,t_vals))

Recursive functions

A recursive function is a function that calls itself during its execution. This implies that, to avoid falling into pitfalls, the function ought to have a valid termination condition. A function that is often used to exemplify recursion is the factorial function n! = n·(n-1)·…·2·1. We can compute the factorial in a simple iterative way as follows (please, be aware that this is a simple implementation of the function and that is neither terribly efficient nor accurate)

def non_rec_factorial(n):
    result = 1
    for iteration in range(2,n+1):
	result *= iteration
    return result

This is a clear example where recursion can be used as n! = n·(n-1)! and the termination condition is 0! = 1. Therefore a recursive implementation of the factorial is

def rec_factorial(n):
    if n==0:
	return 1
    else:
	return n*rec_factorial(n-1)

In this function the termination condition is always fullfilled if the n value is an integer. We can benchmark both functions using the %timeit magic function

%timeit non_rec_factorial(21)
# 997 ns ± 76.4 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)
#
%timeit rec_factorial(21)
#2.2 µs ± 36.4 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

The recursive function is roughly twice slower than the non-recursive one. We can better understand this using the Padovan sequence calculation. The Padovan sequence is the sequence of integers defined by the values P(n) = P(n-2) + P(n-3) with the initial values P(0) = P(1) = P(2) = 1. Therefore P = 1, 1, 1, 2, 2, 3, 4, 5, 7,… (sequence A000931 in the OEIS). We can program the n-th term of this sequence in an iterative and recurrent way

def rec_Padovan(n):
    '''Recursive implementation of the Padovan sequence.'''
    if n <= 3:
	return 1
    else:
	return rec_Padovan(n-2) + rec_Padovan(n-3)
#######
def iter_Padovan(n):
    result = [1, 1, 1]
    #
    for n_val in range(4,n+1):
	new_term = result[0] + result[1]
	result = [result[1], result[2], new_term]
    return result[2]
######
%timeit rec_Padovan(50)
# 94.2 ms ± 746 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)
%timeit iter_Padovan(50)
# 5.72 µs ± 15 ns per loop (mean ± std. dev. of 7 runs, 100000 loops each)

In this case the recursive function is clearly less efficient than the iterative one. The reason of this difference is that when we use the recursive implementation we need to compute several times the same values. For example, if we are computing P(10) one needs P(8) and P(7), and if you check the different values of P(n) needed to get the final result it is obvious that some values are computed several times, imposing a significant computational burden. This can be improved using memoization, i.e. saving the already computed values for example in a dictionary.

Padovan_Sequence = {1:1, 2:1, 3:1}
def rec_save_Padovan(n):
    '''Recursive implementation of the Padovan sequence.'''
    if n not in Padovan_Sequence.keys():
	Padovan_Sequence[n] = rec_save_Padovan(n-2) + rec_save_Padovan(n-3)
    return Padovan_Sequence[n]
###
%timeit rec_save_Padovan(50)
# 123 ns ± 3.74 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

The new implementation is 10⁶ times faster than the previous recursive one and even roughly 50 times faster than the iterative implementation. This will improve for larger argument values.

Iterators and iterables versus generators

The first thing to understand in this section is what is the difference between iterables and iterators. As we have already seen, we can iterate -for example with a for loop- over different objects, e.g. a list, an ndarray, a tuple, or a set. All these objects are iterables. However, not all iterables are also iterators though the reverse is true, an iterator is always an iterable. The difference lies in the fact that iterators have an associated __next__ method that is run when the next() function is applied to the iterator and they can be created from iterables using the iter() function on them. Therefore, an iterator returns one piece of data at a time, when requested by the user.

# List :: iterable but not iterator
lst = [0,1,2,3,4,5]
next(lst)       # Nope
print(sum(lst)) # OK

# ndarray :: iterable but not iterator
ndarr = np.arange(6)
next(ndarr)       # Nope
print(sum(ndarr)) # OK

# tuple :: iterable but not iterator
tpl = (0,1,2,3,4,5)
next(tpl)       # Nope
print(sum(tpl)) # OK

# range :: iterable but not iterator
rng = range(6)
next(rng)       # Nope
print(sum(rng)) # OK

# map :: iterable and iterator
mp = map(lambda x: 2*x, range(6))
print(next(mp))       # Yeah
print(sum(mp)) # OK

When we use for or sum over an iterable, Python automatically transforms it into an iterator. We can also perform this transformation using iter() and adding the __next__ method to the object.

# List :: iterable but not iterator
lst = [0,1,2,3,4,5]
iterator_lst = iter(lst)
print(next(iterator_lst)) 
print(sum(iterator_lst))

# ndarray ::
ndarr = np.arange(6)
iterator_ndarr = iter(ndarr)
print(next(iterator_ndarr))       
print(sum(iterator_ndarr)) 


# tuple ::
tpl = (1,2,3,4,5)
iterator_tpl = iter(tpl)
print(next(iterator_tpl))
print(sum(iterator_tpl))


# range :: iterable but not iterator
rng = range(6)
iterator_rng = iter(rng)
print(next(iterator_rng))
print(sum(iterator_rng))

You can check that calling next once more over these objects once the sum is performed will raise an StopIteration exception. You can also use the iter function to test if an object is an iterable as it can be applied only to iterables

def check_iterable(obj):
    try:
	iter(obj)
	return True
    except:
	return False

Another concept related to iterators is the concept of a generator. A generator is a type of function that helps creating iterators. We can see how can we transform lists or tuples or other iterables into iterators using iter(). This is done -in a transparent way for the user- everytime a for loop is run over a list or other iterables that are not iterators.

some_states = ["Connecticut", "California", "Washington", "Texas", "New York", "Ohio", "Vermont", "Florida", "Utah"]

state_iterator = iter(some_states)
while 1:
    try:
	state = next(state_iterator)
	print(state)
    except StopIteration:
	break

Doing the same with a hash results in iterating over the dict keys.

some_capitals = {"Connecticut": "Hartford", "California": "Sacramento", "Washington": "Olympia", "Texas": "Austin", "New York": "Albany", "Ohio": "Columbus", "Vermont": "Montpelier", "Florida": "Tallahassee", "Utah": "Salt Lake City", "Montana": "Helena"}

capital_iterator = iter(some_capitals)
while 1:
    try:
	state = next(capital_iterator)
	print(state, "\t-->   ", some_capitals[state])
    except StopIteration:
	break

The standard way to build iterators in Python is making use of generators. A generator is very similar to a function, with the difference that it inludes yield statements that is the statement that turns a function into a generator. The generator outputs a series of results instead of a single object as a regular function. The way to access the different results provided by the generator is by calling it repeteadly, as in a for loop. Everytime the generator is invoked the code in the generator is run until a yield statement is found and the corresponding output is returned. The execution stops here and proceeds once the generator is called again, until a new instance of yield is found. The local variables still exist and retain their previous values, which is utterly different to what happens with functions. There may be several yield instances in the generator code, and if a return statement is found the code will stop raising a StopIteration exception. The return value of a generator is an iterator, and as the next function is run for the generator it will provide, one-by-one, the expected output values. A simple generator that outputs the same state names that our previous list is

def state_names():
    yield "Connecticut"
    yield "California"
    yield "Washington"
    yield "Texas"
    yield "New York"
    yield "Ohio"
    yield "Vermont"
    yield "Florida"
    yield "Utah"
state = state_names() # state is a generator
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))
print(next(state))

Once the last yield statement has produced its ouput, a further next call finishes raising a StopIteration exception. Once you start retreiving values from a generator it cannot be reset, but you can creat a new one.

state = state_names() 
print(next(state))
print(next(state))
state = state_names()
print(next(state))
print(next(state))

A simple example of a generator that implements a counter follows

def counter(first_value = 0, step = 1):
    counter = first_value
    while 1:
	yield counter
	counter += step
#
#
# counter
cnt = counter(first_value=1, step=2)
for value in range(12):
    print(next(cnt), end=" ")
#
print()
# new counter
cnt = counter(first_value=0, step=0.25)
for value in range(12):
    print(next(cnt), end=" ")

In case the expression used in the generator is a simple one, you can use a generator expression which is quite similar to a list comprehension. The following example outputs sequentially the square root of odd numbers less than ten

genexample = (i**0.5 for i in range(1,10,2))
####
for result in genexample:
    print(result, end = ", ")

We can implement the Padovan sequence in a generator that will provide the corresponding term of the sequence upon invocation

def Padovan():
    '''Generator providing Padovan sequence terms.'''
    #
    counter = 0
    #
    terms = [1, 1, 1]
    #
    while 1:
	if counter < 3:
	    yield terms[0]
	else:
	    new_value = terms[0] + terms[1]
	    yield new_value
	    terms = [terms[1], terms[2], new_value]
	counter += 1
##############################################
padovan_gen = Padovan()
for value in range(12):
    print(next(padovan_gen), end=" ")
#
print()
# Note the termination condition
padovan_gen = Padovan()
for value in padovan_gen:
    print(value, end=" ")
    if value > 50:
	break

It is important to take care of including a termination condition in this case. We can do this in several ways. A possible option is to include this termination into the generator making use of a return statement.

def Padovan_n(n=10):
    '''Generator providing the first n terms of the Padovan sequence upon sequential calling.'''
    #
    counter = 0
    #
    terms = [1, 1, 1]
    #
    while 1:
	if counter < 3:
	    yield terms[0]
	elif counter == n:
	    return terms[0] + terms[1]
	else:
	    new_value = terms[0] + terms[1]
	    yield new_value
	    terms = [terms[1], terms[2], new_value]
	counter += 1
##############################################
padovan_gen = Padovan_n(15)
for value in padovan_gen:
    print(value, end=" ")

There is another possibility that consists of calling the original generator from another generator that implements the termination condition.

# Invoking a generator from a generator
def first_n_terms(generator, n = 10):
    gen = generator()
    for iteration in range(n):
	yield next(gen)
####
list(first_n_terms(Padovan, n=20))

You can also send a value to a generator, making use of the send method as in this simple example, making the generator act as a coroutine.

def coroutine_gen():
    print("Coroutine has been invoked for a first time")
    while 1:
	value = yield("thingy")
	print("Value received is", value)
####
corout = coroutine_gen()
#
print(next(corout))
print(next(corout))
print(corout.send("Howdy?"))

The variable value takes the value sent through the send method and it is None in case no value is sent. Applying the send method is equivalent to a next instance and the corresponding yield statement value is returned. A generator has to be started with a next statement before the send method is applied. We can make use of this method to program a generator that is a counter that can be modified at will

def counter(first_value = 0, step = 1):
    counter = first_value
    while 1:
	value_sent = yield counter
	#
	if value_sent == None:
	    counter += step
	else:
	    counter = value_sent
#
# counter
cnt = counter(first_value=0, step=2)
print(next(cnt))
print(next(cnt))
print(cnt.send(10))
for value in range(10):
    print(next(cnt), end=" ")
#
print()
print(cnt.send(100))
for value in range(10):
    print(next(cnt), end=" ")
#

From Python 3.3 you can use the yield from expr statement, where expr evaluates to an iterable. This allows removing unnecessary for loops. For example, these two generators are completely equivalent

def gen_1(N = 10):
    for n in range(N):
	yield n
###################
def gen_2(N = 10):
    yield from range(N)
########################
g1 = gen_1()
g2 = gen_2()
##########################
for index in g1:
    print(index, end=", ")
print()
for index in g2:
    print(index, end=", ")

This allows for the combination of generators in an easy and direct way. Note that, as we remarked when noticing the difference between range and arange, a generator can provide substantial memory gains when facing intensive calculations and data reading.

Function decorators

A decorator is a callable object able to modify a function (or a class), and upon its it returns a modified version of the function (or class). The defines and returns a function, called the wrapper. This is a simple example of a decorator

def example_decorator(func):
    '''
    This is a simple decorator that prepends and appends to a function a message 
    indicating the function name.
    '''
    def function_wrapper(x):
	print("Going to run function " + func.__name__ + " with argument " + str(x))
	func(x)
	print("Function " + func.__name__ + " has been executed.")
    return function_wrapper

def foo(x):
    print("Hi, function foo has been called with argument " + str(x))

print("Run undecorated foo ::")
foo("Howdy?")

print("Define decorated foo.")
foo = example_decorator(foo)  # two foo function versions!

print("Run decorated foo ::")
foo("Hello!")

In order to avoid having two different versions of the same function name foo as in our previous example, the usual syntax for decoration in Python is different. We can replace the statement foo = example_decorator(foo) by @example_decorator just in front of the decorated function.

@example_decorator
def foo(x):
    print("Hi, function foo has been called with argument " + str(x))

print("Run decorated foo ::")
foo("Hello!")

Using this syntax we can use our decorator with any single-argument function but not third-party functions that have been imported from modules. In this case you should use the previous syntax.

@example_decorator
def Fahren_2_Kelvin(Temp):
    ''' 
    Function to transform from degrees Fahrenheit to degrees Kelvin.

    Input: 

	  Temp   ::   Temperature expressed in degrees Fahrenheit.
    '''     
    return ((Temp - 32.) * (5./9.)) + 273.15 # Notice that 5/9 and 5./9. are not necessarily equal... (Python 2.7)
#################################
Fahren_2_Kelvin(222)

The previous decorator can be easily extended to accept functions with any number of arguments using tuple references

def example_decorator(func):
    '''
    This is a simple decorator that prepends and appends to a function a message 
    indicating the function name.
    '''
    def function_wrap(*posargs, **keywargs):
	print("Going to run function " + func.__name__)
	print("with positional arguments: ",*posargs)
	print("and keyword arguments: ", **keywargs )
	ffun = func(*posargs, **keywargs)
	print("Function result ", ffun)
	print("Function " + func.__name__ + " has been executed.")
    return function_wrap

@example_decorator
def Fahren_2_Kelvin(Temp):
    ''' 
    Function to transform from degrees Fahrenheit to degrees Kelvin.

    Input: 

	  Temp   ::   Temperature expressed in degrees Fahrenheit.
    '''     
    return ((Temp - 32.) * (5./9.)) + 273.15 # Notice that 5/9 and 5./9. are not necessarily equal... (Python 2.7)

@example_decorator
def bmi_range(weight, height):
    '''
    Body mass index

    Input: 
	weight (kg)
	height (m)
    '''
    def bmi_val(weight, height):
	return weight/height**2
    #
    bmi_value = bmi_val(weight, height)
    #
    if bmi_value < 15:
	bmi_r = "Very severely underweight"
    elif bmi_value < 16:
	bmi_r = "Severely underweight"
    elif bmi_value < 18.5:
	bmi_r = "Underweight"
    elif bmi_value < 25:
	bmi_r = "Normal(healthy weight)"
    elif bmi_value < 30:
	bmi_r = "Overweight"
    elif bmi_value < 35:
	bmi_r = "Obese Class I (Moderately obese)"
    elif bmi_value < 40:
	bmi_r = "Obese Class II (Severely obese)"
    else:
	bmi_r = "Obese Class III (Very severely obese)"
    #

Fahren_2_Kelvin(214)
bmi_range(78, 1.66)

A common use of decorators is to perform a test of the validity of the arguments. For example, the different implementations of the Padovan sequence we have introduced depend on a single positive integer argument. We can define a decorator that tests wether the argument is a natural number or not

def test_positive_integer(f):
    '''
    Decorator to test if the argument of a given function is a positive integer
    '''
    def tester(x):
	if type(x) == int and x > 0:
	    return f(x)
	else:
	    raise Exception("Argument ", x, " is not a positiver integer.")
    return tester
###############################################################################
@test_positive_integer
def rec_Padovan(n):
    '''Recursive implementation of the Padovan sequence.'''
    if n <= 3:
	return 1
    else:
	return rec_Padovan(n-2) + rec_Padovan(n-3)
#########
@test_positive_integer
def iter_Padovan(n):
    result = [1, 1, 1]
    #
    for n_val in range(4,n+1):
	new_term = result[0] + result[1]
	result = [result[1], result[2], new_term]
    return result[2]

print(rec_Padovan(10))
print(rec_Padovan(11.2))
####
print(iter_Padovan(10))
print(iter_Padovan(10.))

Another example where a decorator can be of interest is if we want to keep track of how many times a function has been called. This is a general case, that can cope with several positional and keyword arguments

def call_func_counter(f):
    def helper(*args, **kwargs):
	helper.counter += 1
	return f(*args, **kwargs)
    helper.counter = 0

    return helper
########################
@call_func_counter
def Fahren_2_Kelvin(Temp):
    ''' 
    Function to transform from degrees Fahrenheit to degrees Kelvin.

    Input: 

	  Temp   ::   Temperature expressed in degrees Fahrenheit.
    '''     
    return ((Temp - 32.) * (5./9.)) + 273.15 # Notice that 5/9 and 5./9. are not necessarily equal... (Python 2.7)
#################
@call_func_counter
def bmi_range(weight, height):
    '''
    Body mass index

    Input: 
	weight (kg)
	height (m)
    '''
    def bmi_val(weight, height):
	return weight/height**2
    #
    bmi_value = bmi_val(weight, height)
    #
    if bmi_value < 15:
	bmi_r = "Very severely underweight"
    elif bmi_value < 16:
	bmi_r = "Severely underweight"
    elif bmi_value < 18.5:
	bmi_r = "Underweight"
    elif bmi_value < 25:
	bmi_r = "Normal(healthy weight)"
    elif bmi_value < 30:
	bmi_r = "Overweight"
    elif bmi_value < 35:
	bmi_r = "Obese Class I (Moderately obese)"
    elif bmi_value < 40:
	bmi_r = "Obese Class II (Severely obese)"
    else:
	bmi_r = "Obese Class III (Very severely obese)"
    #
    return bmi_r
########################
Fahren_2_Kelvin(214)
Fahren_2_Kelvin(314)
bmi_range(78, 1.66)
print(Fahren_2_Kelvin.counter)
print(bmi_range.counter)

def farewell(expr):
    def farewell_decorator(func):
	def f_wrapper(*posargs, **kwargs):
	    print(expr)
	    print(func.__name__ + " returns:")
	    return func(*posargs, **kwargs)
	return f_wrapper
    return farewell_decorator

#########################
@farewell("Ciao")
def foo(x):
    return 0.5*((x)**2 + (x)**0.5)

foo(100)

#########################
@farewell("Sayonara baby")
def foo_2(x, y):
    return 0.5*((x)**2 + (y)**2)**0.5

foo_2(20, 22)

We can also avoid the use of the @decorator syntax as follows

def foo_3(x, y, z):
    return (x*y*z)**(1/3)
################
print(foo_3(20, 22, 25))
################
foo_3 = farewell("Bye")(foo_3)
print(foo_3(20, 22, 25))

A very interesting decorator purpose it to perform memoization in a transparent way. Let's assume that we have a function, func, that is computationally costly and it may be worth to save the obtained results for each evaluation. This can be easily done through a hash, and including the hash in a decorator allows for a clean and transparent implementation. We define a decorator called memoize

def memoize(func):
    memo_hash = {}
    def memoization(x):
	if x not in memo_hash:            
	    memo_hash[x] = func(x)
	return memo_hash[x]
    return memoization

We can combine two decorators and we will do this to check how this memoization decorator works in an example, combining it with the decorator farewell defined above

@memoize
@farewell("¡¡Hola!!")
def f(x):
    import numpy as np
    return np.sin(x)*np.exp(-x)/(x-0.5)

In this case we first apply to f the farewell decorator, followed by the memoize decorator. Therefore, everytime the function f is executed the ¡¡¡Hola!!! message will be displayed. We can examine the memoization in action as follows

print(f(2))
print(f(2))

Notice how the order of the decorators is very relevant and changes the final output.

Currying functions

Currying -named after the mathematician Haskell Curry- is a functional desing pattern. It is the technique of converting a function that takes multiple arguments into a sequence of functions that each takes a single argument. Therefore, currying a function F = f(x,y,z)/ that takes three arguments, creates three functions such that /h = g(x), j=h(y) F = j(z) or F = g(x)(y)(z).

We provide an approach to currying using the partial function from the functools library that allows for binding the arguments of a function and the signature function from the inspect library.

We first introduce partial. If we have a function that computed the distance of a point (x,y,z) given in Cartesian coordinates to the origin

def dist_3D(x,y,z):
    import numpy as np
    return np.sqrt(x**2+y**2+z**2)
##

Let's assume that we are limited to work in two dimensions, in the z = 2 plane. Using partial, we can fix z = 2 and use an anonymous function

from functools import partial
dist_2D = partial(dist_3D, z=2)
dist_2D(3,3)
##

You can also include new default values for existing keyword arguments.

Let's assume that we have a function prdct(x,y,z) = xyz and we intend to curry it into cprod(x)(y)(z) = xyz. We can perform this using partial as follows

def prodct(x,y,z):
    return x*y*z
#
prod_first = partial(prodct, 3) # binding first argument
prof_second = partial(prod_first, 5) # binding second argument
#
print(prodct(3,5,7), prof_second(7)) 

This has built a chain of functions but it is not a very elegant way. Let's improve it using partial in a recursive way as a decorator and the signature function that provides a list of arguments of the function

def prodct(x,y,z):
    return x*y**2*z**3
print(prodct(3,5,7))

We define and apply the decorator

from inspect import signature
#
def currying(function):
    #
    def inner_arg(argument):
	# check if there is only one argument
	if len(signature(function).parameters) == 1:
	    return function(argument)
	# 
	return currying(partial(function,argument))
    #
    return inner_arg
#
@currying
def prodct(x,y,z):
    return x*y**2*z**3
#
prodct(3)(5)(7)

Exercises

  • Exercise 6.1: Build a set of N=100 random coordinate values x, y, and z with values between -10 and 10. Using an anonymous function, select which of those points are at a distance less than a given limit dmin to the origin.

  • Exercise 6.2: Define a function that uses recursion to compute the n-th line of the Pascal triangle (1; 1, 1; 1, 2, 1; 1, 3, 3, 1;…).

  • Exercise 6.3: Define a generator that, using the Eratosthenes sieve (see Exercises Lesson 4), outputs a prime number each time it is called up for prime numbers below a given integer value.

  • Exercise 6.4: We have included memoization in the calculation of the Padovan sequence using a hash. Using a decorator make it transparent and also prepare a recursive implementation of the Tribonacci sequence, a third order sequence defined by T[0] = 0, T[1] = T[2] = 1 and T[n] = T[n-1] + T[n-2] + T[n-3] for n>1. Compare the speed of the implementations with and without the memoization decorator.

  • Exercise 6.5: Use as a starting point the password generating function defined in Exercise 4.2. Add an argument that fix the generated parameter length. Then prepare a decorator that would (1) ensure that the argument is an integer or can be transformed into an integer; (2) check that the integer argument value is larger than or equal than six; and (3) perform the same task as in Exercise 4.2, check that there are at least two digits, two uppercase, and two lowcase characters and return the compliant password and the number of times the function has been run to get this password.

Last updated