Partial application and composition

Why every Haskell function takes one argument, partial function application, functools.partial, and composing functions into a sensor pipeline.

A13.4Functional programmingA level25 min

Do this lesson in the simulator

Once functions are values, you can build new functions out of old ones instead of writing each one from scratch. This lesson covers the two ways the exam asks about: giving a function some of its arguments to get a new function (partial application), and joining two functions end to end (composition).

Every function takes one argument

In lesson A13.2, add(3, 4) had type integer × integer → integer: one argument, a pair. Haskell usually writes add differently:

add :: Integer -> Integer -> Integer
add x y = x + y

The arrow -> groups to the right, so the type Integer -> Integer -> Integer means Integer -> (Integer -> Integer). That says: add takes one integer and returns a function from integer to integer. Function application groups to the left, so add 4 6 means (add 4) 6:

  • add 4 is a function: "add 4 to whatever you are given". Its type is Integer -> Integer.
  • Applying that function to 6 gives 10.

Giving a function fewer arguments than it can take, and getting back a function that waits for the rest, is partial function application. In the exam's notation:

add : integer → (integer → integer), and add 4 : integer → integer

Partial application in Python

Python functions normally take all their arguments at once. You can write a function the Haskell way, one argument at a time, by returning a function:

def add(x):
    return lambda y: x + y

add4 = add(4)          # partial application: a new function
print(add4(6))         # 10
print(add(4)(6))       # the same, in one line: (add 4) 6
print(add(100)(-1))

Run this in the simulator

For a function that already takes several arguments, functools.partial fixes the first few and returns a function of the rest:

from functools import partial

def clamp(low, high, x):
    return max(low, min(high, x))

safe_speed = partial(clamp, 0, 60)     # low and high fixed; x still to come
for s in (-20, 35, 95):
    print(s, "->", safe_speed(s))

Run this in the simulator

safe_speed has one parameter left, x, and gives 0, 35 and 60. Partial application is a way of making a specific tool from a general one without writing a new def.

It works on the robot's own commands too. forward takes a speed and a distance; fix the speed and you have a new command:

# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()
from functools import partial

creep = partial(forward, 20)       # speed fixed at 20
dash = partial(forward, 80)        # speed fixed at 80

creep(distance=10)
dash(distance=30)
print("done")

Run this in the simulator

Composition of functions

Composition takes two functions and makes a new one that applies them one after the other. If

f : A → B and g : B → C

then the composition g ∘ f has type A → C, and (g ∘ f)(x) = g(f(x)). It is read "g composed with f", and it means apply f first, then g. The order on the page is the opposite of the order they happen in, which is exactly where exam marks are lost.

The result type of f must fit the argument type of g. Here B is both f's co-domain and g's domain, so every result of f is something g can be applied to.

Take f(x) = x + 2 and g(y) = y³. Then

(g ∘ f)(x) = g(x + 2) = (x + 2)³, so (g ∘ f)(1) = 27

(f ∘ g)(x) = f(x³) = x³ + 2, so (f ∘ g)(1) = 3

Composition is not commutative: g ∘ f and f ∘ g are usually different functions.

Haskell writes ∘ as a full stop:

f x = x + 2
g y = y ^ 3
h = g . f        -- h 1 is 27

h is defined without mentioning its argument at all: it simply is the composition. Python has no composition operator, but a higher-order function gives you one:

def compose(g, f):
    return lambda x: g(f(x))

f = lambda x: x + 2
g = lambda y: y ** 3

print(compose(g, f)(1))    # g after f: (1 + 2) cubed
print(compose(f, g)(1))    # f after g: 1 cubed, plus 2

Run this in the simulator

A sensor pipeline

Composition is how functional programs build pipelines: each stage is a small pure function, and the pipeline is their composition. Suppose the distance sensor reads 2 cm too far, and a display wants millimetres:

def compose(g, f):
    return lambda x: g(f(x))

def offset(amount):
    return lambda cm: cm + amount

def scale(factor):
    return lambda cm: cm * factor

correct = offset(-2)                 # partial application: a stage
to_mm = scale(10)                    # another stage
reading_mm = compose(to_mm, correct) # correct first, then convert

print(reading_mm(38.0))
print(compose(correct, to_mm)(38.0)) # the wrong order

Run this in the simulator

The first gives 360.0: (38 − 2) × 10. The wrong order converts first and subtracts 2 mm instead of 2 cm, giving 378.0. Each stage can be tested on its own, and the pipeline changes by composing different stages, not by editing a loop.

Task: calibrate by composition

Build a calibration pipeline for the distance sensor from small curried functions:

  • scale(factor) returns a function of one number cm that gives cm * factor.
  • offset(amount) returns a function of one number cm that gives cm + amount.
  • compose(g, f) returns a function of one value x that gives g(f(x)).

Using only those three, make pipeline: first offset(-2), then scale(10). Apply it to each reading in RAW = (38.0, 51.5, 20.0) and print the results on one line as mm: 360.0 495.0 180.0. Then compose the same two stages in the wrong order and print its results the same way as wrong order: <three numbers>. Round each result to one decimal place. The robot does not move.

# the two lines every program starts with: the commands, then the robot
from bugbot import *
connect()

RAW = (38.0, 51.5, 20.0)

def pipeline(cm):
    return cm * 10 - 2

print("mm:", pipeline(RAW[0]))

Challenges

  1. partial(clamp, 0, 60) fixes low and high. Can partial fix only high? Try partial(clamp, high=60) and explain what happens.
  2. Write compose_all(functions) that composes a whole list of stages, applying the first in the list first.
  3. Give the types of scale, scale(10) and scale(10)(3.5) in Haskell-style notation.