#!/usr/bin/env python
# coding: utf-8
# # Using functional programming in Python like a boss: Generators, Iterators and Decorators
#
#
# # Features of functions
# 1. First-class functions are objects and thus:
# * Can be assigned to variables
# * Can be stored in data structures
# * Can be used as parameters
# * Can be used as a return value
# 2. Higher order functions:
# * Accept a function as an argument and/or return a function as a value.
# * Create composite functions from simpler functions.
# * Modify the behavior of existing functions.
# 3. Pure functions:
# * Do not depend on hidden state, or equivalently depend only on their input.
# * Evaluation of the function does not cause side effects
#
# In[1]:
# A pure function
def my_min(x, y):
if x < y:
return x
else:
return y
# An impure function
# 1) Depends on global variable, 2) Changes its input
exponent = 2
def my_powers(L):
for i in range(len(L)):
L[i] = L[i]**exponent
return L
# # What can act as a function in Python?
# 1. A function object, created with the `def` statement.
# 2. A `lambda` anonymous function, restricted to an expression in single line.
# 3. An instance of a class implementing the `__call__` function.
# In[2]:
def min_def(x, y):
return x if x < y else y
min_lambda = lambda x, y: x if x < y else y
class MinClass:
def __call__(self, x, y):
return x if x < y else y
min_class = MinClass()
print(min_def(2,3) == min_lambda(2, 3) == min_class(2,3))
# # Common gotchas 1: Mutable Default Arguments
#
# * Python's default arguments are evaluated once when the function is **defined** and **not each time the function is called** (like say, in Ruby).
# * If you use a mutable default argument and mutate it, you will and have mutated that object for all future calls to the function as well.
# * Sometimes you can specifically "exploit" (read: use as intended) this behavior to maintain state between calls of a function. This is often done when writing a caching function.
# In[3]:
def append_to(element, to=[]):
to.append(element)
return to
my_list = append_to(12)
print("my_list:", my_list)
my_other_list = append_to(42)
print("my_other_list:", my_other_list)
def append_to2(element, to=None):
if to is None:
to = []
to.append(element)
return to
my_list2 = append_to2(12)
print("my_list2:", my_list2)
my_other_list2 = append_to2(42)
print("my_other_list2:", my_other_list2)
# # Common gotchas 2: Late Binding Closures
#
# 1. A closure occurs when a function has access to a local variable from an enclosing scope that has finished its execution.
# 2. Python's closures are *late binding*.
# 3. Values of variables used in closures are looked up at the time the inner function is **called** and **not when it is defined**.
# In[4]:
def create_multipliers():
multipliers = []
for i in range(5):
def multiplier(x):
return i * x
multipliers.append(multiplier)
return multipliers
for multiplier in create_multipliers():
print(multiplier(2))
# # Higher order functions and decorators
#
# * Python functions are objects.
# * Can be defined in functions.
# * Can be assigned to variables.
# * Can be used as function parameters or returned from functions.
# * Decorators are syntactic sugar for higher order functions.
#
# In[5]:
# Higher order functions
def makebold(fn):
def wrapped():
return "" + fn() + ""
return wrapped
def hello():
return "hello world"
print(hello())
hello = makebold(hello)
print(hello())
# In[6]:
# Decorated function with *args and **kewargs
def makebold(fn):
def wrapped(*args, **kwargs):
return "" + fn(*args, **kwargs) + ""
return wrapped
@makebold # hello = makebold(hello)
def hello(*args, **kwargs):
return "Hello. args: {}, kwargs: {}".format(args, kwargs)
print(hello('world', 'pythess', where='soho'))
# In[7]:
# Decorators can be combined
def makeitalic(fn):
def wrapped(*args, **kwargs):
return "" + fn(*args, **kwargs) + ""
return wrapped
def makebold(fn):
def wrapped(*args, **kwargs):
return "" + fn(*args, **kwargs) + ""
return wrapped
@makeitalic
@makebold # hello = makeitalic(makebold(hello))
def hello(*args, **kwargs):
return "Hello. args: {}, kwargs: {}".format(args, kwargs)
print(hello('world', 'pythess', where='soho'))
# In[8]:
# Decorators can be instances of callable classes
class BoldMaker:
def __init__(self, fn):
self.fn = fn
def __call__(self, *args, **kwargs):
return "" + self.fn(*args, **kwargs) + ""
@BoldMaker # hello = BoldMaker(hello)
def hello(*args, **kwargs):
return "Hello. args: {}, kwargs: {}".format(args, kwargs)
# hello.__call__(*args, **kwargs)
print(hello('world', 'pythess', where='soho'))
# In[9]:
# Decorators can take arguments
def enclose_in_tags(opening_tag, closing_tag): # returns a decorator
def make_with_tags(fn): # returns a decorated function
def wrapped(): # the function to be decorated (modified)
return opening_tag + fn() + closing_tag
return wrapped
return make_with_tags
# decorator function make_with_tags with the arguments in closure
heading_decorator = enclose_in_tags('
', '
') def hello(): return "hello world" h1_hello = heading_decorator(hello) p_hello = paragraph_decorator(hello) h1_p_hello = heading_decorator(paragraph_decorator(hello)) print(h1_hello()) print(p_hello()) print(h1_p_hello()) # In[10]: # Decorators with arguments combined def enclose_in_tags(opening_tag, closing_tag): def make_with_tags(fn): def wrapped(): return opening_tag + fn() + closing_tag return wrapped return make_with_tags # hello = enclose_in_tags('', '
')(hello) @enclose_in_tags('', '
') def hello(): return "hello world" print(hello()) # hello = enclose_in_tags('', '
')(hello)) @enclose_in_tags('', '
') def hello(): return "hello world" print(hello()) # In[11]: # Decorators with arguments as instances of callable classes class TagEncloser: def __init__(self, opening_tag, closing_tag): self.opening_tag = opening_tag self.closing_tag = closing_tag def __call__(self, fn): def wrapped(): return self.opening_tag + fn() + self.closing_tag return wrapped tag_h1 = TagEncloser('', '
') @tag_h1 @tag_p def hello(): # hello = tag_h1(tag_p(hello)) return "hello world" print(hello()) # # Iterables and Iterators # # 1. **Iteration** is a general term for taking each item of something, one after another. Any time you use a loop, explicit or implicit, to go over a group of items, that is iteration. # 2. An **iterable** is an anything that can be looped over. It either: # - Has an `__iter__` method which returns an **iterator** for that object when you call `iter()` on it, or implicitly in a for loop. # - Defines a `__getitem__` method that can take sequential indexes starting from zero (and raises an `IndexError` when the indexes are no longer valid). # 3. An **iterator** is: # * A stateful helper object which defines a `__next__` method and will produce the next value when you call ``next()`` on it. If there are no further items, it raises the `StopIteration` exception. # * An object that is *self-iterable* (meaning that it has an `__iter__` method that returns `self`). # # Therefore: An *iterable* is an object from which we can get an *iterator*. An *iterator* is **always** an *iterable*. An *iterable* **is not always** an *iterator* but will always **return** an *iterator*. #