Functional Python

Functionally Educational

Programming Paradigms

  • Procedural
    • Programs define operations to perform
    • Close to the hardware
  • Object-Oriented
    • State and behavior encapsulated in objects
    • Intuitive design for many systems
  • Functional
    • Programming abstractions behave mathematically
    • Good for data processing, parallel computing, science

Functional Concepts

  • Pure functions, immutability, side effects
  • Scope, higher order functions (decorators)
  • Anonymous functions
  • Map, reduce
  • Recursion
  • *Generators
  • *Context managers

Functional Purity

Define three vector functions

\[ f(a, \vec{x}, \vec{y}) = a \vec{x} + \vec{y} \]

\[ g(\vec{x}, \vec{y}) = \vec{x} \oslash \vec{y} \]

\[ h(a, \vec{x}) = \vec{x}^{\circ a} \]

Functional Purity

Does the meaning change with order?

\[ f(a, \vec{x}, \vec{y}) = a \vec{x} + \vec{y} \]

\[ g(\vec{x}, \vec{y}) = \vec{x} \oslash \vec{y} \]

\[ h(a, \vec{x}) = \vec{x}^{\circ a} \]

Functional Purity

Does the meaning change with order?

\[ g(\vec{x}, \vec{y}) = \vec{x} \oslash \vec{y} \]

\[ h(a, \vec{x}) = \vec{x}^{\circ a} \]

\[ f(a, \vec{x}, \vec{y}) = a \vec{x} + \vec{y} \]

Functional Purity

Does the meaning change with order?

\[ h(a, \vec{x}) = \vec{x}^{\circ a} \]

\[ f(a, \vec{x}, \vec{y}) = a \vec{x} + \vec{y} \]

\[ g(\vec{x}, \vec{y}) = \vec{x} \oslash \vec{y} \]

Functional Purity

Does the meaning change with order?

\[ h(a, \vec{x}) = \vec{x}^{\circ a} \]

\[ g(\vec{x}, \vec{y}) = \vec{x} \oslash \vec{y} \]

\[ f(a, \vec{x}, \vec{y}) = a \vec{x} + \vec{y} \]

Functional Purity


functions
def func_f(a, x, y):
    ...

def func_g(x, y):
    ...

def func_h(a, x):
    ...

Functional Purity


inputs
import numpy as np

x = np.random.random(10)
y = np.ones_like(x)
a = 12

Functional Purity


Order
f_1 = func_f(a, x, y)
g_1 = func_g(x, y)
h_1 = func_h(a, x)

h_2 = func_h(a, x)
g_2 = func_g(x, y)
f_2 = func_f(a, x, y)

Do \(f_1\) = \(f_2\), \(g_1\) = \(g_2\), \(h_1\) = \(h_2\) ?

It depends on functional purity

Functional Purity

pure functions
def func_f(a, x, y):
    out = a * x + y
    return out

def func_g(x, y):
    out = x / y
    return out

def func_h(a, x):
    out = x ** a
    return out

Functional Purity

impure functions
def func_f(a, x, y):
    x[:] = a * x + y
    return x

def func_g(x, y):
    x[:] = x / y
    return x

def func_h(a, x):
    x[:] = x ** a
    return x

Functional Purity

  • Both pure and impure functions can be correct
  • Pure functions:
    • Behave in a mathematically predictable way
    • Have outputs which depend exclusively on inputs
    • Dont cause side effects
    • Are Referentially transparent
    • Are much easier to parallelize
    • Restrictive

Knowledge Check

Which of these functions are pure?

global_state = {'value': 1}

def add1(a):
    return a + global_state['value']

def add2(a):
    return a + 1
    
def add3(a):
    out = a + 1
    print(out)
    return out

Knowledge Check

Which of these functions are pure?

def simple_range(up_to):
    out = []
    for a in range(up_to):
        out.append(a)
    return out

def other_defaults(arg1=1, some_list=[1,2,3]):
    out = []
    for val in some_list:
        out.append(arg1 + val)

Immutability


list_1 = [1, 2, 3]
tuple_1 = (1, 2, 3)

list_1[0] = 0
tuple_1[0] = 0


  • Why does python have a tuple? Isn’t a list better?
  • Why would we limit ourselves as developers?

Immutability

Immutability


All race conditions, deadlock conditions, and concurrent update problems are due to mutable variables.

- Robert C. Martin

Can We Mix OO and Functional Benefits?

def double(instance):
    doubled_state = instance.state * 2
    return instance.__class__(doubled_state)

class Class1:
    def __init__(self, state):
        self.state = state

    double = double
  
instance = Class1(1)
double_instance_1 = self.double()
double_instance_2 = double(instance)

Immutable Data Containers


from dataclasses import dataclass

@dataclass(frozen=True)
class MyImmutableData:
    value_1: float
    value_2: float = 2.0

data = MyImmutableData(10.0)
data.value_2 = 2  # FrozenInstanceError

Handling Mutable State

Safe behavior by default, opt into unsafe behavior


import pandas as pd

df = pd.read_csv('my_csv.csv')

safe = df.sort_values()

not_safe = df.sort_values(in_place=True)

Handling Mutable State

Mark mutable attributes

from sklearn.cluster import KMeans
import numpy as np

X = np.array([[1, 2], [1, 4], [1, 0],
             [10, 2], [10, 4], [10, 0]])
             
kmeans = KMeans(n_clusters=2).fit(X)

kmeans.labels_
kmeans.cluster_centers_

Immutability


  • Objects cannot change once created
  • Mutation side effects are eliminated
  • Memory location no longer matters (for correctness)
  • New objects are created by functions
  • Immutable built-ins: tuple, str, frozenset
  • Bottom line: avoid modifying data in-place when possible

Immutability Purity in the Wild


Functions as Parameters


Functions are “first class citizens”

def func1():
    print('func1 called')

def func2(func):
    return func()

func2(func1)

Scope


Scope: the order in which python “sees” names and values (LEGB):

  • Local — functions own indent level

  • Enclosing-function — function containing functions

  • Global (module) — top-level or module names

  • Built-in (Python) — python names (open, list, set, ValueError, …)

Scope

arg = 0

def func_1():
    arg = 1
    
    def func_2():
        arg = 2
        return arg
    
    return func_2()

func_1()

returns: 2

Scope

arg = 0

def func_1():
    arg = 1
    
    def func_2():
        return arg
    
    return func_2()

func_1()

returns: 1

Scope

arg = 0

def func_1():
    
    def func_2():
        return arg
    
    return func_2()

func_1()

returns: 0

Scope

arg = 1

def func1():
    return arg

def func2(func):
    arg = 2
    return func()

func2(func1)

returns 1, Why!?

Scope

arg = 0

def func_1():
    arg = 1
    
    def func_2():
        global arg
        return arg
    
    return func_2()

func_1()

returns 0, why?

Scope

arg = 0

def func_1():
    arg = 1
    
    def func_2():
        nonlocal arg
        return arg
    
    return func_2()

func_1()

returns 1, why?

The function definition determines scope, not its use.

Scope


Note

most of the time, using global or nonlocal is not a good idea.

Decorators (finally)

Decorators are a function that take other functions as inputs.

def decorator(func):
    def wrapper(arg1, arg2):
        new_arg1 = arg1 + 1
        new_arg2 = arg2 + 1
        return func(new_arg1, new_arg2)
    return wrapper

def add(arg1, arg2):
    return arg1 + arg2

add = decorator(add)
print(add(1, 1))

Decorators

@ is used as syntactic sugar.

def decorator(func):
    def wrapper(arg1, arg2):
        new_arg1 = arg1 + 1
        new_arg2 = arg2 + 1
        return func(new_arg1, new_arg2)
    return wrapper

@decorator
def add(arg1, arg2):
    return arg1 + arg2

add(1, 1)

Decorators

Decorators don’t need to replace the old function

registry = []

def decorator(func):
    registry.append(func)
    return func

@decorator
def add(arg1, arg2):
    return arg1 + arg2

print(add(1, 1), len(registry))

Decorators

Use wrap to transfer metedata to new function.

from functools import wraps

def decorator(func):
    @wraps(func)
    def _wrapper(arg1, arg2):
        new_arg1 = arg1 + 1
        new_arg2 = arg2 + 1
        return func(new_arg1, new_arg2)
    return _wrapper

Decorators

*args and **kwargs allow any inputs

from functools import wraps

def decorator(func):
    @wraps(func)
    def _wrapper(*args, **kwargs):
        ...
        return func(*args, **kwargs)
    return _wrapper

What are *args and ** kwargs?

*args is used to unpack positional arguments into a tuple and **kwargs unpacks keyword arguments into a dict.

For example:

def args_demo(*args):
    return args

out = args_demo(1, 2, 3, 4)
# out is a tuple (1, 2, 3, 4)

What are *args and ** kwargs?


def kwargs_demo(**kwargs):
    return kwargs

out = kwargs_demo(one=1, two=2)
# out is a dict {"one": 1, "two": 2}

Decorators in the Wild


functools cache

from functools import cache

@cache
def fibonacci(n):
   if n <= 1:
       return n
   fib1 = fibonacci(n-1)
   fib2 = fibonacci(n-2)
   return fib1 + fib2

Decorators in the Wild


functools partial

from functools import partial

def add(a, b):
    return a + b

partial_add = partial(add, b=2)

print(partial_add(1))
3

Decorators in the Wild


Pytest

import pytest

@pytest.fixture()
def setup_data():
    return [1, 2, 3]

def test_data(setup_data):
    assert len(setup_data)

Decorators in the Wild


Flask

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

Decorators Summary


Decorators:

  • Are functions which take other functions as inputs
  • Can use the @ as shorthand for decorator(func)
  • May return a new function (appears to modify original)
  • Often used to mark or register a callable

Anonymous Functions

Anonymous Functions


Functions with no name

my_list = [1, 3, 2]
other_map = {1: 10, 2: 0, 3: 99}

sort_list = sorted(
    my_list, 
    key=lambda x: other_map[x]
)

Anonymous Functions


Lambda def equivalence

# never give a lambda a name
func1 = lambda x, y, z: x + y + z

# use a def instead
def func1(x, y, z):
    return x + y + z 

Map


Apply a function to each element of a sequence

my_range = range(10)

# square the list
out = map(lambda x: x*x, my_range)


out = (x*x for x in range(10))

Map


from itertools import repeat
from random import randrange

arr_1 = map(randrange, repeat(10, 10))


from random import randrange

arr_1 = [randrange(10) for _ in range(10)]

Reduce

Apply a function to first two elements, then the result to the third, etc.

from functools import reduce
my_range = range(1, 6)

# square the list
out = reduce(lambda x,y: x*y, my_range)

Accumulate

Like reduce, but intermediate values are stored (e.g., cumulative max)



from itertools import accumulate
from random import randrange

rand_ints = (randrange(1000) for _ in range(10))
result = accumulate(rand_ints, max)

Recursion


When a function calls into itself. Has a base case and at least one recursive case.

def fibonacci(n):
   if n <= 1:
       return n
   fib1 = fibonacci(n-1)
   fib2 = fibonacci(n-2)
   return fib1 + fib2

Recursion


Recursion:

  • Works well for parsing recursive data structures (e.g., trees)
  • Easy to overflow the stack
  • Can be difficult to understand, should be used sparingly
  • Good for showing off, coding interviews

Generators


  • A function which suspends and resumes state
  • Useful for memory conservation (large or infinite lists)
  • Capable of two-way communication (premise of python async)
  • Implements the Iterable and Iterators protocol

Generators

def generator():
    val = 0
    while True:
        val += 1
        yield val

iterable_thing = generator()

# no get_item
iterable_thing[0]  # raise TypeError

# but it is *iterable*
for val in iterable_thing:  
    print(val)

Generators


def generator():
    val = 0
    while True:
        val += 1
        yield val

# Also iterator
iterator = generator()
val1 = next(iterator)  # 1
val2 = next(iterator)  # 2
val3 = next(iterator)  # 3
# raises StopIteration when exhausted

Yield From


def generator_0(iterable):
    yield iterable

input_list = [1, 2, 3]

one_el = next(generator_0(input_list))

total = [x for x in generator_0(input_list)]

listout = list(generator(input_list)

Yield From


def generator_1(iterable):
    for a in iterable:
        yield a

input_list = [1, 2, 3]

one_el = next(generator_1(input_list))

total = [x for x in generator_1(input_list)]

listout = list(generator(input_list)

Yield From

yield from drives iteration


def generator_1(iterable):
    for a in iterable:
        yield a

def generator_2(iterable):
    yield from iterable

Coroutines

def generator(value):
    while True:
        value += yield value

# Iterator
iterable = generator(0)

first = next(iterable)
out1 = iterable.send(10)
out2 = iterable.send(5)
out3 = iterable.send(2)
print(first, out1, out2, out3)
0 10 15 17

Generators

Generators are everywhere in python!


my_dict = {1: 1, 2: 2}
items = my_dict.items()  

generator_comp = (x for x in range(10))

Context Mangers


  • Uses the with keyword
  • Useful to manage setup/teardown for different contexts


with open('some_file.txt', 'w') as fi:
    fi.write('my txt')
...

Context Mangers

from contextlib import contextmanager

@contextmanager
def my_open(filename, mode):
    fi = open(filename, mode)
    yield fi
    fi.close()

with my_open('test_file.txt', 'w') as fi:
    fi.write('my txt')
...

Context Mangers

Class context managers are more powerful


class MyOpen:
    def __init__(self, path, mode):
        self._path = path
        self._mode = mode
        self._fi = None
        
    def __enter__(self):
        self._fi = open(self._path)
        return self._fi
        
    def __exit__(self, exc_type, exc_value, exc_tb):
        self._fi.close()

with MyOpen('test_file.txt', 'w') as fi:
    fi.write('my txt')
...

Summary

  • Pure function and unchangeable data are advantageous
  • Avoid shared mutable state (when possible)
  • Functions can be passed to functions
  • Decorators mark or modify a callable
  • Recursion is cool, but not usually the simplest approach
  • Generators suspend function state
  • Context managers are a clean way to handle setup/teardown