Functional Programming in Python#
Do you know Python and would like to learn more about functional programming? Are you wondering what the Scala, F#, and Haskell developers are talking about? Let’s do some functional programming in a language that you already know!
This tutorial that will get you up to speed with functional programming in Python using the Expression functional programming library.
Functional Programming#
Functional programming is about programming with functions, or expressions. An expression is a combination of functions and values that evaluates to a value.
Functional programming can sometimes be seen as difficult, scary or even intimidating. Talks about functional programming are often about Applicatives, Functors, and you might have heard about Category Theory or the infamous Monad. But you don’t need to know about category theory and Monads to do functional programming.
Note that object-oriented programming can in the same way be just as scary with words such as Inheritance, Interfaces, Abstract Base Classes, Covariance, Contravariance and Liskovs substitution principle.
Lets look at how some object oriented programming patterns maps to functional programming:
OO pattern / principle |
FP pattern / principle |
---|---|
Single Responsibility Principle |
Functions |
Open/Closed Principle |
Functions |
Dependency Inversion Principle |
Functions, also |
Interface Segregation Principle |
Functions |
Factory pattern |
Yes, functions |
Strategy pattern |
Oh my, functions again |
Decorator pattern |
Functions |
Vistior pattern |
Functions |
(ref: https://fsharpforfunandprofit.com/fppatterns/)
If there is one takeaway from reading this, it is that functional programming is about expressions (functions and values) that evaluates to a value. This is stuff you most likely already know, and it don’t have to be more difficult than that.
Python#
Python is a multi-paradigm language:
Imperative:
assert a == 10
Object-oriented:
class B: ...
Procedural:
print("test")
… and Functional:
xs = map(lambda x: x*2, [1, 2, 3])
So Python is not a functional-first programming language, but it has some functional programming features that we will explore in this tutorial.
Functions#
A function f : A → B
takes some input A
and produces some output B
:
def add(a, b):
return a + b
add(10, 20)
30
Lambda functions#
Using lambda we can create anonymous functions that we can assing to variables:
add = lambda a, b: a + b
add(10, 20)
30
Pure functions#
Another aspect of functional programming is the concept of pure functions. A pure function is a function where the same input always gives the same output . Pure functions are important for several reasons:
Pure functions are deterministic
Pure function can be unit-tested
Pure functions are thread-safe
Pure functions can be memoized
Pure functions lowers the cognitive load. I.e they can be reasoned about.
Pure functions have no side-effects
We will explore this further when we talk about “Effects and Side-effects”.
Higher-Order Functions#
A function that takes another function as an argument, or returns a function as a result, is called a higher-order function. In order to pass functions around, they need to be what’s called first-class. A first-class function can be used the same way as any other Python value:
Assign a function or a method to a variable or object property.
Pass functions as arguments to other functions or methods.
Return functions as the result from other functions.
Create functions dynamically at runtime.
Assign a function to a variable#
multiply = lambda a, b: a * b
def add(a, b):
return a + b
adding = add
A function returning a function#
def add_n(n):
def partial(x):
return x+n
return partial
lambda n: lambda x: x+n
A function that takes a function as an argument#
def square(x, multiply):
return multiply(x, x)
Create function dynamically at runtime#
Warning
You should normally never use eval because of security risks related to code injection
add_10 = eval("lambda x: x+10")
add_10(20)
30
Currying and Partial Application#
A function that takes two arguments is basically exactly the same as a function that takes one argument and returns a function that takes the second argument.
Some languages like F# supports automatic currying of functions. Python does not support automatic currying of functions, but we can define functions that return functions.
Here is a function that takes two arguments:
add = lambda a, b: a + b
add(10, 20)
30
We can write this function as a function that takes one argument and returns a function that takes the second argument:
add = lambda a: lambda b: a + b
add(10)(20)
30
We do not use currying in the core part of Expression, but the library itself supports the concept using the curried decorator:
from expression import curry
@curry(1)
def add(a: int, b: int) -> int:
return a + b
add(3)(4)
7
This enables partial application where a number of arguments except the last is given to a function. Partial application is used extensively in libraries such as Expression, RxPY and aioreactive.
from expression import pipe
from expression.collections import seq
xs = seq.of_iterable(range(10))
mapping = seq.map(lambda x: x * 10)
filter = seq.filter(lambda x: x > 30)
pipe(xs,
mapping,
filter,
list,
)
[40, 50, 60, 70, 80, 90]
Pipelining#
Making pipelines of operations is well known from the Unix terminal and shell programming.
command_1 | command_2 | command_3 | .... | command_N
F# uses the |>
operator for piping. The definition of the pipe operator is
surprisingly simple:
let (|>) x f = f x
The main idea of pipelining is simply to bring the argument to the left side of the function (instead of the right). The main reason for this is that we are teached to read from left to right. Thus the traditional way of nesting functions is not very easy to read:
import functools
from builtins import filter, map
xs = range(10)
functools.reduce(lambda s, x: s + x, filter(lambda x: x > 50, map(lambda x: x * 10, xs)), 0)
300
Python do not have a pipe operators and overloading existing operators e.g |
is not
recommended. Expression provides a
pipe
function that we can use instead:
from expression import pipe
from expression.collections import seq
xs = seq.range(10)
pipe(
xs,
seq.map(lambda x: x * 10),
seq.filter(lambda x: x > 50),
seq.fold(lambda s, x: s + x, 0),
)
300
The key thing to note is that for pipelining to work, then every function can only take
a single argument. So how can we provide both e.g a mapper and an iterable for the
seq.map
function? The answer is to use currying or higher-order functions. Remember
that a function that takes two arguments is the same as a function that takes the first
argument and returns a function that takes the second argument. Here is how the map
function in the sequence module is defined:
def map(mapper: Callable[[TSource], TResult]) -> Callable[[Iterable[TSource]], Iterable[TResult]]:
def _map(source: Iterable[TSource]) -> Iterable[TResult]:
return (mapper(x) for x in source)
return _map
This allows us to do partial application of functions and have the last function take a single argument. The pipe
operator in Expression is defined as follows:
def pipe(value: Any, *fns: Callable[[Any], Any]) -> Any:
"""Functional pipe (`|>`)
Allows the use of function argument on the left side of the function.
Example:
>>> pipe(x, fn) == fn(x) # Same as x |> fn
>>> pipe(x, fn, gn) == gn(fn(x)) # Same as x |> fn |> gn
...
"""
return compose(*fns)(value)
We see that piping is a one-liner that uses a composition operator. Let’s look more into function composition.
Composition#
In programming we are building programs by combining smaller pieces of code into larger ones. This is very similar to building with lego bricks. Combining many small lego bricks into larger constructions, is more flexible than having a few fixed large size pieces to start with.
Composition is the essence of programming - Bartosz Milewski
// F#
let compose (f: 'a -> 'b) (g: 'b -> 'c) : 'a -> 'c =
f >> g
# Python
define compose(f: Callable[[A], B], g: Callable[[B], C]) -> Callable[[A], C]:
return lambda x: g(f(x))
def f(x):
return x+1
def g(x):
return x*2
# Procedural / imperative composition. The function `h` becomes hard-coded to `f` ang `g`
def h(x):
y = f(x)
z = g(y)
return z
h(42)
86
# Functional compose. Inject all dependencies.
def compose(f, g):
return lambda x: g(f(x))
h = compose(f, g)
h(42)
86
Functional Composition is Associative#
def test(x):
f = lambda x: x + 1
g = lambda x: x * 2
h = lambda x: x - 10
right = compose(f, compose(g, h))
left = compose(compose(f, g), h)
assert right(x) == left(x)
test(10)
The compose
operator in Expression is defined as follows:
from typing import Callable, Any
def compose(*fns: Callable[[Any], Any]) -> Callable[[Any], Any]:
"""Compose multiple functions left to right."""
def _compose(source: Any) -> Any:
return reduce(lambda acc, f: f(acc), fns, source)
return _compose
It is basically an iteration of the functions keeping an accumulator with the currently composed functions.
Believe the Type#
Python is a strongly typed dynamic programming language. This means that the type of a variable is only checked at runtime. Other languages such as F#, Scala, Haskell to type checking at compile time.
def run():
return "abc" + 10
run()
But there are a several static type checkers for Python that we can use:
Mypy: python/mypy
Pyre-check: facebook/pyre-check
Pylance: microsoft/pylance-release
Static type checkers can significantly improve the quality of the code. The problem with Python is that the type checkers have not been able to really say if your code is correct or not. So you really don’t know if it’s the code or the type checker that is right (or wrong).
Industrial Strength Code#
With Expression we are aiming for industral strength code. What is industrial strength?
Marked by more than usual power, durability, or intensity
The difference with “more than usual” and “usual” can be subtle, but even a subtle difference can have a significant effect when you start deploying to 100.000 servers (e.g Exchange, Facebook, etc) instead of a single server. You need to plan for more than being lucky.
Functional programmming is not a silver bullet, but it can
Use simple well-tested abstractions. Don’t reinvent the wheel.
Use immutable data types whenever possible.
Use pure functions. Avoid None-taking/returning methods or functions
Use a static type checker like Pylance (in strict mode)
Use unit-testing and property-based testing for core logic. E.g a library like pytest and Hypotheses
Use single-threaded code to avoid Heisenbugs
Cost of failure increases exponentially the further the bug
Type checks (instant)
Unit tests (seconds)
Integration tests (minutes and hours)
Test deployment (hours and days)
Production deployment (days and months)
Dedicated deployment on-prem at customer location (💸)
Note
“A Heisenbug is a classification of an unusual software bug that disappears or alters its behavior when an attempt to isolate it is made. Due to the unpredictable nature of a Heisenbug, when trying to recreate the bug or using a debugger, the error may change or even vanish on a retry.”