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 all the basic concepts needed to make a computer perform all computations in the sense of Turing completeness. People new to programming often think that programming is the art of giving a computer instructions that it understands and executes correctly. In fact, programming is more the art of writing code that other people (who understand the concepts of programming) can understand and ideally even extend. Programming, at least in high-level languages like Python, is therefore less the art of interacting with computers than it is the art of writing human-readable, clear, and concise code. Functions are a cornerstone of programming, allowing the creation of modular, reusable, and organized code. In Python, 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 return a value.


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

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

Default Arguments and Keyword Arguments

Functions can have default arguments, allowing them to be called with fewer arguments than defined. You can also use keyword arguments to provide clarity when passing values to functions.


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

print(power(3))
print(power(3, 3))
print(power(base=2, exponent=3))
        

Variable Scope

When a variable is created within the scope of a function, it is called a local variable. The scope of this local variable is limited to the function in which it is declared, meaning that it can only be accessed and manipulated within that function. It's important to note that this local variable does not interfere with other variables outside the scope of the function, even if they have the same name. This encapsulation ensures that all function calls are independent of each other, making your code more modular and predictable.


x = 10

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

change_x(20)
print(x)      # x outside the function is not changed
        

Lambda Functions

Lambda functions, also known as anonymous functions, are a unique feature typical of functional programming languages. Python, as a multi-paradigm language, supports lambda functions. These are small, compact functions that have no name and are defined using the 'lambda' keyword. Despite their size, lambda functions can be incredibly powerful and useful in many programming scenarios. They are especially useful when you need a small function for a short period of time and don't want to define and call a whole new function.


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

The Bottom Line

Let's remember the bisection search algorithm from the last chapter. This little program works only with the assigned values integer_input and accuracy. If we wanted to reuse this code, we would have to copy it and paste it somewhere else. This is obviously impractical. If we were to assemble larger programs by repeating identical code fragments in this way, the result would quickly become confusing, complicated, error-prone, and difficult to extend. With the knowledge we now have, we can wrap this algorithm into a reusable function and call it whenever we need it. We pass integer_input and accuracy as parameters and get an approximation of the square root. This creates modularity, i.e. independent and reusable subroutines that communicate with each other via interfaces (input/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))
        

Our code is not really practical, it would be much more practical if the function returned a number that we could use for further calculations, for example.


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

But does it make sense to be able to determine the accuracy in most cases? Most of the time we just want to find the square root of a number. Being able to set the precision is a nice feature, but usually not necessary. In large programs, such unnecessary options in the API can lead to errors. Therefore, we can actually do without this option. If we are writing such a function ourselves, and we have specified an accuracy, it may not even make sense to specify the exact approximation. In many cases it would be more elegant to round the result to, say, two digits.


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

Using AI can greatly enhance your ability to write accurate and 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