Skip to content

Latest commit

 

History

History
251 lines (167 loc) · 8.55 KB

File metadata and controls

251 lines (167 loc) · 8.55 KB
layout default
title Functional Python
parent Lessons
nav_order 4
permalink /lessons/functional-python/
course_lesson true
course_index 04
previous_page /lessons/descriptors-metaclasses/
previous_title Descriptors and Metaclasses
next_page /lessons/generators-coroutines/
next_title Generators and Coroutines

04 - Functional Python

Functional style builds programs from small transformations, treats functions as values, and limits hidden mutation. Python is multi-paradigm: use functional ideas where they clarify data flow, not as a rule that forbids every loop or object.

A simple picture

Imagine a line of stations cleaning, checking, and packing fruit. Each station receives something and produces the next result. If a station secretly changes fruit in another basket, the line becomes difficult to trust. Functional pipelines try to make every transformation visible.

raw rows -> clean -> validate -> transform -> group -> report

Functions are objects

A function can be stored, passed, and returned:

def double(number):
    return number * 2


def apply_to_all(function, values):
    return [function(value) for value in values]


operation = double
print(apply_to_all(operation, [1, 2, 3]))

A function that accepts or returns another function is a higher-order function. Use a parameter such as function or a business name such as pricing_rule; do not hide what callers must provide.

Pure functions and side effects

A pure function returns a result based only on its inputs and does not change outside state. Printing, file access, network access, database updates, clock reads, and random numbers are side effects.

def calculate_tax(amount, rate):
    return amount * rate

Pure functions are easy to test and combine. Real applications still need side effects. Keep them at clear boundaries:

raw_text = path.read_text(encoding="utf-8")  # side effect
report = build_report(raw_text)              # pure transformation
output_path.write_text(report, encoding="utf-8")  # side effect

Avoid accidental mutation

def add_total(record):
    updated = dict(record)
    updated["total"] = updated["price"] * updated["quantity"]
    return updated

The function returns a new top-level dictionary. This is a shallow copy: nested mutable objects are still shared. Use immutable records or intentional deeper copying when nested ownership matters; blindly deep-copying large graphs can be expensive and may copy objects that should remain shared.

Comprehension, map, and filter

These are equivalent for a simple transformation:

names = ["  asha ", " BEN "]

cleaned = [name.strip().title() for name in names]
cleaned_with_map = list(map(str.title, map(str.strip, names)))

The comprehension is easier to read here. map() can be useful when an existing named function directly represents the operation. A normal loop is clearer when work needs several conditions, error handling, or intermediate names.

Reduction and safe starting values

A reduction combines many values into one:

from functools import reduce
from operator import mul


product = reduce(mul, [2, 3, 4], 1)
print(product)

The initializer 1 makes the empty product defined. Prefer specialized built-ins such as sum, min, max, any, and all when they express the operation directly.

Partial application

functools.partial creates a callable with some arguments already selected:

from functools import partial


def format_money(amount, *, currency, decimals):
    return f"{currency} {amount:.{decimals}f}"


format_rupees = partial(format_money, currency="INR", decimals=2)
print(format_rupees(125.5))

The returned partial object stores references to the original function and pre-filled arguments. If a pre-filled mutable argument later changes, calls observe that change.

Closures and captured values

def minimum_length_rule(minimum):
    def is_valid(text):
        return len(text) >= minimum

    return is_valid


is_valid_username = minimum_length_rule(4)

The inner function closes over the name minimum. Closures capture variables through cells, not frozen textual values. This matters in loops where late binding can make every function use the final loop value.

A readable pipeline

def run_pipeline(value, *stages):
    result = value
    for stage in stages:
        result = stage(result)
    return result


def strip_rows(rows):
    return [row.strip() for row in rows]


def remove_empty(rows):
    return [row for row in rows if row]


result = run_pipeline(
    [" apple ", "", " banana "],
    strip_rows,
    remove_empty,
)
print(result)

The stages use lists, so this pipeline is eager. For very large or infinite input, return iterators and ensure no stage accidentally calls list().

Pattern matching for structured data

match is structural pattern matching, not a replacement for every if:

def handle(command):
    match command:
        case {"type": "add", "item": str(item)}:
            return f"adding {item}"
        case {"type": "remove", "id": int(item_id)}:
            return f"removing {item_id}"
        case _:
            raise ValueError("unsupported command")

A pattern can both test and bind names. Document accepted shapes and keep validation errors understandable.

Under the hood

Every Python function is an object containing code, defaults, annotations, globals, and possibly closure cells. Calling it creates an execution frame with local state. Many tiny Python-level calls can cost more than a built-in loop implemented in C, so a highly fragmented functional pipeline may be slower even when logically elegant.

Immutability in Python is normally by convention and type choice, not automatic deep immutability. A frozen dataclass prevents normal field assignment, but a mutable list inside it can still change.

Bug Hunter

Bug 1: mutating caller input

def normalize(records):
    for record in records:
        record["name"] = record["name"].strip()
    return records

Bug 2: late-bound closures

checks = [lambda value: value > limit for limit in [10, 20, 30]]

Bug 3: reduction without empty behavior

product = reduce(mul, values)
Show Bug Hunter fixes
  1. Return new records or clearly document in-place mutation.
  2. Bind each current value, for example lambda value, limit=limit: value > limit, or use a named factory.
  3. Supply the identity initializer 1 when an empty input should produce a valid product.

Practice

  1. Write a higher-order function that transforms a sequence.
  2. Compare a comprehension, map, and normal loop for cleaning names.
  3. Separate file I/O from a pure report calculation.
  4. Reduce numbers to a product with defined empty behavior.
  5. Create a reusable pipeline runner.
  6. Use partial to create two currency formatters.
  7. Write a closure that validates a configurable numeric range.
  8. Rewrite a function so it does not mutate its input records.
  9. Use match for three documented command shapes.
  10. Test a pipeline with empty, invalid, and very large lazy input.
Show hints
  1. Accept a callable and iterable. 2. Prefer the clearest version, not the shortest. 3. Pass plain data into the calculation. 4. Use 1 as identity. 5. Feed each result to the next stage. 6. Fill keyword-only arguments. 7. Return the inner function. 8. Copy at the required ownership depth. 9. End with a rejecting wildcard. 10. Ensure stages do not materialize the stream.
Show solution ideas
  1. Return a comprehension applying the callable. 2. Use a comprehension unless named functions make map clearer. 3. Read once, calculate purely, write once. 4. reduce(mul, values, 1). 5. A small loop is more readable than nested calls. 6. Build INR and USD callables from one formatter. 7. Capture minimum and maximum in a factory. 8. Construct new dictionaries or immutable records. 9. Return or dispatch only after the shape matches. 10. Test with a generator that records how far it was consumed.

Homework

Build a report pipeline that cleans, validates, groups, and summarizes records without modifying the original input. Provide both an eager version and a lazy version, then explain their memory and reuse trade-offs.

Checkpoint

Explain purity, side effects, shallow immutability, closure binding, and why a readable normal loop can be more Pythonic than a chain of higher-order functions.