Ndifreke Ekott

Thoughts, stories, ideas and programming

31 Mar 2024

Unlocking Pythons Hidden Powers Deep Dive Into Special Methods Part 2

In part 1, I started by demonstrating a use case for the __iter__() iterator special (magic) methods. I gave an example of how it can be used in a StudentCollection class. I also highlighted the fact that adopting __iter__ leads to more Pythonic code and better utilisation of Python’s language features.

In part 2, I will be writing about the iterator.__next__() special method, which usually gets combined with the iterator.__iter__() method. As part of my reading and preparing to write this piece, I discovered there is a thing called iterator protocol in the Python docs, and a proper description of how to write iterators. I had looked at the code in part 1 and thought it worked, because without knowing I implemented the container.__iter__() approach which is called Iterables.

In light of my new found knowledge, I intend to re-write the StudentCollection class, to adhere to the iterator protocol and in so doing introduce the __next__() method.

Recap of the StudentCollection class.

class StudentCollection:

  def __init__(self, students: list[str] | None = None):
    self._students = students

  def add_student(self, student: str):
    if self._students is None:
      self._students = []
    self._students.append(student)

  # we can remove the list_all_students() method.

  def __iter__(self) -> Generator[str | None, None, None]:
    if self._students:
      for student in self._students:
        yield student

What is the iterator.__next__() special method?

The __next__() method is used to return the next item from an iterator. If there are no items to return, a StopIteration exception is raised. A lot easier to demonstrate in code. I will be updating our container class StudentCollection

class StudentCollection:

  def __init__(self, students: list[str]):
    self._students = students
    self.index = 0
    self.current = None

  def __iter__(self) -> object:
    return self

  def __next__(self):
    if self.index < len(self._students):
      self.current = self._students[self.index]
      self.index += 1
      return self.current

    raise StopIteration

The StudentCollection container/class has been updated to hold two new properties - index and current. You don’t necessarily need the current property but it can be useful to keep a track of the currently returned student. This allows you to write print(StudentCollection.current) to fetch the last or current student at the index. Without the current property, you will need to keep track of the last returned item in your calling code.

The index property on the other hand, is useful for tracking the currently selected or viewed student in the self.students collection. Incrementing the index value, points to the next student in the list. While the value of index is less than the length of the student list, we can still have students to return when asked for. Once that condition doesn’t hold, by the iterator protocol, we should raise a StopIteration exception.

Running the code.

students = StudentCollection(['st1', 'st2', 'st3', 'st4'])

for student in students:
	print('index ', students.index, ' student ', student)

# Output:
# index  1  student  st1
# index  2  student  st2
# index  3  student  st3
# index  4  student  st4

An alternative approach to looping over the StudentCollection is with a while loop. A while loop however throws up one additional requirement, when we run out of students to iterate over, a StopIteration exception is raised and this will need handling in your calling code. This requirement is handled for you when using a for in construct.

students = StudentCollection(['st1', 'st2', 'st3', 'st4'])

try:
  while student := next(students):
    print('index ', students. index, ' student ', student)
except StopIteration:
  print('finish!!')

# Output:
# index  1  student  st1
# index  2  student  st2
# index  3  student  st3
# index  4  student  st4
# finish!!

In conclusion, the __next__() method is a critical part of Python’s iterator protocol. It allows for more efficient and idiomatic iteration over collections, as demonstrated with the StudentCollection class. The __next__() method, along with the __iter__() method, enables Python objects to be used directly in loops and other constructs that expect an iterator. This functionality demonstrates Python’s powerful and flexible design, and underscores the importance of understanding these special methods for effective Python programming.