4  Control Structures

Control structures are fundamental building blocks in Python that determine the flow of program execution. They allow you to make decisions (using if statements), repeat actions (using loops), and create sophisticated branching logic (using match statements). This chapter explores these essential structures, showing how they can be combined to create powerful and flexible programs. We’ll start with loops, which are crucial for performing repetitive tasks efficiently.

4.1 for loops

One of the most fundamental ways to repeat actions in Python is by using a for loop. A for loop systematically goes through each element of a sequence (like a list, tuple, or other iterable) and performs operations on each element in turn. This structure is particularly appropriate when you know, in advance, how many times you need to perform the repetition or when you simply want to process all elements in a collection.

When you use a for loop, you specify a loop variable that successively takes on the values in your collection. Each time the loop variable is assigned a new value, the block of code inside the loop executes. This repeated execution lets you, for instance, print each item in a list, modify a series of elements, or combine different objects in all possible ways.

Below is an example illustrating how a for loop can be applied to iterate through a list of vegetables:

vegetables = ['potato','pumpkin']

for v in vegetables:
    print(v)
potato
pumpkin

Let’s walk through how this code executes step by step. First, Python creates a list vegetables containing two strings. The for statement (the loop header) establishes that we’ll iterate through this list, using v as our loop variable. The indented print(v) forms the loop body - the code that will run for each item.

When execution begins, Python:

  1. Takes the first item “potato” and assigns it to v
  2. Runs the indented code block, printing “potato”
  3. Returns to the loop header and assigns “pumpkin” to v
  4. Runs the indented block again, printing “pumpkin”
  5. Finding no more items in the list, exits the loop

The indentation is crucial here - it tells Python exactly which statements should be repeated as part of the loop body. Without proper indentation, Python wouldn’t know which code belongs to the loop.

Python also permits the nesting of loops. A nested loop is a for loop that appears inside another for loop. This approach is helpful for scenarios where you need to pair or combine every element in one list with every element in another, or more generally, when multiple dimensions of iteration are required. Below is an example:

vegetables = ['potato','pumpkin']
method = ['boil','fry']

for m in method:
    for v in vegetables:
        print(f"How to {m} {v}")
How to boil potato
How to boil pumpkin
How to fry potato
How to fry pumpkin

Let’s examine how this nested structure executes. The deeper indentation of the inner loop’s body shows it belongs to both loops - it runs for each combination of method and vegetable. Here’s the step-by-step execution:

  1. The outer loop starts with m = "boil"
    • The inner loop runs completely:
      • First with v = "potato" → prints “How to boil potato”
      • Then with v = "pumpkin" → prints “How to boil pumpkin”
  2. The outer loop continues with m = "fry"
    • The inner loop runs again:
      • First with v = "potato" → prints “How to fry potato”
      • Then with v = "pumpkin" → prints “How to fry pumpkin”

The deeper indentation level clearly shows which code is part of the inner loop, making it run 4 times total (2 methods × 2 vegetables).

4.1.1 Iterables

Python’s for loops work smoothly with iterables. An iterable is any Python object that can return its elements one at a time. Lists, tuples, dictionaries, ranges, and more are all iterable. Whenever you see a for statement, you can often substitute any iterable in place of a list.

4.1.1.1 range

In many repetitive tasks, we might not have a predefined list but still need to iterate a specific number of times or generate a sequence of integers. Python’s built-in range function is perfect for this scenario. The range function returns a sequence of integers that can be iterated over. By default, it starts at 0 and ends one value before its argument. Below is an example:

squares = []
for n in range(11):
    squares.append(n**2)
print(squares)
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

Here, range(11) generates the integers 0, 1, 2, …, 10. In each loop iteration, the variable n is assigned one of those integers, and the code block inside calculates the square of n and appends it to the list squares. By the end of the loop, squares contains the squares of the integers 0 through 10.

The range function actually accepts up to three arguments:

  • range(stop): Generates numbers from 0 up to (but not including) stop
  • range(start, stop): Generates numbers from start up to (but not including) stop
  • range(start, stop, step): Generates numbers from start up to stop, counting by step

Here are examples of each:

# Count down from 10 to 1
for n in range(10, 0, -1):
    print(n, end=' ')
print()  # New line

# Count by 2s from 1 to 10
for n in range(1, 11, 2):
    print(n, end=' ')
print()

# Start at 5, go up to (but not including) 10
for n in range(5, 10):
    print(n, end=' ')
print()
10 9 8 7 6 5 4 3 2 1 
1 3 5 7 9 
5 6 7 8 9 

The step argument is particularly useful for tasks like:

  • Counting backwards (using negative step)
  • Taking every nth item (using step > 1)
  • Starting from non-zero values (using start)

4.1.1.2 sorted

We sometimes wish to loop through the elements of a collection in sorted order. The sorted function returns a list of the elements in ascending order, leaving the original collection unchanged. When combined with a for loop, it makes your code straightforward for tasks where ordering is essential:

xs = [9,3,2]
for x in sorted(xs):
    print(x)
2
3
9

Although [9,3,2] is initially unsorted, the sorted function changes its order to [2,3,9]. The loop then processes these sorted values one by one.

The key difference is that sorted(xs) creates a new sorted sequence while leaving xs unchanged, whereas xs.sort() modifies xs directly. You can also sort in reverse order by adding the reverse=True argument:

xs = [9,3,2]
print(f"Original xs: {xs}")
print(f"sorted(xs): {sorted(xs)}")
print(f"sorted(xs, reverse=True): {sorted(xs, reverse=True)}")
print(f"xs is still: {xs}")

xs.sort()  # Modifies xs in-place
print(f"After xs.sort(): {xs}")
Original xs: [9, 3, 2]
sorted(xs): [2, 3, 9]
sorted(xs, reverse=True): [9, 3, 2]
xs is still: [9, 3, 2]
After xs.sort(): [2, 3, 9]

4.1.1.3 Dictionary keys

Dictionaries are another example of an iterable, but when you loop over a dictionary directly, Python iterates through its keys. Keys are the identifiers you used when creating each key-value pair in the dictionary:

d = {'a':1, 'b':2}
for x in d:
    print(x)
a
b

Since d is {'a':1, 'b':2}, Python automatically retrieves the keys "a" and "b". The code inside the loop prints each key on its own line. This iteration ignores the dictionary’s values.

4.1.1.4 Dictionary key, value pairs

You can also iterate over both keys and values by using the .items() method, which returns a collection of (key, value) pairs. This is particularly helpful in many data processing tasks where you need the key to address a specific entry and the value to do further computations or checks:

d = {'a':1, 'b':2}
for k,v in d.items():
    print(f"The key {k} has value {v}")
The key a has value 1
The key b has value 2

This loop retrieves each pair (key, value) from d. The loop variable k is assigned the key, while v is assigned the corresponding value. The print statement then displays a string describing that relationship.

You can also iterate over just the values using the .values() method:

d = {'a':1, 'b':2}
for v in d.values():
    print(f"Found value {v}")
Found value 1
Found value 2

This loop retrieves only the values (1 and 2) from the dictionary, ignoring the keys completely. This is useful when you need to process the values but don’t need their associated keys.

4.2 if statement

While loops handle repetition, the if statement controls conditional branching, meaning it chooses whether certain code should execute based on a condition. The most basic form is the single if, which checks a condition. If the condition is true, the code indented beneath the if runs; otherwise, Python skips it entirely.

xs = [1,3,4,6,2]
for x in xs:
    if x > 2:
        print(x)
3
4
6

This snippet goes through each element in xs. Within the loop, the if x > 2 condition tests whether x exceeds 2. If so, print(x) is executed; if not, it is skipped. As a result, the output will be the subset of numbers from xs that are larger than 2.

Situations often arise that require more nuance than just a single condition. For example, you may need to do one thing if a condition is true, and do another thing otherwise. That’s where if and else combine:

xs = [-1,1,3,-5]
for x in xs:
    if x>=0: 
        print(x)
    else:
        print(-x)
1
1
3
5

The condition x >= 0 tests if the number is non-negative. If this condition is satisfied, the number is printed as is. Otherwise, Python executes the else part and prints -x, thus flipping negative numbers to positive ones.

Beyond simple if-else branching, Python provides the elif (else if) statement for handling multiple conditions in sequence. When you have several possible conditions to check, elif lets you chain them together clearly:

x = 42
if x < 0:
    print("Negative")
elif x == 0:
    print("Zero")
elif x < 100:
    print("Small positive")
else:
    print("Large positive")
Small positive

Python checks these conditions in order: 1. First the if condition 2. Then each elif condition in sequence 3. Finally the else if no previous condition was true

Only the code block under the first true condition runs - once a condition is true, Python skips all remaining checks.

4.3 Example: FizzBuzz

FizzBuzz is a classic programming problem that tests your understanding of conditional logic. The problem statement is:

Write a program that prints numbers from 1 to N. But for multiples of 3, print “Fizz” instead of the number, and for multiples of 5, print “Buzz”. For numbers that are multiples of both 3 and 5, print “FizzBuzz”.

This problem tests understanding of:

  • Division and remainder operations
  • Multiple conditional checks
  • The importance of check order

Here’s a solution using the if-elif-else construct:

for n in range(21):  # Numbers 0 through 20
    if n%3 == 0 and n%5 == 0:  # Check for FizzBuzz first!
        print("FizzBuzz")
    elif n%3 == 0:  # Then check for Fizz
        print("Fizz")
    elif n%5 == 0:  # Then check for Buzz
        print("Buzz")
    else:  # If none of the above, print the number
        print(n)

Let’s break down how this works:

  1. The modulo operator % gives us the remainder after division
  2. n%3 == 0 tests if n is divisible by 3
  3. The order of checks is crucial:
    • We must check for “FizzBuzz” first (divisible by both)
    • Then check for “Fizz” (divisible by 3)
    • Then check for “Buzz” (divisible by 5)
    • Finally, just print the number if none apply

The order of these checks is critical for correct behavior. If we were to put the “Fizz” check first, numbers like 15 (which should print “FizzBuzz”) would only print “Fizz”. This happens because 15 is divisible by 3, so it would match the first condition and skip all subsequent checks. Similarly, putting the “Buzz” check first would cause 15 to incorrectly print “Buzz”. This illustrates a common principle in programming: when conditions can overlap, always check the most specific case first (numbers divisible by both 3 and 5) before checking more general cases (numbers divisible by just 3 or just 5).

4.4 Example: listing primes

A prime number is a number greater than 1 that has no positive divisors other than 1 and itself. Generating prime numbers is an illustrative exercise to show how looping and condition checks can be combined. Here’s a first attempt at finding prime numbers up to a chosen limit N:

primes = []
N = 10 
for n in range(2,N+1):
    divisors = [p for p in primes if n%p == 0]
    if not divisors:
        primes.append(n)
print(primes)
[2, 3, 5, 7]

Let’s walk through how this code works for the first few numbers:

When n = 2:

  • primes is empty []
  • divisors = [p for p in primes if 2%p == 0] creates an empty list since there are no primes to check
  • Since divisors is empty, 2 is added to primes
  • Now primes = [2]

When n = 3:

  • primes contains [2]
  • divisors = [p for p in primes if 3%p == 0] checks if 3 is divisible by 2
  • Since 3÷2 has remainder 1, divisors remains empty
  • 3 is added to primes
  • Now primes = [2, 3]

When n = 4:

  • primes contains [2, 3]
  • divisors = [p for p in primes if 4%p == 0] checks if 4 is divisible by 2 or 3
  • Since 4÷2 = 2 remainder 0, divisors becomes [2]
  • Since divisors is not empty, 4 is not added to primes
  • primes remains [2, 3]

This approach works because any composite number must have at least one prime factor smaller than itself. By building our list of primes incrementally, we only need to check division by previously found primes.

While this code works, it has a significant inefficiency: it creates a complete list of all divisors before making a decision. This means even after finding one divisor that proves a number is composite, it continues checking all remaining primes unnecessarily. For example, when checking if 12 is prime, after finding it’s divisible by 2, there’s no need to check if it’s divisible by 3.

We can improve this using Python’s any() function, which stops checking as soon as it finds a True value:

primes = []
N = 10
for n in range(2,N+1):
    is_composite = any(n%p==0 for p in primes)
    if not is_composite:
        primes.append(n)
print(primes)
[2, 3, 5, 7]

The any() function takes an iterable and returns True if any element is True. In our solution, we use a generator expression n%p==0 for p in primes - a memory-efficient way to create an iterable. When a generator expression is the only argument to a function like any(), all(), or sum(), Python allows us to omit the extra parentheses that would normally be required. Unlike a list comprehension which uses square brackets [...] and creates the entire list at once, a generator expression normally uses parentheses (...) and generates values one at a time as needed. This is particularly efficient because:

  1. It works without creating a full list in memory
  2. It stops checking as soon as it finds a divisor
  3. It returns False only after checking all elements

Let’s walk through how this improved solution works:

When n = 2:

  • primes is empty []
  • is_composite = any(2%p==0 for p in primes) evaluates to False because there are no primes to check
  • Since is_composite is False, 2 is added to primes
  • Now primes = [2]

When n = 3:

  • primes contains [2]
  • is_composite = any(3%p==0 for p in primes) checks if 3 is divisible by 2
  • Since 3÷2 has remainder 1, no divisors are found, so is_composite is False
  • 3 is added to primes
  • Now primes = [2, 3]

When n = 4:

  • primes contains [2, 3]
  • is_composite = any(4%p==0 for p in primes) starts checking divisors
  • When checking 2: 4÷2 has remainder 0, so any() immediately returns True
  • It doesn’t need to check division by 3 since we already know 4 is composite
  • Since is_composite is True, 4 is not added to primes
  • primes remains [2, 3]

The key improvement is that any() stops as soon as it finds a divisor, making the code more efficient than the previous version which always checked all primes.

Here are some simple examples of any() and its complement all():

# any() examples
any([False, False, True, False])  # Returns True, stops at first True
any([])                          # Returns False for empty iterables
any(x > 5 for x in [1,2,3,4])   # Returns False, no number > 5

# all() examples
all([True, True, True])         # Returns True
all([True, False, True])        # Returns False, stops at False
all(x < 10 for x in [1,2,3])   # Returns True, all numbers < 10

4.4.1 break statement

The break statement provides a way to exit a loop prematurely. It is particularly valuable when you already have the result you want or you find it unnecessary to continue further. In prime detection, you can stop checking as soon as you realize n is divisible by a smaller prime or once your prime factor checks exceed \(\sqrt{n}\).

This \(\sqrt{n}\) optimization is based on a fundamental property of composite numbers: if a number \(n\) is composite, it must have at least one prime factor less than or equal to its square root. Here’s why:

  • If \(n\) is composite, it can be written as \(n = a \times b\) where both \(a\) and \(b\) are integers greater than 1.
  • If both \(a\) and \(b\) were larger than \(\sqrt{n}\), then \(a \times b\) would be larger than \(\sqrt{n} \times \sqrt{n} = n\).
  • Therefore, at least one of these factors must be less than or equal to \(\sqrt{n}\).
  • And if \(n\) has no prime factors up to \(\sqrt{n}\), it cannot have any larger prime factors either.
primes = []
N = 10
for n in range(2,N+1):
    sqrt_n = n**0.5
    is_composite = False
    for p in primes:
        if p > sqrt_n:
            break
        if n%p == 0:
            is_composite = True
            break
    if not is_composite:
        primes.append(n)
print(primes)
[2, 3, 5, 7]

When the loop encounters p > sqrt_n, there is no need to look at larger primes because if none smaller than or equal to \(\sqrt{n}\) divides n, none beyond \(\sqrt{n}\) will either. This speeds up the search for primes.

4.4.1.1 continue statement

While break finishes a loop altogether, continue stops processing the current loop iteration and moves on to the next one. It is a useful tool when you want to skip certain values without exiting the entire loop. For instance, in some filtering tasks, you might choose to continue and skip the remaining statements of the loop body if a condition is not met.

Here’s a simple example that prints only even numbers from 1 to 5:

for n in range(1, 6):
    if n % 2 != 0:  # if n is odd
        continue    # skip to next iteration
    print(n)       # only reached for even numbers
2
4

When n is odd, the continue statement skips the rest of the loop body (the print statement) and moves to the next number. This results in printing only the even numbers 2 and 4.

4.4.1.2 pass statement

The pass statement is a null operation - it does nothing. It’s used as a placeholder when a statement is syntactically required but you don’t want any code to execute. This is particularly useful when:

  1. You’re sketching out code structure and want to fill in details later
  2. You need an empty block in a control structure
  3. You want to explicitly indicate that nothing should happen in a certain case

Here’s a simple example using pass in an if statement:

for n in range(5):
    if n < 3:
        print(n)
    else:
        pass  # Do nothing for n >= 3
0
1
2

While the else part of the if statement is optional, here the pass statement makes it clear that we intentionally want to do nothing in the else block. Without pass, but with else an empty block would cause a syntax error.

4.5 while loops

A while loop keeps running as long as a specified condition remains true. This control structure is ideal when the number of iterations cannot be easily determined beforehand or depends on changing conditions.

The following code counts the number of decimal digits in the number 2025.

number = 2025
digits = 0
while number > 0:
    number //= 10
    digits += 1
print(digits)

In this example, the code initializes the variable number to 2025 and digits to 0. Then, as long as number exceeds 0, the loop executes.

The // operator performs integer division, always returning a whole number by discarding any decimal part. When dividing by 10, this effectively removes the rightmost digit. Let’s see how this works step by step:

number = 2025
print(f"Starting number: {number}")
number = number // 10  # 2025 ÷ 10 = 202.5 → 202 (removes 5)
print(f"After first //10: {number}")
number = number // 10  # 202 ÷ 10 = 20.2 → 20 (removes 2)
print(f"After second //10: {number}")
number = number // 10  # 20 ÷ 10 = 2.0 → 2 (removes 0)
print(f"After third //10: {number}")
number = number // 10  # 2 ÷ 10 = 0.2 → 0 (removes 2)
print(f"After fourth //10: {number}")
Starting number: 2025
After first //10: 202
After second //10: 20
After third //10: 2
After fourth //10: 0

Each pass uses // to divide number by 10 and keep only the quotient, which removes the rightmost digit, and increments digits by 1. When number finally becomes 0, the loop’s condition no longer holds, causing the loop to terminate. The final value of digits (4) equals the number of times we had to divide by 10 to reach 0, which is the number of digits in 2025.

4.6 Infinite Loops

Sometimes bugs in our code can create infinite loops - loops that never terminate. Here’s a common example:

n = 1
while n != 0:
    n += 1

This loop will never end because n starts at 1 and keeps increasing - it will never equal 0. If you accidentally run such code, you’ll need to know how to stop it:

  • In a terminal/command prompt press Ctrl+C

  • In a Jupyter notebook:

    • Click the ⬛ (square) button in the notebook toolbar
    • Or select “Interrupt Kernel” from the Kernel menu
    • If the kernel becomes unresponsive, you may need to select “Restart Kernel”

Here’s another subtle example that looks correct but creates an infinite loop:

numbers = [1, 2, 3]
i = 0
while i < len(numbers):
    if numbers[i] == 2:
        numbers.append(2)  # Adds to the list we're iterating over!
    i += 1

This creates an infinite loop because:

  1. When i=1, we find numbers[1] == 2
  2. We append 2 to numbers, making it [1, 2, 3, 4]
  3. The loop continues because i < len(numbers) is still true
  4. This keeps happening, continuously growing the list

To fix this, you should avoid modifying a collection while iterating over it. Instead, create a new list or use a different approach.

4.7 match statement

The match statement, introduced in Python 3.10, is a powerful pattern matching feature that provides a more elegant way to express complex conditional logic. While similar to switch/case statements in other languages, Python’s match is more sophisticated, allowing pattern matching against data structures and multiple values simultaneously.

Let’s start with a simple example that demonstrates basic pattern matching:

for n in [0,1,5]:
    match n:
        case 0:
            print("zero")
        case 1:
            print("one")
        case _:
            print("something else")
zero
one
something else

Let’s break down the syntax and execution of this example:

  1. The match statement starts with the keyword match followed by the value to match against (n in this case)

  2. Each case line defines a pattern to match:

    • case 0: matches exactly the value 0
    • case 1: matches exactly the value 1
    • case _: is a special pattern that matches anything (like a default case)
  3. The indented code under each case runs when that pattern matches

When n=0:

  • Python compares 0 against each pattern in order
  • It matches the first case 0: pattern
  • Executes print("zero")
  • Exits the match statement

When n=5:

  • 5 doesn’t match case 0:
  • 5 doesn’t match case 1:
  • The case _: matches any value
  • Executes print("something else")

The match statement can also handle multiple patterns in a single case using |:

for n in [0,1,3,6]:
    match n:
        case 0:
            print("zero")
        case 1 | 2:
            print("small")
        case 3 | 4 | 5:
            print("medium")
        case _:
            print("large")
zero
small
medium
large

One of the most powerful features of match is its ability to match against tuples or other structured patterns. This makes it particularly elegant for problems that involve multiple conditions. The FizzBuzz problem is a perfect example of this capability:

for n in range(21):
    match n%3, n%5:
        case 0,0:
            print("FizzBuzz")
        case 0,_:
            print("Fizz")
        case _,0:
            print("Buzz")
        case _:
            print(n)
FizzBuzz
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz

This FizzBuzz implementation showcases how match can elegantly handle multiple conditions. The expression n%3, n%5 creates a tuple of remainders, and each case pattern matches different combinations:

  • case 0,0: matches when both remainders are 0 (number divisible by both 3 and 5)
  • case 0,_: matches when only the first remainder is 0 (divisible by 3)
  • case _,0: matches when only the second remainder is 0 (divisible by 5)
  • case _: matches anything else

The underscore _ acts as a wildcard pattern that matches any value. The order of cases is important - more specific patterns should come before more general ones. In the FizzBuzz example, we check for numbers divisible by both 3 and 5 first, before checking for numbers divisible by just one of them.

The match statement can also capture values from patterns using variable names. Here’s an example using a list of coordinates:

points = [(0,0), (1,1), (2,0), (0,2)]
for point in points:
    match point:
        case (0, y):
            print(f"Point on y-axis at y={y}")
        case (x, 0):
            print(f"Point on x-axis at x={x}")
        case (x, y):
            print(f"Point at x={x}, y={y}")
Point on y-axis at y=0
Point at x=1, y=1
Point on x-axis at x=2
Point on y-axis at y=2

In this example, the variables x and y capture the actual values from the tuple being matched. When a coordinate like (0,2) matches the pattern (0, y), the value 2 is captured in the variable y. This allows us to use the captured values in our code.

While this example shows pattern matching with numbers, match can work with many other Python data types including strings, lists, and custom objects. This makes it a powerful tool for writing clear, maintainable code that handles complex conditional logic.

4.7.1 Exercises

  1. Looping with for
    Consider the first five natural numbers 1 through 5. Write a for loop that prints the square of each number. Create a list [1, 2, 3, 4, 5] first, and then write a loop that calculates and prints each square. Explain why the loop ends after printing the square of 5.

  2. Combining if and for
    Suppose you have the list xs = [10, -3, 7, -1, 5]. Use a for loop to check each number in turn. Print the number only if it is positive. Next, modify your code so that if the number is negative, the code prints its absolute value and if it is non-negative, it simply prints the number. Reflect on how the if and else statements guide which code is run.

  3. Testing while conditions
    Look at the following fragment:

    counter = 5
    while counter > 0:
        print(counter)
        counter -= 1

    What is printed and why does the loop stop when it does? What changes, if any, occur if you switch the condition from while counter > 0: to while counter != 0:?

  4. Exploring break
    Write a loop that prints the numbers from 1 to 10. However, include a break statement that stops the loop when the number 7 is reached. Notice how none of the numbers greater than 7 get printed. Remove the break statement and see how the behavior changes.

  5. Nested Loops
    Consider two lists of numbers: [1, 2, 3] and [4, 5]. Print out every possible pair you can form with an element from the first list and an element from the second. Clarify how the outer loop goes through its elements and, for each element in the outer loop, the inner loop completes a full pass through the other list.

  6. match usage
    Write a small snippet that iterates over the list [0, 1, 2, 3, 4]. Use a match statement on each number n to do the following: print “zero” if it is 0, print “small” if it is 1 or 2, print “medium” if it is 3, and print “large” otherwise. Make sure to include a default case (using _) and think carefully about the order in which each case appears.

By developing a thorough understanding of for loops, while loops, conditionals like if and elif, and additional enhancements such as break, continue, and match, you build the foundation for sophisticated data processing and logic in Python. These control structures allow your programs to respond differently based on changing data and to repeat tasks as needed, making them indispensable for robust, flexible code. Once you master them, you will be better equipped to tackle more advanced operations, such as iterating over large datasets and implementing complex decision-making logic in your programs.