Lecture#
Programming Paradigms#
Different ways to organize and think about code.
Refer to paradigms_demo.py to follow along with the examples below.
What Is a Programming Paradigm?
A programming paradigm is a fundamental style or approach to organizing and structuring code. It defines how you think about problems and express solutions.
Three Major Paradigms
Procedural – You tell the computer how to do something step by step. Code is organized around statements that change program state.
Object-Oriented (OOP) – You organize code around objects that bundle data (attributes) and behavior (methods). Emphasis on encapsulation, inheritance, and polymorphism.
Functional – You express computation as the evaluation of mathematical functions. Emphasis on immutability, pure functions, and avoiding side effects.
Note
Python is a multi-paradigm language. You can mix procedural, object-oriented, and functional styles in the same program.
Comparing Approaches
Same Problem, Three Paradigms
Task: Given a list of numbers, compute the sum of all even numbers.
# Imperative
nums = [1, 2, 3, 4, 5, 6]
total = 0
for n in nums:
if n % 2 == 0:
total += n
print(total) # 12
# Functional
nums = [1, 2, 3, 4, 5, 6]
total = sum(filter(
lambda x: x % 2 == 0,
nums
))
print(total) # 12
# Object-Oriented
class NumberProcessor:
def __init__(self, numbers: list[int]):
self._numbers = numbers
def sum_even(self) -> int:
return sum(n for n in self._numbers if n % 2 == 0)
processor = NumberProcessor([1, 2, 3, 4, 5, 6])
print(processor.sum_even()) # 12
Key Principles of Functional Programming
Functional programming is built on a small set of principles that promote predictable, testable, and composable code.
Pure Functions – A function whose output depends only on its inputs and produces no side effects (no modifying external state, no I/O). Given the same inputs, a pure function always returns the same output.
Think of a mathematical function like f(x) = x^2 + 1: for any input x, the output is always the same, and computing f(3) = 10 does not change anything else in the world.
Immutability – Data is not modified after creation. Instead of changing an object, you create a new one.
For example, rather than sorting a list in place with
my_list.sort(), a functional approach usessorted(my_list)to produce a new sorted list while leaving the original unchanged.
First-Class Functions – Functions can be assigned to variables, passed as arguments, and returned from other functions.
Higher-Order Functions – Functions that take other functions as arguments or return functions as results.
Avoiding Side Effects – Minimize or isolate operations that change state outside the function’s scope.
Example: Pure vs. Impure
# Pure function (no side effects, depends only on inputs)
def add(a: int, b: int) -> int:
return a + b
# Impure function (side effect: modifies external state)
results = []
def add_and_store(a: int, b: int) -> int:
result = a + b
results.append(result) # Side effect!
return result
Callables#
Objects that behave like functions.
Refer to callables_demo.py to follow along with the examples below.
What Is a Callable?
A callable is any object that can be called using parentheses (). In Python, several types of objects are callable:
Functions defined with
defLambda expressions
Classes (calling a class creates an instance)
Instances of classes that define the
__call__methodBuilt-in functions like
len,print,range
def do_nothing():
pass
print(callable(do_nothing)) # True
print(callable(lambda x: x)) # True
print(callable(int)) # True (classes are callable)
print(callable(42)) # False (integers are not callable)
print(callable("hello")) # False
First-Class Functions#
Functions as objects you can assign, pass, and return.
Refer to first_class_demo.py to follow along with the examples below.
What Does “First-Class” Mean?
In Python, functions are first-class objects. This means functions can be treated just like any other object (integers, strings, lists). Specifically, functions can be:
Assigned to variables – Store a function reference in a variable name.
Passed as arguments – Hand a function to another function as a parameter.
Returned from functions – Have a function produce another function as output.
Stored in data structures – Put functions in lists, dictionaries, or other containers.
def compute_square(x):
return x**2
# Assigning a function to a variable
f = compute_square # No parentheses! f is now the function object
print(f(5)) # 25
print(type(f)) # <class 'function'>
Warning
f = compute_square assigns the function object. f = compute_square() calls the function and assigns the return value. These are very different!
Tip
When debugging callbacks or higher-order functions, print the function object itself (not its return value) to verify you are passing the right function. For example, print(do_nothing) shows <function do_nothing at 0x...>, while print(do_nothing()) calls the function and prints its return value.
Passing Functions as Arguments
Callbacks and Higher-Order Functions
A higher-order function is a function that takes another function as an argument or returns one. The function passed in is sometimes called a callback.
def apply_operation(func, a, b):
"""Apply the given function to a and b."""
return func(a, b)
def add(x, y):
return x + y
def multiply(x, y):
return x * y
print(apply_operation(add, 3, 4)) # 7
print(apply_operation(multiply, 3, 4)) # 12
Note
Robotics Application: Callbacks are commonly used in ROS 2 subscriber nodes, where you pass a function that is invoked each time a new message arrives on a topic.
Returning Functions
Factory Functions
A function can create and return a new function. This is the foundation for closures and decorators, which we will cover later in this lecture.
def make_multiplier(factor):
"""Return a function that multiplies its input by factor."""
def multiply_value(x):
return x * factor
return multiply_value # Return the inner function object
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(type(double)) # <class 'function'>
Note
Key Insight: Each call to make_multiplier creates a new multiply_value function with its own factor value. The inner function “remembers” the value of factor even after make_multiplier has returned. This is a closure.
Functions in Data Structures
Storing Functions in Collections
Since functions are objects, they can be stored in lists, dictionaries, and other data structures. This pattern is useful for dispatch tables and plugin systems.
def add(a, b):
return a + b
def multiply(a, b):
return a * b
# Dispatch table: map operation names to functions
operations = {
"add": add,
"multiply": multiply,
}
op_name = "multiply"
result = operations[op_name](6, 7)
print(f"{op_name}(6, 7) = {result}") # multiply(6, 7) = 42
Built-in Higher-Order Functions: map
``map``: Apply a Function to Every Element
map(func, iterable) applies func to each element and returns a lazy iterator. Wrap the result in list() to see all values.
def compute_square(x):
return x**2
nums = [1, 2, 3, 4, 5]
squared = list(map(compute_square, nums))
print(squared) # [1, 4, 9, 16, 25]
# Also works with built-in functions
words = ["hello", "world"]
upper_words = list(map(str.upper, words))
print(upper_words) # ['HELLO', 'WORLD']
Note
map returns a lazy iterator, not a list. Wrap it in list() to materialize the results. We will see a more concise way to write the function argument when we cover lambda functions later in this lecture.
Built-in Higher-Order Functions: filter
``filter``: Keep Elements That Satisfy a Condition
filter(func, iterable) keeps only the elements for which func returns True.
def check_even(x):
return x % 2 == 0
nums = [1, 2, 3, 4, 5, 6]
evens = list(filter(check_even, nums))
print(evens) # [2, 4, 6]
def check_positive(x):
return x > 0
readings = [12.5, -3.1, 8.0, -0.5, 15.2]
valid = list(filter(check_positive, readings))
print(valid) # [12.5, 8.0, 15.2]
Note
Like map, filter also returns a lazy iterator. If func is None, filter removes all falsy values (0, "", None, etc.).
Built-in Higher-Order Functions: sorted with key
``sorted``: Sort by a Custom Criterion
The key parameter of sorted accepts a function that extracts a comparison value from each element.
# Sort strings by length
words = ["banana", "apple", "cherry", "date"]
by_length = sorted(words, key=len)
print(by_length) # ['date', 'apple', 'banana', 'cherry']
# Sort tuples by second element using a named function
def extract_score(pair):
return pair[1]
students = [("Alice", 88), ("Bob", 95), ("Charlie", 72)]
by_score = sorted(students, key=extract_score)
print(by_score) # [('Charlie', 72), ('Alice', 88), ('Bob', 95)]
# Reverse order
by_score_desc = sorted(students, key=extract_score, reverse=True)
print(by_score_desc) # [('Bob', 95), ('Alice', 88), ('Charlie', 72)]
List Comprehensions vs. map/filter
List comprehensions are often more readable than map and filter, especially for simple transformations.
nums = [1, 2, 3, 4, 5]
# Using map + filter
result_1 = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, nums)))
print(result_1) # [4, 16]
# Using list comprehension (preferred)
result_2 = [x ** 2 for x in nums if x % 2 == 0]
print(result_2) # [4, 16]
Note
Guideline: Prefer list comprehensions when the transformation is simple and the lambda would be short. Use map/filter when you already have a named function to pass.
Lambda Functions#
Small, anonymous, single-expression functions.
Refer to lambda_demo.py to follow along with the examples below.
Definition and Syntax
What Is a Lambda?
A lambda is a small anonymous function defined with the lambda keyword. It can take any number of parameters but contains only a single expression (no statements, no multi-line logic).
Note
Syntax: lambda parameters: expression
# Regular function
def add(a, b):
return a + b
# Equivalent lambda
add_lambda = lambda a, b: a + b
print(add(3, 4)) # 7
print(add_lambda(3, 4)) # 7
Warning
Assigning a lambda to a variable (like add_lambda = lambda ...) is discouraged by PEP 8. If you need a named function, use def. Lambdas are intended for short, inline use.
Common Use Cases: Inline Sorting Keys
Lambdas are most commonly used as short, throwaway functions for arguments like key in sorted.
# Sort a list of tuples by the second element
pairs = [(1, "banana"), (3, "apple"), (2, "cherry")]
sorted_pairs = sorted(pairs, key=lambda pair: pair[1])
print(sorted_pairs)
# [(3, 'apple'), (1, 'banana'), (2, 'cherry')]
# Sort robots by speed (descending)
robots = [
{"name": "TurtleBot", "speed": 0.26},
{"name": "Spot", "speed": 1.6},
{"name": "Atlas", "speed": 2.5},
]
fastest_first = sorted(robots, key=lambda r: r["speed"], reverse=True)
for r in fastest_first:
print(f" {r['name']}: {r['speed']} m/s")
Common Use Cases: map and filter
# Convert sensor readings from Celsius to Fahrenheit
celsius = [0.0, 20.0, 37.5, 100.0]
fahrenheit = list(map(lambda c: c * 9 / 5 + 32, celsius))
print(fahrenheit) # [32.0, 68.0, 99.5, 212.0]
# Filter out negative sensor readings
readings = [12.5, -3.1, 8.0, -0.5, 15.2]
valid = list(filter(lambda x: x >= 0, readings))
print(valid) # [12.5, 8.0, 15.2]
Lambda with Default Arguments
# Lambda with a default parameter
compute_power = lambda base, exp=2: base**exp
print(compute_power(3)) # 9
print(compute_power(3, 3)) # 27
Limitations
What Lambdas Cannot Do
Lambdas are restricted to a single expression. They cannot contain:
Statements – No
if/elseblocks (but conditional expressions are allowed), nofor/whileloops, notry/except.Assignments – No
x = ...inside a lambda.Multiple expressions – Only one expression is evaluated and returned.
Docstrings – Lambdas cannot have documentation strings.
Type hints – Lambda parameters cannot be annotated.
# Conditional expression in a lambda (valid)
classify = lambda x: "positive" if x > 0 else "non-positive"
print(classify(5)) # "positive"
print(classify(-3)) # "non-positive"
# Multi-line logic is NOT possible in a lambda
# Use a regular function instead
Note
Rule of Thumb: If a lambda is hard to read on one line, use a def function instead.
Closures#
Functions that remember their enclosing scope.
Refer to closures_demo.py to follow along with the examples below.
What Is a Closure?
A closure is a function that retains access to variables from its enclosing scope, even after the enclosing function has finished executing. Three conditions must be met:
There must be a nested function (a function defined inside another function).
The nested function must reference a variable from the enclosing function’s scope (a “free variable”).
The enclosing function must return the nested function.
def make_greeter(greeting):
"""Return a function that greets with the given greeting."""
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
howdy = make_greeter("Howdy")
print(hello("Alice")) # Hello, Alice!
print(howdy("Bob")) # Howdy, Bob!
Practical Example: Logger Factory
Configurable Logger
Closures are useful for creating pre-configured utility functions.
def make_logger(prefix: str):
"""Return a logging function with a fixed prefix."""
def log(message: str) -> None:
print(f"[{prefix}] {message}")
return log
info = make_logger("INFO")
error = make_logger("ERROR")
debug = make_logger("DEBUG")
info("System started") # [INFO] System started
error("Sensor timeout") # [ERROR] Sensor timeout
debug("x = 42") # [DEBUG] x = 42
Stateful Closures
Closures can maintain mutable state across calls without using global variables or classes. Use nonlocal to modify the captured variable.
def make_counter(start=0):
"""Return a counter function that increments on each call."""
count = start
def increment():
nonlocal count
count += 1
return count
return increment
counter_a = make_counter()
print(counter_a()) # 1
print(counter_a()) # 2
print(counter_a()) # 3
counter_b = make_counter(10)
print(counter_b()) # 11
Note
Each call to make_counter creates an independent closure with its own count variable. The two counters do not share state.
How Closures Capture State
Understanding the mechanism behind closures helps explain why they work even after the enclosing function returns.
Step 1: Python Inserts a Cell Object
During make_counter(start=0), Python sees that the inner function increment() references count from the enclosing scope. Instead of letting count point directly to the integer 0, Python inserts a cell object between them. This indirection exists so that both the enclosing scope and the inner function can share the same variable.
Step 2: ``make_counter`` Returns the Inner Function
When make_counter executes return increment, Python attaches the cell object to the function’s __closure__ tuple. Now two references point to the same cell: the local variable count (from the still-active scope) and counter_a.__closure__.
Step 3: Scope Is Discarded, Cell Survives
After make_counter returns, its local scope and the count variable are discarded. However, the cell object is not garbage collected because counter_a.__closure__ still references it, and the int(0) stays alive because the cell’s cell_contents still references it.
Step 4: Each Call Mutates the Cell
Each call to counter_a() follows the same steps: read cell_contents, compute the increment, write the new value back, and return. The nonlocal count declaration tells Python to modify the cell’s contents in place rather than creating a new local variable.
Decorators#
Wrapping functions to extend their behavior without modification.
Refer to decorators_demo.py to follow along with the examples below.
What Is a Decorator?
A decorator is a function that takes another function as input, adds some functionality, and returns a new function. Decorators allow you to extend or modify the behavior of functions without changing their source code.
The Manual Way (Without ``@`` Syntax)
def trace_calls(func):
def wrapper():
print("Before the function call")
func()
print("After the function call")
return wrapper
def say_hello():
print("Hello!")
# Manually applying the decorator
say_hello = trace_calls(say_hello)
say_hello()
# Before the function call
# Hello!
# After the function call
The @ Syntax
Syntactic Sugar
The @decorator syntax is shorthand for applying a decorator. It is placed directly above the function definition.
def trace_calls(func):
def wrapper():
print("Before the function call")
func()
print("After the function call")
return wrapper
@trace_calls
def say_hello():
print("Hello!")
# This is equivalent to: say_hello = trace_calls(say_hello)
say_hello()
# Before the function call
# Hello!
# After the function call
Handling Arguments
Our trace_calls from the previous section defines wrapper() with no parameters. What happens if we try to decorate a function that takes arguments?
@trace_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# TypeError: wrapper() takes 0 positional arguments but 1 was given
Warning
After decoration, greet is replaced by wrapper. When we call greet("Alice"), Python actually calls wrapper("Alice"), but wrapper accepts no arguments. We need a more flexible approach.
Decorating Functions with Arguments
To make a decorator work with any function, the wrapper should accept *args and **kwargs.
def trace_calls(func):
def wrapper(*args, **kwargs):
print(f"Before calling {func.__name__}")
result = func(*args, **kwargs)
print(f"After calling {func.__name__}")
return result
return wrapper
@trace_calls
def greet(name):
print(f"Hello, {name}!")
@trace_calls
def say_hello():
print("Hello!")
greet("Alice")
say_hello()
Preserving Metadata
The Problem: Metadata Is Lost
When you wrap a function with a decorator, the wrapper replaces the original function. This means the original function’s name, docstring, and other metadata are lost.
import time
def measure_time(func):
"""Measure and print the execution time of a function."""
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@measure_time
def compute_sum(n: int) -> int:
"""Compute sum of range(n)."""
return sum(range(n))
print(compute_sum.__name__) # 'wrapper' (not 'compute_sum'!)
print(compute_sum.__doc__) # None (docstring is lost!)
The Fix: ``functools.wraps``
functools.wraps is itself a decorator that you apply to your wrapper function. It copies the original function’s __name__, __doc__, __module__, and other attributes onto the wrapper so that introspection tools see the original function’s identity.
from functools import wraps
import time
def measure_time(func):
@wraps(func) # Copies metadata from func to wrapper
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@measure_time
def compute_sum(n: int) -> int:
"""Compute sum of range(n)."""
return sum(range(n))
print(compute_sum.__name__) # 'compute_sum'
print(compute_sum.__doc__) # 'Compute sum of range(n).'
Warning
Always use @functools.wraps(func) in your wrapper functions. Without it, debugging tools, documentation generators, and introspection code will show the wrong function name and docstring.
Stacking Decorators
Applying Multiple Decorators
Multiple decorators can be applied to a single function. They are applied from bottom to top (innermost first).
def apply_bold(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<b>{func(*args, **kwargs)}</b>"
return wrapper
def apply_italic(func):
@wraps(func)
def wrapper(*args, **kwargs):
return f"<i>{func(*args, **kwargs)}</i>"
return wrapper
# @apply_bold @apply_italic def greet <=> greet = apply_bold(apply_italic(greet))
@apply_bold # executed second
@apply_italic # executed first
def greet(name):
return f"Hello, {name}"
print(greet("Alice")) # <b><i>Hello, Alice</i></b>
Decorators with Arguments
Parameterized Decorators
Sometimes you want to pass arguments to a decorator itself. This requires an extra layer of nesting: a decorator factory that returns the actual decorator.
def repeat(n: int):
"""Decorator factory: repeat the function call n times."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
result = None
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def say_hello():
print("Hello!")
say_hello()
# Hello!
# Hello!
# Hello!
Understanding the Three Layers
repeat(n)– The decorator factory. Called with the argumentnand returns the actual decorator.decorator(func)– The actual decorator. Takes the function to be decorated and returns the wrapper.wrapper(*args, **kwargs)– The wrapper function. Replaces the original function and adds the repeated-call behavior.
# @repeat(3) is processed in two steps:
# Step 1: repeat(3) is called, returning 'decorator'
# Step 2: decorator(say_hello) is called, returning 'wrapper'
# So: say_hello = repeat(3)(say_hello)
Note
Pattern: Whenever you need a decorator that accepts arguments, use three nested functions: factory(args) -> decorator(func) -> wrapper(*args, **kwargs).
Partial Functions#
Pre-filling function arguments for convenience and reuse.
Refer to partial_demo.py to follow along with the examples below.
What Is functools.partial?
functools.partial creates a new function with some arguments of the original function pre-filled (“frozen”). The new function takes fewer arguments.
Note
Syntax: partial(func, *args, **kwargs)
from functools import partial
def compute_power(base, exponent):
return base ** exponent
# Create specialized functions by freezing one argument
square = partial(compute_power, exponent=2)
cube = partial(compute_power, exponent=3)
print(square(5)) # 25
print(cube(5)) # 125
Note
Key Insight: partial does not call the function. It returns a new callable with some arguments already set. You supply the remaining arguments when you call the partial.
Inspecting Partial Objects
def compute_power(base, exponent):
return base ** exponent
# Freezing a keyword argument
square = partial(compute_power, exponent=2)
print(square.func) # <function compute_power at 0x...>
print(square.args) # ()
print(square.keywords) # {'exponent': 2}
print(square(10)) # 100
# Freezing a positional argument (fills left to right)
power_of_ten = partial(compute_power, 10)
print(power_of_ten.func) # <function compute_power at 0x...>
print(power_of_ten.args) # (10,)
print(power_of_ten.keywords) # {}
print(power_of_ten(3)) # 1000 (10 ** 3)
Partial objects expose three useful attributes:
.func– The original function..args– Positional arguments that were frozen (filled left to right)..keywords– Keyword arguments that were frozen.
Practical Example: Unit Conversion
Robotics Application: Unit Conversion
def convert_distance(value, from_unit, to_unit):
"""Convert between distance units."""
# Lookup table: how many meters one unit equals
to_meters = {"m": 1.0, "cm": 0.01, "ft": 0.3048, "in": 0.0254}
# Step 1: Convert the input value to meters (common denominator)
meters = value * to_meters[from_unit]
# Step 2: Convert from meters to the target unit
return meters / to_meters[to_unit]
# Create specialized converters by freezing from_unit and to_unit
ft_to_m = partial(convert_distance, from_unit="ft", to_unit="m")
cm_to_in = partial(convert_distance, from_unit="cm", to_unit="in")
# Only 'value' remains as an argument
print(f"{ft_to_m(10):.2f} m") # 3.05 m
print(f"{cm_to_in(100):.2f} in") # 39.37 in
Partial vs. Lambda vs. Closure
Three Ways to Pre-Fill Arguments
def compute_power(base, exponent):
return base ** exponent
# Using partial
square_partial = partial(compute_power, exponent=2)
# Using lambda
square_lambda = lambda base: compute_power(base, exponent=2)
# Using closure
def make_power_func(exp):
def compute_value(base):
return compute_power(base, exp)
return compute_value
square_closure = make_power_func(2)
# All produce the same result
print(square_partial(5), square_lambda(5), square_closure(5))
# 25 25 25
Note
When to use which? partial is best for simple argument freezing and works well with introspection tools. Lambda is good for very short inline use. Closures offer the most flexibility for complex logic.
Putting It All Together#
This section combines the concepts from the entire lecture into a comprehensive exercise.
Summary#
Paradigms – Imperative, OOP, and functional styles; Python is multi-paradigm
First-Class Functions – Assign, pass, return, and store functions like any object
Lambdas – Anonymous single-expression functions for inline use
Closures – Functions that capture and retain enclosing scope variables
Callables – The
__call__method makes instances callableDecorators – Wrap functions to add behavior; use
@wrapsto preserve metadataStacking/Parameterized – Multiple decorators; three-layer pattern for arguments
Partials –
functools.partialfreezes arguments for reuse
Concept |
Mechanism |
Use Case |
|---|---|---|
First-class function |
|
Callbacks, dispatch tables |
Lambda |
|
Short inline sort keys |
Closure |
Nested function + free variable |
Stateful factories |
Callable class |
|
Complex stateful behavior |
Decorator |
|
Logging, timing, validation |
Partial |
|
Argument freezing |
Note
Reminder: Review and experiment with all provided code before next class.
Preview: What’s Next in L6#
Classes and objects
Attributes and methods
Constructors and
__init__Encapsulation and properties
Dunder methods
Note
Today’s lecture gives you the advanced function concepts that are essential for understanding object-oriented programming, decorators in frameworks, and functional patterns used throughout Python.