Comp 112

Lecture 12

Higher-Order Functions

2017.11.21

Literal Expressions

In Python, we can write expressions, called literals, for:

Lambda Expressions

We can also write literal expressions for functions:

double  =  lambda x : 2 * x

This is the function that doubles its argument (here called x)

$x ⟼ 2 * x$

It is equivalent to:

def double (x) :
    return 2 * x

The terminology “lambda” is historical: it comes from the original model of universal computation, called “λ-calculus”, which was invented in the 1930s, before computers even existed.

The general form of a lambda expression is:

lambda <parameter_variables> : <expression_using_those_parameter_variables>

Function Composition

One of the most fundamental operations on functions is function composition: using the output from one function as the input for another.

double  =  lambda x : 2 * x
succ  =  lambda x : x + 1
two_x_plus_one  =  lambda x : succ (double (x))
four_x  =  lambda x : double (double (x))

We can write the function composition operation itself as a higher-order function:

def compose (f , g) :
    # signature: any a , b , c . function (a -> b) , function (b -> c) -> function (a -> c)
    return lambda x : g (f (x))

Letting us write:

two_x_plus_one  =  compose (double , succ)
four_x  =  compose (double , double)

The Identity Function

Compound Compositions

Using induction, identity and compose we can define more complex patterns of function composition:

def iterate (n , f) :
    # signature: any a . int , function (a -> a) -> function (a -> a)
    # precondition: n >= 0
    # composes a function with itself a given number of times
    if n == 0 :
        return identity
    elif n > 0 :
        return compose (f , iterate (n - 1 , f))
eight_times_x  =  iterate (3 , double)
def compose_all (fs) :
    # signature: list (function) -> function
    # composes together all the functions in the list
    # note: our signature language is not rich enough to describe the condition that
    # the output type of each function must equal the input type of the next one.
    if fs == [] :
        return identity
    else :
        return compose (fs [0] , compose_all (fs [1 : ]))
four_x_plus_three  =  compose_all ([double , succ , double , succ])

Partial Application of Functions

We can turn a function that expects two arguments into a function that expects the first argument and returns a function expecting the next one:

# signature: function (int , int -> int)
mult  =  lambda x , y : x * y

# signature: function (int -> function (int -> int))
mult_c  =  lambda x : lambda y : mult (x , y)

triple  =  mult_c (3)  #  =  lambda y : mult (3 , y)

We can write a higher-order function to do this for any two-argument function, this is called function currying:

# signature: any a , b , c . function (a , b -> c) -> function (a -> function (b -> c))
curry  =  lambda f : lambda x : lambda y : f (x , y)

mult_c  =  curry (mult)

Aside: using induction, we can curry functions with any number of arguments (although this is a bit tricky).

def curry_n (n , f) : 
    def curry_n_h (n , f , args) :
        return  f (* args)  if  n == 0  else  lambda x : curry_n_h (n - 1 , f , args + [x])
    return curry_n_h (n , f , [])

Curried Higher-Order Functions

Higher-order functions like map and filter become even more useful when we curry them:

# signature : any a , b . function (function (a -> b) -> function (iterator (a) -> iterator (b)))
map_c  =  curry (map)
# signature : any a . function (function (a -> bool) -> function (iterator (a) -> iterator (a)))
filter_c  =  curry (filter)

because then we can partially apply them to easily form useful new functions:

double_all  =  map_c (double)
list (double_all ([1 , 2 , 3 , 4 , 5]))
just_evens  =  filter_c (lambda x : x % 2 == 0)
list (just_evens ([1 , 2 , 3 , 4 , 5]))

The ability to manipulate functions just like any other kind of data greatly expands our power of expression.

Example: Rat Diets

Recall the homework problem of finding the average weight of rats on a given diet:

data = \
[   #    name      ,    diet        , weight
    ('Whiskers'    , 'rat chow'     , 300.0) ,
    ('Mr. Squeeky' , 'swiss cheese' , 450.0) ,
    ('Pinky'       , 'rat chow'     , 320.0) ,
    ('Fluffball'    , 'swiss cheese' , 500.0)
]

Here is a high-level strategy:

def avg_weight (diet , table) :
    matching_weights = compose_all ([filter_c (lambda row : row [1] == diet) , map_c (lambda row : row [2]) , list]) (table)
    return  sum (matching_weights) / len (matching_weights)  if  matching_weights != []  else  0.0

This function contains no loops and no recursion. Higher-order functions let us work at higher levels of conceptual abstraction so that we can write complex programs concisely, with minimal bureaucratic overhead.

The Accumulator Pattern

In the accumulator patten for processing a list:

acc = init               # accumulator initial value
for x in xs :
    acc = f (acc , x)    # accumulator update function
return acc

Reducing Lists

We can turn the accumulator pattern into a higher-order function for processing lists:

def reduce (f , init , xs) :
    # signature: any a , b . function (b , a -> b) , b , list (a) -> b
    acc = init               # accumulator initial value
    for x in xs :
        acc = f (acc , x)    # accumulator update function
    return acc

Conceptually, the reduce function is doing this:

As with map and filter, reduce becomes even more useful when we curry it:

reduce_c  =  curry_n (3 , reduce)

Examples: List Reduction

Now we can do list reductions with no loops and no recursion, often as one-liners:

# the sum of the numbers in a list (or 0 if empty):
sum  =  reduce_c (lambda acc , x : acc + x) (0)
sum ([1 , 2 , 3 , 4 , 5])
# the longest string in a list (or '' if empty):
longest_string  =  reduce_c (lambda acc , x : acc if len (acc) >= len (x) else x) ('')
longest_string (['was' , 'it' , 'the' , 'best' , 'of' , 'times' , 'or' , 'not' , '?'])
# the largest number in a non-empty list:
max  =  lambda xs : reduce_c (lambda acc , x : acc if acc >= x else x) (xs [0]) (xs [1 : ])
max ([1 , 2 , 3 , 4 , 3 , 2 , 1])
# the curried map function:
map_c_red  =  lambda f : reduce_c (lambda ys , x : ys + [f (x)]) ([])
map_c_red (double) ([1 , 2 , 3])
# the curried filter function:
filter_c_red  =  lambda p : reduce_c (lambda xs , x : xs + [x] if p (x) else xs) ([])
filter_c_red (lambda x : x % 2 == 0) ([1 , 2 , 3 , 4 , 5])
# average rat weight:
def avg_weight_red (diet , table) :
    init = (0.0 , 0)  #  (sum of matching rat weights , count of matching rats)
    f = lambda acc , row : (acc [0] + row [2] , acc [1] + 1) if row [1] == diet else acc
    (total_weight , total_count)  =  reduce_c (f) (init) (table)
    return  0.0  if  total_count == 0  else  total_weight / total_count

To Do This Week: