Function Composition
We can now highlight first great generalization of this course: a function, we call it compose, that combines any two compatible functions, g and f, into a new function, denoted (g ∘ f), and pronounced g after f, where for any compatible argument, a, (g ∘ f) a is defined as g (f a). By compatible we mean that (1) a is the input type of f, and (2) the output type of f is the input type of g. When this is the case, we can provide a as an input to f and (f a) as an input to g to compute g (f a) as a final result. In this chapter we build up from where we left off in the last chapter to a precise and general mathematical definition of function composition.
Given f : α → α, apply2 returns (f ∘ f)
We start by further analyzing our polymorphic apply2 function from the last chapter. Here it is again.
def apply2: {α : Type} → (α → α) → α → α
apply2 {α: Type
α : Type: Type 1
Type} : (α: Type
α → α: Type
α) → α: Type
α → α: Type
α
| f: α → α
f, a: α
a => f: α → α
f (f: α → α
f a: α
a)
Given any type, α (implicitly), apply2 takes a function, f : α → α, and a value a : α, as its arguments and returns the value of f (f a).
But given our definition of function composition, we can see that this result can also be written as (f ∘ f) a: as we just defined (f ∘ f) a* to be nothing other than (f (f a)). Revisit the first paragraph of this chapter to be sure that's clear.
As an aside, here, again, are a few simple functions from the last chapter, again, for use in examples to follow.
def double: Nat → Nat
double (n: Nat
n : Nat: Type
Nat) := 2: Nat
2 * n: Nat
n
def square: Nat → Nat
square (n: Nat
n : Nat: Type
Nat) := n: Nat
n ^ 2: Nat
2
def exclaim: String → String
exclaim (s: String
s : String: Type
String) := s: String
s ++ "!": String
"!"
def is_even: Nat → Bool
is_even (n: Nat
n : Nat: Type
Nat) := n: Nat
n % 2: Nat
2 == 0: Nat
0
Now consider a simple application of apply2. Before reading any further, be sure that you fully understand what it computes and how.
apply2apply2: {α : Type} → (α → α) → α → αdoubledouble: Nat → Nat5 -- (double (double 5) -- (double ∘ double) 5 -- expect 205: Nat
Now consider how this expression is evaluated. Remember: function application is left associative. The expression, apply2 double 5, is thus evaluated as (apply2 double) 5. The expression (apply2 double) returns a function that then takes a next argument, such as 5, which it then doubles twice. Be sure you see that (apply2 double) is a function.
apply2apply2: {α : Type} → (α → α) → α → αdoubledouble: Nat → Nat5 -- expect 205: Nat(apply2apply2: {α : Type} → (α → α) → α → αdouble)double: Nat → Nat5 -- exactly the same5: Nat
Now a key question: what function is (apply2 double)? Well, it's the function that, when applied to an argument, a, applies double to it and then applies double to that result. It's thus exactly the function, (double ∘ double). Let's bind the name double_after_double to this function.
defdouble_after_double := (double_after_double: Nat → Natapply2apply2: {α : Type} → (α → α) → α → αdouble) -- Sure enough it's a function from Nat to Natdouble: Nat → Nat(double_after_double) -- And it *behaves* just as expecteddouble_after_double: Nat → Natdouble_after_doubledouble_after_double: Nat → Nat0 -- expect 0 (double (double 0))0: Natdouble_after_doubledouble_after_double: Nat → Nat1 -- expect 4 (double (double 1))1: Natdouble_after_doubledouble_after_double: Nat → Nat5 -- expect 20 (double (double 5))5: Nat
Leaving the final argument to be provided later, we see that (apply2 double) is (double ∘ double). More generally, given any type, α, apply2 applied to any function, f : α → α, returns the function, f ∘ f: f composed with itself.
defsquare_after_square :=square_after_square: Nat → Natapply2apply2: {α : Type} → (α → α) → α → αsquare -- square ∘ squaresquare: Nat → Natsquare_after_squaresquare_after_square: Nat → Nat5 -- expect 625 def5: Natexclaim_after_exclaim :=exclaim_after_exclaim: String → Stringapply2apply2: {α : Type} → (α → α) → α → αexclaim -- exclaim ∘ exclaimexclaim: String → Stringexclaim_after_exclaimexclaim_after_exclaim: String → String"Love math" -- "Love math!!""Love math": String
Generalizing to Functions of Different Types
Of course, apply2 is a pretty limited mechanism for composing functions: it can only compose a function, f : α → α, with itself, to return (f ∘ f). As you saw on the homework, we can also glue functions with different types together, as long as the output type of one is the same as the input type of the second.
On the homework, we worked up to defining glue_funs as a polymorphic function that, given any three types, α, β, and γ, takes two functions, g : β → γ and f : α → β along with any argument a : α and that returns (g (f a)), which we now understand to be (g ∘ f) a.
def glue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γ
glue_funs {α: Type
α β: Type
β γ: Type
γ : Type: Type 1
Type} :
(β: Type
β → γ: Type
γ) → -- type of g
(α: Type
α → β: Type
β) → -- type of f
α: Type
α → -- type of a
γ: Type
γ -- result type
| g: β → γ
g, f: α → β
f, a: α
a => g: β → γ
g (f: α → β
f a: α
a)
Let's see an easy example. In this example,
- α is String
- β is Nat
- γ is Bool
-- We need a function f : String → Nat deflen :len: String → NatString →String: TypeNat :=Nat: TypeString.lengthString.length: String → Nat(len) -- String → Nat -- We need a function of type Nat → Bool deflen: String → Natev (ev: Nat → Booln :n: NatNat) :Nat: TypeBool :=Bool: Typen%n: Nat2=2: Nat00: Nat(ev) -- Nat → Bool -- glue_funs composes ev and len into a String → Bool function!ev: Nat → Boolglue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γevev: Nat → Boollenlen: String → Nat"Hello" -- expect false -- Remember application is left associative"Hello": String(glue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γevev: Nat → Boollen)len: String → Nat"Hello" -- expect false -- (glue_funs ev len) is the function we want!"Hello": String(glue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γevev: Nat → Boollen) -- String → Bool -- Applied to a String it gives back a Bool!len: String → Nat(glue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γevev: Nat → Boollen)len: String → Nat"Hello!" -- expect true -- We can even name this function then use it. def"Hello!": Stringev_string := (ev_string: String → Boolglue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γevev: Nat → Boollen)len: String → Natev_stringev_string: String → Bool"Hi!" -- expect false"Hi!": Stringev_stringev_string: String → Bool"Hello" -- expect false"Hello": Stringev_stringev_string: String → Bool"" -- expect true"": Stringev_stringev_string: String → Bool"I Love Logic" -- true"I Love Logic": String
Wow. So glue_funs is in essence a function for gluing together two functions into a new function, where the input of one is the output of the other, given a value to which the whole thing is applied.
Recall that just as with apply2, leaving off the third argument, a, to glue_funs, we will return exactly the function, (g ∘ f). That is, we'll get the function that, when applied to an argument, (a : α), return (g (f a)). What function does that? It's just (g ∘ f). Pronounce this function as g after f. The idea is that it applies g after (to the result of) applying f to a.
glue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γsquaresquare: Nat → Natdoubledouble: Nat → Nat4 -- expect 64 -- Apply glue_funs to first two arguments def4: Natsquare_after_double :=square_after_double: Nat → Natglue_funsglue_funs: {α β γ : Type} → (β → γ) → (α → β) → α → γsquaresquare: Nat → Natdouble -- Apply the resulting function to 4double: Nat → Natsquare_after_doublesquare_after_double: Nat → Nat4 -- (square ∘ double) 4 -- square (double 4) -- square 8 -- 644: Nat
The square_after_double function is (square ∘ double). Indeed, you even pronounce (square ∘ double) as square after double. That makes sense because when you apply (square ∘ double) to an argument a you apply square after you apply double to a.
Self-test: What is the type of the function returned by glue_funs when applied to two function arguments, g : β → γ and f : α → β?
Insight: glue_funs is a general function composition operation! A better name for it is compose! We'll start by calling it compose' and then make some improvements.
In this firt definition we've added explicit parentheses around (α → γ). As → is right associative, doing this leaves the meaning unchanged from above. This is just an exact repeat of glue_funs. But with parentheses, the type of compose' reads better: it takes two functions, of types (β → γ) and (α → β), and returns a function of type (α → γ). That's it!
def compose': {α β γ : Type} → (β → γ) → (α → β) → α → γ
compose' {α: Type
α β: Type
β γ: Type
γ : Type: Type 1
Type} :
(β: Type
β → γ: Type
γ) →
(α: Type
α → β: Type
β) →
(α: Type
α → γ: Type
γ)
| g: β → γ
g, f: α → β
f, a: α
a => g: β → γ
g (f: α → β
f a: α
a)
Self-test: Using compose', define a function, is_even_len : String → Bool, that takes a string and returns true if it's of even length and false otherwise.
-- Answer here. defis_even_lenis_even_len: ?m.46075-- Be sure you can solve it #eval"Love" -- Expect true #eval"Love": ?m.46079"Love!" -- Expect false #eval"Love!": ?m.46098"Love!!" -- Expect true"Love!!": ?m.46117
Function (Also Known As Lambda) Expressions
Our code for compose' is a little more complex than it needs to be. If all we're going to do is use it to return functions, then (1) we don't expect to have to give a third argument, a; and (2) we expect the type of the return value to be a function type. Here's our final definition of compose. It introduces a new idea: that of anonymous function expressions.
def compose: {α β γ : Type} → (β → γ) → (α → β) → α → γ
compose {α: Type
α β: Type
β γ: Type
γ : Type: Type 1
Type} :
(β: Type
β → γ: Type
γ) →
(α: Type
α → β: Type
β) →
(α: Type
α → γ: Type
γ)
| g: β → γ
g, f: α → β
f => (fun a: α
a => g: β → γ
g (f: α → β
f a: α
a))
Note that we've again written the type of compose to emphasize that it takes two functions and returns a function. We then match on the first two function arguments, calling them g and f. Finally, what we return is the value of a new kind of expression: (fun a => g (f a)). It specifies an unnamed function, taking an argument, a, and returning the value of g (f a).
Such a function expression, often called a lambda expression, is defined in Lean by the keyword, fun (you can also use a Greek lower case lambda, λ), then arguments, then =>, then the expression that defines the return value. It's essential to understand that such an expression defines a function: one that is waiting for an argument a and that computes a final result only then.
You can use a function expression anywhere you need a function value. Here are some examples.
-- Here we give new names to old function friends deffun_double := fun (fun_double: Nat → Natn :n: NatNat) =>Nat: Type2 *2: Natn defn: Natfun_square := fun (fun_square: Nat → Natn :n: NatNat) =>Nat: Typen ^n: Nat2 -- Here we pass an unnamed function to composee2: Natcompose (fun (compose: {α β γ : Type} → (β → γ) → (α → β) → α → γn :n: NatNat) =>Nat: Typen%n: Nat2 ==2: Nat0)0: NatString.lengthString.length: String → Nat"Love!""Love!": String
In Lean4, the compose function is Function.compose and the infix notation, ∘, is a convenient way to apply it.
-- Composing functions with Lean infix notation for compose defdouble_after_square := (double_after_square: Nat → Natdouble ∘double: Nat → Natsquare) defsquare: Nat → Natis_even_len'' := (is_even_len'': String → Boolis_even ∘is_even: Nat → BoolString.length)String.length: String → Natdouble_after_squaredouble_after_square: Nat → Nat5 -- expect 505: NatString.lengthString.length: String → Nat"Hello Higher Mathematics!" -- 25"Hello Higher Mathematics!": Stringis_even_len''is_even_len'': String → Bool"Hello Higher Mathematics!" -- false"Hello Higher Mathematics!": String
Welcome to Higher Mathematics
We now have the first really big idea in this course. In elementary mathematics you've always had numbers and ways to combine numbers into new numbers: using operators such as + and *. That is, you've always had an algebra with numbers as basic objects and + and * as operators that you can use to combine them. Now you have a higher algebra with functions as basic objects and composition (∘) an an operator, akin to addition or multiplication, to combine functions into new functions.
Being able to understand, define, and apply the general concept of function composition is a milestone in learning. You will use the concepts embedded in this chapter for the rest of the semester. You should take some time to savor the beautifully concise and powerful concept developed here. Here it is again, expressed even more cleanly.
def compose {α β γ : Type} (g : β → γ) (f : α → β) := λ a => g (f a)
This definition gives names to all of the arguments (before the colon); leaves the return type implicit, which Lean infers; and uses λ instead of fun (for the "fun" of it) to write the function that this compose function is to return.
Extra, extra!
Did you know that Java and Python support lambda expressions? In this section, we'll show you, and present implementations of our apply2 and compose functions in Python. You will now see how to program with higher-order functions in Python. You will also know what we mean when we say that you can expect to be able to do so in many other capable languages, as well. You can run the following code in the VSCode container for this class.
# Here's an ordinary definition of a squaring function
def square(x) : return(x**2)
print(square(5)) # expect 25
# Here's square defined with a Python lambda expression
square = lambda x : x**2
print(square(5)) # Expect: 25
# Here we apply an unnamed lambda to 6; expect 36
print((lambda x : x**2)(6))
# Here's apply2 in Python, where f is a function argument
def apply2(f) :
return lambda x : f(f(x))
# Here we use apply2 to apply a cubing function twice to 2
print(apply2(lambda x: x**3)(2)) # Expect (2^3)^3 = 512
# Here is a general compose in Python; g and f are functions
def compose(g,f) :
return (lambda a : g(f(a)))
# Here's an example of its use; understand this code
cube_after_square = compose((lambda x : x**3),(lambda x : x**2))
print(square_after_cube(3)) # Expect (3^2)^3 = 729
The programming and reasoning principles you learn in Discrete Math and Theory will prove exceptionally valuable to you no matter what languages you are ultimately asked to use for everyday programming.