# Topic covered
* Generators
* Iterable and Iterator in Python
    * Create an Iterator Class

11.1 Generators

Generator is a function which is responsible to generate a sequence of values.

We can write generator functions just like ordinary functions, but it uses yield keyword to return values.

def simpleGeneratorFun(): 
  yield 1         
  yield 2         
  yield 3         

for value in simpleGeneratorFun(): 
  print(value, end=' ')
# 1 2 3

x = simpleGeneratorFun() 
print(list(x))
# [1, 2, 3]

Advantages of Generator Functions

  1. When compared with class level iterators, generators are very easy to use
  2. Improves memory utilization and performance.
  3. Generators are the best suitable for reading data from large number of large files
  4. Generators work great for web scraping and crawling.
* Saves memory
* Don't want the result immediately
* Lazy evaluation

[…] → list comprehension (eager, stores everything in memory).

We will get MemoryError in this case because all these values are required to store in the memory.

# Normal Collection --> []
l=[x*x for x in range(10000000000000000)]
print(l[0])

(…) → generator expression (lazy, memory efficient).

We won't get any MemoryError because the values won’t be stored at the beginning

# Generators --> () 
g=(x*x for x in range(10000000000000000))
print(next(g))
# 0

It doesn’t compute all the values at once (which would be impossible here, because 10^16 is huge).

Instead, it computes values lazily, one at a time, when you ask for them.

👉 Memory efficient — it doesn’t store billions of numbers.

Infinite Generators

An infinite generator is a generator that never stops producing values — it doesn’t have a natural end, unlike a finite list or range.

👉 You control how much you consume (using next() or loops with break)

def infinite_chai():
    count = 1
    while True:
        yield f"Refil #{count}"
        count += 1

refill = infinite_chai()
user2 = infinite_chai()

for _ in range(5):
    print(next(refill))

for _ in range(6):
    print(next(user2))

g = infinite_chai()
print(next(g))
print(next(g))
print(next(g))

Send data to Generators

.send(value) passes a value back into the generator at the point where it was paused.

def chai_customer():
    print("Welcome ! What chai would you like ?")
    order = yield  
    # the generator will paused at order = yield, waiting for input.
    while True:
        print(f"Preparing: {order}")
        order = yield  
        # Pauses again at the next yield - it will always sy=tuck in this loop

stall = chai_customer()
next(stall) # start the generator

stall.send("Masala Chai")
stall.send("Lemon Chai")

# Output
# Welcome ! What chai would you like ?
# Preparing: Masala Chai
# Preparing: Lemon Chai

✅ So, this code models a chai stall that keeps taking and preparing orders one by one, without restarting the function every time

Close generators

def local_chai():
    yield "Masala Chai"
    yield "Ginger Chai"

def imported_chai():
    yield "Matcha"
    yield "Oolong"

def full_menu():
    yield from local_chai()
    yield from imported_chai()

for chai in full_menu():
    print(chai)

# Masala Chai
# Ginger Chai
# Matcha
# Oolong


def chai_stall():
    try:
        while True:
            order = yield "Waiting for chai order"
    except:
        print("Stall closed, No more chai")


stall = chai_stall()
print(next(stall))
stall.close() #cleanup

11.2 Iterable and Iterator in Python

  • Iteration means to access each item of something one after another generally using a loop
  • list, tuples, dictionaries, etc. –> all are iterables
  • One important property it has
    • an __iter__() method or iter() method
    • which allows any iterable to return an iterator object.
  • iter() and next()
# list of cars
cars = ['Audi', 'BMW', 'Jaguar', 'Kia', 'MG', 'Skoda']

# get iterator object using the iter() / __iter__() method
cars_iter = iter(cars) 
cars_iter = cars.__iter__()   

# use the next / __next__() method to iterate through the list
print(next(cars_iter))      
# OP: Audi

print(cars_iter.__next__()) 
# OP: BMW

Create an Iterator Class

  • To create an object/class as an iterator you have to implement the methods __iter__() and __next__() to your object.
  • To prevent the iteration to go on forever, we can use the StopIteration statement.
class MyNumbers:
  def __iter__(self):
    self.a = 1
    return self

  def __next__(self):
      if self.a <= 3:
          x = self.a
          self.a += 1
          return x
      else:
          raise StopIteration

my_class = MyNumbers()
my_iter = iter(my_class)

print(next(my_iter))
print(next(my_iter))
print(next(my_iter))
print(next(my_iter))
print(next(my_iter))