λ-Calculus, Church Encodings and Combinators [🌱]
Caution
NEED A INTRO TO LAMBDA CALC.
First, lets define "calculus"
A particular method or system of calculation or reasoning.
What you're probably used to hearing refered to as just "calculus" is actually refering to differential calculus and integral calculus (or as the group infinitesimal calculus). This is your intro to derivatives, integrals, etc.
Identifier expression ::= variable
Application | expression expression
Abstraction | λ variable.expression
Grouping | (expression)
JS | |
---|---|
x |
|
(a) |
Applying a function to its arguments in λ-calculus is just juxtaposition, a space between. In λ-calculus all functions are unary (/ˈyo͞onərē/), which means they have a single argument. So in the case of applying multiple arguments, the functions are curried.
JS | |
---|---|
f(a) |
|
f(a)(b) |
Parens can be used to disambiguate parts of the function.
JS | |
---|---|
(f(a))(b) |
Since function application is left-associative (meaning it's left to right), they are unneeded in this case. But parens can be used to force evaluation in a different order.
JS | |
---|---|
f(a(b)) |
const identity = x => x
)
=>
in a JS lambda)
const identity = x
)
x
)
The
This is called alpha equivalence, as Wikipedia defines it
A basic form of equivalence, definable on lambda terms, is alpha equivalence. It captures the intuition that the particular choice of a bound variable, in a lambda abstraction, does not (usually) matter.
Caution
Need an image for
Type | JS | Notes | |
---|---|---|---|
Variables¹ | x |
||
Variables² | (x) |
||
Application¹ | f(a) |
||
Application² | f(a)(b) |
Curried | |
Application³ | (f(a))(b) |
Uneeded. Left Associative | |
Application⁴ | f((a)(b)) |
Force different order | |
Abstractions¹ | a => b |
||
Abstractions² | a => b(x) |
Is greedy. Swallows all to the right | |
Abstractions³ | a => (b(x)) |
Disambiguate. But not needed | |
Abstractions⁴ | (a => b)(x) |
Apply a => b to x
|
|
Abstractions⁵ | a => b => a |
Nested functions. |
A beta reduction (also written
$\beta$ reduction) is the process of calculating a result from the application of a function to an expression.
The function
Combinatory logic is a theoretical framework in mathematical logic and the study of lambda calculus. It deals with expressing computations using function combinators, which are abstract, pure functions with no free variables. Combinatory logic was developed by Moses Schönfinkel and Haskell Curry in the early 20th century and is a fundamental part of the foundations of functional programming.
Combinators are pure functions that take other functions as arguments and return new functions. They are used to combine and manipulate functions. Combinators have no free variables. A free variable is some variable in a function body that is not bound to some parameter. In other words, external data or context.
Not all parameters HAVE to be used. Both
One of the primary operations in combinatory logic is function application, which is achieved by applying one function (combinator) to another function or argument.
Combinatory logic uses lambda abstraction, represented by the Greek letter
Some combinators are designed to find fixed points of functions, which are inputs that, when applied to the function, result in the same output as the input. The most famous fixed-point combinator is the
In combinatory logic, the goal is to eliminate variables and represent all operations solely through function combinators. This can lead to concise and purely functional representations of complex computations.
- Bound variables are variables used in an abstraction which appear in the head of the abstraction. (parameters)
- Free variables are variables used in an abstraction which do not appear in the head, their value is taken to be unknown within the context of the abstraction. (global variables)
The
The
const I = a => a;
id 5 == 5
Identity of identity is identity.
I(I) === I;
The
The
const M = f => f(f);
It takes a function as input and invokes that function passing in itself. Self-application combinator.
M(I);
//> Function I
M(M);
//> Infinite call. Callstack exceeded error.
// Halting problem.
The
const K = a => b => a;
const 7 2 == 7
It takes an a
and a b
and then returns the a
. A function that is fixated on a particular value. This is the Constant combinator.
K(I)(M);
//> Function I
K(K)(M);
//> Function K
const K5 = K(5);
K5(3);
//> 5
K5(9);
//> 5
K(I)(x)(y) === y
//> true
// K(I)(x) === I so K(I)(x)(y) == I(y) == y
// So K(I) takes two values and returns the second (Opposite of K).
// This is a derivation of the next (KI)...
The
In combinatory logic, both
const KI = a => b => b;
// const KI = K(I);
KI(4)(9);
//> 9
Takes a function and two arguments and calls the function with the arguments reversed.
const C = f => a => b => f(b)(a);
flip const 1 8 == 8
C(K)(I)(M)
//> Function M
// C(K) == KI. Takes two things and returns the second.
C(K)(1)(2);
//> 2
KI(1)(2);
//> 2
K(I)(1)(2);
//> 2
Its function application. If its "true" then it selects the first expression. If its "false" it selects the second expression.
Functions that select either the first or the second expression already exist!
const T = K;
const F = KI;
We can add a little helper to make this easier to read (in JS).
const util = require('node:util');
T[util.inspect.custom] = () => `True (K)`;
F[util.inspect.custom] = () => `False (KI)`;
Caution
Need excalidraw image.
So this is selecting between two possibilities. We already have that defined. The defined "booleans" themselves already negate themselves.
// K
const T (F)(T);
Kestrel (
// KI
const F (F)(T);
Meanwhile, the Kite (
So
const NOT = p => p(F)(T);
Not(T)
//> False (KI)
Not(F)
//> True (K)
So we need a function that takes two arguments. If the first argument is F
then we can short circuit and return F
. If it is T
then we need to check the second argument. Since both T
and F
are themselves functions that take two arguments, T
returning the first and F
returning the second.
// If p is F, then p. Else whatever q is.
const AND = p => q => p(q)(p);
So if p
is F
, it will just return the second argument which should be F
. But since we just determined that p
is F
we can just use itself. If p
is T
it will be whatever q
is. T
or F
since they both have to be T
for the expression to be T
.
AND(T)(T)
//> True (K)
AND(T)(F)
//> False (KI)
AND(F)(T)
//> False (KI)
AND(F)(F)
//> False (KI)
Like AND
, its a function that takes two arguments. If the first argument is T
then we can short circuit in the T
direction this time. And like AND
we can let it determine itself from the F
path. And, again, since we know p
is T
in the first place, we can just use itself.
// If p is T then T. Else, whatever q is.
const OR = p => q => p(p)(q);
OR(T)(F)
//> True (K)
OR(F)(T)
//> True (K)
OR(F)(F)
//> False (KI)
const XOR = p => q => p(NOT(q))(q);
XOR(T)(T)
//> False (KI)
XOR(F)(F)
//> False (KI)
XOR(T)(F)
//> True (K)
XOR(F)(T)
//> True (K)
const BEQ = p => q => p(q)(NOT(q)) == NOT(XOR);
BEQ(T)(F)
//> False (KI)
BEQ(T)(T)
//> True (K)
BEQ(F)(F)
//> True (K)
The negation of a disjunction is the conjunction of the negations.
The negation of a conjunction is the disjunction of the negations.
Where:
-
$P$ and$Q$ are propositions -
$\neg$ is the negation logic operator (NOT) -
$\land$ is the conjunction logic operator (AND) -
$\lor$ is the disjunction logic operator (OR) -
$\iff$ is a metalogical symbol meaning "if and only if"
const Lit_Fst = !(p || q) === (!p) && (!q);
const Lit_Snd = !(p && q) === (!p) || (!q);
const Fst = p => q => BEQ(NOT(OR(p)(q)))(AND(NOT(p))(NOT(q)));
const Snd = p => q => BEQ(NOT(AND(p)(q)))(OR(NOT(p))(NOT(q)));
Fst(T)(T)
//> True (K)
Fst(T)(F)
//> True (K)
Fst(F)(F)
//> True (K)
Snd(T)(T)
//> True (K)
Snd(T)(F)
//> True (K)
Snd(F)(F)
//> True (K)
const N0 = f => a => a;
Which is saying, take two things and return the second. Which is the same as
const N0(NOT)(T)
//> True (K)
const F(NOT)(T)
//> True (K)
const N1 = f => a => f(a);
So we are applying a function to a thing. This is also familiar. This is Identity once removed. So 0 is false and 1 is identity.
const N1(NOT)(T) // = NOT(T)
//> False (KI)
const N2 = f => a => f(f(a));
We are applying a function to the application of a function to a thing.
const N2(NOT)(T) // = NOT(NOT(T))
//> True (K)
const N3 = f => a => f(f(f(a)));
We are applying a function to the application of an application of a function to a thing.
const N3(NOT)(T) // = NOT(NOT(NOT(T)))
//> False (KI)
We are just building up function applications on a thing. The announce of having to spell out the fold constantly should be apparent. We need something to do this for us.
We want to be able to do
This is actually Peano numbers.
Peano numbers are a simple way of representing the natural numbers using only a zero value and a successor function.
const SUCC = n => f => a => f(n(f)(a));
SUCC(N0)
//> [Function]
Church numerals are not intentionally equals, its only extensional equal. So we need to prove that this is the
SUCC(N0)(NOT)(T)
//> False (KI)
// Ergo, N1
Extensionality, or extensional equality, refers to principles that judge objects to be equal if they have the same external properties. Intensionality, or intensional equality, is concerned with whether the internal definitions of objects are the same.
This can get a little annoying to always prove, so we can make a helper to make it easier (in JS).
const toJSNum = n => n(x => x + 1)(0);
toJSNum(SUCC(N0));
//> 1
So now we can count, and set, our numbers a little easier by just using the SUCC
of the previous.
const N0 = F;
const N1 = SUCC(N0);
const N2 = SUCC(N1);
const N3 = SUCC(N2);
const N4 = SUCC(N3);
const N5 = SUCC(N4);
The
It essentially represents function composition, where
The
const B = f => g => a => f(g(a));
This is just simple function composition.
B(NOT)(NOT)(T)
//> True (K)
Now that we have the power of the B combinator, we can simplify. Since we are just feeding function results in other functions:
Caution
Need excalidraw image.
We can just use function composition.
const SUCC = n => f => B(f)(n(f));
toJSNum(SUCC(N4))
//> 5
toJSNum(SUCC2(SUCC2(N4)))
//> 6
So if we say
then it is easy to see that we are doing function composition. And Church Numerals are functions that create
Since it is the 3-fold composition of
const ADD = n => k => n(SUCC)(k);
toJSNum(ADD(N3)(N4))
//> 7
toJSNum(ADD(N1)(N4))
//> 5
We know that
Since multiplication is associative
We can group them. Theres our three-fold (
And since we are doing the two-fold composition on
Cancel the
Now we can cancel out the
So multiplying two number is just getting their composition.
But now we can cancel the numerals out
So multiplication is just the
The two definitions are
const MULT = B;
toJSNum(MULT(N3)(N4))
//> 12
toJSNum(MULT(N2)(N3))
//> 6
Pattern should be getting more clear.
Just takes the numerals and flips them around. Which is itself a common combinator. Called the Thrush combinator (Th).
// const Th = a => f => f(a);
const POW = Th;
toJSNum(POW(N2)(N3))
//> 8
toJSNum(POW(N3)(N3))
//> 27
const Is0 = n => n(K(F))(T);
Numerals are
Is0(N1)
//> False (KI)
Is0(N0)
//> True (K)
Is0(N9)
//> False (KI)
You give this function two arguments. Then you can move this box around and pass it where you want. When you are ready to use te arguments, you give it a function to apply to the arguments. This is the smallest data structure. A closure. Can also be called
const V = a => b => f => f(a)(b);
V(I)(M)(K) // First
//> Function I
V(I)(M)(KI) // Second
//> Function M
const FST = p => p(K);
const SND = p => p(KI);
FST(PAIR(I)(M))
//> Function I
SND(PAIR(I)(M))
//> Function M
const PHI = p => PAIR(SND(p))(SUCC(SND(p)));
const p0 = PAIR(N0)(N0);
const p1 = PHI(p0);
toJSNum(FST(p1))
//> 0
toJSNum(SND(p1))
//> 1
const p2 = PHI(p1)
toJSNum(FST(p2))
//> 1
toJSNum(SND(p2))
//> 2
Going forward,
So it takes the Second of the input pair and moves it to the First of the output. Then it takes the Second of the input pair and puts the Succ of that as the Second of the output pair.
Eureka! We have subtraction!
const PRED = n => FST(n(PHI)(PAIR(N0)(N0)));
toJSNum(PRED(N5))
//> 4
const SUB = n => k => k(PRED)(n);
toJSNum(SUB(N7)(N2))
//> 5
Just SUB
const LEQ = n => k => Is0(SUB(n)(k));
LEQ(N4)(N2)
//> False (KI)
LEQ(N4)(N4)
//> True (K)
LEQ(N2)(N4)
//> True (K)
Just checks LEQ in both directions.
const EQ = n => k => AND(LEQ(n)(k))(LEQ(k)(n));
EQ(N4)(N2)
//> False (KI)
EQ(N2)(N4)
//> False (KI)
EQ(N4)(N4)
//> True (K)
Binary function composition.
const B1 = f => g => a => b => f(g(a)(b));
const GT = B1(NOT)(LEQ);
GT(N4)(N3)
//> True (K)
GT(N3)(N4)
//> False (KI)
Sym. | Bird |
|
JS | Haskell | Use |
---|---|---|---|---|---|
I | Idiot | x => x |
id |
identity | |
B | Bluebird | f => g => a => f(g(a)) |
(.) |
unary composition | |
M | Mockingbird | f => f(f) |
not definable | self-application | |
K | Kestrel | a => b => a |
const |
first, const | |
KI | Kite | a => b => b |
const id |
second | |
C | Cardinal | f => a => b => f(b)(a) |
flip |
reverse args | |
Th | Thrush | a => f => f(a) |
flip id |
hold an argument | |
V | Vireo | a => b => f => f(a)(b) |
flip . flip id |
hold a pair of args | |
B1 | Blackbird | f => g => a => b => f(g(a)(b)) |
(.).(.) |
binary to unary composition | |
Y | f => (x => f(x(x)))(x => f(x(x))) |
recursion (for lazy) | |||
Z | f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v))) |
recursion (for strict, JS) |
Name |
|
Use |
---|---|---|
True | Encoding for true | |
False | Encoding for false | |
Not | Negation | |
And | Conjunction | |
Or | Disjunction | |
Beq | Equality |
Sym. | Name |
|
Use |
---|---|---|---|
N0 | Zero | apply |
|
N1 | Once | apply |
|
N2 | Twice | apply |
|
N3 | Thrice | apply |
|
N4 | Fourfold | apply |
|
N5 | Fivefold | apply |
Name |
|
Use |
---|---|---|
Succ | successor of n | |
Add | addition of |
|
Mult | multiplication of |
|
Pow | raise |
|
Pred | predecessor of |
|
Sub | subtract |
Name |
|
Use |
---|---|---|
Is0 | test if |
|
Leq | test if |
|
Eq | test if |
|
Gt | test if |
Sym. | Name |
|
Use |
---|---|---|---|
PAIR | pair two arguments | ||
FST | extract first of a pair | ||
SND | extract second of a pair | ||
Phi | copy 2nd to 1st, inc 2nd |