Higher-Order Functions, Decorators & Simple Classes

Powerful Python Patterns for Cleaner Code

Oliver Bonham-Carter

On For Today

Let’s explore three powerful Python patterns!

Topics covered in today’s discussion:

  • 🐍 Higher-Order Functions β€” Functions that take or return other functions
  • 🐍 Passing Functions as Arguments β€” Treating functions as first-class citizens
  • 🐍 Returning Functions from Functions β€” Building function factories
  • 🐍 Decorators β€” Wrapping functions to add behavior
  • 🐍 Decorator Syntax (@) β€” Python’s elegant shorthand
  • 🐍 Simple Classes β€” Bundling data and behavior together
  • 🐍 The __init__ Method β€” Constructing objects
  • 🐍 Methods and Attributes β€” Working with class instances
  • 🐍 Challenge Problems & Solutions β€” Practice what you’ve learned!

Motivation: Parametric Curves & Higher-Order Functions

Important

The Butterfly Curve β€” A Parametric Function A parametric equation expresses coordinates as functions of a shared variable \(t\):

\[x(t) = \sin(t)\!\left(e^{\cos t} - 2\cos(4t) - \sin^5\!\tfrac{t}{12}\right)\]

\[y(t) = \cos(t)\!\left(e^{\cos t} - 2\cos(4t) - \sin^5\!\tfrac{t}{12}\right)\]

Note

As \(t\) sweeps from \(0\) to \(12\pi\), the pair \((x(t),\,y(t))\) traces the butterfly shape.

The Python Connection

Important

Plotting this curve means applying the same mathematical building blocks β€” \(e^{\cos t}\), \(\cos(4t)\), \(\sin^5(t/12)\) β€” inside both \(x\) and \(y\). We can define each piece as a function and pass it as an argument. That is precisely what higher-order functions make possible!

Plotting the Butterfly Curve in Python

Higher-Order Functions Make This Clean

import numpy as np
import matplotlib.pyplot as plt

# Define each reusable mathematical building block as a function
def radial(t):
    return np.exp(np.cos(t)) - 2*np.cos(4*t) - np.sin(t/12)**5

def x_coord(t, r_func):   # <-- accepts a function as an argument!
    return np.sin(t) * r_func(t)

def y_coord(t, r_func):   # <-- accepts a function as an argument!
    return np.cos(t) * r_func(t)

t = np.linspace(0, 12 * np.pi, 10_000)

plt.figure(figsize=(6, 6))
plt.plot(x_coord(t, radial), y_coord(t, radial), linewidth=0.6)
plt.axis("equal"); plt.axis("off")
plt.title("Butterfly Curve"); plt.tight_layout(); plt.show()

The higher-order connection: x_coord and y_coord each accept r_func as a parameter β€” they don’t hard-code the radial formula. Swapping in a different function instantly produces a different curve. This is a higher-order function in real scientific code.

Parametric Functions with Python

Note

We can plug in any function we like for fn() β€” the functions x() and y() still work as parameters! This is the essence of higher-order programming: variables and functions can be used as parameters.

Interactive Parametric Curve Explorer Code


Part 1: Higher-Order Functions

What Is a Higher-Order Function?

A higher-order function is a function that does at least one of the following:

  1. Takes another function as an argument, or
  2. Returns a function as its result.

You have already seen some! map(), filter(), and sorted() are all higher-order functions because they accept a function as a parameter.

Key Insight: In Python, functions are first-class citizens β€” they can be stored in variables, passed around, and returned, just like numbers or strings.

Functions Are Just Objects

Assigning Functions to Variables

def shout(text):
    return text.upper() + "!"

# Assign the function to a new variable (no parentheses!)
yell = shout

print(shout("hello"))   # Output: HELLO!
print(yell("hello"))    # Output: HELLO!
print(type(yell))       # Output: <class 'function'>

Important

How it works: yell = shout does not call the function. It copies the reference to the function object into yell. Both names point to the same function β€” calling either one produces the same result.

Passing a Function as an Argument β€” Simple

A Function That Accepts Another Function

def apply_twice(func, value):
    return func(func(value))

def add_three(x):
    return x + 3

result = apply_twice(add_three, 10)
print(result)  # Output: 16

Step-by-step: apply_twice(add_three, 10) sets func = add_three and value = 10. Inner call: func(10) β†’ add_three(10) β†’ 13. Outer call: func(13) β†’ add_three(13) β†’ 16. Result 16 is returned.

Passing a Function as an Argument β€” More Complex

Applying Different Operations to a List

def transform_list(data, operation):
    result = []
    for item in data:
        result.append(operation(item))
    return result

def square(x):  return x ** 2
def negate(x):  return -x

numbers = [1, 2, 3, 4, 5]
print(transform_list(numbers, square))  # [1, 4, 9, 16, 25]
print(transform_list(numbers, negate))  # [-1, -2, -3, -4, -5]
print(transform_list(numbers, str))     # ['1', '2', '3', '4', '5']

Important

How it works: transform_list doesn’t care which function it receives β€” it simply calls operation(item) for each element. We can even pass the built-in str. This makes the function reusable with any transformation.

Returning a Function from a Function β€” Simple

A Function Factory

def make_multiplier(factor):
    def multiplier(x):
        return x * factor
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))   # Output: 10
print(triple(5))   # Output: 15
print(double(10))  # Output: 20

Important

How it works: make_multiplier(2) builds and returns the inner multiplier function with factor = 2 baked in. double is now that function. This is called a closure β€” the inner function remembers factor from its enclosing scope even after make_multiplier has finished.

Returning a Function β€” More Complex

A Greeting Factory

def make_greeter(greeting, punctuation="!"):
    def greeter(name):
        return f"{greeting}, {name}{punctuation}"
    return greeter

say_hello   = make_greeter("Hello")
say_goodbye = make_greeter("Goodbye", "...")
say_hey     = make_greeter("Hey", "! πŸ‘‹")

print(say_hello("Alice"))    # Hello, Alice!
print(say_goodbye("Bob"))    # Goodbye, Bob...
print(say_hey("Charlie"))    # Hey, Charlie! πŸ‘‹

Why this is useful: Instead of three separate greeting functions we build one factory that produces customized functions on demand. Each returned function carries its own greeting and punctuation values locked in via closure.

Built-In Higher-Order Functions Revisited

map(), filter(), and sorted() Are Higher-Order Functions

They are higher-order because they accept a function as an argument. You can pass any callable β€” including built-in functions β€” directly, without parentheses.

numbers = [1, -3, 5, -2, 8, -7, 4]

absolutes = list(map(abs, numbers))
print(absolutes)  # [1, 3, 5, 2, 8, 7, 4]

positives = list(filter(lambda x: x > 0, numbers))
print(positives)  # [1, 5, 8, 4]

by_abs = sorted(numbers, key=abs)
print(by_abs)     # [-2, 1, -3, 4, 5, -7, 8]

Notice: abs is passed directly β€” no parentheses, no lambda needed. Any callable works!

Part 2: Decorators

What Is a Decorator?

A decorator is a higher-order function that takes a function, adds some behavior to it, and returns a new function β€” all without modifying the original function’s code.

Analogy: Think of a decorator like a gift wrapper 🎁. The gift (your function) stays the same; the wrapper adds something extra β€” logging, timing, validation β€” around it.

Decorator Pattern β€” Manual Approach

Before the @ Syntax

def my_decorator(func):
    def wrapper():
        print("Something happens BEFORE the function.")
        func()
        print("Something happens AFTER the function.")
    return wrapper

def say_hello():
    print("Hello!")

say_hello = my_decorator(say_hello)  # manually decorate
say_hello()

Output: Something happens BEFORE the function. / Hello! / Something happens AFTER the function.

Important

How it works: my_decorator wraps say_hello inside wrapper and returns it. Re-assigning say_hello = my_decorator(say_hello) means every future call runs wrapper(), which sandwiches the original call with extra behavior.

The @ Syntax β€” Python’s Shorthand

Much Cleaner!

def my_decorator(func):
    def wrapper():
        print("Before the function runs.")
        func()
        print("After the function runs.")
    return wrapper

@my_decorator
def say_goodbye():
    print("Goodbye!")

say_goodbye()

Output: Before the function runs. / Goodbye! / After the function runs.

Important

Key Point: @my_decorator above a function is exactly the same as writing say_goodbye = my_decorator(say_goodbye). The @ symbol is syntactic sugar β€” it looks cleaner and is the Pythonic way.

Decorator with Arguments β€” Simple

Handling Any Signature with *args and **kwargs

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@log_call
def add(a, b):
    return a + b

print(add(3, 5))
# Calling add with args=(3, 5)
# add returned 8
# 8

Important

How it works: *args captures positional arguments as a tuple; **kwargs captures keyword arguments as a dictionary. The wrapper can therefore decorate any function signature. The original return value is captured and passed through unchanged.

Talking About Arguments (**kwargs)!

Important

def print_details(**details): # **details is acting like kwargs that captures all keyword arguments as a dictionary      
    print("Type:", type(details))
    for key, value in details.items():
        print(f"{key}: {value}")

print_details(name="Alice", age=30, city="New York") #proces a dictionary of keyword arguments
print_details(a="Alice", b=30, c="New York") # process another dictionary
# Output:
# Type: <class 'dict'>
# name: Alice
# age: 30
# city: New York

Note

  • Syntax: The double asterisk (**) is the key element that enables this functionality, not the specific name kwargs (though kwargs is the widely accepted convention).

  • Data Type: Inside the function, kwargs is a standard Python dictionary, which means you can access its elements using standard dictionary operations like kwargs[β€˜key_name’] or by iterating over its items using kwargs.items().

  • Flexibility: This is particularly useful when you need to write functions that can handle an unknown number of inputs or optional parameters, making your code more dynamic and reusable.

  • Order: When combining **kwargs with other parameters (positional, default, and *args), **kwargs must be placed last in the function’s parameter

A Practical Decorator β€” Timing a Function

Measuring Execution Time

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        print(f"Starting time: {start}")
        result = func(*args, **kwargs)
        end = time.time()
        print(f"Ending time: {end}")
        elapsed = end - start
        print(f"{func.__name__} took {elapsed:.4f} seconds")
        return result
    return wrapper

@timer
def slow_sum(n):
    total = 0
    for i in range(n):
        total += i
    return total

result = slow_sum(1_000_000)
print(f"Result: {result}")

Note

Why decorators are powerful: Timing was added to slow_sum without changing its code. @timer can be placed on any function to profile it β€” reusable, composable behavior.

Stacking Decorators

Multiple Decorators on One Function

def bold(func):
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

@bold
@italic
def say(text):
    return text

print(say("Hello"))  # <b><i>Hello</i></b>

Important

Execution order: Decorators apply bottom-up. @italic wraps say first, then @bold wraps the result. Calling say("Hello") is equivalent to bold(italic(say))("Hello").

Part 3: Simple Classes

What Is a Class?

A class is a blueprint for creating objects. Each object (instance) bundles together:

  • Attributes β€” data (variables) that belong to the object
  • Methods β€” functions that belong to the object

Analogy: A class is like a cookie cutter πŸͺ. The cutter (class) defines the shape; each cookie (instance) is made from it and can have its own decorations (attribute values).

Your First Class β€” Simple Example

Defining and Using a Class

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

    def bark(self):
        return f"{self.name} says: Woof!"

my_dog   = Dog("Buddy", "Golden Retriever")
your_dog = Dog("Max", "Poodle")

print(my_dog.bark())    # Buddy says: Woof!
print(your_dog.name)    # Max

Important

Step-by-step: class Dog: defines the blueprint. __init__ is the constructor β€” it runs automatically when you create a new instance. self refers to the specific object being created. my_dog.bark() passes my_dog as self automatically.

Understanding self

Each Instance Has Its Own Data

class Counter:
    def __init__(self):
        self.count = 0

    def increment(self):
        self.count += 1

    def get_count(self):
        return self.count

counter_a = Counter()
counter_b = Counter()

counter_a.increment()
counter_a.increment()
counter_b.increment()

print(counter_a.get_count())  # 2
print(counter_b.get_count())  # 1

Key Point: counter_a and counter_b each have their own count attribute. self.count inside counter_a.increment() only changes counter_a’s data β€” counter_b is unaffected.

A More Complete Class β€” BankAccount

Encapsulating Data and Operations

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        if amount > 0:
            self.balance += amount
            return f"Deposited ${amount}. Balance: ${self.balance}"
        return "Amount must be positive."

    def withdraw(self, amount):
        if amount > self.balance:
            return "Insufficient funds!"
        self.balance -= amount
        return f"Withdrew ${amount}. Balance: ${self.balance}"

account = BankAccount("Alice", 100) # create a new account (an object) for Alice with $100
print(account.deposit(50))    # Deposited $50. Balance: $150
print(account.withdraw(200))  # Insufficient funds!

Important

How it works: BankAccount keeps data (owner, balance) and operations (deposit, withdraw) together. This prevents outside code from misusing the data and makes the logic easy to maintain.

The __str__ Method β€” Making Objects Printable

Custom String Representation

class Student:
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade

    def __str__(self):
        return f"Student({self.name}, grade={self.grade})"

    def is_passing(self):
        return self.grade >= 60

s1 = Student("Alice", 92)
s2 = Student("Bob", 55)

print(s1)               # Student(Alice, grade=92)
print(s1.is_passing())  # True
print(s2.is_passing())  # False

Why __str__? Without it print(s1) shows <__main__.Student object at 0x...>. Defining __str__ tells Python how to render your object as a human-readable string whenever print() or str() is called on it.

Classes with Collections β€” Playlist

Storing a List Inside an Object

class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []

    def add_song(self, song):   self.songs.append(song)
    def total_songs(self):      return len(self.songs)

    def show(self):
        print(f"🎡 {self.name}")
        for i, song in enumerate(self.songs, 1):
            print(f"  {i}. {song}")

my_playlist = Playlist("Study Vibes")
my_playlist.add_song("Lo-Fi Beats")
my_playlist.add_song("Piano Chill")
my_playlist.show()
print(f"Total: {my_playlist.total_songs()} songs")

Important

How it works: self.songs = [] creates an independent list per instance. Each Playlist object manages its own song list; the methods provide a clean interface for manipulating it.

Classes + Higher-Order Functions Together

sorted() and filter() Work Great with Objects

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price
    def __str__(self):
        return f"{self.name}: ${self.price:.2f}"

products = [
    Product("Laptop", 999.99),
    Product("Mouse", 29.99),
    Product("Keyboard", 79.99),
]

by_price   = sorted(products, key=lambda p: p.price)
affordable = list(filter(lambda p: p.price < 100, products))

for p in by_price:   print(p)
print("---")
for p in affordable: print(p)

Note

The connection: Higher-order functions like sorted() and filter() accept a lambda that extracts an attribute from each object. This shows how all three topics β€” higher-order functions, lambdas, and classes β€” work together in real Python code.

Challenge Problems

🏁 Try These on Your Own!

Work through each challenge before looking at the solutions on the next slides.

Challenge 1 β€” Apply a Function Repeatedly

Higher-Order Function Challenge

Write a function apply_n_times(func, value, n) that applies func to value exactly n times.

def apply_n_times(func, value, n):
    # Your code here
    pass

def double(x):
    return x * 2

print(apply_n_times(double, 3, 4))
# Expected: 48   (3 β†’ 6 β†’ 12 β†’ 24 β†’ 48)

Challenge 2 β€” A Decorator That Counts Calls

Decorator Challenge

Write a decorator count_calls that tracks how many times the decorated function is called. Store the count as an attribute on the wrapper.

# Write your count_calls decorator here

@count_calls
def say_hi():
    print("Hi!")

say_hi()
say_hi()
say_hi()
print(f"say_hi was called {say_hi.call_count} times")
# Expected output:  Hi! / Hi! / Hi! / say_hi was called 3 times

Challenge 3 β€” A Rectangle Class

Class Challenge

Create a Rectangle class with width and height, an area() method, a perimeter() method, an is_square() method, and a __str__ method.

# Write your Rectangle class here

r1 = Rectangle(5, 3)
r2 = Rectangle(4, 4)
print(r1)                          # Rectangle(5 x 3)
print(f"Area:      {r1.area()}")   # Area:      15
print(f"Perimeter: {r1.perimeter()}")  # Perimeter: 16
print(r1.is_square())              # False
print(r2.is_square())              # True

Challenge 4 β€” Function Composer

Higher-Order Function Challenge

Write compose(f, g) that returns a new function applying g first, then f. In math: compose(f, g)(x) = f(g(x)).

def compose(f, g):
    pass   # Your code here

def add_one(x): return x + 1
def double(x):  return x * 2

add_then_double = compose(double, add_one)
double_then_add = compose(add_one, double)

print(add_then_double(5))  # Expected: 12
print(double_then_add(5))  # Expected: 11

Challenge 5 β€” GradeBook with Higher-Order Functions

Putting It All Together!

Build a GradeBook class with: add_student(name, grade), get_passing(min_grade=60) using filter, get_curved_grades(curve_func) using map, and top_student().

gb = GradeBook()
gb.add_student("Alice", 92)
gb.add_student("Bob", 55)
gb.add_student("Charlie", 78)
gb.add_student("Diana", 43)

print(gb.get_passing())
# Expected: ['Alice', 'Charlie']
print(gb.get_curved_grades(lambda g: g + 10))
# Expected: {'Alice': 102, 'Bob': 65, 'Charlie': 88, 'Diana': 53}
print(gb.top_student())
# Expected: Alice

Challenge Solutions

Solution 1 β€” Apply a Function Repeatedly

def apply_n_times(func, value, n):
    result = value
    for _ in range(n):
        result = func(result)
    return result

def double(x):
    return x * 2

print(apply_n_times(double, 3, 4))  # 48

Important

How it works: result starts at 3. Each loop iteration applies func, chaining output to input: 3 β†’ 6 β†’ 12 β†’ 24 β†’ 48. This is a higher-order function because func is received as a parameter.

Solution 2 β€” Decorator That Counts Calls

def count_calls(func):
    def wrapper(*args, **kwargs):
        wrapper.call_count += 1
        return func(*args, **kwargs)
    wrapper.call_count = 0
    return wrapper

@count_calls
def say_hi():
    print("Hi!")

say_hi(); say_hi(); say_hi()
print(f"say_hi was called {say_hi.call_count} times")

Important

How it works: Functions are objects β€” you can attach attributes to them. wrapper.call_count = 0 initialises the counter on the function object itself. Each call increments it before delegating to the original func. After @count_calls, say_hi is wrapper, so say_hi.call_count gives the total.

Solution 3 β€” Rectangle Class

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def area(self):       return self.width * self.height
    def perimeter(self):  return 2 * (self.width + self.height)
    def is_square(self):  return self.width == self.height
    def __str__(self):    return f"Rectangle({self.width} x {self.height})"

r1 = Rectangle(5, 3)
r2 = Rectangle(4, 4)
print(r1)               # Rectangle(5 x 3)
print(r1.area())        # 15
print(r1.is_square())   # False
print(r2.is_square())   # True

Important

How it works: __init__ stores dimensions as instance attributes. Each method uses self to access that specific instance’s data. __str__ provides a readable string when print() is called on the object.

Solution 4 β€” Function Composer

def compose(f, g):
    def composed(x):
        return f(g(x))
    return composed

def add_one(x): return x + 1
def double(x):  return x * 2

add_then_double = compose(double, add_one)
double_then_add = compose(add_one, double)

print(add_then_double(5))  # 12
print(double_then_add(5))  # 11

Important

How it works: compose(f, g) returns composed β€” a closure that calls g first, feeds the result to f. add_then_double(5): add_one(5) = 6, then double(6) = 12. double_then_add(5): double(5) = 10, then add_one(10) = 11. composed is a closure β€” it remembers f and g from the enclosing scope.

Solution 5 β€” GradeBook (Code)

class GradeBook:
    def __init__(self):
        self.students = {}

    def add_student(self, name, grade):
        self.students[name] = grade

    def get_passing(self, min_grade=60):
        passing = filter(
            lambda item: item[1] >= min_grade,
            self.students.items()
        )
        return [name for name, grade in passing]

    def get_curved_grades(self, curve_func):
        curved = map(
            lambda item: (item[0], curve_func(item[1])),
            self.students.items()
        )
        return dict(curved)

    def top_student(self):
        return max(self.students, key=self.students.get)

Solution 5 β€” GradeBook (How It Works)

gb = GradeBook()
gb.add_student("Alice", 92); gb.add_student("Bob", 55)
gb.add_student("Charlie", 78); gb.add_student("Diana", 43)

print(gb.get_passing())                        # ['Alice', 'Charlie']
print(gb.get_curved_grades(lambda g: g + 10))  # {'Alice': 102, ...}
print(gb.top_student())                        # Alice

Explanation

  • self.students is a dict mapping names β†’ grades.
  • get_passing() uses filter() on self.students.items() β€” keeps pairs whose grade β‰₯ min_grade; a list comprehension extracts just the names.
  • get_curved_grades() uses map() to apply curve_func to every grade, returning a new dict. Accepting curve_func as a parameter makes this a higher-order method.
  • top_student() uses max() with key=self.students.get β€” finds the key with the highest value.

What’s the Big Picture?

Higher-Order Functions

  • Accept or return other functions
  • Functions are first-class citizens in Python
  • Closures remember their enclosing scope
  • map(), filter(), sorted() are built-in examples

Decorators

  • Wrap a function to add behavior
  • @decorator is clean syntactic sugar
  • *args, **kwargs handle any signature
  • Decorators can be stacked (bottom-up order)
  • Uses: logging, timing, validation

Simple Classes

  • Blueprint for creating objects
  • __init__ is the constructor
  • self refers to the current instance
  • __str__ makes objects printable
  • Combine with higher-order functions for powerful, expressive code