'''
Generators are iterators that generate values on the fly. Each time a new value
is generated, that value is yielded, and the state of the generator function is
frozen until it is iterated upon again; at this point, the old generated value
is discarded, and a new value is generated. This is because generators are simply
functions, and cannot hold state (but do hold namespace) after execution.
'''
'''
The generator, just like any iterable object, contains an internal __next__()
method. Whenever this is called, the function code runs until it reaches an
automatic StopIteration exception or the yield statement.
'''
def gen(i, n):
while i < n:
yield i**2
i += 1
j = gen(1, 10) # <-- calling gen(i, n) returns a generator object, but NO CODE RUNS YET
for k in j: # <-- whenever an iteration occurs, next() is called
print(k)