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):
    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.


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

result = add(5, 7)
print(result)
        

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(power(3))
print(power(3, 3))
print(power(base=2, exponent=3))
        

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.


x = 10

def change_x(new_x):
    x = new_x
    print(f"Inside the function, x is {x}")

change_x(20)
print(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 and prefer not to define a standard function block.


multiply = lambda x, y: x * y
print(multiply(5, 6))
        

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_input 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, the bisection search algorithm found {midpoint} to be close to the square root of {integer_number} with an accuracy of {accuracy}. "


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.


def square_root(integer_number: int, accuracy: float):
    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

        return midpoint


print(square_root(100, 0.01))
        

However, do we really need to allow adjustable accuracy for every call? Usually, we just want the square root without worrying about precision details. 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

        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.

Resulting AI-generated code:


def average_without_zeros(numbers):
    filtered_numbers = [num for num in numbers if num != 0]
    if not filtered_numbers:
        return 0
    return sum(filtered_numbers) / len(filtered_numbers)

numbers = [1, 2, 0, 4, 0, 5]
print(average_without_zeros(numbers))
        
Back to Home