We saw by the end of the
previous article
a first example of “closure”, which you can
grossly think of as “function”. We’re going to progressively
explore this notion in-depth here: we’ll see in particular how they can
be used to encode all kinds of data structures: lists, integers,
booleans, objects, graphs, etc.
Some tiny programming languages like the λ-calculus, of which
we’ll implement an interpreter
to conclude this series,
is essentially reduced to closures: this minimalism is convenient for
theoretical/mathematical analysis, or for programming practice. But
it’s naturally ill-suited for real-life programming.
Besides, we’ll also re-implement many ordinary functions on lists, using
an ad hoc, closure-based list implementation, instead of genuine Nix’s lists,
genuine lists that we’ll explore
soon enough.
As always, the code for this series is available on github.
In Nix, functions are first class-citizens, meaning,
they can be provided as arguments, or returned:
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let callFunWith = f: x: (y: f x y);
add = x: y: x + y;
add3To = callFunWith add 3;
in trace(add3To 7)
trace((callFunWith add 7) 3)
"ok"
trace: 10trace: 10"ok"
This, this ability to pass functions around, is the key to use functions
to encode structured data. Let’s see how.
Structures
You may be familiar with the notion of struct in C or C-like
languages: those are simple collections of (named) items:
/* C */struct user {
char*name;
int age;
}
/* Go */typeuserstruct {
namestringageint}
Closures can actually be used to implement such a struct:
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let mkUser = name: age: (x: if x then name else age);
u = mkUser "Bob"42;
in trace (u true)
trace (u false)
"ok"
trace: Bob
trace: 42"ok"
The “trick” is to observe that the parameters to the mkUser function
(name and age) are available to the function returned by mkUser, without being
explicit parameters of this returned function: the set of all such
implicit parameters is called an environment, and the key idea here is to
use this environment to store data.
In the previous example, we returned a function whose single boolean parameter
allows to fetch either value, name or age, from the environment. Saying
it otherwise, we parametrized the returned function by providing it some specific
values for name and age.
Let’s see if you’ve understood the previous struct example correctly:
Exercise: Add an email field to the our previous struct; make
sure you can access all three values.
I'm using here a string to determine which field to return. An
integer (an enumeration) would have been more efficient, but slightly
more difficult to read/write. Either solution is fine. Two boolean arguments
instead of one is yet another valid technique.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let mkUser = name: age: email: (key:
if key =="name"then name
elseif key =="age"then age
else email
);
u = mkUser "Bob"42"bob@corp.com";
in trace (u "name")
trace (u "age")
trace (u "email")
"ok"
trace: Bob
trace: 42trace: bob@corp.com
"ok"
Methods, state, inheritance
Let’s push things one step further: we can store values, and return them,
but can we define methods on a struct, to simulate a loose object?
Exercise: Well, yes we can, and you should be able to figure
it out without too much trouble: add to our previous three-fields struct
a get method to retrieve the value of the fields, and
an isAdult method, returning true if the user is above 18, false
otherwise.
Feel free to implement things however you like.
For example, you could have one get function per field:
getName, getAge and getEmail, or a single
get function taking one parameter describing which field you want
to access.
The goal is to make sure you understand how to add methods
to our "object", not to argue on minor implementation details.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let mkUser = name: age: email: (func:
if func =="isAdult"then age >=18else (key:
if key =="name"then name
elseif key =="age"then age
else email
)
);
u = mkUser "Bob"42"bob@corp.com";
v = mkUser "Boss"2"boss@babycorp.com";
in trace (u "get""name")
trace (u "get""age")
trace (u "get""email")
trace (u "isAdult")
trace (v "isAdult")
"ok"
trace: Bob
trace: 42trace: bob@corp.com
trace: true
trace: false
"ok"
Exercise: Slightly more complicated: add a new method
set, which allows to set fields from the struct.
Again, feel free to use any convention you may prefer.
The difficulty here lies in understanding how to alter values
stored in the environment: the solution is simple, but
unusual in imperative languages.
In the functional realm, there's often this notion of purity:
states are immutable and never altered. Instead, we create a new
"updated" state from the previous one.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let mkUser = name: age: email: (func:
if func =="isAdult"then age >=18elseif func =="set"then (key: value:
if key =="name"then mkUser value age email
elseif key =="age"then mkUser name value email
else mkUser name age value
)
else (key:
if key =="name"then name
elseif key =="age"then age
else email
)
);
u = mkUser "Bob"42"bob@corp.com";
v = (u "set""age"2) "set""email""nein@plan.com";
in trace (u "get""name")
trace (u "get""age")
trace (u "get""email")
trace (u "isAdult")
trace (v "get""name")
trace (v "isAdult")
trace (v "get""email")
"ok"
trace: Bob
trace: 42trace: bob@corp.com
trace: true
trace: Bob
trace: false
trace: nein@plan.com
"ok"
Exercise: Okay, just for fun: create a
second type of object, say teacher, who inherits from
our previous user object, but whose name field is now unalterable via
set (why not…), and who is equipped with an extra field attribute,
which can be read/written via get/set.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let mkUser = name: age: email: (func:
if func =="isAdult"then age >=18elseif func =="set"then (key: value:
if key =="name"then mkUser value age email
elseif key =="age"then mkUser name value email
else mkUser name age value
)
else (key:
if key =="name"then name
elseif key =="age"then age
else email
)
);
mkTeacher = name: age: email: field: let super = mkUser name age email;
in (func:
if func =="set"then (key: value:
# Ignoreif key =="name"then mkTeacher name age email field
elseif key =="field"then mkTeacher name age email value
# We can't just return `super "set" key value`# here, because we would get a user and not# a teacher.elselet super2 = super "set" key value;
in mkTeacher
(super2 "get""name")
(super2 "get""age")
(super2 "get""email")
field
)
elseif func =="get"then (key: if key =="field"then field else super "get" key)
else super func
);
u = mkTeacher "Bob"42"bob@uni.com""CS";
v = ((u "set""name""Joe") "set""field""IT") "set""age"16;
in trace (u "get""name")
trace (u "get""age")
trace (u "get""email")
trace (u "get""field")
trace (u "isAdult")
trace (v "get""name")
trace (v "get""field")
trace (v "isAdult")
"ok"
trace: Bob
trace: 42trace: bob@uni.com
trace: CS
trace: true
trace: Bob
trace: IT
trace: false
"ok"
Not fantastic syntactically: better languages, equipped
with advanced macros, can improve the situation. But it should nevertheless
be enough to convince you that it should be possible to build full-blown
objects.
This is actually ancient programming knowledge, captured by a famous Koan:
The venerable master Qc Na was walking with his student, Anton. Hoping to
prompt the master into a discussion, Anton said “Master, I have heard that
objects are a very good thing - is this true?” Qc Na looked pityingly at
his student and replied, “Foolish pupil - objects are merely a poor man’s
closures.”
Chastised, Anton took his leave from his master and returned to his cell,
intent on studying closures. He carefully read the entire “Lambda: The
Ultimate…” series of papers and its cousins, and implemented a small
Scheme interpreter with a closure-based object system. He learned much, and
looked forward to informing his master of his progress.
On his next walk with Qc Na, Anton attempted to impress his master by
saying “Master, I have diligently studied the matter, and now understand
that objects are truly a poor man’s closures.” Qc Na responded by hitting
Anton with his stick, saying “When will you learn? Closures are a poor man’s
object.” At that moment, Anton became enlightened.
Let’s now see how we can implement lists using closures. The difficulty
is that, while a struct has a fixed, predefined number of fields, a list,
by design, doesn’t. But we’ve just learnt how to jump from struct to
closures, so let’s recall how lists are typically implemented in C.
Of course, there are more than one approach, but we’re in particular
interested in the prevailing notion of a (simply) linked list,
which is represented by that kind of struct:
typedefstruct list list;
struct list {
void*value;
list *next;
}
To which we’ll add the convention that NULL corresponds to the empty list.
This is all almost identical to the usual recursive definition of a
list, which permeates functional programming, where a list is defined
as being either:
the empty list;
a value called the head, attached to another
list, called the tail.
Okay, let me give you one teeny tiny thing: I’ve explained that in C,
NULL is used as a synonym for the empty list. We can easily
translate this to Nix as follow, and equip ourselves with a function
to test the emptiness of a list:
...# Empty list nil =null;
# Check whether a list is empty or not isEmpty = l: l == nil;
...
Exercise: Using your previouisly acquired expertise, convert this
new struct to a closure “constructor”.
We don't care about any object-oriented refinements here: we're
not about to create a list object and add methods to it, we just
want a bare-bone, two-fields struct.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let cons = h: t: (x: if x then h else t);
nil =null;
isEmpty = l: l == nil;
access = x: l: if isEmpty l
thenthrow"list is empty"else l x
;
car = access true;
cdr = access false;
x = nil;
y = cons 3 nil;
z = cons "hello" (cons 3 (cons (x: x) nil));
# We're being "lazy" here... w = cons nil (cons nil nil);
in# This is just to force Nix to evaluate everything;# we'll soon see how to print lists. deepSeq [x y z w]
"ok"
"ok"
Note: The name “cons” refers to a similar operation
in Lisp; while our definition and usage is different
from Lisp’s, I’ll keep similar naming conventions,
as was the case for nil, the empty list, or will be for
car/cdr, which are used as synonyms for
head/tail, respectively to get, well, the head/tail of a list.
Exercise: Now we have everything we need to implement car
and cdr. Let me say it again: those functions act on a list which may
be empty or not, and return respectively the head (first element) or the
tail (everything but the first element wrapped in a list) of that list.
You need to think about the error case: if the list is empty,
you can't access neither its head nor its tail, simply because
the access doesn't even make sense to begin with!
I'll be using
builtins.throw;
there are other approaches, but it'll be good enough for us.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let nil =null;
isEmpty = l: l == nil;
cons = h: t: (x: if x then h else t);
access = x: l: if isEmpty l
thenthrow"list is empty"else l x
;
car = access true;
cdr = access false;
l = cons 3 nil;
in trace(car l)
trace(cdr l)
trace(car nil)
"ok"
trace: 3trace: null
error:
… while calling the 'trace' builtin
at ./nix/closure/list-car-cdr.nix:17:2:
16| in
17| trace(car l) | ^
18| trace(cdr l) … while calling the 'throw' builtin
at ./nix/closure/list-car-cdr.nix:9:8:
8| access = x: l: if isEmpty l
9| then throw "list is empty" | ^
10| else l x
error: list is empty
Because it’s going to be annoying to repeat all our primitive
list functions, I’m going to store them in a module that we
can import from other .nix files; I’ll revisit this pattern in
detail later:
you may want to consider it a magic spell for now.
Say the first panel is the content of a file list.nix; the second
will import this previous file, allowing the use the functions defined
in list.nix.
rec {
cons = h: t: (x: if x then h else t);
nil =null;
isEmpty = l: l == nil;
access = x: l: if isEmpty l
thenthrow"list is empty"else l x
;
car = access true;
cdr = access false;
}
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
...
Exercise: Write a function cadr which returns
the second element of a list. Write a function caddr which
returns the third element of a list. Write a function cdar which
returns the tail of the first element of a list (cadr’s
input is a list of lists then).
Try to use the list.nix module technique I’ve just presented.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
let cadr = l: car (cdr l);
caddr = l: car (cdr (cdr l));
cdar = l: cdr (car l);
in"untested"
"untested"
Note: Those are common shortcuts in Lisp-like languages.
Believe it or not, we now have everything we need to deal with
list: we have a precise definition, and a few primitive
operations, which are sufficient to build all the usual functions
you can think of on lists (calculating their length, filtering
elements, etc.)
Iteration pattern
Well, almost everything. There’s one more thing I need to tell you
about before we can start writing code: there’s a pattern to
recursively travel through a list which naturally arises from (our)
definition of a list:
if the list is empty, well, we can’t go any further;
otherwise, then the list must have a head and a tail (or a car
and a cdr); we can do something with the head, usually recurse
on the tail, and eventually glue the result of both actions somehow.
This can be done with or without an accumulator: this is conceptually identical to
what we’ve done
when implementing fold on integer sequences, foldln. In Nix
pseudo-code, this yields something like:
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let f = l:
if isEmpty l then# empty list case:...elselet x = car l;
xs = cdr l;
in# do something with x and often,# recursively apply f to xs... ;
...in...
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let f = l: let aux = acc: l:
if isEmpty l then# empty list case, generally something like: acc
elselet x = car l;
xs = cdr l;
in# Do something with x via actOnHead, glue this with# the accumulator somehow via addToAcc, and# eventually (maybeRecurse) recurse on the tail/cdr (maybeRecurse aux (addToAcc (actOnHead x) acc) xs)
;
...in...
Standard list functions
Let’s re-write a bunch of"standard" list functions to exercise, but
not using Nix’s lists, but using our previous ad hoc list implementation.
The code is for the most part a direct application of the previous
recursive patterns; until they both become second-nature, you may want to try
solving each exercise twice, once with each pattern.
Exercise: Write a (recursive) function isMember e xs, which
returns true if e exists in xs, false otherwise.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
let length = xs: let aux = n: xs:
if isEmpty xs then n
else aux (n +1) (cdr xs)
; in aux 0 xs;
length2 = xs:
if isEmpty xs then0else1+ (length2 (cdr xs))
;
xs = cons 1 (cons 2 (cons 3 nil));
in trace(length xs)
trace(length2 xs)
"ok"
trace: 3trace: 3"ok"
Exercise: Write a (recursive) print xs function which
prints a list xs. You can either use builtins.trace or dump it
to a string. Tests your function on the lists we’ve defined in an
earlier exercise:
x = nil;
y = cons 3 nil;
z = cons "hello" (cons 3 (cons (x: x) nil));
# We're still being "lazy", but you may to be fancier w = cons nil (cons nil nil);
The goal is for you to navigate the list, the output format doesn’t matter much,
it can be a bit clumsy-looking.
There is an "unsolvable" issue, highlighted by one of the suggested
lists.
It's a limitation of our conventions. We could have reduced the problem
with different choices, but that would have been more work, and
for essentially no benefits to the study of closures, or of lists.
We're considering here in print and later for this
article that our lists cannot contain
regular functions: if we find a function, then we systematically
assume it's a list, thus allowing us to construct lists of lists.
Similarly, we assume that all null instances refer
to the empty list nil.
This convention will be useful in later exercises.
Don't hesitate to register all those functions in a list-lib.nix
module, so as to use print at will for example.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
let x = nil;
y = cons 3 nil;
# This list is now invalid# z = cons "hello" (cons 3 (cons (x: x) nil));# But we can now construct lists of lists: w = cons nil (cons nil nil);
t = cons
(cons
(cons 1 (cons 2 (cons 3 nil)))
(cons (cons 4 (cons 5 (cons 6 nil))) nil))
(cons (cons 7 nil) nil);
print = xs: let aux = acc: xs:
let h = car xs;
t = cdr xs;
s =if h == nil then"[]"elseif typeOf h =="lambda"then (print h) else"${toString h}";
inif isEmpty t then acc+"${s}"else aux (acc+"${s}, ") t
; inif isEmpty xs then"[]"else (aux "[" xs) +"]";
in trace (print x)
trace (print y)
# trace (print z) trace (print w)
trace (print t)
"OK"
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let range = n: m: let aux = acc: i:
if i < n then acc
else aux (cons i acc) (i - 1)
; in aux nil m
;
xs = range 310;
ys = range 103;
in trace(print xs)
trace(print ys)
"ok"
trace: [3, 4, 5, 6, 7, 8, 9, 10]trace: []"ok"
Exercise: Write a (recursive) function reverse xs
which inverts the order of the elements of xs.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let reverse = xs: let aux = acc: xs:
if isEmpty xs then acc
else aux (cons (car xs) acc) (cdr xs)
; in aux nil xs
;
xs = range 14;
in trace(print (reverse xs)) "ok"
trace: [4, 3, 2, 1]"ok"
Exercise: Write a (recursive) function append xs ys
which concatenates two lists xs and ys. Write a version
with and without an accumulator.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let append = xs: ys:
if isEmpty xs then ys
else cons (car xs) (append (cdr xs) ys)
;
append2 = xs: ys: let sx = reverse xs;
# Interestingly, this auxiliary function is exactly# the same that we've used to implement reverse. aux = acc: sx:
if isEmpty sx then acc
else aux (cons (car sx) acc) (cdr sx)
; in aux ys sx
;
xs = range 13;
ys = range 46;
in trace (print (append2 xs ys)) (cadddr (append xs ys))
trace: [1, 2, 3, 4, 5, 6]4
Exercise: Write a (recursive) function map f xs mapping
the function f to all elements of xs; more precisely:
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let map = f: xs:
if isEmpty xs then xs
else cons (f (car xs)) (map f (cdr xs))
;
# Again, I'm not sure this can be implemented with# an accumulator, without using reverse. Though,# we may use append instead of cons within our# implementation. map2 = f: xs: let aux = acc: xs:
if isEmpty xs then acc
else aux (cons (f (car xs)) acc) (cdr xs)
; in reverse (aux nil xs)
;
in# You may want to take a moment to appreciate how# much we're stacking on top of our bare-bone,# closure-based list implementation.## It's really functions all the way down. trace(print (map (x: x * x) (range 1221)))
trace(print (map2 (x: x * x) (range 1221)))
"ok"
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let foldl = f: s: xs: let aux = acc: xs:
if isEmpty xs then acc
else aux (f acc (car xs)) (cdr xs)
;in aux s xs;
ys = foldl (acc: x: cons (x * x) acc) nil (range 110);
in trace(print ys) "OK"
trace: [100, 81, 64, 49, 36, 25, 16, 9, 4, 1]"OK"
Note: There’s a famous, old (1999) paper about fold
by Graham Hutton that you may enjoy studying.
Exercise: Rewrite isMember, length, print, reverse,
append and map, all using foldl. The fact that we can do that is
rather significant: fold really is a fundamental recursive list operator.
Implementing foldr via foldl should be
the most challenging part of this exercise, and perhaps one of the two
most difficult exercises in this article. Try using closures.
More precisely, to implement foldr f s xs via foldl,
you should use foldl to build a closure (start with an
initial value of (x: x), the id function). Then, call the
result of the call to foldl (...) (x: x) xs, which is a function,
on the initial value s. Something like:
foldr = f: s: xs: ((foldl (...) (x: x) xs) s);
You may want to "reverse-engineer" the function by looking at
the expected expansion on small lists (say, up to length 2),
and see how to build the closure chain from there.
Exercise: Use foldl to write a flatten function, which
takes a list that may contain lists, and flatten them all to a single list.
Write a deepFlatten function which does so recursively.
Use foldr to rewrite flatten; how does it compare to the foldl-based
implementation?
Again, you have to assume that such lists do not contain regular Nix
functions. There is bound to be some "type friction" between our
implementation and the host language.
The foldr-based implementation of flatten
can use cons instead of append in the case
of elements which aren't lists. Generally speaking,
using cons is preferable, performance-wise, than having
to create a list with a single element so as to call append.
But foldl presents the advantage over foldr to
be tail-recursive, so this really is to be taken with a grain of salt.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let flatten = foldl (acc: x:
if typeOf x =="lambda"then (append acc x)
else (append acc (cons x nil))
) nil;
flatten2 = foldr(x: acc:
if typeOf x =="lambda"then (append x acc)
else cons x acc
) nil;
deepFlatten = foldl (acc: x:
if typeOf x =="lambda"then (append acc (deepFlatten x))
else (append acc (cons x nil))
) nil;
in trace(print (flatten (cons (range 13) (cons (range 46) (cons 7 nil)))))
trace(print (flatten2 (cons (range 13) (cons (range 46) (cons 7 nil)))))
# deepFlatten should work as flatten on list of lists trace(print (deepFlatten (cons (range 13) (cons (range 46) (cons 7 nil)))))
# [ [[1 2 3] [4 5 6]] [7] ] trace(print (deepFlatten (
cons
(cons (range 13) (cons (range 46) nil))
(cons (cons 7 nil) nil))))
"ok"
This is a trick question: it doesn't make much sense to implement
zipWith via foldl, a direct implementation
is conventional. Not all recursive functions on lists are reasonably
implemented with fold*.
On another note, you can choose the convention you want when lists
don't have the same size. Usually, extra elements are simply ignored.
Exercise: Last but not least, we’ve already mentioned apply a few
times. Try to remember what it did, to re-implement it to work with our bespoke lists.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list.nix);
with (import./list-lib.nix);
let apply = f: xs: foldl (acc: x: acc x) f xs;
sum3 = apply (x: y: z: x + y + z);
in trace(sum3 (cons 1 (cons 2 (cons 3 nil))))
"ok"
trace: 6"ok"
Booleans (single bit)
Exercise: I would expect finding how to implement booleans on
your own to be too difficult: maybe give it a shot for 10/15min before
you start unrolling those tips; trying is more important than succeeding.
Let me give you a first advise: if it behaves like a boolean, then it’s a
boolean (duck typing). Think about how we can get/set
a boolean’s value.
But as for the list, there’s no need for any object-oriented feature (well,
at least for the standard approach we’re going to take).
Our ifelse needs to be able, given the value of a boolean
b, determine whether to continue the execution by calling a first
function f if it's true, or a second function
g.
Without the last tip, I don't think I would have found it on my own,
so my congratulations if you did. The simplest (only reasonable?) way
to encode a difference between two values with closure is to have two
parameters, and use one value per parameter. The following technique
is commonly referred to as "Church booleans":
true= (x: y: x);
false= (x: y: y);
ifelse = b: x: y: b x y;
Exercise: Implement a few common boolean operations on
those Church booleans: and, or, xor, not. Try
implementing them both with and without ifelse.
Alright, let’s have some fun with our lists again. Looking at
our implementation once again, you can see that we were relying
genuine Nix booleans:
rec {
cons = h: t: (x: if x then h else t);
nil =null;
isEmpty = l: l == nil;
access = x: l: if isEmpty l
thenthrow"list is empty"else l x
;
car = access true;
cdr = access false;
}
Let’s try to progressively replace those booleans by our ad hoc,
closure-based booleans. This will imply also getting rid
of using null for the empty list.
Exercise: I guess the most straightforward is the one
used in cons, so start with this one. Because all the functions
on lists we wrote earlier are independent of the implementation details
our our list.nix modules, they should all keep working (that’s
your tests)
Note: There’s some “strange” behavior with Nix when exporting
functions/objects that are defined by the language (e.g. map, true,
false). For instance, the true/false returned by isMember
above are Nix booleans, not our redefined booleans. Perhaps using different
names would have been wiser, but highlighting this corner case isn’t
necessarily a bad idea either.
Exercise: This one is more delicate: let’s get rid of all
Nix booleans, and implement our lists fully with closures. There’s
an important consequence to this: our lists will become, like our
booleans, functions: they will be executable.
Now you may think, but isn’t cons already executable? Well yes,
but cons itself is not a list, it’s a list constructor, and nil
is clearly not a function either.
Saying it otherwise, this means that, as we can execute true and
false, we’ll want to be able to execute nil and cons h t
(again, not cons, but the lists we construct via cons).
Don’t bother too much: keep the throw, and focus on adjusting
the list implementation (you can ignore the list library).
I expect this to be challenging. There are a few different ways
to do so. I'm going to tip you in one specific direction: if
you feel like you're onto something, don't hesitate to keep
pushing it further, even if it doesn't look like what I'm
proposing, for you may reach an equally valid solution.
Ok, so here's the idea: we want a list (i) to be either empty or not
and (ii) when it's not empty, the ability to extract either its
head/tail.
That first "(i)" point can be implemented kinda like with the booleans:
both nil and cons h t return a function which
takes two parameters (say, a and b), and
one parameter is assigned to each function.
This way, when you execute a list (remember, a list only has two forms:
nil and cons h t, both of which are now functions),
if it's empty then only the first parameter will be used, otherwise only
the second.
Now for the second "(ii)" point, the idea is that this second parameter
need to be sophisticated enough to potentially act on the head/tail
of the list. You'll want to try to see how isEmpty and
access can use this second parameter.
See, nil is up to renaming identical to true:
it simply returns its first argument. So in isEmpty, when
we execute a nil list, we're simply returning true.
On the other hand, cons h t, like
nil, returns a function of two arguments (a and
b), but of those two, a is discarded, while
b is executable, and takes as parameters both the head and tail.
It's the simplest, most generic way of having this parameter acting
on either the head or the tail.
Now, in the b parameter passed to the list executed in
isEmpty, we don't care about the head/tail, we systematically
return false. But in access, when the list isn't empty
this b function now select either the head or the tail depending
on access's x parameter.
Simple, very simple code, but in my opinion, far from trivial.
with builtins;
rec {
true= (x: y: x);
false= (x: y: y);
ifelse = p: x: y: p x y;
nil = (a: b: a);
cons = h: t: (a: b: (b h t));
isEmpty = l: l true (h: t: false);
access = x: l:
l
(throw"list is empty")
(h: t: ifelse x h t);
car = access true;
cdr = access false;
}
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
with (import./list-bool-full.nix);
let range = n: m: let aux = acc: i:
if i < n then acc
else aux (cons i acc) (i - 1)
; in aux nil m;
foldl = f: s: xs: let aux = acc: xs:
ifelse (isEmpty xs)
acc
(aux (f acc (car xs)) (cdr xs))
; in aux s xs;
isMember = e: foldl (acc: x: if x == e thentrueelse acc) false;
length = foldl (acc: _: acc +1) 0;
print = xs: "["+(foldl (acc: x:
let y =if x == nil then"[]"elseif typeOf x =="lambda"then (print x) else"${toString x}";
in acc+" ${y} " ) "" xs)+"]";
reverse = foldl (acc: x: (cons x acc)) nil;
append = xs: ys: foldl (acc: x: (cons x acc)) ys (reverse xs);
map = f: xs: reverse (foldl (acc: x: (cons (f x) acc)) nil xs);
foldr3 = f: s: xs: ((foldl (acc: x: (y: acc (f x y))) (x: x) xs) s);
map3 = f: xs: foldr3 (x: acc: cons (f x) acc) nil xs;
in trace(ifelse (isEmpty nil) "true""false")
trace(ifelse (isEmpty (cons 1 nil)) "true""false")
trace(car (cons 1 nil))
trace(car (cdr (cons 1 (cons 2 nil))))
trace(length (cons 1 (cons 2 nil)))
trace(isMember 3 (range 110))
trace(isMember 0 (range 45))
trace(length (range 110))
trace(length (range 45))
trace(print (reverse (range 35)))
trace(print (append (range 12) (range 34)))
trace(print (map (x: x * x) (range 15)))
trace(print (cons
(cons (range 13) (cons (range 46) nil))
(cons (cons 7 nil) nil)))
trace(print(map3 (x: x+2) (range 15)))
"ok"
Well, yes we do.
Base-2 encoded
integers are but sequences of 0s and 1s, that is lists of booleans.
Note: Once we have integers, we also have strings, as they
can be considered (and are, for instance in Haskell)
as lists of characters, with syntactic sugar and other goodies to help
handle character sets.
Exercise: Propose another simple integer encoding,
relying on lists only.
The idea is to have the length of a list to encode an integer;
concatenation (append) of lists then corresponds
to addition. Negative numbers are poorly represented as-is, but
it would be trivial to extend the idea: an integer is a pair:
Whose first element is an empty list for positive numbers,
a non-empty lists for negative numbers;
And whose second element is yet another list, whose
length encodes the integer.
Exercise: Implement a function to multiply two such integers. You
don’t have to handle negative integers (I won’t).
A \(m\times n\) matrix is an array containing \(n\) entries, each
containing arrays of length \(m\) (or for our intent and purposes,
the reverse). Meaning, we can encode the result of \(m\times n\) in a list
of lists.
Some of our previously implemented lists functions might become handy.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
/*
* This is the first list implementation, but things would
* work identically either later ones, relying Church booleans/pairs.
*/with (import./list.nix);
with (import./list-lib.nix);
let# Interestingly, we must redefine map here: importing# it from e.g. list-lib.nix wouldn't overide the default# map implementation. map = f: xs: reverse (foldl (acc: x: (cons (f x) acc)) nil xs);
mult = xs: ys: flatten (map (_: ys) xs);
two = range 12;
three = range 13;
five = range 15;
ten = range 110;
in# Note that we still rely on Nix's integers for printing here trace("${toString (length two)} * ${toString (length three)} =")
trace(length(mult two three))
trace("${toString (length five)} * ${toString (length ten)} =")
trace(length(mult five ten))
"ok"
A third’s time a charm: forget our lists and try to focus solely
on implementing integers, directly from closures. A common way to proceed
is to use Church numerals. We could devise other methods, but Church numerals
have a handy property: it’s not too complicated to write common
operations (addition, multiplication, etc.) on them. Let’s see how
we could encode the first few (natural) integers:
zero = x: y: x;
one = x: y: y x;
two = x: y: y (y x);
three = x: y: y (y (y x));
Essentially, we represent a number as a function taking two arguments.
The number encoded correspond to the number of time the second argument,
a function, is called when expanding the expression of the number (e.g.
the three calls y three times).
Exercise: Try to find, in this order, how to:
Get a visual representation of a number (print them somehow);
For addition, try to chain the two numbers, so that once we reach
the bottom of the calls to y for the first number,
we "jump" to the other number and keep iterating from here.
For multiplication, this is similar to what we've done when
considering matrix multiplication. You might find it helpful
to manually unroll the multplication of two small numbers
(say 2×2).
Regarding addition, the idea is that you'd want the x
parameter of the first number to be the other number you want to add.
If you try to evaluate the function corresponding to a number \(n\), it will
execute y(y(...(y x)...)), where y is called \(n\)
times. If you swap x for another number \(m\), then you can manage,
when executing \(n\), to have y being called \(m\) more times.
Then, it's just a matter of finding a way to build a new number
corresponding to this evaluation process. Again, knowing that we represent
numbers as functions taking two arguments, the second one being
a function whose number of execution corresponds to the resulting
number.
For multiplication, the idea is similar, but this time,
you want to replace y, so that instead of it corresponding
to something executed once, it corresponds to something being executed
\(m\) times.
Here's the Wikipedia page on
"Church numerals",
which goes further than what we've been doing here.
#!/usr/bin/env -S nix-instantiate --evalwith builtins;
let zero = x: y: x;
one = x: y: y x;
two = x: y: y (y x);
print = n: n "anything" (x: trace("+1") x);
toInt = n: (n 0 (x: x +1));
add = n: m: (x: y: m (n x y) y);
# two = x: y: y (y x);# two * two = x: y: (x: y (y x)) ((x: y (y x)) x); mul = n: m: (x: y: m x (x: n x y));
twoplustwoplusone = add two (add two one);
twotimestwo = mul two two;
twotimesonetimestwo = mul two (mul two one);
twotimesonetwoplusone = mul two (add two one);
in trace("one (${toString (toInt one)}):")
seq (print one)
trace("two (${toString (toInt two)}):")
seq (print two)
trace("2+(1+2) (${toString (toInt twoplustwoplusone)}):")
seq (print (twoplustwoplusone))
trace("2*2: (${toString (toInt twotimestwo)})")
seq (print (twotimestwo))
trace("2*(1*2) (${toString (toInt twotimesonetimestwo)}):")
seq (print (twotimesonetimestwo))
trace("2*(1+2) (${toString (toInt twotimesonetwoplusone)}):")
seq (print (twotimesonetwoplusone))
"ok"
Well, yes, considering our list implementation. We can represent
a set as a list of pairs (where a pair is a list with two elements):
the first element of the pairs correspond to names of
the elements of the set
while the second correspond to the value associated to
a given name
Encoding a graph as a list isn't too complicated either:
simply collect all edges, where each edge is represented
as a list of 3 elements:
the (unique) label of the left node;
eventually a label for the edge;
the (unique) label of the right node.
I don’t think there’s much more to say: we could try implementing
them, either from lists or from scratch, but this wouldn’t bring
much more to the conversation.
Conclusion
This article mainly was a way to practice using the
λ-calculus, that hasn’t been introduce properly, yet.
We’ve started from a “frankenstein” implementation of lists, exhaustively
practiced with lists, before progressively studying how we could make this
implementation become more and more closure-based.
I don’t think this can be considered an easy topic, especially if you’re
coming from an imperative background. The differences between
the fold* can be subtle, and wrapping one’s head on using functions
to encode anything, is likely to be disturbing. If this is still foggy,
don’t hesitate to wait a few days before looking at the material again.
Comments
By email, at mathieu.bivert chez: