5. Functions and Functional ProgrammingIntroduction and ContextArtificial intelligence and machine-learning systems are built from operations that must be repeated, tested, composed, and maintained. Data cleaning, feature transformation, model evaluation, validation, logging, and deployment all involve procedures that should not be copied throughout a program. Python functions provide the principal mechanism for expressing such procedures once and reusing them reliably. Functions are more than syntactic conveniences. They establish abstraction boundaries, control the visibility of data, support testing, and allow large programs to be decomposed into manageable units. Functional programming extends this idea by treating computation as the composition and transformation of functions. Python is not a purely functional language, but it provides first-class functions, higher-order functions, closures, iterators, generators, and decorators. These features are particularly useful in AI and ML pipelines, where data often flows through a sequence of transformations. This chapter develops functions from basic definitions to advanced patterns. It also considers design quality, type hints, documentation, performance, common errors, and the ethical implications of reusable computational components. Learning ObjectivesAfter completing this chapter, a learner should be able to: - define and invoke functions using positional, keyword, default, variable-length, and keyword-only parameters;
- distinguish parameters from arguments and explain return values and scope;
- use recursion appropriately and recognize when iteration is preferable;
- apply lambda expressions and higher-order functions to data-processing tasks;
- create and use closures, iterators, and generators;
- implement decorators while preserving function metadata;
- write functions with type hints and useful documentation;
- assess functional designs for readability, testability, efficiency, and maintainability;
- apply these techniques in AI and ML workflows.
Functions as Abstraction and ReuseA function is a named, reusable block of code that performs a defined operation. In Python, a function is created with `def` and may accept inputs and produce an output. ```python def mean(values): if not values: raise ValueError("values must not be empty") return sum(values) / len(values) average = mean([2, 4, 8]) ``` The names `values` and `average` improve the readability of the program. The function hides implementation details while exposing a meaningful operation. A good function generally has a clear purpose, a small and coherent interface, and predictable behavior. A function call creates a new local execution context. The expressions supplied in a call are arguments; the names in the function definition are parameters. For example, in `mean([2, 4, 8])`, the list is an argument and `values` is a parameter. A function that does not explicitly return a value returns `None`. `return` immediately terminates the current function call. It may return one object, and several comma-separated values are packaged into a tuple. ```python def min_max(values): return min(values), max(values) lowest, highest = min_max([3, 7, 1]) ``` Returning a result is usually preferable to printing it. Printed output is directed to a user or terminal, whereas a returned value can be tested, stored, transformed, or passed to another function. Parameter DesignPython supports several parameter forms. Positional parameters are matched by order, while keyword arguments are matched by name. ```python def train_model(data, target, epochs=10, *, learning_rate=0.01): """Illustrative training interface.""" return {"epochs": epochs, "learning_rate": learning_rate} train_model(X, y, 20, learning_rate=0.001) ``` The parameter `epochs` has a default value. The asterisk makes `learning_rate` keyword-only, which prevents ambiguous calls. Variable-length positional arguments use `args`, and variable-length keyword arguments use `*kwargs`. ```python def log_metrics(model_name, scores, *metadata): return { "model": model_name, "scores": scores, "metadata": metadata } ``` A major design issue concerns mutable default arguments. Default expressions are evaluated once, when the function is defined, not each time it is called. ```python def add_item(item, items=[]): items.append(item) return items ``` Successive calls share the same list. The safe pattern is: ```python def add_item(item, items=None): if items is None: items = [] items.append(item) return items ``` This principle applies to lists, dictionaries, sets, and other mutable objects. Scope, Names, and StateScope determines where a name can be accessed. Python commonly operates with local, enclosing, global, and built-in scopes, remembered by the LEGB lookup rule. A local name belongs to a function invocation. An enclosing name belongs to an outer function. A global name is defined at module level, and built-ins include names such as `len` and `sum`. ```python rate = 0.1 def cost(price): tax = price * rate return tax ``` The function can read the global `rate`, but dependence on global mutable state makes testing and reuse more difficult. Prefer passing configuration explicitly. An assignment inside a function creates a local name unless `global` or `nonlocal` is used. `global` modifies a module-level binding; `nonlocal` modifies a binding in an enclosing function. Both should be used sparingly because they increase hidden coupling. Python passes object references by assignment. A function receives a reference to an object, not an independent copy. Mutating a list supplied by the caller changes that list; rebinding the parameter does not change the caller’s variable. ```python def mutate(values): values.append(4) def rebind(values): values = [99] ``` Understanding this distinction is essential when preprocessing datasets or updating configuration objects. Recursion and Its Appropriate UseRecursion occurs when a function calls itself. A recursive solution requires a base case and a recursive case that moves toward it. ```python def factorial(n): if n < 0: raise ValueError("n must be non-negative") if n in (0, 1): return 1 return n * factorial(n - 1) ``` Recursion naturally represents tree traversal, divide-and-conquer algorithms, and nested structures. For example, a directory tree or a nested JSON document can be processed by handling the current node and recursively processing its children. Python does not generally optimize tail recursion, and deeply recursive code can exceed the recursion limit. Iteration is often clearer and more efficient for linear repetition. In machine learning, recursive definitions may explain an algorithm, but production implementations frequently use iterative or vectorized operations for performance and memory control. Functional Programming in PythonFunctional programming emphasizes expressions, function composition, transformation of data, and minimizing unintended state changes. Python remains multi-paradigm: object-oriented, procedural, imperative, and functional styles may be combined. A function is first-class when it can be assigned to a variable, stored in a collection, passed as an argument, or returned from another function. ```python def square(x): return x * x operation = square print(operation(5)) ``` A higher-order function accepts a function, returns a function, or does both. This permits algorithms to be separated from policies. For example, a general ranking function can receive a scoring function. ```python def best_item(items, score): return max(items, key=score) best = best_item(["cat", "elephant", "dog"], len) ``` Python’s built-in `map`, `filter`, and `sorted` illustrate this approach. In modern Python, comprehensions are often more readable than `map` and `filter` for simple transformations. ```python scaled = [x / 100 for x in measurements] positive = [x for x in measurements if x > 0] ordered = sorted(records, key=lambda record: record["loss"]) ``` A lambda is a small anonymous function containing one expression. ```python add = lambda a, b: a + b ``` Lambdas are appropriate for short, local policies, particularly as keys or callbacks. A named function is preferable when logic is substantial, reused, or worthy of documentation. Excessively complex lambdas harm readability. Pure Functions and CompositionA pure function produces the same output for the same input and has no observable side effects. Pure functions are easier to test, cache, parallelize, and reason about. Reading a file, changing a global variable, printing, or modifying a shared object are side effects. Pure functions are not always sufficient. Training, logging, database access, and model deployment necessarily interact with external systems. A robust architecture isolates side effects at system boundaries and keeps transformations as pure as practical. Function composition means connecting the output of one function to the input of another. ```python def compose(first, second): def combined(value): return second(first(value)) return combined clean_then_scale = compose(clean_text, scale_features) ``` Composition supports pipelines, but functions must agree on input and output contracts. Type hints and documentation help make those contracts explicit. Closures and FactoriesA closure is an inner function that retains access to variables from its enclosing scope after the outer function has returned. ```python def make_multiplier(factor): def multiply(value): return value * factor return multiply triple = make_multiplier(3) triple(5) # 15 ``` The value `factor` is captured by the closure. Closures are useful for configurable transformations, delayed computation, callbacks, and lightweight state. They can replace a small class when only one operation is needed. A common issue is late binding in loops: closures capture a variable, not necessarily its value at each iteration. ```python functions = [lambda: i for i in range(3)] ``` These functions refer to the final value of `i`. A default argument can capture the current value: ```python functions = [lambda i=i: i for i in range(3)] ``` For complex state or many operations, a class may communicate intent better than a closure. Iterators and Generators: Managing Data FlowAn iterable is an object capable of producing its members one at a time. An iterator maintains its current position and implements `__next__`; exhaustion raises `StopIteration`. The `iter` function obtains an iterator, and `next` advances it. ```python numbers = iter([10, 20, 30]) next(numbers) # 10 ``` A generator is a convenient way to create an iterator. A function containing `yield` pauses at each yield point and resumes later. ```python def read_batches(rows, size): for start in range(0, len(rows), size): yield rows[start:start + size] ``` Generators use lazy evaluation. They do not construct all results in memory at once, which is valuable for large datasets, streaming logs, and mini-batch processing. However, a generator is single-use and may be slower than an in-memory list when the data is small and repeated access is required. Generator expressions provide compact lazy transformations: ```python squares = (x * x for x in range(1_000_000)) ``` Generators can also receive values through `send`, but this advanced pattern should be reserved for clearly documented coroutine designs. In ordinary data pipelines, a straightforward generator is usually more maintainable. Decorators and Cross-Cutting BehaviorA decorator is a callable that receives a function and returns a replacement function. It allows behavior such as timing, logging, authorization, caching, or retry policy to be attached without modifying the function’s core logic. ```python from functools import wraps from time import perf_counter def timed(function): @wraps(function) def wrapper(args, *kwargs): start = perf_counter() result = function(args, *kwargs) elapsed = perf_counter() - start print(f"{function.__name__}: {elapsed:.4f}s") return result return wrapper @timed def transform(values): return [value * 2 for value in values] ``` `@timed` is equivalent to `transform = timed(transform)`. `functools.wraps` preserves metadata such as the original function’s name and docstring, which supports debugging, documentation, and introspection. Decorators can accept their own arguments, requiring an additional factory layer. Stacked decorators are applied from the nearest decorator outward, so their order can affect behavior. Decorators should preserve return values, exceptions, and relevant signatures unless intentional changes are documented. Type Hints and DocumentationType hints communicate intended interfaces to readers, editors, linters, and static analysis tools. They do not normally enforce types at runtime. ```python from collections.abc import Sequence def normalize(values: Sequence[float]) -> list[float]: maximum = max(values) if maximum == 0: return [0.0 for _ in values] return [value / maximum for value in values] ``` Modern Python supports built-in generic forms such as `list[str]`. `Callable` describes function parameters and return types. ```python from collections.abc import Callable def apply_twice(function: Callable[[int], int], value: int) -> int: return function(function(value)) ``` Documentation should state purpose, parameters, return value, exceptions, assumptions, and side effects when relevant. A docstring is most valuable when it explains behavior that is not obvious from the signature. ```python def split_batches(rows: list[dict], size: int) -> list[list[dict]]: """Partition rows into consecutive batches. Raises: ValueError: If size is not positive. """ if size <= 0: raise ValueError("size must be positive") return [rows[i:i + size] for i in range(0, len(rows), size)] ``` Type hints and documentation form part of a function’s contract. They improve collaboration, but they do not replace tests or validation of runtime data. An Applied Case: A Streaming Feature PipelineBackgroundThis illustrative case concerns a small fraud-detection service that receives transaction records continuously. The service must clean numeric fields, derive features, process records in batches, and record execution time. Loading the entire stream into memory is undesirable. ProblemAn initial script contains duplicated conversion logic, prints intermediate results, and creates a large list before model scoring. Changes to the cleaning rule require edits in several locations, increasing the risk of inconsistent training and inference behavior. AnalysisThe problem has three dimensions. First, transformation rules should be pure and independently testable. Second, records should be processed lazily. Third, timing and logging are cross-cutting concerns and should not be mixed with feature definitions. ApproachA typed, documented function performs one-record transformation. A generator yields batches. A higher-order function applies a supplied scoring function, while a decorator measures execution. ```python from collections.abc import Iterable, Iterator, Callable from functools import wraps from time import perf_counter def feature(record: dict[str, object]) -> dict[str, float]: amount = float(record.get("amount", 0.0)) frequency = float(record.get("frequency", 0.0)) return { "amount": max(amount, 0.0), "frequency": frequency, "amount_per_event": amount / frequency if frequency else 0.0, } def batches(records: Iterable[dict[str, object]], size: int) -> Iterator[list[dict[str, object]]]: batch = [] for record in records: batch.append(record) if len(batch) == size: yield batch batch = [] if batch: yield batch def measured(function): @wraps(function) def wrapper(args, *kwargs): start = perf_counter() result = function(args, *kwargs) print(f"processed in {perf_counter() - start:.4f}s") return result return wrapper @measured def score_stream(records, score: Callable[[dict[str, float]], float]): for batch in batches(records, 64): for record in batch: yield score(feature(record)) ``` A real implementation would need careful treatment of generator decoration: timing the generator function alone measures creation of the generator, not consumption of all records. The decorator may instead wrap iteration explicitly or materialize results, depending on the monitoring requirement. This illustrates why advanced features require semantic analysis, not merely syntactic use. Outcome and Learning PointsThe design separates feature logic, batching, scoring, and measurement. Each component can be tested independently, and memory usage is controlled by lazy processing. However, data validation, missing-value policy, feature versioning, and monitoring must be specified. In a regulated fraud context, every transformation should be traceable so that decisions can be explained and reproduced. Common Errors, Risks, and Professional PracticeSeveral errors recur in function-heavy Python programs. Functions may silently return `None` because a `return` statement was omitted. Mutable defaults may retain state between calls. Broad use of global variables creates hidden dependencies. A function may mutate its input unexpectedly, making later computations order-dependent. Functional abstractions can also be overused. A chain of nested lambdas, decorators, and closures may be shorter but less understandable than a few named functions. Readability is a technical quality attribute, particularly in safety-critical or collaborative ML systems. Performance requires measurement. Generators reduce memory consumption but do not automatically improve execution time. Python-level loops may be slower than vectorized numerical libraries for large arrays. Repeated function calls can have overhead, but premature optimization should not sacrifice clear interfaces. Profiling should identify actual bottlenecks. In AI and ML, reusable functions must also address data leakage, reproducibility, and fairness. A preprocessing function that uses statistics from the full dataset can leak information from a test set into training. Random seeds, configuration values, and transformation versions should be explicit. If a function filters or scores people, its assumptions and error consequences deserve documentation and governance review. Current Developments and Best PracticesContemporary Python projects increasingly combine type checking, automated testing, linting, formatters, documentation generation, and continuous integration. Data-oriented systems use iterator protocols and streaming abstractions to handle large or distributed data. Libraries for numerical computing may provide vectorized functions, while workflow systems compose transformations into reproducible pipelines. Recommended practices include: - give each function one coherent responsibility;
- prefer explicit inputs and returned outputs over hidden state;
- use keyword-only parameters for important configuration;
- avoid mutable defaults and undocumented mutation;
- write docstrings for public or non-obvious functions;
- add type hints to stable interfaces;
- preserve metadata in decorators with `functools.wraps`;
- use generators when data is large or naturally streamed;
- test edge cases, exceptions, empty inputs, and numerical boundaries;
- profile before optimizing;
- make data transformations deterministic and versioned where reproducibility matters.
Chapter SummaryFunctions provide abstraction, reuse, and controlled interfaces for Python programs. Parameters determine how inputs enter a function, while return values make computation composable. Scope explains name resolution and warns against hidden global state. Recursion is useful for naturally nested problems but is not always suitable for large linear computations. Functional programming treats functions as values and emphasizes higher-order functions, pure transformations, composition, and reduced side effects. Lambdas support small local policies; closures retain configuration; iterators and generators support lazy data flow; and decorators add cross-cutting behavior. Type hints and documentation make interfaces clearer, although runtime validation and testing remain necessary. In AI and ML, these techniques support reusable preprocessing, streaming pipelines, evaluation procedures, and monitoring. Professional practice requires balancing abstraction with readability, memory efficiency with simplicity, and technical reuse with reproducibility, fairness, and governance. Key TermsFunction: A named callable unit that performs an operation. Parameter: A name in a function definition representing an expected input. Argument: A value supplied when a function is called. Return value: The object sent back by a function through `return`. Scope: The region in which a name can be accessed. Closure: An inner function retaining access to enclosing-scope variables. Recursion: A technique in which a function calls itself. Higher-order function: A function that accepts or returns another function. Lambda: An anonymous function defined as a single expression. Pure function: A function without observable side effects and with deterministic output for a given input. Side effect: An observable change outside a function’s returned result. Composition: Combining functions so that one function’s output becomes another’s input. Iterable: An object capable of supplying its elements sequentially. Iterator: An object that produces successive values using the iteration protocol. Generator: A function or expression that lazily produces values, usually with `yield`. Decorator: A callable that modifies or replaces another callable. Type hint: Annotation expressing intended types for program elements. Docstring: A string attached to a module, class, or function to document it. Lazy evaluation: Delaying computation until a result is requested. Mutable default argument: A mutable object used as a default parameter and shared across calls. Review QuestionsShort Answer Questions1. Distinguish a parameter from an argument using a Python example. 2. Why can mutable default arguments produce unexpected results? 3. What is the purpose of `functools.wraps` in a decorator? 4. Explain the difference between an iterable and an iterator. 5. State two characteristics of a pure function. Long Answer Questions1. Explain Python’s LEGB scope model and discuss why excessive global state is undesirable. 2. Compare recursion and iteration, including suitable use cases and practical limitations. 3. Explain higher-order functions, lambdas, and function composition with AI or ML examples. 4. Describe generators and lazy evaluation, including their benefits and limitations in data pipelines. 5. Discuss how type hints, docstrings, tests, and decorators contribute to professional-quality functions. Analytical / Application Questions1. Design a typed function that processes a stream of transaction records in fixed-size batches. Explain why a generator is appropriate. 2. A decorator is used to measure the runtime of a generator function, but it reports only the time required to create the generator. Analyze why this occurs and propose a correction. 3. An ML preprocessing function modifies a shared list and reads a global normalization constant. Evaluate the risks for testing and reproducibility, and redesign the interface. 22. Multiple Choice Questions with AnswersQuestion 1Which statement best describes a higher-order function? A. A function that can accept or return another function B. A function that always uses recursion C. A function with only keyword arguments D. A function that cannot produce a return value Correct Answer: A. A function that can accept or return another function Question 2What is the safest way to use a list as a default-like accumulator for independent calls? A. Use an empty list directly as the default B. Use None as the default and create a list inside the function C. Declare the list as global D. Convert the list to a tuple after appending Correct Answer: B. Use None as the default and create a list inside the function Question 3What happens when a function containing yield is called? A. All yielded values are immediately stored in a list B. The function returns a generator that produces values lazily C. The function must return a dictionary D. The function executes indefinitely Correct Answer: B. The function returns a generator that produces values lazily Question 4Why is functools.wraps commonly used inside a decorator? A. To make the function recursive B. To force runtime type checking C. To preserve metadata of the wrapped function D. To convert the function into a generator Correct Answer: C. To preserve metadata of the wrapped function Question 5Which design is most consistent with a pure function? A. It changes a global counter on every call B. It prints results and returns None C. It modifies the caller’s list without documentation D. It returns the same result for the same input without observable side effects Correct Answer: D. It returns the same result for the same input without observable side effects Question 6What is the principal memory advantage of a generator when processing a large dataset? A. It stores every result permanently B. It produces values incrementally instead of materializing all results C. It automatically parallelizes computation D. It eliminates all function-call overhead Correct Answer: B. It produces values incrementally instead of materializing all results Question 7In Python’s LEGB lookup model, what does nonlocal allow an inner function to do? A. Modify a name in an enclosing function scope B. Modify any built-in name C. Create a new module D. Convert a local variable into a type hint Correct Answer: A. Modify a name in an enclosing function scope Question 8Which parameter is keyword-only in the following definition: def fit(data, epochs=10, *, rate=0.01)? A. data B. epochs C. rate D. All parameters Correct Answer: C. rate Question 9Why might a decorator measuring a generator function fail to measure full processing time? A. Generators cannot be decorated B. The function call creates the generator, while work occurs during iteration C. perf_counter cannot measure Python code D. yield disables all timing functions Correct Answer: B. The function call creates the generator, while work occurs during iteration Question 10Which practice best supports reproducible ML preprocessing? A. Hide configuration in global variables B. Use undocumented mutation of shared data C. Make transformation inputs and assumptions explicit and versionable D. Replace all named functions with lambdas Correct Answer: C. Make transformation inputs and assumptions explicit and versionable
|