Directly Looping in Functions

def average(nums: list[int]) -> float:
    total = 0
    count = 0
    for num in nums:
        total = total + num
        count = count + 1
    return total / count

print(average([1, 4, 5]))

Sometimes, we might need to find the mean (or average) of something/a value. For example, finding the average temperatures for a particular month or the number of cupcakes sold each year at a bake sale. We calculate the mean by adding together the elements and dividing that total by the number of elements.

You can calculate the mean, or average, of a list by adding together the elements and dividing by the number of elements. Finding the average is a common operation for a list. You could write this function by creating a loop that combines the sum and count patterns. This is a valid solution but ends up being a little complicated inside of the loop with the two statements.

Indirectly Looping in Functions

def sum(nums: list[int]) -> int:
    sum = 0
    for num in nums:
        sum = sum + num
    return sum

def count(nums: list[int]) -> int:
    count = 0
    for num in nums:
        count = count + 1
    return count

def average(nums: list[int]) -> float:
    return sum(nums) / count(nums)

Alternatively, you could define functions to sum and count and then use those functions in your average function. This version does not require a loop directly in the average function because the loop happens indirectly in a helper function. Although this version takes more code, each function only must do one thing, making it easier to test and debug each individual component.

Functional Decomposition

A diagram showing how a main function is decomposed into calls to several helper functions.

Earlier, we learned to break up complex functions into multiple parts using functional decomposition. With loops, we finally see why that becomes necessary. The control flow for a function with a loop is complicated. When you start nesting loops and if statements inside of functions, mistakes are likely to occur. Functional decomposition allows us to tame that complexity by focusing on one conceptual step at a time.

Complex Combinations

A person is shown thinking about the many different actions they must take.

Let’s say that we wanted to add up all the negative numbers in a list. You could write a for loop that combines the filter pattern and the sum pattern. You might even succeed. But what if you also needed to convert all the elements from strings to integers first? What if you needed to also find the average? Suddenly, combining all these patterns becomes difficult.

Composing Loop Patterns

def map_strs_to_ints(numbers: list[str]) -> list[int]:
    integers = []
    for number in numbers:
        integers.append(int(number))
    return integers

def remove_negatives(numbers: list[int]) -> list[int]:
    positives = []
    for number in numbers:
        if number >= 0:
            positives.append(number)
    return positives

def average(nums: list[int]) -> int:
    total = 0
    count = 0
    for num in nums:
        total = total + num
        count = count + 1
    return total / count

def average_positives(numbers: list[str]) -> float:
    return average(remove_negatives(map_strs_to_ints(numbers)))

Instead, let’s focus on defining functions to solve each part of the puzzle in turn. We define a map_strs_to_ints function that maps string conversion over a list of strings. We define a remove_negatives function that filters out negative integers. We use the count and sum patterns to define the average function that consume lists of integers to accumulate. Then, we can compose those functions as needed into an average_positives function. This may seem like a lot more work — suddenly we must define and test five functions instead of one. But the advantage is that each function is relatively easy to define and test. If you make a mistake, you will be able to track it down very quickly.

Filter/Take and Min/Max

def only_questions(words: list[str]) -> list[str]:
    questions = []
    for word in words:
        if "?" in word:
            questions.append(word)
    return questions

def last_word(words: list[str]) -> str:
    last = words[0]
    for word in words:
        if word > last:
            last = word
    return last

# Main function
def last_question_word(words: list[str]) -> str:
    questions = only_questions(words)
    # DEFENSIVE GUARD IS CRITICAL
    if not questions:
        return "no question words!"
    return last_word(questions)

Both the filter and take patterns consume a list and return a new list with potentially fewer elements. This can cause trouble when composing these patterns with the min/max patterns. Recall that the min and max patterns expect a non-empty list. Usually, we must guard against empty lists before using these patterns. But, if you guard too early, then you run the risk of still potentially having an empty list. It is critical to delay the guard until just before you are going to index the list.

Summary