CS1602Introduction to Computation
Lecture 14Part 6 Pythonic Python and Modern PracticeAI Level 1

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.

NeedReach for
mappinga comprehension [f(x) for x in xs]
filteringa comprehension [x for x in xs if cond]
totals, extremes, countssum(), max(), len()
folding into one valuefunctools.reduce, or simply a loop
a sort keylambda — its most natural home

Paradigms

ParadigmCentral ideaIn Python
imperativetell the machine the stepsfor loops, assignment
object-orientedpackage data with behaviorthe classes of L9 and L10
functionaltransform data with functions, avoid mutationthis 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:

LevelFull nameWhere
LLocalinside the current function
EEnclosingan enclosing function (the closure level)
GGlobalthe top level of this module
BBuilt-inPython’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
  • nonlocal targets an enclosing function, global the module; reading needs neither
  • @decorator is func = decorator(func), nothing more
  • Always add @functools.wraps(func)
  • @property makes 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/reduce work, 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 global sparingly; never shadow built-ins

Exercises

  1. 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))
  2. 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.)
  3. Write a decorator count_calls recording how often a function was called, exposed as func.call_count.
  4. 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])
  5. Use sorted with a lambda to order a list of students by descending score, breaking ties by ascending name.
  6. Rewrite this as a comprehension and compare readability:
    result = list(map(lambda x: x[0].upper(), filter(lambda x: len(x) > 3, words)))
  7. For each x below, say which LEGB level it belongs to:
    x = 1
    def outer():
        x = 2
        def middle():
            def inner():
                print(x)
            inner()
        middle()
    outer()
    Then run it to check.

The lab problems are in Lab 14, and the team project opens this week.