Closures


In [2]:
def make_multiplier_of(n):
    def multiplier(x):
        return x * n
    return multiplier

In [3]:
times3 = make_multiplier_of(3)

In [4]:
type(times3)


Out[4]:
function

In [5]:
times3(3)


Out[5]:
9

In [6]:
times3(11)


Out[6]:
33

In [7]:
times5 = make_multiplier_of(5)

In [8]:
times5(3)


Out[8]:
15

In [9]:
timessomething = make_multiplier_of()


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-9-fff9220ace35> in <module>()
----> 1 timessomething = make_multiplier_of()

TypeError: make_multiplier_of() missing 1 required positional argument: 'n'

Decorators


In [10]:
def my_decorator(func):
    def inner():
        print("Running my_decorator")
    return inner

In [12]:
def other_func():
    print("Running other_func")

In [13]:
other_func()


Running other_func

And now for the magic moment. This relies on Python's ability to rebind a reference. The function my_decorator will now go by a new name, other_func, and simultaneously other_func is used in the definition of my_decorator. In this bare metal example other_func doesn't realy do anything inside my_decorator. In other words it isn't used, it's merely passed in as an argument (func). Code linters would call foul: if you're going to pass in an argument you should use it. Fair enough. We will get to more interesting examples soon.


In [14]:
other_func = my_decorator(other_func)

In [15]:
other_func()


Running my_decorator

In [16]:
other_func


Out[16]:
<function __main__.my_decorator.<locals>.inner>

In [19]:
@my_decorator
def another_func():
    print("Running another_func")

In [20]:
another_func()


Running my_decorator

Timer decorator


In [31]:
from time import time
from time import sleep

Now we're actuly going to do something with the func that is passed in. We're going to call it right in the middle of our new timer decorator. We do a little work before it is called -- we take a snapshot of the system clock with time() -- and a little more after -- another call to time(). This is the essence of a decorator.


In [45]:
def timer(func):
    def wrapper():
        t1 = time()
        func()
        t2 = time()
        print("{} duration is {}".format(func.__name__, t2 - t1))
    return wrapper

In [46]:
def slow_func():
    sleep(2)

Sticking with the basic/explicit way to create a decorator for the moment....


In [47]:
slow_func = timer(slow_func)

In [48]:
slow_func()


slow_func duration is 2.0084915161132812

And now the fancy "syntatic sugar" way of creating the decorator.


In [49]:
@timer
def even_slower_func():
    sleep(5)

In [50]:
even_slower_func()


even_slower_func duration is 5.005693674087524

Stacked decorators


In [62]:
def italic(fn):
    def _():
        return "<i>" + fn() + "</i>"
    return _

In [63]:
def bold(fn):
    def _():
        return "<b>" + fn() + "</b>"
    return _

In [68]:
@bold
@italic
def hello():
    return "hello world"

In [69]:
print(hello())


<b><i>hello world</i></b>

Seems to me that I've seen this senario used multiple times in the context of decorators, so I suspect it is in a mooc or online tutorial somewhere. This reference might be interesting: https://stackoverflow.com/questions/27342149/decorator-execution-order

Iterables


In [70]:
my_list = [2, 4, 6, 8]

In [71]:
my_first_iterator = iter(my_list)

Let's check this out. What has the call to iter() actually returned?


In [72]:
type(my_list)


Out[72]:
list

In [73]:
type(my_first_iterator)


Out[73]:
list_iterator

In [74]:
dir(my_list)


Out[74]:
['__add__',
 '__class__',
 '__contains__',
 '__delattr__',
 '__delitem__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getitem__',
 '__gt__',
 '__hash__',
 '__iadd__',
 '__imul__',
 '__init__',
 '__iter__',
 '__le__',
 '__len__',
 '__lt__',
 '__mul__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__reversed__',
 '__rmul__',
 '__setattr__',
 '__setitem__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 'append',
 'clear',
 'copy',
 'count',
 'extend',
 'index',
 'insert',
 'pop',
 'remove',
 'reverse',
 'sort']

In [75]:
dir(my_first_iterator)


Out[75]:
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__iter__',
 '__le__',
 '__length_hint__',
 '__lt__',
 '__ne__',
 '__new__',
 '__next__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__setstate__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

In [76]:
for i in my_list:
    print(i)


2
4
6
8

In [77]:
for i in my_first_iterator:
    print(i)


2
4
6
8

An iterator eventually expires... it runs out of stuff and then raises StopIteration. Push it beyond its limit at your own risk.


In [78]:
next(my_first_iterator)


---------------------------------------------------------------------------
StopIteration                             Traceback (most recent call last)
<ipython-input-78-8671ced2c566> in <module>()
----> 1 next(my_first_iterator)

StopIteration: 

In [79]:
my_second_iterator = iter(my_list)

In [80]:
my_third_iterator = iter(my_list)

In [81]:
next(my_second_iterator)


Out[81]:
2

In [82]:
next(my_second_iterator)


Out[82]:
4

In [83]:
next(my_third_iterator)


Out[83]:
2

In [84]:
my_first_iterator()


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-84-3f52f7f9ea11> in <module>()
----> 1 my_first_iterator()

TypeError: 'list_iterator' object is not callable

In [85]:
dir(my_first_iterator)


Out[85]:
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__iter__',
 '__le__',
 '__length_hint__',
 '__lt__',
 '__ne__',
 '__new__',
 '__next__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__setstate__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

In [86]:
my_string = "abcdefg"

In [87]:
for i in my_string:
    print(i)


a
b
c
d
e
f
g

In [89]:
my_string = "ijklmno"

In [90]:
next(my_string)


---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-90-fb9958b5c06d> in <module>()
----> 1 next(my_string)

TypeError: 'str' object is not an iterator

In [91]:
dir(str)


Out[91]:
['__add__',
 '__class__',
 '__contains__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getitem__',
 '__getnewargs__',
 '__gt__',
 '__hash__',
 '__init__',
 '__iter__',
 '__le__',
 '__len__',
 '__lt__',
 '__mod__',
 '__mul__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__rmod__',
 '__rmul__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 'capitalize',
 'casefold',
 'center',
 'count',
 'encode',
 'endswith',
 'expandtabs',
 'find',
 'format',
 'format_map',
 'index',
 'isalnum',
 'isalpha',
 'isdecimal',
 'isdigit',
 'isidentifier',
 'islower',
 'isnumeric',
 'isprintable',
 'isspace',
 'istitle',
 'isupper',
 'join',
 'ljust',
 'lower',
 'lstrip',
 'maketrans',
 'partition',
 'replace',
 'rfind',
 'rindex',
 'rjust',
 'rpartition',
 'rsplit',
 'rstrip',
 'split',
 'splitlines',
 'startswith',
 'strip',
 'swapcase',
 'title',
 'translate',
 'upper',
 'zfill']

In [92]:
my_iter_string = iter(my_string)

In [93]:
next(my_iter_string)


Out[93]:
'i'

In [94]:
dir(my_iter_string)


Out[94]:
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__iter__',
 '__le__',
 '__length_hint__',
 '__lt__',
 '__ne__',
 '__new__',
 '__next__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__setstate__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

Generators

Keep in mind that Generators are lazy and poentailly infinite. Don't let my use of for loops and range() cause you to loose sight of these two features of generators; they're helpful constructs for exploring this space.


In [95]:
def gen_numbers():
    for n in range(9):
        yield n

In [96]:
my_gen = gen_numbers()

In [97]:
type(my_gen)


Out[97]:
generator

In [98]:
for i in my_gen:
    print(i)


0
1
2
3
4
5
6
7
8

In [100]:
for n in range(9):
    print(n)


0
1
2
3
4
5
6
7
8

How might we create something similar to yield if we did not have yield? A class perhaps? Let's try it....


In [118]:
class MyGenNumbers(object):
    def __init__(self):
        self.max = 9
        self.current = -1

    def __next__(self):
        if self.current >= self.max:
            raise StopIteration
        self.current += 1
        return self.current
    
    def __iter__(self):
        return self

In [119]:
my_gen_numbers = MyGenNumbers()

In [120]:
type(my_gen_numbers)


Out[120]:
__main__.MyGenNumbers

In [121]:
for i in my_gen_numbers:
    print(i)


0
1
2
3
4
5
6
7
8
9

Generators, comprehension style

First a list comprehension to refresh on the basic structure of a list comprehension.


In [122]:
my_list = [x * 100 for x in range(10)]

In [123]:
my_list


Out[123]:
[0, 100, 200, 300, 400, 500, 600, 700, 800, 900]

In [124]:
type(my_list)


Out[124]:
list

And now for the coolness... a generator comprehension:


In [125]:
my_generator = (x * 100 for x in range(10))

In [126]:
type(my_generator)


Out[126]:
generator

In [127]:
for i in my_generator:
    print(i - 3)


-3
97
197
297
397
497
597
697
797
897

What did we gain here? Lazyness for starters. Replace range() with something more interesting, say a call to a message queue, and we potentially get infinity too.


In [ ]: