Lesson 11 / 16
Lambda and Higher-Order Functions
A function is an object and can be passed as an argument: on two hundred items, the key function for sorted, min, and max is called exactly once per item — a hand-written sort doing the same job calls it 39800 times.
Contents
In the previous lesson, both the filter condition and the item expression stayed embedded inside the comprehension. Both stated a rule — “let this through,” “turn this into that” — but the rule could not be used outside where it was written. Repeating the same filter in a second comprehension meant writing it again.
Rules can be named. A function is exactly this: a named rule. This lesson’s question is whether that function can be passed as an argument, and how many times it is called when it is. Is a sort’s key computed once per item, or freshly on every comparison? The gap between the two will produce two numbers on two hundred items, and they will not be the same order of magnitude.
A Function Is an Object
In Python, def is not a declaration, it is a statement: when run, it produces
a function object and binds it to a name. The binding is like any other — the
object can go into a list, be a value in a dict, be passed as an argument to a
function, be returned from a function.
lambda produces the same kind of object without binding a name. It carries a
single expression, return is not written, its result is the expression’s value.
The produced object is the same type as what def produces; the only place they
differ is the name it carries.
A function that takes a function as an argument or returns one is called a higher-order function. The Programming Fundamentals course built this concept at the entrance to functional programming; it is not repeated here. What is measured is how many times Python’s built-in higher-order functions call the given function, and how many objects they materialize.
The Key Function’s Contract
sorted, min, and max accept a key argument: a function giving the comparison
value computed for each item. The contract’s measurable side is this — how many
times per item is this function called?
Two answers come to mind. Sorting compares items with each other, and comparison count exceeds item count; if the key is computed on every comparison, call count would be the same. Or the key is computed once and stored, and comparisons run on the stored values. The second route lowers call count but raises retention: a key value has to stay alive for every item. The measurement says which one holds.
The measurement’s assumptions:
- IF36 — The shared reference’s
Itemclass is used as is, with its counting arrangement;Itemonly counts production, key calls are kept in a separate counter. - IF37 — The key function counts every call to itself; the measure for “how many times was it called” is this counter, and it is the oracle itself.
- IF38 — The source has 200 items, and all four operations run on the same source. The counter is reset before every operation.
- IF39 — The key value is computed with
-o.value % 97; the number is arbitrary and chosen only so sorting produces an order different from the source’s. - IF40 — The hand-written selection sort computes the key twice per comparison; this is the behavior of a notation that does not store the key, and is the measurement’s opposite end. Sorting is used here not as an algorithm but as a rig producing a call count.
- IF41 — The distinction between
sortedandlist.sortis measured by identity; identity is shown withis, no number is printed. - IF42 — In the map and filter measurement, the counter is read right after the
setup line; the item-producing function deliberately produces new
Items, so laziness becomes visible through production. - IF43 — That the three functions are separate objects is shown with pairwise
iscomparisons; no identity number is printed. - IF44 — In the binding measurement, all three notations build the same three functions, and all are called with the same item; the only thing that changes is where the factor is written.
Measurement
"""Function is an object: how many times is the key function called, when does a stream materialize.""" import functools COUNTER = {"produced": 0, "key_calls": 0} class Item: """An item that counts every production of itself.""" def __init__(self, value): COUNTER["produced"] += 1 self.value = value def __repr__(self): return f"Item({self.value})" def reset(): COUNTER["produced"] = 0 COUNTER["key_calls"] = 0 def produced(): return COUNTER["produced"] def key_function(o): """The item's sort key; counts every call to itself.""" COUNTER["key_calls"] += 1 return -o.value % 97 N = 200 SOURCE = [Item(i) for i in range(N)] def manual_select(items, key): """Selection sort that recomputes the key on every comparison.""" remaining, ordered = list(items), [] while remaining: min_idx = 0 for i in range(1, len(remaining)): if key(remaining[i]) < key(remaining[min_idx]): min_idx = i ordered.append(remaining.pop(min_idx)) return ordered OPERATIONS = (("sorted(n, key=f)", lambda n: len(sorted(n, key=key_function))), ("min(n, key=f)", lambda n: min(n, key=key_function)), ("max(n, key=f)", lambda n: max(n, key=key_function)), ("manual selection sort", lambda n: len(manual_select(n, key_function)))) print(f"{'operation':<24s} {'key calls':>16s} {'per item':>11s}") for name, operation in OPERATIONS: reset() operation(SOURCE) print(f" {name:<22s} {COUNTER['key_calls']:16d} {COUNTER['key_calls'] / N:11.1f}") reset() ordered = sorted(SOURCE, key=key_function) print() print(f"sorted(n) is n -> {ordered is SOURCE}, materialized {produced()}," f" shared {sum(1 for o in ordered if any(o is k for k in SOURCE))}") copy = list(SOURCE) before = copy result = copy.sort(key=key_function) print(f"list.sort return value {result!r}, container stayed the same -> {before is copy}") def named(o): return o.value unnamed = lambda o: o.value print() print(f"{'function':<12s} {'callable':>13s} {'__name__':>12s} {'same type':>12s}") for name, f in (("via def", named), ("via lambda", unnamed)): print(f" {name:<10s} {str(callable(f)):>13s} {f.__name__:>12s}" f" {str(type(f) is type(named)):>12s}") REGISTRY = {"increment": lambda o: o.value + 1, "double": lambda o: o.value * 2} print(f"functions held in dict: {sorted(REGISTRY)}," f" REGISTRY['double'](Item(21)) -> {REGISTRY['double'](Item(21))}") reset() mapped = map(lambda o: Item(o.value * 2), SOURCE) filtered = filter(lambda o: o.value % 2 == 0, SOURCE) at_setup = produced() first = next(mapped) print() print(f"map and filter materialized {at_setup} objects at setup," f" once the first item is requested {produced()}") print(f"once fully walked: map {len(list(mapped)) + 1} items," f" filter {len(list(filtered))} items, materialized {produced()}") reset() total = functools.reduce(lambda a, o: a + o.value, SOURCE, 0) print(f"total via reduce {total}, materialized {produced()}") def factor_maker(k): def multiply(o): return o.value * k return multiply three_funcs = [factor_maker(k) for k in (1, 2, 3)] separate = all(a is not b for i, a in enumerate(three_funcs) for b in three_funcs[i + 1:]) print() print(f"are the three functions separate objects -> {separate}," f" results {[f(Item(10)) for f in three_funcs]}") print(f"factor_maker(2) is factor_maker(2) -> " f"{factor_maker(2) is factor_maker(2)}") late = [lambda o: o.value * k for k in (1, 2, 3)] early = [lambda o, k=k: o.value * k for k in (1, 2, 3)] partial = [functools.partial(lambda o, k: o.value * k, k=k) for k in (1, 2, 3)] print() print(f"{'binding form':<26s} {'objects':>7s} {'results':>16s}") for name, funcs in (("free name in body", late), ("default argument", early), ("via partial", partial)): print(f" {name:<24s} {len(funcs):7d} {str([f(Item(10)) for f in funcs]):>16s}")
operation key calls per item sorted(n, key=f) 200 1.0 min(n, key=f) 200 1.0 max(n, key=f) 200 1.0 manual selection sort 39800 199.0 sorted(n) is n -> False, materialized 0, shared 200 list.sort return value None, container stayed the same -> True function callable __name__ same type via def True named True via lambda True <lambda> True functions held in dict: ['double', 'increment'], REGISTRY['double'](Item(21)) -> 42 map and filter materialized 0 objects at setup, once the first item is requested 1 once fully walked: map 200 items, filter 100 items, materialized 200 total via reduce 19900, materialized 0 are the three functions separate objects -> True, results [10, 20, 30] factor_maker(2) is factor_maker(2) -> False binding form objects results free name in body 3 [30, 30, 30] default argument 3 [10, 20, 30] via partial 3 [10, 20, 30]
One Call Per Item
The top table’s first three rows give the same number: 200 calls, exactly 1.0 per item. Sorting, finding the minimum, finding the maximum — in all three, the key function is called once per item, no more.
For min and max this is expected; looking at each item once is enough. For
sorted it is not. Sorting has to compare items with each other, and comparison
count exceeds item count. Yet the key call stays at 200: keys must be computed
once and stored, with all comparisons run on the stored values.
The fourth row shows the opposite end of this decision. A sort doing the same job, never storing the key and recomputing it on every comparison, calls the key 39800 times on the same two hundred items — 199.0 per item. The ratio between the two numbers is two hundred, and it grows as item count grows.
Read from the course’s axis: built-in sorted makes a trade. Storing key
values means holding as many references as there are items; in exchange, call count
drops to item count. The notation avoiding retention — the fourth row — stores no
intermediate value and pays with call count instead. This is the second lesson’s
result reversed: there, the non-retaining form won; here, the retaining form wins.
What is retained matters as much as what retaining removes.
This measurement is not a sorting-algorithm comparison; algorithms and complexity analysis are the Algorithms course’s subject. The fourth row here was hand-built only to show how many calls a notation not storing the key produces.
New Container or In Place
The next two lines separate two notations of the same job. sorted produces a new
list: sorted(n) is n is false, materialized Item is 0, shared is
200. Sorting duplicates no items, it only writes them into a new container in a
different order — the same container-building measurement from the first lesson.
list.sort builds no new container: the container stays the same and the return
value is None. This is the shared signature of in-place methods in Python, and
it has a consequence — the notation n = n.sort() replaces the list with None.
The distinction shows in one line: want the result, use sorted; want to keep the
container, use sort.
Proof That a Function Is an Object
The middle table compares two functions and gives the same answer on two of three
columns. The objects defined with def and produced with lambda are both
callable, both the same type. The only column they differ on is
__name__: one carries its own name, the other <lambda>.
This single difference looks minor but is a preview of the next lesson’s measurement. A function object carries its own name in an attribute, and that attribute is used in error traces, log records, and documentation generation. A nameless object cannot give this information.
The next line shows the practical payoff of being an object: two functions sit as values in a dict and get called by key. The rule now lives in the same place as data — nameable, storable, passable. The filter that stayed embedded in the previous lesson’s comprehension could have been a dict value here.
Lazy Higher-Order Built-ins
The last three lines measure the map and filter built-ins, and a familiar
number comes out: 0 objects at setup. Both return a stream; the given function
is never called at setup time. Once the first item is requested, produced becomes
1; once fully walked, 200.
This is the third lesson’s claim, restated on built-ins: map does not avoid
production, it defers it. Requesting two hundred items births two hundred objects.
Filtering follows the same pattern and lets 100 of two hundred items through; no
new object is built for the ones that do not pass, so production stays at two
hundred.
The choice between map and a comprehension is therefore not a cost choice, it is a
notation choice — one gives a stream, the other a container. Their numbers were
measured in the previous two lessons and do not change here.
The last line shows a third higher-order tool: the standard library’s reduce
function applies a two-argument function across a stream and collapses it into a
single value. Total is 19900, materialized 0 — no intermediate container is
built, only the accumulating value is carried at every step. A comprehension
produces a container, map a stream, reduce a value; all three take the same
function as an argument.
A Function That Returns a Function
The last two blocks reverse the direction: a function is now not an argument but a return value.
When factor_maker is called, the def inside its body runs and produces a new
function object. Three calls give three separate objects, and three produce
separate results — each carrying its own factor. Even two calls with the same
argument give separate objects:
factor_maker(2) is factor_maker(2) is false. Producing a function means
materializing an object every time; ten separate factors mean ten separate function
objects.
The Programming Fundamentals course’s closures — an inner function carrying an outer scope’s name — was built as a concept; the only thing added here is measuring that carrying by object count, and that what is carried is not a value but a name. The last table gives the consequence of this distinction.
All three notations produce three functions, and object count is 3 in all
three. But the results are not the same. The free name in body form gives
[30, 30, 30]: all three functions look at the same name k, and by the time they
are called, that name’s last value is 3. The closure did not copy k’s value,
it bound to its name.
The default argument form gives [10, 20, 30]. The Programming Fundamentals
course’s function-definition lesson measured that a default value is evaluated
once, at definition time; there it was a trap, here it is the fix. Since the
value is computed at definition time, each function carries its own factor along.
The standard library’s partial tool gives the same result by binding the argument
in advance.
The three rows are three readings of the same notation, and the difference between them is the difference between a name and a value. In a comprehension, the loop name vanishes once the comprehension ends; the function bound to it does not vanish and sees the last value. Binding to a name is not the same as taking a value.
Summary
defis a statement producing a function object;lambdagives the same kind of object without binding a name. Both are callable, both the same type, and the only place they differ is__name__.- A function object can be held as a value in a container and passed as an argument; a function taking or returning a function is higher-order.
- On two hundred items, the key function for
sorted,min, andmaxis called 200 times — exactly once per item. A hand-written sort not storing the key calls the same function 39800 times. sortedbuilds a new container and shares 200 items, duplicating none;list.sortruns in place and returnsNone.mapandfiltermaterialize 0 objects at setup, 200 once fully walked; filtering lets 100 of two hundred items through.reducecollapses to a single value without building any container.
Next Step
In this lesson functions always passed as arguments; none of them was changed. Yet
if a function can be returned, it is also possible to take a function and return
another function in its place — a wrapper that preserves the original behavior
and adds something around it. The next lesson measures this pattern: how many
objects does wrapping a function materialize, what happens to the original object,
and what does the wrapped function’s __name__ attribute say? This lesson’s one
seemingly minor column will produce a result there.
To keep your progress and take notes, Log in
My notes
Log in to take notes.