Monday, January 31, 2022

Stack Abuse: Guide to enumerate() in Python - Forget Counters

Introduction

Looping with a counter variable/index - a classic in Computer Science! Typically, you'd either explicitly define a counter variable/index, and manually increment it on each loop, or you'd use some sort of syntactic sugar to avoid this process through enhanced for loops:

some_list = ['Looping', 'with', 'counters', 'is', 'a', 'classic!']

# Manual counter incrementation
i = 0
for element in some_list:
    print(f'Element Index: {i}, Element: {element}')
    i += 1

# Automatic counter incrementation
for i in range(len(some_list)):
    print(f'Element Index: {i}, Element: {some_list[i]}')

Both of these snippets result in the same output:

Element Index: 0, Element: Looping
Element Index: 1, Element: with
Element Index: 2, Element: counters
Element Index: 3, Element: is
Element Index: 4, Element: a
Element Index: 5, Element: classic!

Due to how common looping like this is in day-to-day work - the enumerate() function was built into the Python namespace. You can, without any extra dependencies, loop through an iterable in Python, with an automatic counter variable/index with syntax as simple as:

for idx, element in enumerate(some_list):
     print(idx, element)

Note: It's common, but not necessary, convention to name the index as idx if no other label is applicable, since id is a reserved keyword. Commonly, based on the iterable you're working with, more meaningful names can be attributed, such as: batch_num, batch in enumerate(...).

This piece of code results in:

0 Looping
1 with
2 counters
3 is
4 a
5 classic!

Let's dive into the function and explore how it works! It's a classic and common one - and in true Python fashion, it simplifies a common, redundant operation down and improves readability of your code.

The enumerate() Function in Python

The enumerate() function accepts an iterable collection (such as a tuple, list or string), and returns an enumerate object, which consists of a key-set and value-set, where the keys correspond to a counter variable (starting at 0) and the values correspond to the original elements of the iterable collection:

obj = enumerate(some_list)
print(type(obj))
# <class 'enumerate'>

Note: The enumerate object is, itself, iterable! You can use the standard for syntax, unpacking the keys and values of the enumerate object.

Using Python's standard for syntax, we can unpack the keys and values from this object and inspect their types:

for key, value in obj:
    print(type(key), type(value))
    
# <class 'int'> <class 'str'>
# <class 'int'> <class 'str'>
# <class 'int'> <class 'str'>
# <class 'int'> <class 'str'>
# <class 'int'> <class 'str'>
# <class 'int'> <class 'str'>

The data types of the values (elements from the original collection) are retained, so even if you pass custom data types, as long as they're a valid iterable collection - they'll simply be annotated with a counter variable. If you were to collect the object itself into a list, its structure would become very clear:

print(list(obj))
# [(0, 'Looping'), (1, 'with'), (2, 'counters'), (3, 'is'), (4, 'a'), (5, 'classic!')]

It's just a set of tuples with two elements each - a counter variable, starting at 0, and each element of the original iterable mapped to the indices.

You can set an optional start argument, denoting not the starting index in the iterable, but the starting value for the first counter/index that the function will generate. For instance, say we'd like to start at 1 instead of 0:

obj = enumerate(some_list, 1)
print(list(obj))
# [(1, 'Looping'), (2, 'with'), (3, 'counters'), (4, 'is'), (5, 'a'), (6, 'classic!')]

Loop Through Iterable with enumerate()

Having said all that - looping through an enumerate object looks the same as looping through other iterables. The for loop comes in handy here as you can assign reference variables to the returned tuple values. Additionally, there's no need to reference the object explicitly, as it's very rarely used outside of a single loop so the returned value is typically used directly in the loop itself:

# No need to assign the returned `enumerate` object to a distinct reference variable
for idx, element in enumerate(some_list):
     print(f'{idx}, {element}')

This results in:

0, Looping
1, with
2, counters
3, is
4, a
5, classic!

If you'd like to read more about f-Strings and formatting output in Python, read our Guide to String Formatting with Python 3's f-Strings!

Annotating each element in an iterable - or rather, incrementing a counter and returning it, while accessing elements of iterables is as easy as that!

It's worth noting that nothing special really happens within the enumerate() function. It really is, functionally equivalent, to the initial loop we wrote, with an explicit counter variable being returned with an element. If you take a look at the note in the official documentation, the result of the function is functionally equivalent to:

def enumerate(sequence, start=0):
    n = start
    for elem in sequence:
        yield n, elem
        n += 1

You can see that the code is quite similar to the first implementation we've defined:

# Original implementation
i = 0
for element in some_list:
    print(f'Element Index: {i}, Element: {some_list[i]}')
    i += 1
    
# Or, rewritten as a method that accepts an iterable    
def our_enumerate(some_iterable, start=0):
    i = start
    for element in some_iterable:
        yield i, element
        i += 1

The key point here is - the yield keyword defines a generator, which is iterable. By yielding back the index and the element itself, we're creating an iterable generator object, which we can then loop over and extract elements (and their indices) from via the for loop.

If you'd like to read more about the usage of the yield keyword here, read our Guide to Understanding Python's "yield" Keyword!

If you were to use the our_enumerate() function instead of the built-in one, we'd have much the same results:

some_list = ['Looping', 'with', 'counters', 'is', 'a', 'classic!']

for idx, element in our_enumerate(some_list):
     print(f'{idx}, {element}')
        
obj = our_enumerate(some_list)
print(f'Object type: {obj}')

This results in:

0, Looping
1, with
2, counters
3, is
4, a
5, classic!
Object type: <generator object our_enumerate at 0x000002750B595F48>

The only difference is that we just have a generic generator object, instead of a nicer class name.

Conclusion

Ultimately, the enumerate() function is simply syntactic sugar, wrapping an extremely common and straightforward looping implementation.

In this short guide, we've taken a look at the enumerate() function in Python - the built-in convenience method to iterate over a collection and annotate the elements with indices.



from Planet Python
via read more

No comments:

Post a Comment

TestDriven.io: Working with Static and Media Files in Django

This article looks at how to work with static and media files in a Django project, locally and in production. from Planet Python via read...