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 in exp

  • 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

  1. Create similar functions

  2. Combine functions

  3. Pass functions with private data to iterators

  4. Provide an abstract data type

  5. Currying and partial application

  6. 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 with op and replaces '() with b: (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

    Note

    Because of the confusing use of (flip op) in this specification of foldl, some languages define foldl 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 as foldr's combining function, facilitating reuse of code between the two folds.

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 are e2 and e3

  • When (let (…​) e) is in tail position, so is e, and similary for let* and letrec.

  • When (begin e1 …​ en) is in tail position, so is en.

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.