From ImpCore to uScheme
Things that should offend you about Impcore:
-
Look up function vs look up variable requires different interfaces!
-
To get a variable, must check 2 or 3 environments.
-
Can’t create a function without giving it a name:
-
High cognitive overhead
-
A sign of second-class citizenship
-
All these problems have one solution: lambda
Anonymous, First-class Functions
Consider the following sequence of declarations:
(val one 1)
(val two 2)
; the function that maps x to x plus 1
(define add-one (x) (+ x one))
; the function that maps x to x plus 2
(val add-two (lambda (x) (+ x two)))
Where do one
and two
live? in ImpCore? in uScheme?
Where does add-one
live? in ImpCore? in uScheme?
Where does add-two
live? in ImpCore? in uScheme?
In uScheme, only one environment (more details next week).
In uScheme, functions are first-class values:
-
passed as arguments to functions
-
returned as results from functions
-
stored in (global and local) variables (e.g., using set)
-
stored in heap-allocated data structures (e.g., using cons)
define
vs val
/lambda
In uScheme, define
is just syntactic sugar for a val
with lambda
:
(define f (y1 y2 y3) exp)
becomes
(val f (lambda (y1 y2 y3) exp))
Anatomy of lambda
(lambda (x1 ... xn) exp)
-
The variables
x1
, …,xn
are bound inexp
-
Any other variables are free in
exp
Ability to capture (i.e., "remember") free variables makes lambda
interesting.
Free variables can be a global variable, formal parameter, or let-bound variable of an enclosing function. (Can tell at compile time what is captured.)
Anonymous, First-class, Nested Functions
Consider
(define mk-linear-fn (a b)
(let ((a-times-x-plus-b (lambda (x) (+ (* a x) b))))
a-times-x-plus-b))
mk-linear-fn
is a higher-order function, because it returns another (escaping) function as a result.
Note: there is no need to name the escaping function:
(define mk-linear-fn (a b)
(lambda (x) (+ (* a x) b)))
Consider
(define find-zero-between (f lo hi)
(if (>= (+ lo 1) hi)
hi
(let ((mid (/ (+ lo hi) 2)))
(if (< (f mid) 0)
(find-zero-between f mid hi)
(find-zero-between f lo mid)))))
(define find-zero (f) (find-zero-between -1000 1000))
find-zero-between
and find-zero
are higher-order functions,
because they take another (escaping) function as an argument. (But,
nothing escapes; you can do this in C.)
Escaping Functions and Closures
"Escape" means "outlive the activation record (i.e., stack frame) in which the lambda
was evaluated."
-
Typically returned
-
More rarely, stored in a global variable or a heap-allocated data structure
Escaping and lifetimes are universal decisions every programmer has to consider.
Recall:
(define mk-linear-fn (a b)
(lambda (x) (+ (* a x) b)))
Where are a
and b
stored? when inside mk-linear-fn
? when inside lambda
?
Values that escape must be allocated on the heap.
C programmers sometimes do this explicitly with malloc
.
In a language with first-class, nested functions, storage of escaping
values is part of the semantics of lambda
and the responsibility of
the language implementer.
Closures are used to represent escaping functions:
-
In operational semantics: \(\langle \mathtt{(}\mathtt{lambda}~\mathtt{(}x_1~\ldots~x_n\mathtt{)}~e\mathtt{)}, \rho \rangle\)
-
\(\rho\) binds the free variables of \(e\)
-
-
In compiled system: a heap-allocated record containing
-
pointer to the code
-
free variables (efficiently stored)
-
What is the picture for mk-linear-fn
?
Closure Idioms
Closure: Function plus environment where function was defined
Environment matters when function has free variables
-
Create similar functions
-
Combine functions
-
Pass functions with private data to iterators
-
Provide an abstract data type
-
Currying and partial application
-
Staging computation
Create similar functions
For example, our mk-linear-fn
example.
Combine functions
Compose
Preview: in math, what is the following equal to?
(f o g)(x) == ???
Another algebraic law, another function:
(define o (f g) (lambda (x) (f (g x))))
Use o
(compose) to create functions:
(define even? (n) (= 0 (mod n 2)))
(val odd? (o not even?))
Conjunction and Disjunction
(define conjoin (p? q?)
(lambda (x) (if (p? x) (q? x) #f)))
(define disjoin (p? q?)
(lambda (x) (if (p? x) #t (q? x))))
Lexicographic Comparison
(define mk-<-pair (<a <b)
(lambda (p1 p2) (if (<a (car p1) (car p2))
#t
(if (<a (car p2) (car p1))
#f
(<b (cdr p1) (cdr p2))))))
Provide an Abstract Data Type
See Exercises 38 and 39 from Chapter 2 of Programming Languages: Build, Prove, and Compare.
Currying and Partial Application
(val curry
(lambda (f)
(lambda (x)
(lambda (y) (f x y)))))
curry
turns a function of two-arguments returning an answer into a
function of one argument returning a function of one argument
returning an answer.
(val uncurry
(lambda (f)
(lambda (x y) ((f x) y))))
uncurry
turns a function of one argument returning a function of one
argument returning an answer into a function of two arguments
returning an answer.
What’s the algebraic law for curry?
... (curry f) ... = ... f ...
Keeping in mind all you can do with a function is apply it?
The term partial application generally refers to anytime one notionally has a function of \(n\) arguments returning an answer and one provides \(i<n\) arguments to obtain a function of \(n-i\) argumetns to an answer. A curried function makes partial application easy.
Staging
Perform some expensive work on initial arguments before getting remaining arguments. Compare:
(define re-match (re s)
(run-dfa (re-to-dfa re) s))
vs
(define re match (re)
(let ((dfa (re-to-dfa re)))
(lambda (s) (run-dfa dfa s))))
Arguably, a special case of partial application, but an important special case.
Higher-order Functions for Lists
In a language without first-class functions, many list processing
functions repeat the same boilerplate list-traversal code: a while
or for
loop and a cur = cur→next
assignment.
Higher-order functions for lists capture common patterns of computation or algorithms over lists:
-
exists?
,all?
-
filter
-
map
-
foldr
,foldl
exists?
and all?
(define exists? (p? xs)
(if (null? xs)
#f
(if (p? (car xs))
#t
(exists? p? (cdr xs)))))
(define all? (p? xs)
(if (null? xs)
#t
(if (p? (car xs))
(all? p? (cdr xs))
#f)))
filter
(define filter (p? xs)
(if (null? xs)
'()
(if (p? (car xs))
(cons (car xs) (filter p? (cdr xs)))
(filter p? (cdr xs)))))
map
(define map (f xs)
(if (null? xs)
'()
(cons (f (car xs)) (map f (cdr xs)))))
foldr
and foldl
Consider finding the sum and the product of a list:
(define sum (xs)
(if (null? xs)
0
(+ (car xs) (sum (cdr xs)))))
(define prod (xs)
(if (null? xs)
1
(* (car xs) (sum (cdr xs)))))
These two functions have identical structure and differ only in the value to return for the empty list and the function used to combine the head of a non-empty list with the result of the recusive call on the tail of the non-empty list. Abstracting these two aspects of the functions leads to the definition of foldr
:
(define foldr (op b xs)
(if (null? xs)
b
(op (car xs) (foldr op b (cdr xs)))))
Some ways of understanding (foldr op b (cons x1 (cons x2 ( … (cons xm (cons xn '()))…))))
:
-
It replaces each
cons
withop
and replaces'()
withb
:(op x1 (op x2 ( … (op xm (op xn b))…)))
-
It places
op
between each list element and associates to the right:x1 ⊕ (x2 ⊕ ( … (xm ⊕ (xn ⊕ b))…))
Note that with foldr
the function is applied to the last element of the list first and the first element of the list last (because the recursive call to foldr
returns before op
is called).
We could also have written the sum and the product of a list using an accumulating helper function:
(define sumacc (acc xs)
(if (null? xs)
acc
(sumacc (+ (car xs) acc) (cdr xs))))
(define sum (xs) (sumacc 0 xs))
(define prodacc (acc xs)
(if (null? xs)
acc
(prodacc (* (car xs) acc) (cdr xs))))
(define prod (xs) (prodacc 1 xs))
These functions have identical structure and differ only in the initial accumulating value and the function used to combine the head of a non-empty list with the current accumulating value for the recusive call on the tail of the non-empty list. Abstracting these two aspects of the functions leads to the definition of foldl
:
(define foldl (op b xs)
(if (null? xs)
b
(foldl op (op (car xs) b) (cdr xs))))
Some ways of understanding (foldl op b (cons x1 (cons x2 ( … (cons xm (cons xn '()))…))))
:
-
It places
(flip op)
between each list element and associates to the left:((…((b ⊞ x1) ⊞ x2) ⊞ …) xm) ⊞ xn
NoteBecause of the confusing use of
(flip op)
in this specification offoldl
, some languages definefoldl
with a combining operator that takes the accumulating value first and the list element second:(define foldl (op b xs) (if (null? xs) b (foldl op (op b (car xs)) (cdr xs))))
An argument for the definition of
foldl
given in the main text above is that its combining function takes the list element and the base/accumulating value in the same order asfoldr
's combining function, facilitating reuse of code between the twofold
s.
Note that with foldl
the function is applied to the first element of the list first and the last element of the list last (because the recursive call to foldl
occurs after op
is called).
Tail Calls
A tail call is a function application in tail position.
Tail position is defined inductively:
-
The body of a function is in tail position
-
When
(if e1 e2 e3)
is in tail position, so aree2
ande3
-
When
(let (…) e)
is in tail position, so ise
, and similary forlet*
andletrec
. -
When
(begin e1 … en)
is in tail position, so isen
.
Idea: The last thing that happens
Whatever is in tail position is the last thing executed and the result of the whole expression.
Leads to tail-call optimization.
General approach to executing a call: Push a stack frame for the callee, wait for the callee to return a result (during this time, our stack frame is inactive), continue executing with the result (during this time, our stack frame is active).
Specializing to executing a tail call: Push a stack frame for the callee, wait for the callee to return a result (during this time, our stack frame is active), immediately return the result as our result to our caller (during this time, our frame was active for only an instant).
Optimization for executing a tail call: Replace our stack frame with a stack frame for the callee, so that callee returns a result directly to our caller.
Tail-call optimization can lead to asymptotic space savings (and, often, this space savings becomes time savings (though rarely an asymptotic time savings)).
Recall accumulating reverse
:
(define revapp (xs zs)
(if (null? xs) zs
(revapp (cdr xs) (cons (car xs) zs))))
(define reverse (xs)
(revapp xs '()))
The recusive revapp
call is in tail position. Reversing a list of n elements requires n calls of revapp
, but only requires constant stack space.