Functions, Functional Programming and Scope
A function is not merely a block of code you can call — it is a value you can pass around, return, and wrap. Grasp that and the Python constructs that look like magic stop looking like magic.
By the end of this lecture you should be able to
- Say what "functions are first-class citizens" actually means
- Avoid the two classic traps: mutable defaults and late binding
- Add behavior to existing functions with closures and decorators
- Write functional-style code with lambda, map, filter and reduce — and know when not to
- Use the LEGB rule to explain where any name comes from
Contents
Functions are values
L10 mentioned in passing that everything in Python is an object, functions included. Here is that in full.
def greet(name: str) -> str:
return f"hello, {name}"
print(type(greet))
print(greet.__name__)
print(greet.__doc__)
Being an object means a function can do anything a value can do:
def greet(name: str) -> str:
return f"hello, {name}"
# 1. assign it to a variable
f = greet
print(f("Alex"))
# 2. put it in a container
funcs = [str.upper, str.lower, str.title]
for fn in funcs:
print(fn("hello World"))
# 3. pass it as an argument
def apply_twice(fn, x):
return fn(fn(x))
print(apply_twice(lambda s: s + "!", "hi"))
# 4. return it
def make_greeter(greeting: str):
def greeter(name: str) -> str:
return f"{greeting}, {name}"
return greeter
hello = make_greeter("hello")
morning = make_greeter("good morning")
print(hello("Alex"))
print(morning("Blake"))
That is what “first-class citizen” means: functions get the same treatment as numbers and strings.
You have been relying on it already — the len in sorted(words, key=len) is a function passed as an argument.
Two classic traps
Before the advanced uses, clear these two out of the way. Both come from the same source: the moment you think evaluation happens is not the moment it happens.
Trap one: mutable default arguments
L4 flagged this; here is the full story.
def add_item(item: str, box: list[str] = []) -> list[str]:
box.append(item)
return box
print(add_item("apple"))
print(add_item("banana")) # you expected ['banana']?
print(add_item("orange"))
A default is evaluated once, when the function is defined, and every call shares that object.
def add_item(item: str, box: list[str] = []) -> list[str]:
box.append(item)
return box
print("the default lives here:", add_item.__defaults__)
add_item("x")
print("after one call:", add_item.__defaults__) # it was modified
The correct pattern uses None as a sentinel:
def add_item(item: str, box: list[str] | None = None) -> list[str]:
if box is None:
box = [] # a fresh one on every call
box.append(item)
return box
print(add_item("apple"))
print(add_item("banana"))
print(add_item("orange", ["existing"]))
Trap two: late binding
funcs = []
for i in range(3):
funcs.append(lambda: i) # each lambda returns i
print([f() for f in funcs]) # you expected [0, 1, 2]?
All twos. Because the i inside a lambda is looked up when the lambda is called, and by then the loop has finished with i sitting at 2.
funcs = []
for i in range(3):
funcs.append(lambda: i)
i = 99 # change the outer i
print([f() for f in funcs]) # all three follow
When a function uses an outer variable, it uses the value at call time, not at definition time.
The fix is to pin the value down at definition time with a default argument — exploiting the mechanism behind trap one:
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # defaults are evaluated at definition
print([f() for f in funcs])
Closures
def make_counter():
count = 0 # a local of make_counter
def increment() -> int:
nonlocal count # declare that we modify the outer count
count += 1
return count
return increment # return the inner function
c1 = make_counter()
c2 = make_counter()
print(c1(), c1(), c1())
print(c2(), c2()) # independent counters
make_counter has already finished, so its local count should be gone. But increment still remembers it.
An inner function retaining an outer function’s variables is a closure.
def make_counter():
count = 0
def increment() -> int:
nonlocal count
count += 1
return count
return increment
c = make_counter()
c(); c()
print("captured cells:", c.__closure__)
print("current value:", c.__closure__[0].cell_contents)
def outer():
x = 1
def read_only():
return x # reading needs no declaration
def modify():
nonlocal x # modifying does
x = 100
print("read:", read_only())
modify()
print("after modify:", x)
outer()
Decorators
The most common use of a closure is adding behavior to an existing function without editing it.
Starting from the mechanism
def logged(func):
"""Take a function, return a wrapped version of it."""
def wrapper(*args, **kwargs):
print(f" calling {func.__name__} with {args}")
result = func(*args, **kwargs)
print(f" returned {result}")
return result
return wrapper
def add(a: int, b: int) -> int:
return a + b
add = logged(add) # replace it with the wrapped version
print(add(2, 3))
add = logged(add) is so common that Python gave it syntax:
def logged(func):
def wrapper(*args, **kwargs):
print(f" calling {func.__name__} with {args}")
result = func(*args, **kwargs)
print(f" returned {result}")
return result
return wrapper
@logged # exactly equivalent to add = logged(add)
def add(a: int, b: int) -> int:
return a + b
print(add(2, 3))
@decorator is shorthand for func = decorator(func). There is no other magic in it.
A useful example: timing
import time
def timed(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
print(f" {func.__name__} took {time.time() - start:.4f} s")
return result
return wrapper
@timed
def slow_sum(n: int) -> int:
total = 0
for i in range(n):
total += i
return total
print(slow_sum(1_000_000))
functools.wraps: preserving the original’s identity
def logged(func):
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"""Add two numbers."""
return a + b
print("name:", add.__name__) # now it says wrapper
print("doc:", add.__doc__) # and the docstring is gone
import functools
def logged(func):
@functools.wraps(func) # copy the original's metadata across
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
@logged
def add(a, b):
"""Add two numbers."""
return a + b
print("name:", add.__name__)
print("doc:", add.__doc__)
Always add @functools.wraps(func) when writing a decorator. Without it you lose the docstring and every debugging aid.
A decorator you have already used
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n: int) -> int:
return n if n <= 1 else fib(n - 1) + fib(n - 2)
print(fib(100))
L11 used this to turn exponential into linear and promised L14 would explain decorators. Now you know: lru_cache is a closure that wraps your function and checks a cache first.
@property
class Circle:
def __init__(self, radius: float) -> None:
self._radius = radius
@property
def radius(self) -> float:
return self._radius
@radius.setter
def radius(self, value: float) -> None:
if value <= 0:
raise ValueError("radius must be positive")
self._radius = value
@property
def area(self) -> float:
"""Looks like an attribute; it is computed."""
return 3.14159 * self._radius ** 2
c = Circle(2)
print(c.radius, f"{c.area:.4f}")
c.radius = 3 # looks like assignment; calls the setter
print(c.radius, f"{c.area:.4f}")
c.radius = -1 # the setter refuses
@property makes a method behave like an attribute. The benefit: start with a plain attribute, and when you later need validation or computation, convert it to a property — and not one line of calling code changes.
This continues L9’s encapsulation, except Python does not require getters and setters up front.
Functional programming
lambda: a throwaway function
square = lambda x: x ** 2 # do not write this
print(square(5))
def square2(x): return x ** 2 # if it gets a name, use def
print(square2(5))
words = ["banana", "kiwi", "apple", "fig"]
print(sorted(words, key=len)) # by length
print(sorted(words, key=lambda w: w[-1])) # by last letter
print(sorted(words, key=lambda w: (len(w), w))) # length, then alphabetically
people = [("Alex", 18), ("Blake", 22), ("Chen", 20)]
print(sorted(people, key=lambda p: p[1], reverse=True))
A lambda holds one expression and no statements:
f = lambda x:
if x > 0: return "positive"
map / filter / reduce
nums = [1, 2, 3, 4, 5]
print(list(map(lambda x: x ** 2, nums))) # map
print(list(filter(lambda x: x % 2 == 0, nums))) # filter
from functools import reduce
print(reduce(lambda a, b: a + b, nums)) # fold
print(reduce(lambda a, b: a * b, nums))
map and filter return iterators (L13), which is why list() is needed to see anything.
reduce folds a sequence into one value, optionally from a starting point:
from functools import reduce
print(reduce(lambda a, b: a + b, [1, 2, 3, 4], 100)) # start from 100
print(reduce(lambda a, b: a + b, [], 0)) # an empty sequence needs one
from functools import reduce
print(reduce(lambda a, b: a + b, [])) # without one it raises
But Python prefers comprehensions
nums = [1, 2, 3, 4, 5]
# the functional spelling
print(list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, nums))))
# the Pythonic spelling
print([x ** 2 for x in nums if x % 2 == 0])
Same result. The second is shorter, reads better, and does not have to be read inside out.
| Need | Reach for |
|---|---|
| mapping | a comprehension [f(x) for x in xs] |
| filtering | a comprehension [x for x in xs if cond] |
| totals, extremes, counts | sum(), max(), len() |
| folding into one value | functools.reduce, or simply a loop |
| a sort key | lambda — its most natural home |
Paradigms
| Paradigm | Central idea | In Python |
|---|---|---|
| imperative | tell the machine the steps | for loops, assignment |
| object-oriented | package data with behavior | the classes of L9 and L10 |
| functional | transform data with functions, avoid mutation | this lecture |
Python is multi-paradigm — it does not force a choice. In practice you mix: classes for the large structure, comprehensions for the data, loops for the main flow.
LEGB: where a name comes from
Now for a question you have been living with unexamined: when Python sees a name, where does it look?
Four places in order, abbreviated LEGB:
| Level | Full name | Where |
|---|---|---|
| L | Local | inside the current function |
| E | Enclosing | an enclosing function (the closure level) |
| G | Global | the top level of this module |
| B | Built-in | Python’s built-in names |
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print("inner sees:", x)
inner()
print("outer sees:", x)
outer()
print("module level sees:", x)
Strip away the layers and watch it search outward:
x = "global"
def outer():
x = "enclosing"
def inner():
print("no local, so:", x) # finds enclosing
inner()
outer()
x = "global"
def outer():
def inner():
print("no local or enclosing, so:", x) # finds global
inner()
outer()
def f():
print(len) # not local, enclosing or global → built-in
print(len([1, 2, 3]))
f()
The Enclosing level only exists inside a closure — which is why closures came before LEGB in this lecture. Without nested functions there is no E.
Assignment changes the verdict
x = 10
def read():
print("reading:", x) # no assignment, so it looks outward
read()
x = 10
def write():
print(x) # raises
x = 20 # because of this line, x is local throughout
write()
If a function assigns to a name anywhere, that name is local everywhere in the function — even before the assignment. So the print(x) on the first line reads an unassigned local.
Modifying an outer variable requires a declaration:
x = 10
def write_global():
global x
x = 20
write_global()
print("module-level x is now:", x)
Shadowing built-ins
def f():
list = [1, 2, 3] # a local shadowing the built-in list
return list
print(f())
print(list("abc")) # the outer list survives; shadowing was local
list = [1, 2, 3] # shadowing at module level causes trouble
print(list("abc"))
L3 and L8 both said not to use built-in names as variables. LEGB explains why: your name sits at G and the built-in at B, and G is searched first.
Summary
Functions as values
- Functions can be assigned, stored, passed and returned — first-class
- Defaults are evaluated once at definition: never a list or dict, use
None - Free variables are evaluated at call time: pin them with a default when building closures in a loop
Closures and decorators
- A closure is an inner function retaining an outer function’s variables
nonlocaltargets an enclosing function,globalthe module; reading needs neither@decoratorisfunc = decorator(func), nothing more- Always add
@functools.wraps(func) @propertymakes a method behave like an attribute without changing calling code
Functional style
- Use a lambda only inline; if it gets a name, use
def map/filter/reducework, but comprehensions are more Pythonic- The valuable functional idea is avoiding mutation, not those three functions
Scope
- Names resolve by LEGB: Local → Enclosing → Global → Built-in
- Enclosing only exists inside closures
- An assignment anywhere makes the name local everywhere in that function
- Use
globalsparingly; never shadow built-ins
Exercises
- Explain the output of each, without running it:
def f(x, acc=[]): acc.append(x) return acc print(f(1)); print(f(2)); print(f(3)) - Write a decorator
retry(n)that retries the wrapped function up to n times on failure. (Hint: a decorator taking arguments needs three levels of nesting.) - Write a decorator
count_callsrecording how often a function was called, exposed asfunc.call_count. - This is meant to build three different multiplier functions but produces three that multiply by 3. Fix it:
multipliers = [lambda x: x * i for i in range(1, 4)] print([m(10) for m in multipliers]) - Use
sortedwith alambdato order a list of students by descending score, breaking ties by ascending name. - Rewrite this as a comprehension and compare readability:
result = list(map(lambda x: x[0].upper(), filter(lambda x: len(x) > 3, words))) - For each
xbelow, say which LEGB level it belongs to:
Then run it to check.x = 1 def outer(): x = 2 def middle(): def inner(): print(x) inner() middle() outer()
The lab problems are in Lab 14, and the team project opens this week.