An Introduction to the Art of Computer Programming Using Python in the Age of Generative AI

IX. Functions

Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Tim Peters, Zen of Python

Introduction to Functions

In Chapters I-VII, we introduced the basic concepts required to make a computer perform any computation in the sense of Turing completeness. People new to programming often think that programming is the art of giving a computer instructions it understands and executes correctly. In reality, programming is more about writing code that other people (who understand programming concepts) can read and even extend. In Python, functions are a cornerstone of well-organized code, allowing you to create modular and reusable blocks. Functions are defined using the def keyword, followed by the function name and a pair of parentheses that can enclose parameters.


def greet(name):
    # This block belongs to the function
    print(f"Hello, {name}!")

greet("Elizabeth")
        

Defining and Calling Functions

A function is defined by parameters and can return a value. To execute a function, you call it with the necessary arguments.


def greet(name):
    print(f"Hello, {name}!")

greet("Elizabeth")
        

Return Values

The return statement is used to exit a function and provide a value back to the caller. Unlike print, which merely displays text, return passes data back so it can be stored in a variable or used in further calculations.


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

result = add(5, 7)
print(f"Result: {result}")
        

Type Hints and Docstrings

In modern Python (and especially when working with AI), it is best practice to annotate your inputs and outputs using Type Hints and describe the function's purpose with a Docstring (triple quotes). This makes your code self-documenting.


def calculate_area(radius: float) -> float:
    """
    Calculates the area of a circle given its radius.

    Args:
        radius (float): The radius of the circle.
    Returns:
        float: The calculated area.
    """
    return 3.14159 * (radius ** 2)

print(f"Area: {calculate_area(5.0)}")
        

Default Arguments and Keyword Arguments

Functions can have default arguments, letting them be called with fewer arguments than defined. You can also use keyword arguments for clarity when passing values.


def power(base, exponent=2):
    return base ** exponent

print(f"Default (3^2): {power(3)}")
print(f"Positional (3^3): {power(3, 3)}")
print(f"Keyword (2^3): {power(base=2, exponent=3)}")
        

Flexible Arguments (*args and **kwargs)

Sometimes you may not know in advance how many arguments will be passed to your function. Python allows you to handle an arbitrary number of arguments using *args (for positional arguments) and **kwargs (for keyword arguments).


def summarize_data(*args, **kwargs):
    print(f"Data points: {args}")
    print(f"Metadata: {kwargs}")

summarize_data(10, 20, 30, source="sensor_1", date="2024-01-01")
        

Variable Scope

A variable created within a function is considered a local variable. Its scope is limited to that function, meaning you can only access and modify it there. This design ensures independence of function calls and promotes clearer, modular code.

If you need to modify a variable from an outer scope inside a function, Python provides the global keyword:


x = 10

def modify_global_var():
    global x
    x = 20
    print(f"Inside the function, x is {x}")

modify_global_var()
print(f"Outside the function, x is also {x}")
        

Although global can be helpful in certain scenarios, it is generally discouraged in large programs because it can make your code less modular and more prone to bugs (side effects).


x = 10

# Better approach: Pass the value as an argument
def change_x(new_x):
    # This 'x' is a new local variable, distinct from the global 'x'
    x = new_x
    print(f"Inside the function, x is {x}")

change_x(20)
print(f"Outside, global x is still: {x}")
        

Lambda Functions

Lambda functions, also known as anonymous functions, are a feature typical of functional programming languages. Python, being multi-paradigm, supports these compact functions with the lambda keyword. They are particularly useful when you need a small, short-lived function (e.g., for sorting or filtering data).


multiply = lambda x, y: x * y
print(f"Lambda result: {multiply(5, 6)}")

# Practical usage: Sorting a list of tuples by the second element
data = [(1, 'apple'), (3, 'cherry'), (2, 'banana')]
sorted_data = sorted(data, key=lambda item: item[1])
print(f"Sorted by name: {sorted_data}")
        
Generative AI Insight: Function Calling (Tool Use)
Modern AI models (like GPT-4) can be taught to use Python functions. If you provide an AI with a function definition—including its name, arguments, and docstring—the AI can decide to "call" that function to perform tasks it cannot do alone (like calculating math or fetching live data). This capability is often called Tool Use.

The Bottom Line

Recall the bisection search algorithm from the previous chapter. That program only works with integer_input and accuracy. Reusing it would mean copying and pasting the same code, which is impractical. Larger programs stitched together from repeated code fragments quickly become confusing, error-prone, and tough to maintain. By encapsulating the bisection logic in a function and passing integer_number and accuracy as parameters, we make the logic modular and reusable. In other words, we get independent, reusable subroutines that communicate through their parameters (input) and return values (output).


def square_root(integer_number: int, accuracy: float):
    if integer_number < 0:
        return "There is no true square root of a negative number."

    else:
        minimum = 0
        maximum = max(1, integer_number)
        number_of_iterations = 0
        midpoint = (maximum + minimum) / 2

        while abs(midpoint ** 2 - integer_number) >= accuracy:
            if midpoint ** 2 < integer_number:
                minimum = midpoint
            else:
                maximum = midpoint
            midpoint = (maximum + minimum) / 2
            number_of_iterations += 1

        return f"After {number_of_iterations} iterations, found {midpoint}."

print(square_root(100, 0.01))
        

This code is not entirely practical if we need a numeric result for further calculations. Returning a string is useful for displaying, but not for additional computation. It is better to return the raw number.


def square_root(integer_number: int, accuracy: float):
    if integer_number < 0:
        print("There is no true square root of a negative number.")
        return None # Implicitly returns None

    else:
        minimum = 0
        maximum = max(1, integer_number)
        number_of_iterations = 0
        midpoint = (maximum + minimum) / 2
        while abs(midpoint ** 2 - integer_number) >= accuracy:
            if midpoint ** 2 < integer_number:
                minimum = midpoint
            else:
                maximum = midpoint
            midpoint = (maximum + minimum) / 2
            number_of_iterations += 1

        return midpoint

print(f"Numeric result: {square_root(100, 0.01)}")
        

However, do we really need to allow adjustable accuracy for every call? Excessive options in a function interface can lead to confusion in larger projects. Often, setting a default accuracy or rounding the result is enough.


def square_root(integer_number: int, accuracy: float = 0.01):
    if integer_number < 0:
        print("There is no true square root of a negative number.")
        return

    else:
        minimum = 0
        maximum = max(1, integer_number)
        number_of_iterations = 0
        midpoint = (maximum + minimum) / 2
        while abs(midpoint ** 2 - integer_number) >= accuracy:
            if midpoint ** 2 < integer_number:
                minimum = midpoint
            else:
                maximum = midpoint
            midpoint = (maximum + minimum) / 2
            number_of_iterations += 1

        # We round the result to 2 decimal places for cleaner output
        return round(midpoint, 2)

print(f"Standard Accuracy: {square_root(100)}")
print(f"User-defined accuracy: {square_root(100, 0.0001)}")
        

Prompting Generative AI for Useful and Efficient Functions

Leveraging AI can significantly enhance your ability to write accurate, efficient functions.

Example Prompt:
Generate a Python function that takes a list of numbers and returns the average, ignoring any zero values. Include type hints and a docstring.

Resulting AI-generated code:


def average_without_zeros(numbers: list[float]) -> float:
    """
    Calculates the average of a list of numbers, excluding zeros.
    """
    filtered_numbers = [num for num in numbers if num != 0]

    if not filtered_numbers:
        return 0.0

    return sum(filtered_numbers) / len(filtered_numbers)

numbers = [1, 2, 0, 4, 0, 5]
print(f"Average: {average_without_zeros(numbers)}")
        
Back to Home