Partial application and composition
Why every Haskell function takes one argument, partial function application, functools.partial, and composing functions into a sensor pipeline.
Do this lesson in the simulatorOnce 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 4is a function: "add 4 to whatever you are given". Its type isInteger -> Integer.- Applying that function to
6gives10.
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))
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))
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")
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
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
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 numbercmthat givescm * factor.offset(amount)returns a function of one numbercmthat givescm + amount.compose(g, f)returns a function of one valuexthat givesg(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
partial(clamp, 0, 60)fixeslowandhigh. Canpartialfix onlyhigh? Trypartial(clamp, high=60)and explain what happens.- Write
compose_all(functions)that composes a whole list of stages, applying the first in the list first. - Give the types of
scale,scale(10)andscale(10)(3.5)in Haskell-style notation.