Functional programming: Scheme, lambda, map and recursion
The idea of a mathematical function, LISP data structures (atom, list, cons cell), car/cdr/cons in Scheme, define and lambda, if/cond/let, recursive list functions, higher-order functions (map, filter, compose) and tail recursion.
Functional programming sees a program as a combination of mathematical functions. No variables, no assignment, no loops; a function always gives the same result with the same argument. The functional language of this course is Scheme (a small, clean dialect of LISP). Two question types come up on exams: finding the value of a Scheme expression (car, cdr, cons chains) and tracing what a short recursive function returns.
Golden rules:
- Evaluate from the inside out. Rewrite the list at each step.
- car = first element, cdr = the rest of the list, cdr always returns a list (for suitable input).
- cons adds to the front of the second argument. If the second argument is not a list, it is a dotted pair.
- Recursion pattern: the empty list is the base case, process with car, continue with cdr.
- The quote (
') stops evaluation.
Mathematical functions
A mathematical function maps each element of its domain to an element of its range. The order of evaluation is determined only by the arguments, not by side effects:
Lambda notation writes an anonymous function: . Application: . Scheme’s lambda comes directly from here.
A functional form (higher-order function): a function that takes functions as parameters or returns a function. Three classics:
- Composition: , .
- Apply-to-all: apply a function to every element of a list (map).
- Construction: apply a list of functions to one argument, list the results.
The difference from imperative languages: in an imperative program, variables are memory cells and the state changes. In a pure functional program there is no state; that is why referential transparency holds and concurrency becomes easier. The cost: efficiency on a von Neumann machine.
LISP data structure
In LISP (1958, John McCarthy) there are two kinds of data objects: atoms (a, 17, hello) and lists ((a b c), (a (b c) d)). Lists are held internally as a chain of cons cells: each cell has two fields, car (the element) and cdr (the next cell). The cdr of the last cell is the empty list ().
(a (b c) d)
[a|•]──►[•|•]──►[d|/]
│
▼
[b|•]──►[c|/]
The program is written in the same form too: (+ 2 3) is a list and evaluates to 5. That code and data have the same form is the most distinctive feature of LISP.
Scheme basics
The interpreter is a read-evaluate-print loop. Evaluation rule: when you see a list, take the first element as the function, evaluate the other elements, and apply the function. Numbers evaluate to themselves.
| Expression | Value | Note |
|---|---|---|
(+ 2 (* 3 4)) | 14 | Prefix notation |
'(a b c) | (a b c) | quote: don’t evaluate |
(car '(a b c)) | a | The first element |
(cdr '(a b c)) | (b c) | The rest |
(cons 'a '(b c)) | (a b c) | Add to the front |
(list 'a (quote b) '(c)) | (a b (c)) | A list from the arguments |
(null? '()) | #t | Is it the empty list? |
car, cdr, cons
(car (cdr '(a b c))) ; b
(cdr '((a b) c d)) ; (c d)
(car '((a b) c d)) ; (a b)
(cdr '(x)) ; ()
(cons '(a) '(b)) ; ((a) b)
(cons 'a 'b) ; (a . b)
(car (car (cdr '(1 (2 3) 4)))) ; 2
b
(c d)
(a b)
()
((a) b)
(a . b)
2
Trace of the last expression:
(cdr '(1 (2 3) 4)) → ((2 3) 4)
(car ((2 3) 4)) → (2 3)
(car (2 3)) → 2
PITFALL · the difference between cons and list.
(cons '(a) '(b))gives the two-element((a) b);(list '(a) '(b))gives((a) (b)). cons takes the second argument as a list and adds to its front; list makes every argument a separate element.(car '())is an error: the empty list has no first element.
Defining functions and control
(define pi 3.14159) ; binding a name
(define (square x) (* x x)) ; a function
(define square2 (lambda (x) (* x x))) ; the same, with lambda
(if (> x 0) x (- x)) ; two-way selection
(cond ((< x 0) 'neg)
((= x 0) 'zero)
(else 'pos)) ; multiple-way selection
(let ((a 2) (b 3)) (+ a (* a b))) ; 8, local names
Predicate functions return a boolean (#t, #f): null?, eq?, equal?, pair?, number?, zero?. eq? asks the equality of two symbols or the same object, equal? asks the structural equality of two lists: (equal? '(1 (2)) '(1 (2))) → #t.
Recursive list functions
No loops; everything is recursion. The pattern is always the same:
(define (f lst)
(cond ((null? lst) <base value>)
(else (<combine> (car lst) (f (cdr lst))))))
(define (sum lst)
(if (null? lst) 0 (+ (car lst) (sum (cdr lst)))))
(sum '(3 5 7)) ; 15
(define (member atm lis)
(cond ((null? lis) #f)
((eq? atm (car lis)) #t)
(else (member atm (cdr lis)))))
(member 'c '(a b c d)) ; #t
(define (rev l)
(if (null? l) '() (append (rev (cdr l)) (list (car l)))))
(rev '(1 2 3 4)) ; (4 3 2 1)
15
#t
(4 3 2 1)
Trace of (sum '(3 5 7)):
(sum '(3 5 7)) = 3 + (sum '(5 7))
= 3 + 5 + (sum '(7))
= 3 + 5 + 7 + (sum '())
= 3 + 5 + 7 + 0 = 15
In nested lists the car can also be a list; then you recurse in two directions:
(define (count-atoms l)
(cond ((null? l) 0)
((pair? (car l)) (+ (count-atoms (car l)) (count-atoms (cdr l))))
(else (+ 1 (count-atoms (cdr l))))))
(count-atoms '(a (b c) ((d)) e)) ; 5
Higher-order functions
(map (lambda (x) (* x x)) '(1 2 3)) ; (1 4 9)
(define (filter p lst)
(cond ((null? lst) '())
((p (car lst)) (cons (car lst) (filter p (cdr lst))))
(else (filter p (cdr lst)))))
(filter (lambda (x) (> x 2)) '(5 1 4 2 3)) ; (5 4 3)
(define (compose f g) (lambda (x) (f (g x))))
((compose car cdr) '(1 2 3)) ; 2
(define (adder n) (lambda (x) (+ x n)))
(map (adder 3) '(1 2 3)) ; (4 5 6)
(define (twice f) (lambda (x) (f (f x))))
((twice (lambda (x) (* x 3))) 2) ; 18
(1 4 9)
(5 4 3)
2
(4 5 6)
18
The same ideas exist today in imperative languages too:
console.log([1, 2, 3].map((x) => x * x), [5, 1, 4, 2, 3].filter((x) => x > 2), [1, 2, 3, 4].reduce((a, b) => a * b, 1));
[ 1, 4, 9 ] [ 5, 4, 3 ] 24
Java 8 streams (list.stream().map(...).filter(...)), Python’s map, filter and lambdas, and C#‘s LINQ were taken from functional programming.
Tail recursion
(define (fact1 n) (if (= n 0) 1 (* n (fact1 (- n 1)))))
(define (fact2 n acc) (if (= n 0) acc (fact2 (- n 1) (* n acc))))
(fact2 5 1) ; 120
In fact1, a multiplication is waiting after the recursive call: the ARI of every call must stay on the stack, and if n is large the stack overflows. In fact2 the call is the last job: its result is returned directly. Scheme turns this into a loop (the new call reuses the old ARI). The way to make a function tail recursive is usually to add an accumulator parameter.
Other functional languages
| Language | Feature |
|---|---|
| Common LISP | Large, a merger of many LISP dialects; macros |
| ML | Statically typed, type inference, syntax close to Pascal |
| Haskell | Pure (no side effects), lazy evaluation: values are not computed until needed, infinite lists are possible |
| F# | The ML family on .NET |
Common mistakes
| Wrong | Correct |
|---|---|
(cdr '(a b c)) = b | (b c); cdr returns a list |
(cdr '(x)) = x | () |
(cons '(a) '(b)) = (a b) | ((a) b) |
(cons 'a 'b) = (a b) | (a . b), not a list |
Unquoted (a b c) as list data | a is taken as a function; write '(a b c) |
Comparing lists with eq? | Use equal? |
| Every recursion is tail recursive | Not if there is work after the call |
| filter keeps 2 > 2 | > is strict; 2 is dropped |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
What is the value of the Scheme expression?
(car (cdr '(a b c)))Show answer
From the inside out: (cdr '(a b c)) gives what comes after the first element: (b c). car of this is the first element: b. car = first element, cdr = the rest of the list.
What are the values of these Scheme expressions?
(cdr '((a b) c d))
(car '((a b) c d))
(cdr '(x))Show answer
The first element can be a list: (a b). car returns it as is; cdr returns the rest: (c d). The cdr of a one-element list is the empty list ().
What are the values of these Scheme expressions?
(cons 'a '(b c))
(cons '(a) '(b))
(cons 'a 'b)Show answer
cons creates a new cons cell: the car part is the first argument, the cdr part is the second. If the second argument is a list, the result is a list with an element added at the front. (a) is added as one element: ((a) b). If the second argument is not a list, the result is not a list but a dotted pair: (a . b).
What is the value?
(car (car (cdr '(1 (2 3) 4))))Show answer
(cdr '(1 (2 3) 4)) = ((2 3) 4). car → (2 3). car → 2. In questions like this, work from the inside out and rewrite the list at each step.
What does (f 5) return?
(define (f n)
(if (= n 0)
1
(* 2 (f (- n 1)))))Show answer
f(n) = 2 · f(n - 1), f(0) = 1. That is . f(5) = 2 · 2 · 2 · 2 · 2 · 1 = 32. In functional languages recursion is used instead of loops.
What is the value?
(map (lambda (x) (* x x)) '(1 2 3))Show answer
lambda is an anonymous function: it takes x and returns x · x. map applies this function to each element of the list and collects the results in a new list. The original list does not change. The JavaScript equivalent is [1, 2, 3].map(x => x * x).
What is the value?
(let ((a 2) (b 3)) (+ a (* a b)))Show answer
let binds local names to values (not variables, unchangeable names). a = 2, b = 3 → 2 + 2 · 3 = 8. let is actually shorthand for a lambda application: ((lambda (a b) (+ a (* a b))) 2 3).
Using the function below, what is (filter (lambda (x) (> x 2)) '(5 1 4 2 3))?
(define (filter p lst)
(cond ((null? lst) '())
((p (car lst)) (cons (car lst) (filter p (cdr lst))))
(else (filter p (cdr lst)))))Show answer
cond tries the conditions in order. If the list is empty, (). If the first element satisfies p, cons it onto the filtered rest; if not, skip it. 5 ✔, 1 ✘, 4 ✔, 2 ✘ (2 > 2 is false), 3 ✔ → (5 4 3). The order is preserved.
What is the value?
(define (compose f g) (lambda (x) (f (g x))))
((compose car cdr) '(1 2 3))Show answer
compose is a functional form (a higher-order function): it takes two functions and returns a new function. (compose car cdr) applies first cdr and then car to x: cdr → (2 3), car → 2. In mathematics, .
What does (count-atoms '(a (b c) ((d)) e)) return?
(define (count-atoms l)
(cond ((null? l) 0)
((pair? (car l)) (+ (count-atoms (car l)) (count-atoms (cdr l))))
(else (+ 1 (count-atoms (cdr l))))))Show answer
If the first element is a list, recurse both into it and into the rest; if it is an atom, count 1 and continue. The atoms: a, b, c, d, e → 5. This "double recursion on car and cdr" pattern is standard for nested lists. A len that counts only the top level would give 4.
Which of these two factorial definitions is tail recursive, and why does it matter?
(define (fact1 n) (if (= n 0) 1 (* n (fact1 (- n 1)))))
(define (fact2 n acc) (if (= n 0) acc (fact2 (- n 1) (* n acc))))Show answer
In fact1, after the recursive call returns, a multiplication is still to be done, so the ARI of every call must be kept. In fact2 the result of the call is returned directly; since no work remains, the new ARI can take the place of the old one (like a loop). The Scheme standard requires tail calls to be implemented this way. (fact2 5 1) = 120.
What is the value?
(define (adder n) (lambda (x) (+ x n)))
(map (adder 3) '(1 2 3))Show answer
(adder 3) returns a closure: a function that adds 3 to x, carrying n = 3 along. map applies it to each element. That functions can be returned as values and passed as parameters (first-class functions) is the foundation of functional programming.
Verification: Since Scheme is not installed, a small Scheme interpreter was written in node (verify/SEN2001/lib/scheme.mjs: quote, lambda, define, if, cond, let, let*, car/cdr/cons/list, null?, eq?, equal?, map, apply) and every Scheme expression on the page was run in this interpreter (verify/SEN2001/13.mjs). List notation follows Scheme's standard output format. The JavaScript equivalents were run with node.