Programming Fundamentals for Engineers

Chapter 5
Functions, Modularity, Scope, and Recursion

Bachelors Program in Electrical Engineering
Programming Fundamentals for Engineers   •   Bachelor

Functions, Modularity, Scope, and Recursion

Introduction

Engineering programs rarely remain manageable when every instruction is placed in one long sequence. Sensor processing, circuit calculations, control algorithms, data conversion, and user interfaces all require repeated operations and clearly separated responsibilities. Functions and modules provide the structure needed to build such systems.

A function is a named unit of computation with a defined interface. It may receive inputs, perform operations, and return an output. Modularity is the organization of a program into components whose internal details are separated from their publicly usable behavior. Scope determines where a name can be accessed, while lifetime determines how long the associated object exists. Recursion expresses a solution in terms of smaller instances of the same problem.

These ideas support abstraction, testing, reuse, team development, reliability, and maintainability. They are therefore central to professional engineering software.

Learning Objectives

After studying this chapter, students should be able to:

  • design reusable functions with clear names, parameters, return values, and documentation;
  • distinguish positional, keyword, default, variable-length, and variadic arguments;
  • explain local, enclosing, global, and built-in name resolution;
  • distinguish scope from object lifetime and identify common state-related errors;
  • decompose an engineering problem into cohesive modules with low coupling;
  • express preconditions, postconditions, and invariants as software contracts;
  • trace recursive functions and identify valid base cases and progress toward termination;
  • select between iteration and recursion using efficiency, clarity, and stack considerations;
  • apply these principles in a small engineering-oriented program.

1. Functions as Abstractions

A function packages an operation behind an interface. The caller needs to know what the function accepts and guarantees, but normally does not need to know every implementation detail.

def celsius_from_resistance(resistance_ohm, r0_ohm=100.0, alpha_per_c=0.00385):
    """Return temperature using a linear RTD model."""
    if r0_ohm <= 0 or alpha_per_c <= 0:
        raise ValueError("reference resistance and alpha must be positive")
    return (resistance_ohm / r0_ohm - 1.0) / alpha_per_c

The function has a name, three parameters, a documented model, validation, and a return value. Its interface is more important to a caller than the local calculation.

A useful design test is to ask:

  1. What single responsibility does the function have?
  2. What inputs are required, and what types and units do they have?
  3. What result is returned?
  4. What errors or exceptional conditions are possible?
  5. Can the function be tested without unrelated hardware or user-interface code?

Parameters and arguments

A parameter is a variable in a function definition; an argument is a value supplied by the caller.

def voltage_divider(v_in, r1, r2):
    return v_in * r2 / (r1 + r2)

v_out = voltage_divider(v_in=12.0, r1=10_000.0, r2=10_000.0)

Here, v_in, r1, and r2 are parameters. The supplied values are arguments. Keyword arguments improve readability and reduce mistakes when several values have similar types.

Default parameters provide a standard choice:

def power_from_voltage_current(voltage, current, efficiency=1.0):
    if not 0 < efficiency <= 1:
        raise ValueError("efficiency must be greater than 0 and at most 1")
    return voltage * current * efficiency

Defaults should represent a defensible common case, not hide an important engineering assumption.

Return values and side effects

A function can return a value, modify an object, perform input/output, or do several of these. Functions that calculate and return results without changing external state are easier to test. A function that writes a file, changes a global variable, or controls a motor has a side effect and should make that behavior clear in its name or documentation.

Returning a result is not the same as printing it:

def mean(values):
    if len(values) == 0:
        raise ValueError("at least one value is required")
    return sum(values) / len(values)

The caller can print, store, compare, or transmit the returned value. A function that only prints would be less reusable.

Variadic parameters

Variadic parameters allow a function to accept a variable number of arguments. In Python, args collects extra positional arguments into a tuple, while *kwargs collects extra keyword arguments into a dictionary.

def mean_of(*readings):
    if not readings:
        raise ValueError("at least one reading is required")
    return sum(readings) / len(readings)

mean_of(30.0, 35.0, 32.5)       # 32.5


def configure_channel(channel, **settings):
    allowed = {"gain", "sample_rate", "enabled"}
    unknown = set(settings) - allowed
    if unknown:
        raise ValueError(f"unsupported settings: {unknown}")
    return {"channel": channel, **settings}

Variadic parameters are useful for aggregation, logging, configuration, and wrapper functions. They should not be used merely to avoid designing a clear interface. A function accepting arbitrary data can become difficult to validate and document.

2. Contracts and Defensive Interfaces

A contract states what must be true before a function runs and what will be true after it completes.

  • Precondition: a requirement on inputs or program state.
  • Postcondition: a guarantee about the result or state after execution.
  • Invariant: a property that remains true during a process or across repeated operations.

For a resistor-divider function, a precondition is R1 + R2 > 0, with resistances measured in ohms. The postcondition is that the output is a voltage in the same voltage unit as the input, provided the ideal divider assumptions apply.

def voltage_divider(v_in, r1, r2):
    """Ideal unloaded divider; r1 and r2 are positive resistances."""
    if r1 <= 0 or r2 <= 0:
        raise ValueError("resistances must be positive")
    return v_in * r2 / (r1 + r2)

The ideal equation is:

Vout = Vin × R₂ / (R₁ + R₂)

Here Vin and Vout are input and output voltages, and R1 and R2 are resistances. The model assumes an unloaded output and ideal resistors. If a load resistance RL is connected, the lower branch becomes R2 || RL, where:

R₂ || RL = (R₂ × RL) / (R₂ + RL)

A function contract must therefore state modeling assumptions, not just data types.

3. Scope, Namespace, and Lifetime

Scope is the region of source code in which a name can be referenced. Lifetime is the period during execution in which an object exists. These concepts are related but not identical: a name may leave scope while an object remains alive because another reference points to it.

Python commonly resolves names using the LEGB sequence:

  1. Local: names inside the current function;
  2. Enclosing: names in an enclosing function;
  3. Global: names defined at module level;
  4. Built-in: names supplied by the language environment.
scale = 2.0                 # global name

def make_converter(offset):
    factor = 100.0          # enclosing name
    def convert(raw):
        corrected = raw + offset  # local name; offset is enclosing
        return corrected * factor * scale
    return convert

convert retains access to offset and factor after make_converter returns. This retained environment is called a closure.

Global mutable state is risky because any function can change it, making behavior dependent on execution history. Prefer explicit parameters and return values. If shared state is necessary, encapsulate it in an object or module with a narrow interface.

A local variable is normally created when execution enters the relevant function and becomes inaccessible by name when the function returns. Python's memory management may retain the object longer if another reference exists. Thus, scope describes visibility, whereas lifetime describes existence.

Mutation and aliasing

Two names may refer to the same mutable object:

samples = [1.0, 2.0]
alias = samples
alias.append(3.0)
# samples is now [1.0, 2.0, 3.0]

Unexpected mutation can violate a function's contract. A function should document whether it modifies an input. For critical data, make a copy or use immutable structures where practical.

4. Modularity and Decomposition

Decomposition converts a large problem into smaller responsibilities. A useful module often contains functions and data that change for the same reason. Two design goals are especially important:

  • High cohesion: elements within a module belong together.
  • Low coupling: modules depend on one another through small, stable interfaces.

For a temperature-monitoring application, reasonable modules might be:

ModuleResponsibility
acquisitionRead raw sensor values and timestamps
calibrationConvert raw values using calibration parameters
validationDetect missing, saturated, or implausible readings
analysisCompute averages, trends, and alarms
storageSave validated records
interfaceDisplay results or accept commands

A dependency should generally point from a higher-level policy to a lower-level service through an explicit interface. Hardware acquisition should not be entangled with statistical calculation. This separation allows analysis functions to be tested using simulated values.

Information hiding

A module should expose what users need and hide implementation details likely to change. For example, a storage module can expose save_reading(record) without requiring the caller to know whether data are stored in a CSV file, database, or network service.

Module-level execution

Importing a module should ideally define functions and constants rather than immediately performing risky actions. In Python, executable entry behavior is commonly protected as follows:

def main():
    readings = [30.0, 35.0, 32.5]
    print(mean(readings))

if __name__ == "__main__":
    main()

This permits the module to be imported for testing without automatically running the application.

5. Worked Engineering Example: Sensor Calibration

Suppose an idealized resistance-temperature relationship is defined by:

T = 0.5R − 20

where R is resistance in ohms and T is temperature in °C. This is a deliberately specified linear calibration model; it should not be generalized to every sensor.

For three measurements, R = 100, 110, 105 Ω:

  • at 100 Ω: T = 0.5(100) − 20 = 30 °C;
  • at 110 Ω: T = 0.5(110) − 20 = 35 °C;
  • at 105 Ω: T = 0.5(105) − 20 = 32.5 °C.

The average is:

Tavg = (30 + 35 + 32.5) / 3 = 32.5 °C

A modular implementation separates conversion from aggregation:

def temperature_from_resistance(resistance):
    return 0.5 * resistance - 20.0


def average_temperature(resistances):
    if not resistances:
        raise ValueError("no measurements supplied")
    temperatures = [temperature_from_resistance(r) for r in resistances]
    return sum(temperatures) / len(temperatures)

The formula's units must be interpreted consistently: the coefficient 0.5 has units °C/Ω, and the constant −20 has units °C. A production system would also specify sensor range, uncertainty, calibration date, missing-data policy, and behavior outside the validated range.

6. Recursion

A recursive function calls itself on a smaller or simpler instance of the same problem. A correct recursive design requires:

  1. a base case that can be solved directly;
  2. a recursive case that reduces the problem;
  3. a clear argument that repeated reduction reaches the base case.

Factorial example

For non-negative integers, factorial is defined by:

n! = 1, if n = 0; and n! = n × (n − 1)!, if n ≥ 1

A contract must reject negative values and Boolean values if the interface requires an integer count. In Python, bool is a subclass of int, so isinstance(True, int) is True. Therefore an integer-only check must explicitly exclude Boolean values.

def factorial(n):
    if isinstance(n, bool) or not isinstance(n, int):
        raise TypeError("n must be an integer, not Boolean")
    if n < 0:
        raise ValueError("n must be non-negative")
    if n == 0:
        return 1
    return n * factorial(n - 1)

For 4!, the trace is 4 × 3 × 2 × 1 × 0! = 24. Each call reduces n by one, so termination is guaranteed for a valid non-negative integer. The recursion depth is n + 1 calls, and the time complexity is O(n).

An iterative version avoids call-stack growth:

def factorial_iterative(n):
    if isinstance(n, bool) or not isinstance(n, int):
        raise TypeError("n must be an integer, not Boolean")
    if n < 0:
        raise ValueError("n must be non-negative")
    result = 1
    for value in range(2, n + 1):
        result *= value
    return result

For simple linear repetition, iteration is usually preferable in Python because recursion has function-call overhead and Python does not generally optimize tail calls.

Recursive tree algorithms

Recursion is natural when the data structure is recursive, such as a directory tree or binary tree. For Fibonacci numbers, the naive recurrence

Fn = Fn−1 + Fn−2, with F0 = 0 and F1 = 1

recomputes the same subproblems and has exponential time in its naive form. Memoization stores previous results and reduces the computation to O(n) time and O(n) additional space. This illustrates that recursive clarity does not automatically imply computational efficiency.

7. Testing and Documentation

A function should be tested at normal, boundary, and invalid inputs. For factorial, useful cases include 0, 1, a moderate positive integer, a negative integer, a floating-point value, and True. Tests should verify both correct values and correct exception types.

Documentation should state purpose, parameters, units, return value, assumptions, side effects, and exceptions. Type annotations improve communication but do not replace runtime validation:

def mean(values: list[float]) -> float:
    if not values:
        raise ValueError("values must not be empty")
    return sum(values) / len(values)

In engineering applications, tests should also include dimensional consistency, sensor limits, numerical precision, and representative fault conditions. Property-based tests can check general relationships, such as whether a calibrated temperature increases when resistance increases under a positive-slope model.

8. Applied Case: Modular Temperature Monitor

Background

This illustrative case concerns a small embedded laboratory monitor that reads three resistance sensors and reports calibrated temperatures.

Problem

A monolithic program directly reads hardware, applies calibration, computes an average, and prints an alarm. Such a design is difficult to test because a calculation cannot be run without the sensor board.

Analysis

The system is decomposed into acquisition, calibration, analysis, and presentation. The calculation layer accepts ordinary numeric values, while the acquisition layer supplies them. Contracts specify that resistances must be finite and within the sensor's calibrated range.

Approach and solution

def calibrate_many(resistances):
    if not resistances:
        raise ValueError("at least one resistance is required")
    temperatures = []
    for resistance in resistances:
        if resistance < 80 or resistance > 140:
            raise ValueError("resistance outside calibrated range")
        temperatures.append(0.5 * resistance - 20.0)
    return temperatures


def monitor_status(temperatures, upper_limit=40.0):
    average = sum(temperatures) / len(temperatures)
    return {"average": average, "alarm": average > upper_limit}

For readings 100 Ω, 110 Ω, and 105 Ω, the temperatures are 30 °C, 35 °C, and 32.5 °C; the average is 32.5 °C, so the 40 °C alarm is false. The functions can be tested with simulated inputs before hardware integration.

Outcome and learning points

The design isolates hardware concerns, makes assumptions visible, and permits independent testing. It also reveals a limitation: a simple average may conceal one dangerously high local temperature. A production monitor might use maximum temperature, rate-of-change alarms, sensor confidence, timestamp validation, and redundant sensing.

9. Limitations, Safety, and Professional Practice

Poorly designed functions can create hidden coupling, ambiguous units, silent coercion, and unsafe defaults. In control or protection systems, a function should fail in a known safe manner rather than silently return a plausible but invalid value. Input validation must distinguish missing data from zero, and physical limits must be based on the equipment specification.

Software modularity also supports governance: version-controlled calibration constants, traceable changes, code review, documented assumptions, and reproducible tests. AI-assisted code generation can accelerate prototyping, but generated functions must be reviewed for incorrect formulas, unsafe edge cases, privacy issues, and licensing constraints. An engineer remains responsible for validation, especially where software affects people, equipment, or regulated measurements.

Best-Practice Checklist

  • Give each function one clear responsibility.
  • Prefer explicit inputs and outputs over hidden global state.
  • State units, ranges, assumptions, and failure behavior.
  • Use keyword arguments when they improve clarity.
  • Use args and *kwargs only when variable arity is part of the interface.
  • Exclude Boolean values when a contract requires genuine integers.
  • Keep modules cohesive and interfaces small.
  • Test base cases, boundaries, invalid inputs, and physical limits.
  • Use recursion only when its structure improves clarity and termination is demonstrable.
  • Select iteration when recursion would create unnecessary depth or overhead.

Chapter Summary

Functions provide reusable abstractions with parameters, return values, contracts, and controlled side effects. Variadic parameters support variable-length interfaces but require careful validation. Scope governs name visibility, whereas lifetime governs object existence; explicit data flow is generally safer than uncontrolled global state. Modularity depends on high cohesion, low coupling, information hiding, and stable interfaces. Contracts make preconditions, postconditions, invariants, units, and assumptions explicit. Recursion requires a base case, a reducing recursive case, and a termination argument; iteration is often more efficient for simple repetition. The sensor-monitor case demonstrates how decomposition enables testing and separates hardware from engineering computation.

Key Terms

Function: A named, callable unit of computation.

Parameter: A variable specified in a function definition.

Argument: A value supplied when a function is called.

Return value: Data sent from a function to its caller.

Side effect: A change outside a function's local computation.

Variadic parameter: A parameter accepting a variable number of arguments.

Contract: A statement of required conditions and guaranteed behavior.

Precondition: A condition required before execution.

Postcondition: A condition guaranteed after execution.

Invariant: A property that remains true during an operation.

Scope: The region where a name is accessible.

Lifetime: The period during which an object exists.

Namespace: A mapping between names and objects.

Closure: A function retaining references to an enclosing environment.

Module: A unit containing related program definitions.

Cohesion: The degree to which module elements belong together.

Coupling: The degree of dependency between modules.

Recursion: Solving a problem through calls on smaller instances of itself.

Base case: A recursive case solved without another recursive call.

Memoization: Storing previous results to avoid repeated computation.

Review Questions

Short Answer Questions

  1. Distinguish a parameter from an argument.
  2. Why is returning a value generally more reusable than printing it inside a function?
  3. Explain the difference between scope and lifetime.
  4. What are the three essential elements of a terminating recursive function?
  5. Why should a module generally hide its implementation details?

Long Answer Questions

  1. Explain how high cohesion and low coupling influence the design of an engineering monitoring system.
  2. Describe Python's LEGB name-resolution rule and discuss risks associated with global mutable state.
  3. Compare recursive and iterative implementations of factorial in terms of clarity, time, and memory.
  4. Design a contract for a function that computes an ideal voltage divider, including units and assumptions.
  5. Explain how variadic positional and keyword parameters work, including appropriate and inappropriate uses.

Analytical / Application Questions

  1. Design a modular architecture for a data logger that reads sensors, validates measurements, computes statistics, and stores results. Identify at least four modules and their interfaces.
  2. A calibration function accepts True as a count because it uses isinstance(value, int). Explain the cause and write a corrected validation condition.
  3. Given resistances of 90 Ω, 120 Ω, and 130 Ω under T = 0.5R − 20, calculate each temperature and the average. State whether all values lie within a hypothetical calibrated range of 25–50 °C.

Multiple Choice Questions with Answers

Question 1

For the calibration model T = 0.5R − 20, what temperature corresponds to R = 100 Ω?

A. 20 °C

B. 30 °C

C. 50 °C

D. 70 °C

Correct Answer: B. 30 °C

Question 2

Using the same model, what is the average temperature for resistances 100 Ω, 110 Ω, and 105 Ω?

A. 30 °C

B. 32.5 °C

C. 35 °C

D. 97.5 °C

Correct Answer: B. 32.5 °C

Question 3

In Python, what does *args collect inside a function?

A. Extra positional arguments in a tuple

B. Extra keyword arguments in a dictionary

C. All global variables

D. Only Boolean arguments

Correct Answer: A. Extra positional arguments in a tuple

Question 4

Which check enforces a genuine integer while rejecting Boolean values?

A. isinstance(x, int)

B. type(x) is int

C. isinstance(x, bool)

D. type(x) is float

Correct Answer: B. type(x) is int

Question 5

Which statement correctly distinguishes scope from lifetime?

A. Scope is object existence; lifetime is name visibility

B. Scope and lifetime always end simultaneously

C. Scope concerns name visibility; lifetime concerns object existence

D. Lifetime applies only to global variables

Correct Answer: C. Scope concerns name visibility; lifetime concerns object existence

Question 6

Which property describes strong internal relatedness within a module?

A. Coupling

B. Cohesion

C. Aliasing

D. Recursion

Correct Answer: B. Cohesion

Question 7

What is essential for a recursive function to terminate?

A. A global variable

B. A base case and progress toward it

C. At least two return statements

D. A variadic parameter

Correct Answer: B. A base case and progress toward it

Question 8

What does **kwargs collect in Python?

A. Extra positional arguments in a list

B. Extra keyword arguments in a dictionary

C. Local variables in a tuple

D. Module imports

Correct Answer: B. Extra keyword arguments in a dictionary

Question 9

For the ideal divider with Vin = 12 V and R1 = R2, what is Vout?

A. 0 V

B. 6 V

C. 12 V

D. 24 V

Correct Answer: B. 6 V

Question 10

Which design most directly improves testability of sensor analysis code?

A. Place hardware reads and calculations in one global function

B. Use explicit measurement inputs and separate acquisition from analysis

C. Suppress all input validation

D. Store every reading in global mutable state

Correct Answer: B. Use explicit measurement inputs and separate acquisition from analysis

Programming Fundamentals for Engineers
Chapter 5 of 15