Key Word(s): Python, closures, decorators



Lecture 5: Thursday, September 19th 2019

Recap

At this point, you should be comfortable with:

  • The command line
  • Basic version control

Now it's time to write some code.

Topics:

  • Recap: How does Python really work?
  • Nested environments
  • Closures
  • Decorators

Recap So Far

It is assumed that you are familiar with the very basics of Python and especially its syntax. For example, you should have reviewed the supplementary Python notebooks that go along with this course. They contain, among other things, the following topics:

  • python types
  • Basic data structures including lists, dictionaries, and tuples
  • How to write user-defined functions including variable numbers of arguments (i.e. the *args and **kwargs syntax)
  • for loops including the indispensible enumerate and zip formats
  • Proper synax for opening files (i.e. the with syntax)
  • Basic exception handling
  • Plotting with matplotlib

Today, we will fill in a few of the gaps by revealing a bit more about what is going on under the hood.

Preliminaries

Before we begin, there is something that you should know about. It's http://pythontutor.com/ and it's a great way to learn what is going on under the hood when you write Python. You can visualize the Python code you write: Visualize. I'll have you test out and visualize some very small scripts using that website.

Note: When trying to embed HTML into your notebook, you need to use the syntax: HTML('url'). pythontutor has a Generate embed code button which will generate the necessary code to embed into your webpage.

Reference Variables

Let's revisit the most basic Python. Here, we'll just assign values to some names.

Note that a variable in Python is called a name. So the assignment statement a = 1 says that the name a is assigned the integer value 1. You can call a a variable too if you like.

In [1]:
from IPython.display import HTML # Allows us to embed HTML into our notebook.
HTML('')
Out[1]:

So what is going on? Well, Python variables are reference variables. You could say "the variable a (b) is assigned to a list" rather than "the list is assigned to the variable a (b)".

From the Python Language Reference, Section 3.1:

Every object has an identity, a type and a value. An object’s identity never changes once it has been created; you may think of it as the object’s address in memory. The ‘is‘ operator compares the identity of two objects; the id() function returns an integer representing its identity (currently implemented as its address).

From Fluent Python: sticksnotboxes

Note, if the example on the website doesn't render, here is the code for you to try in pythontutor.com:

a = [1, 3, 5]
b = a
print("a = {0} and has id {1}".format(a, id(a)))
print("b = {0} and has id {1}".format(b, id(b)))
print("Is b a? {0}".format(b is a))

a.append(7)
print("a = {}".format(a))
print("b = {}".format(b))

Python Types

  • Every variable in Python gets a type
  • Python is a strongly typed language
  • It is also dynamically typed
    • Types are assigned at run-time rather than at compile time as in a language like C
    • This makes it slower since the way data is stored cannot be initially optimal
    • When the program starts you don't know what that variable will point to.

Here is a discussion from Chapter 11: Further Reading in Fluent Python:

Strong versus weak typing

"If the language rarely performs implicit conversion of types, it’s considered strongly typed; if it often does it, it’s weakly typed. Java, C++, and Python are strongly typed. PHP, JavaScript, and Perl are weakly typed."

Static versus dynamic typing

"If type-checking is performed at compile time, the language is statically typed; if it happens at runtime, it’s dynamically typed. Static typing requires type declarations (some modern languages use type inference to avoid some of that). Fortran and Lisp are the two oldest programming languages still alive and they use, respectively, static and dynamic typing."

Frames

Whenever we use Python Tutor we see two columns. The first column is labeled Frames.

What is a frame?

The evaluation of any expression requires knowledge of the context in which the expression is being evaluated. This context is called a frame. An environment is a sequence of frames, with each frame or context having a bunch of labels, or bindings, associating variables with values.

The sequence starts at the "global" frame, which has bindings for imports, built-ins, etc.

In [2]:
HTML('')
Out[2]:

Note, if the example on the website doesn't render, here is the code for you to try in pythontutor.com:

a = [2, 3, 4]
c1 = 2.0**2.0
c2 = [i**2.0 for i in a]
print(c2)

Functions and Environments

Functions are first class in Python. If you don't know what this means, please consult the supplementary Python lecture notes.

In [3]:
HTML('')
Out[3]:

Note, if the example on the website doesn't render, here is the code for you to try in pythontutor.com:

s = 'The lost world...'
len_of_s = len(s)
my_len = len
my_len_of_s = my_len(s)

Defining your own environment

When we apply a user defined function to some arguments, something slightly different happens from what we saw in the previous example:

  1. We bind the names of the arguments in a new local frame
  2. We evaluate the body of the function in this new frame
In [4]:
HTML('')
Out[4]:

Note, if the example on the website doesn't render, here is the code for you to try in pythontutor.com:

def check_oddness(x):
    if x%2 == 0:
        return x / 2.0
    else:
        return (x - 1.0) / 2.0

a = 6.0
n1 = check_oddness(a)

b = 15.0
n2 = check_oddness(b)

Model of Evaluation

The combination of

  • environments
  • variables bound to values
  • functions

together describes a Model of Evaluation. This model can be used to implement an interpreter for a programming language.

Parameters are passed by sharing in Python

Each formal parameter in a function gets "a copy of the reference". Thus the parameters inside the function arguments become aliases of the actual arguments. You could also say: a function gets a copy of the arguments, but the arguments are always references.

Actually, this discussion can be a bit more nuanced than what we just presented. Here are some more detailed references for those interested:

In [1]:
def f(x):
    print(id(x))

Note: id(x) is the memory address where x is stored.

In [2]:
d={'a':17.0, 'b':35.0}
print(id(d))
4443566296
In [3]:
f(d)
4443566296

A few more comments

The binding of names (from Python Execution Model Document)

"The following constructs bind names: formal parameters to functions, import statements, class and function definitions (these bind the class or function name in the defining block), and targets that are identifiers if occurring in an assignment, for loop header, or after as in a with statement or except clause. The import statement of the form from ... import * binds all names defined in the imported module, except those beginning with an underscore. This form may only be used at the module level."

"If a name is bound in a block, it is a local variable of that block, unless declared as nonlocal or global. If a name is bound at the module level, it is a global variable. (The variables of the module code block are local and global.) If a variable is used in a code block but not defined there, it is a free variable."

The lookup of names

A scope defines the visibility of a name within a block. If a local variable is defined in a block, its scope includes that block. If the definition occurs in a function block, the scope extends to any blocks contained within the defining one, unless a contained block introduces a different binding for the name.

When a name is used in a code block, it is resolved using the nearest enclosing scope. The set of all such scopes visible to a code block is called the block’s environment.

In [4]:
import numpy as np
c = 5000.0
def do_integral(function):
    c = 13.0
    # Some algorithm for carrying out an integration
    print(c)

x = np.linspace(-1.0, 1.0, 100)
y = x * x
do_integral(y)
13.0

Towards Intermediate Python

  • Nested environments
  • Closures
  • Decorators

Nested Environments

You can nest the definitions of functions. When you do this, inner function definitions are not even evaluated until the outer function is called. These inner functions have access to the name bindings in the scope of the outer function.

In the example below, in make_statement(), both s and key will be defined.

In [5]:
def make_statement(s):
    def key(k):
        c = (s, k)
        return c
    return key
key_val = make_statement('name: ') # We have captured the first element of the tuple as a "kind of state"
In [6]:
name = key_val('Albert')
print(name)
('name: ', 'Albert')
In [7]:
name2 = key_val('Emmy')
print(name2)
('name: ', 'Emmy')

In key, you have access to s. This sharing is called lexical scoping.

Here is a more explicit explanation: In the line key_val = make_statement('name: '), make_statement() has returned the inner function key and the inner function has been given the name key_val. Now, when we call key_val() the inner function returns the desired tuple.

The reason this works is that in addition to the environment in which a user-defined function is running, that function has access to a second environment: the environment in which the function was defined. Here, key has access to the environment of make_statement. In this sense the environment of make_statement is the parent of the environment of key.

This enables two things:

  1. Names inside the inner functions (or the outer ones for that matter) do not interfere with names in the global scope. Inside the outer and inner functions, the "most lexically local" names are the ones that matter
  2. An inner function can access the environment of its enclosing (outer) function

By the way, you can read about Albert and Emmy.

Closures

Since the inner functions can "capture" information from an outer function's environment, the inner function is sometimes called a closure.

Notice that s, once captured by the inner function, cannot now be changed: we have lost direct access to its manipulation.

def make_statement(s):
    def key(k):
        c=(s, k)
        return c
    return key

This process is called encapsulation, and is a cornerstone of object oriented programming.

Augmenting Functions

Since functions are first class, we might want to augment them to put out, for example, call information, time information, etc.

Example 1

In the following function, timer() accepts a function f as its argument and returns an inner function called inner.

In [8]:
# First we write our timer function
import time
def timer(f):
    def inner(*args):
        t0 = time.time()
        output = f(*args)
        elapsed = time.time() - t0
        print("Time Elapsed", elapsed)
        return output
    return inner
# First we write our timer function
import time
def timer(f):
    def inner(*args):
        t0 = time.time()
        output = f(*args)
        elapsed = time.time() - t0
        print("Time Elapsed", elapsed)
        return output
    return inner

inner accepts a variable argument list and wraps the function f with timers to time how long it takes f to execute.

Note that f is passed a variable argument list (see the supplementary notes).

In [9]:
# Now we prepare to use our timer function
import numpy as np # Import numpy

# User-defined functions
def allocate1(x, N):
    return [x]*N

def allocate2(x, N):
    return x * np.ones(N)

x = 1.0
N = 2**25
print(N)

# Time allocation with lists
my_alloc = timer(allocate1)
l1 = my_alloc(x, N)


# Time allocation with numpy array
my_alloc2 = timer(allocate2)
l2 = my_alloc2(x, N)
33554432
Time Elapsed 0.10662484169006348
Time Elapsed 0.20488190650939941

That seemed pretty useful. We might want to do such things a lot (and not just for timing purposes).

Decorators

Let's recap the pattern that was so useful.

Basically, we wrote a nice function to "decorate" our function of interest. In this case, we wrote a timer function whose closure wrapped up any function we gave to it in a timing construct. In order to invoke our nice decorations, we had to pass a function to the timer function and get a new, decorated function back. Then we called the decorated function.

So the idea is as follows. We have a decorator (call it decorator) that sweetens up some function (call it target).

def target():
    pass
decorated_target = decorator(target)

Python provides what's called syntactic sugar. We can just write:

@decorator
def target():
    pass

Now target is decorated. Let's see how this all works.

In [21]:
@timer
def allocate1(x, N):
    return [x]*N

x = 2.0
N = 2**20
l1 = allocate1(x, N)
Time Elapsed 0.000993967056274414

Very nice! Make sure you understand what happened here. That syntactic sugar hides all of the details.

Example 2

We'll just create a demo decorator here.

In [22]:
def decorate(f):
    print("Let's decorate!")
    d = 1.0
    def wrapper(*args):
        print("Entering function.")
        output = f(*args)
        print("Exited function.")
        if output > d :
            print("My distance is bigger than yours.")
        elif output < d:
            print("Your distance is bigger than mine.")
        else:
            print("Our distances are the same size.")
    return wrapper

@decorate
def useful_f(a, b, c):
    d1 = np.sqrt(a * a + b * b + c * c)
    return d1
Let's decorate!
In [23]:
d = useful_f(1.0, 2.0, 3.0)
Entering function.
Exited function.
My distance is bigger than yours.

A key thing to remmember is that a decorator is run right after the function is defined, not when the function is called. Thus if you had the above decorator code in a module, it would print "Let's decorate!" when importing the module.

Notice that the concept of a closure is used: the state d=1 is captured into the decorated function above.