Programming in Python

Chapter 5
Functions, Scope and Modular Program Design

Bachelors Program in AI & ML
Programming in Python   •   Bachelor

Functions, Scope and Modular Program Design

Introduction

Functions are the principal abstraction mechanism in Python. They allow a programmer to name a computation, supply inputs, obtain outputs, and reuse the same logic in multiple contexts. In artificial intelligence and machine learning, functions appear everywhere: data-cleaning pipelines, feature transformations, loss calculations, model-evaluation procedures, search algorithms, and deployment services are all constructed from cooperating functions and modules.

A well-designed function does more than reduce duplicated code. It establishes a boundary of responsibility. Its parameters describe required information, its return value describes its result, and its documentation and type hints communicate intended use. A collection of cohesive functions can be organized into modules and packages, producing software that is easier to test, extend, debug, and govern.

This chapter develops the concepts needed to design reusable Python functions, including parameters, return values, recursion, scope, closures, lambda expressions, higher-order functions, type hints, contracts, and functional decomposition.

Learning Objectives

By the end of this chapter, you should be able to:

  • define functions with appropriate parameters and return values;
  • distinguish positional, keyword-only, default, variadic, and unpacked arguments;
  • explain local, enclosing, global, and built-in name resolution;
  • use recursion appropriately and recognize its limitations;
  • create and reason about closures and higher-order functions;
  • apply lambda expressions without sacrificing readability;
  • use type hints and contracts to communicate assumptions;
  • decompose a problem into cohesive, testable functions;
  • organize functions into modules and select suitable interfaces;
  • evaluate design choices concerning maintainability, reliability, and security.

Functions as Abstraction and Interface

A function is defined with `def` and contains a suite of statements. Calling the function executes that suite in a new local execution context.

```python

def area_of_rectangle(width: float, height: float) -> float:

"""Return the area of a rectangle."""

return width * height

area = area_of_rectangle(3.0, 4.5)

```

The function name is an interface through which other code uses the computation. `width` and `height` are parameters; `3.0` and `4.5` are arguments. The `return` statement transfers a value to the caller. If execution reaches the end of a function without `return`, Python returns `None`.

A function should generally have one clear purpose. This does not mean that every function must contain only one statement; it means that its statements should contribute to a coherent responsibility. A function that reads a file, cleans data, trains a model, prints a report, and sends an email has several responsibilities and is difficult to test or reuse.

Parameters and Arguments

Python supports several parameter forms:

```python

def describe(value, prefix="Value", *, uppercase=False):

text = f"{prefix}: {value}"

return text.upper() if uppercase else text

```

`prefix` has a default value. The asterisk makes `uppercase` keyword-only, so it must be supplied as `uppercase=True`. Keyword-only parameters are useful when a Boolean or configuration option would otherwise be unclear at a call site.

Positional and keyword arguments can be combined, provided positional arguments occur first:

```python

describe(42, uppercase=True)

describe(42, prefix="Score")

```

Variadic parameters collect an arbitrary number of arguments:

```python

def mean(*values: float) -> float:

if not values:

raise ValueError("mean requires at least one value")

return sum(values) / len(values)

def configure_model(**options):

return options

```

`values` is a tuple, whereas `*options` is a dictionary. The same syntax unpacks existing collections:

```python

scores = [0.8, 0.9, 0.7]

mean_score = mean(*scores)

settings = {"learning_rate": 0.01, "epochs": 20}

model_settings = configure_model(**settings)

```

Mutable default arguments should usually be avoided. Default expressions are evaluated once, when the function is defined, not each time it is called:

```python

Risky

def add_item(item, items=[]):

items.append(item)

return items

```

A safe alternative is `None` as a sentinel:

```python

def add_item(item, items=None):

if items is None:

items = []

items.append(item)

return items

```

The distinction between mutation and reassignment is important. A function receiving a list can mutate the caller-visible list, while assigning a new list to the local parameter does not replace the caller's variable. Clear interfaces should document whether a function mutates an argument. In many applications, returning a new value instead of mutating shared state reduces surprising behavior.

Return Values and Errors

A function may return any Python object, including tuples, dictionaries, custom objects, or another function. Returning several related values as a tuple is sometimes useful:

```python

def min_max(values):

if not values:

raise ValueError("values cannot be empty")

return min(values), max(values)

low, high = min_max([4, 2, 9])

```

Errors should be represented deliberately. A function should raise an exception when it cannot fulfill its stated contract, rather than silently returning an invalid value. `ValueError` is appropriate when an argument has an unacceptable value; `TypeError` is appropriate when an argument has an inappropriate type. Exceptions should be caught at a level capable of taking meaningful action.

Scope and Name Resolution

Scope determines where a name can be accessed. Python commonly describes four nested namespaces using the LEGB rule:

1. Local: names defined inside the current function.

2. Enclosing: names in surrounding function scopes.

3. Global: names defined in the module.

4. Built-in: names such as `len`, `sum`, and `ValueError`.

```python

rate = 0.1 # global name

def calculate(price):

tax = price * rate # price and tax are local; rate is global

return tax

```

Reading a global name is possible, but relying heavily on global mutable state makes programs difficult to test and reason about. Passing dependencies explicitly is usually preferable:

```python

def calculate(price, rate):

return price * rate

```

Assignment creates a local name unless the function declares otherwise. Therefore, this code does not increment the global variable:

```python

count = 0

def attempt():

count = 1 # a new local name

```

The `global` statement can explicitly rebind a module-level name, and `nonlocal` can rebind a name in an enclosing function. Both are legitimate but should be used sparingly because they introduce hidden state.

```python

def make_counter():

count = 0

def next_value():

nonlocal count

count += 1

return count

return next_value

```

Names can also be shadowed. Defining a variable called `sum` prevents access to the built-in `sum` through that name. Avoiding names that mask important built-ins improves clarity.

Recursion and Iteration

Recursion occurs when a function calls itself. A recursive design requires:

  • a base case that terminates the process;
  • a recursive case that moves toward the base case;
  • progress that guarantees termination for valid inputs.

The factorial function is a simple example. For a non-negative integer `n`, factorial is defined by `0! = 1` and `n! = n × (n − 1)!` for `n > 0`.

```python

def factorial(n: int) -> int:

if n < 0:

raise ValueError("n must be non-negative")

if n == 0:

return 1

return n * factorial(n - 1)

```

Each call creates a stack frame. Python does not generally optimize tail recursion, and it imposes a recursion-depth limit. Consequently, recursion is suitable for naturally recursive structures such as trees, but iteration is often safer for long linear computations.

```python

def factorial_iterative(n: int) -> int:

if n < 0:

raise ValueError("n must be non-negative")

result = 1

for value in range(2, n + 1):

result *= value

return result

```

A naive recursive Fibonacci implementation repeatedly solves the same subproblems and has exponential time growth. Memoization or dynamic programming avoids this duplication. The broader lesson is that a mathematically elegant recursive definition is not automatically an efficient implementation.

Closures and Higher-Order Functions

Python treats functions as first-class objects. They can be assigned to variables, stored in collections, passed as arguments, and returned from other functions. A function that accepts or returns another function is a higher-order function.

A closure is an inner function that remembers variables from its enclosing scope after the enclosing function has returned:

```python

def make_multiplier(factor: float):

def multiply(value: float) -> float:

return value * factor

return multiply

scale_by_two = make_multiplier(2.0)

print(scale_by_two(5.0)) # 10.0

```

`multiply` retains access to `factor`. Closures are useful for configuration, lightweight factories, callbacks, and decorators. They should not obscure important state. If state becomes complex, a class or a clearly defined object may communicate the design better.

A common closure issue occurs in loops because closures capture variables by reference, not by copying their current value:

```python

functions = [lambda: i for i in range(3)]

Each function observes the final value of i in the shared scope.

```

A default parameter can capture the current value:

```python

functions = [lambda i=i: i for i in range(3)]

```

The `lambda` expression creates a small anonymous function containing one expression:

```python

square = lambda x: x * x

```

Lambdas are convenient for short transformations passed to functions such as `sorted`, `map`, and `filter`:

```python

records = [("A", 82), ("B", 91), ("C", 76)]

ordered = sorted(records, key=lambda record: record[1], reverse=True)

```

A named function is preferable when the logic needs documentation, testing, multiple statements, or a meaningful name. In modern Python, comprehensions are often clearer than `map` and `filter` for straightforward collection processing:

```python

positive = [x for x in values if x > 0]

```

Type Hints and Contracts

Type hints document intended types and allow static-analysis tools, integrated development environments, and reviewers to detect some errors before execution. They do not automatically enforce types at runtime.

```python

from collections.abc import Sequence

def average(values: Sequence[float]) -> float:

if not values:

raise ValueError("values cannot be empty")

return sum(values) / len(values)

```

Modern annotations can express optional values, unions, generic collections, and callable interfaces. For example:

```python

from collections.abc import Callable

def apply_twice(function: Callable[[int], int], value: int) -> int:

return function(function(value))

```

A contract states what a function requires, guarantees, and possibly preserves. Preconditions are requirements on inputs; postconditions describe outputs or effects; invariants remain true during or after an operation. Contracts can be expressed through documentation, type hints, assertions, and explicit validation.

```python

def percentage(part: float, whole: float) -> float:

"""Return part as a percentage of whole.

Preconditions: whole is not zero.

Postcondition: result equals 100 * part / whole.

"""

if whole == 0:

raise ValueError("whole must not be zero")

return 100 * part / whole

```

Assertions are appropriate for programmer assumptions that indicate a defect if false; they are not a substitute for validating untrusted user input, because optimized execution can remove assertions. Validation of files, network data, and user requests must remain explicit.

Functional Decomposition and Modular Design

Functional decomposition breaks a problem into smaller operations with clear interfaces. A useful decomposition is based on responsibilities and data flow rather than arbitrary line counts. For a machine-learning data pipeline, separate functions might load records, validate fields, normalize values, split data, and calculate metrics.

Consider a classification evaluation task. Instead of writing one large procedure, define:

```python

def accuracy(actual, predicted) -> float:

if len(actual) != len(predicted) or not actual:

raise ValueError("inputs must have equal non-zero length")

correct = sum(a == p for a, p in zip(actual, predicted))

return correct / len(actual)

```

This function has explicit inputs, a defined result, and no dependence on files, global model state, or printing. It can be tested independently. A reporting function can call it without duplicating the calculation.

Good decomposition tends to maximize cohesion: the elements of a function belong together. It also minimizes coupling: functions should depend on as few external details as practical. Dependency injection—passing a reader, model, or configuration as an argument—makes components replaceable and testable.

A module is a Python file containing related definitions. A package is a directory structure used to organize modules. Public interfaces should be deliberate; internal helpers can be named with a leading underscore by convention. Avoid circular imports and avoid executing substantial work merely because a module is imported. Put executable demonstrations behind:

```python

if __name__ == "__main__":

...

```

This allows the file to be imported without unexpectedly running its command-line behavior.

Worked Application: A Reusable Feature-Scaling Component

The following illustrative case concerns a small AI project that receives numeric features from several data sources. The team initially placed validation, scaling, printing, and file access in one script. Tests were difficult because every test required creating files and configuring global variables.

Problem. The program must transform a sequence of numeric values to the interval from 0 to 1. The minimum and maximum must be learned from training data and then reused for later data. If all training values are equal, ordinary min–max scaling would require division by zero.

Analysis. The computation should be separated into (1) learning scaling parameters and (2) applying them. The learned parameters form state, so a small class is clearer than a hidden closure for this case.

```python

class MinMaxScaler:

def __init__(self):

self.minimum = None

self.maximum = None

def fit(self, values):

if not values:

raise ValueError("training values cannot be empty")

self.minimum = min(values)

self.maximum = max(values)

return self

def transform(self, values):

if self.minimum is None or self.maximum is None:

raise RuntimeError("scaler must be fitted first")

if self.maximum == self.minimum:

return [0.0 for _ in values]

width = self.maximum - self.minimum

return [(value - self.minimum) / width for value in values]

```

For training values `[10, 20, 30]`, the learned minimum is 10 and maximum is 30. The transformed value for 20 is `(20 − 10) / (30 − 10) = 0.5`. The calculation is applied to new values using training parameters, preventing information from the test set from influencing training preprocessing. This is important in machine learning because data leakage can produce unrealistically optimistic evaluation results.

Outcome. The scaler can be tested with ordinary lists, reused by a pipeline, and replaced by another transformer with a compatible interface. File reading and reporting are separate concerns. The edge case of constant features is explicit rather than producing a division-by-zero error.

Learning points. State should be visible in an appropriate abstraction; contracts should cover lifecycle requirements such as fitting before transforming; edge cases belong in the design; and modularity supports both correctness and responsible model evaluation.

Best Practices, Limitations, and Professional Implications

Use descriptive names, concise docstrings, type hints for public interfaces, and predictable return conventions. Prefer pure functions—functions whose result depends only on arguments and that do not mutate external state—when practical. Pure functions are easier to test and parallelize. Keep functions small enough to understand but not so fragmented that the overall algorithm becomes difficult to follow.

Avoid excessive nesting, hidden global state, mutable defaults, broad exception handling, and functions that mix computation with user-interface or file-system effects. Test normal cases, boundary cases, invalid inputs, and state transitions. Code review should examine not only whether a function works, but whether its contract is clear and whether its interface prevents misuse.

In AI systems, modular functions also support governance. Separating data validation, feature processing, inference, and logging makes it easier to audit where personal data is used and where decisions are produced. Functions must not log sensitive values unnecessarily. Input validation helps resist malformed data, but it is not a complete security strategy: authorization, dependency management, secret handling, and threat modeling remain necessary.

Type hints can improve reliability but are not proof of correctness. A function annotated as accepting `float` may still receive an invalid numeric value, such as `NaN`, unless runtime checks address it. Similarly, decomposition cannot compensate for a flawed algorithm or biased data. Software structure supports quality; it does not guarantee it.

Contemporary Python development increasingly uses static type checkers, automated testing, linters, formatters, documentation generators, and data-validation libraries. These tools complement rather than replace conceptual understanding. In production AI, reproducibility also requires explicit configuration, controlled randomness, versioned data and models, and separation between training and inference behavior.

Chapter Summary

Functions provide reusable computational abstractions through parameters, return values, and explicit contracts. Default, keyword-only, variadic, and unpacked arguments support flexible interfaces, while careful handling of mutable values prevents hidden state. Python resolves names through local, enclosing, global, and built-in scopes; `nonlocal` and `global` should be used deliberately. Recursion is appropriate for naturally recursive structures but must have a valid base case and may be limited by call-stack depth. Closures retain enclosing variables, and higher-order functions treat functions as data. Lambda expressions are useful for short operations, but named functions improve readability for substantial logic. Type hints communicate intent without enforcing runtime correctness. Functional decomposition, cohesive modules, dependency injection, testing, and explicit contracts produce maintainable programs. In AI and ML, modular design additionally supports reproducibility, data-leakage prevention, auditability, and responsible handling of sensitive data.

Key Terms

Function: A named, reusable block of code that performs an operation.

Parameter: A name in a function definition representing an input.

Argument: A value supplied when a function is called.

Return value: The object sent from a function to its caller.

Default parameter: A parameter value used when the caller supplies no argument.

Variadic parameter: A parameter that collects an arbitrary number of arguments.

Scope: The region in which a name can be accessed.

LEGB: Local, Enclosing, Global, and Built-in name-resolution order.

Closure: An inner function retaining access to enclosing-scope variables.

Higher-order function: A function that accepts or returns another function.

Lambda: An anonymous function expression containing one expression.

Recursion: A technique in which a function calls itself.

Base case: The terminating condition of a recursive function.

Type hint: An annotation expressing intended types.

Contract: A specification of inputs, outputs, and behavioral obligations.

Precondition: A requirement that must hold before a function executes.

Postcondition: A property guaranteed after successful execution.

Functional decomposition: Breaking a problem into focused cooperating functions.

Cohesion: The degree to which a component's responsibilities belong together.

Coupling: The degree of dependence between components.

Review Questions

Short Answer Questions

1. What is the difference between a parameter and an argument?

2. Explain the LEGB rule in Python.

3. Why can mutable default arguments cause unexpected behavior?

4. What two conditions are essential for a terminating recursive function?

5. Why are type hints not a substitute for runtime validation?

Long Answer Questions

1. Explain how function interfaces, contracts, and return values contribute to maintainable software.

2. Compare recursion and iteration, including their advantages, limitations, and appropriate use cases.

3. Explain closures and higher-order functions with examples of practical uses.

4. Discuss cohesion, coupling, dependency injection, and functional decomposition in modular design.

5. Describe how modular Python design can improve reliability and governance in an AI/ML pipeline.

Analytical / Application Questions

1. Design a set of functions for a data pipeline that reads records, validates them, transforms features, and reports errors. Identify each function's inputs, outputs, and possible side effects.

2. A function uses a global model object, modifies a shared list, and catches every exception with `except Exception`. Analyze the risks and propose a more testable design.

3. Design a reusable recursive or iterative function for traversing a tree-shaped data structure. State its contract, termination condition, edge cases, and expected complexity qualitatively.

Multiple Choice Questions with Answers

Question 1

What does a function return when execution reaches its end without a return statement?

A. 0

B. False

C. None

D. An empty string

Correct Answer: C. None

Question 2

Which parameter syntax collects keyword arguments into a dictionary?

A. *args

B. **kwargs

C. &kwargs

D. []kwargs

Correct Answer: B. kwargs**

Question 3

In Python's LEGB rule, which scope is searched immediately after the local scope?

A. Global

B. Built-in

C. Module import

D. Enclosing

Correct Answer: D. Enclosing

Question 4

Which keyword allows an inner function to rebind a variable in an enclosing function?

A. global

B. outer

C. nonlocal

D. enclose

Correct Answer: C. nonlocal

Question 5

What is a necessary property of a correct recursive function?

A. It must use a global variable

B. It must include a base case

C. It must return a list

D. It must use a lambda

Correct Answer: B. It must include a base case

Question 6

What does a closure retain from its enclosing environment?

A. Only imported modules

B. Access to relevant enclosing variables

C. The caller's entire program state

D. Only global constants

Correct Answer: B. Access to relevant enclosing variables

Question 7

What is the main purpose of a keyword-only parameter?

A. To force conversion to a string

B. To prevent all default values

C. To require explicit naming at the call site

D. To make recursion automatic

Correct Answer: C. To require explicit naming at the call site

Question 8

What is the primary role of a type hint?

A. To guarantee runtime type safety

B. To communicate intended types and support analysis tools

C. To improve network speed

D. To replace all tests

Correct Answer: B. To communicate intended types and support analysis tools

Question 9

For training values [10, 20, 30], what is the min–max scaled value of 20?

A. 0.25

B. 0.5

C. 0.67

D. 1.0

Correct Answer: B. 0.5

Question 10

Which design choice generally improves testability?

A. Using hidden global mutable state

B. Combining file access and computation in one function

C. Passing dependencies explicitly

D. Catching every exception silently

Correct Answer: C. Passing dependencies explicitly

Programming in Python
Chapter 5 of 15