Search code examples
pythonfor-loopstopiteration

Would a StopIteration make python slow?


As far as i know, monitoring exception will make a program slower.

Would an iterator exception monitor, such as StopIteration make a for loop slower?


Solution

  • While exception monitoring has some small overhead in the usual case, in the case of iterators there does not appear to be any overhead involved in handling StopIteration exceptions. Python optimises iterators as a special case so that StopIteration doesn't involve any exception handlers. (I'll also observe---and I may be missing something---that it's hard to come up with a Python for loop that doesn't implicitly use iterators).

    Here's some examples, first using the built-in range function and a simple for loop:

    Python 2.7.5
    >>> import dis
    >>> def x():
    ...   for i in range(1,11):
    ...     pass
    ...
    >>> dis.dis(x)
      2           0 SETUP_LOOP              23 (to 26)
                  3 LOAD_GLOBAL              0 (range)
                  6 LOAD_CONST               1 (1)
                  9 LOAD_CONST               2 (11)
                 12 CALL_FUNCTION            2
                 15 GET_ITER
            >>   16 FOR_ITER                 6 (to 25)
                 19 STORE_FAST               0 (i)
    
      3          22 JUMP_ABSOLUTE           16
            >>   25 POP_BLOCK
            >>   26 LOAD_CONST               0 (None)
                 29 RETURN_VALUE
    

    Note that range is essentially being treated as an iterator.

    Now, using a simple generator function:

    >>> def g(x):
    ...   while x < 11:
    ...     yield x
    ...     x = x + 1
    ...
    >>> def y():
    ...   for i in g(1):
    ...     pass
    ...
    >>> dis.dis(y)
      2           0 SETUP_LOOP              20 (to 23)
                  3 LOAD_GLOBAL              0 (g)
                  6 LOAD_CONST               1 (1)
                  9 CALL_FUNCTION            1
                 12 GET_ITER
            >>   13 FOR_ITER                 6 (to 22)
                 16 STORE_FAST               0 (i)
    
      3          19 JUMP_ABSOLUTE           13
            >>   22 POP_BLOCK
            >>   23 LOAD_CONST               0 (None)
                 26 RETURN_VALUE
    >>> dis.dis(g)
      2           0 SETUP_LOOP              31 (to 34)
            >>    3 LOAD_FAST                0 (x)
                  6 LOAD_CONST               1 (11)
                  9 COMPARE_OP               0 (<)
                 12 POP_JUMP_IF_FALSE       33
    
      3          15 LOAD_FAST                0 (x)
                 18 YIELD_VALUE
                 19 POP_TOP
    
      4          20 LOAD_FAST                0 (x)
                 23 LOAD_CONST               2 (1)
                 26 BINARY_ADD
                 27 STORE_FAST               0 (x)
                 30 JUMP_ABSOLUTE            3
            >>   33 POP_BLOCK
            >>   34 LOAD_CONST               0 (None)
                 37 RETURN_VALUE
    

    Note that y here is basically the same as x above, the difference being one LOAD_CONST instruction, since x references the number 11. Likewise, our simple generator is basically equivalent to the same thing written as a while loop:

    >>> def q():
    ...   x = 1
    ...   while x < 11:
    ...     x = x + 1
    ...
    >>> dis.dis(q)
      2           0 LOAD_CONST               1 (1)
                  3 STORE_FAST               0 (x)
    
      3           6 SETUP_LOOP              26 (to 35)
            >>    9 LOAD_FAST                0 (x)
                 12 LOAD_CONST               2 (11)
                 15 COMPARE_OP               0 (<)
                 18 POP_JUMP_IF_FALSE       34
    
      4          21 LOAD_FAST                0 (x)
                 24 LOAD_CONST               1 (1)
                 27 BINARY_ADD
                 28 STORE_FAST               0 (x)
                 31 JUMP_ABSOLUTE            9
            >>   34 POP_BLOCK
            >>   35 LOAD_CONST               0 (None)
                 38 RETURN_VALUE
    

    Again, there's no specific overhead to handle the iterator or the generator (range may be somewhat more optimised than the generator version, simply because its a built-in, but not due to the way Python handles it).

    Finally, let's look at an actual explicit iterator written with StopIteration

    >>> class G(object):
    ...   def __init__(self, x):
    ...     self.x = x
    ...   def __iter__(self):
    ...     return self
    ...   def next(self):
    ...     x = self.x
    ...     if x >= 11:
    ...       raise StopIteration
    ...     x = x + 1
    ...     return x - 1
    ...
    >>> dis.dis(G.next)
      7           0 LOAD_FAST                0 (self)
                  3 LOAD_ATTR                0 (x)
                  6 STORE_FAST               1 (x)
    
      8           9 LOAD_FAST                1 (x)
                 12 LOAD_CONST               1 (11)
                 15 COMPARE_OP               5 (>=)
                 18 POP_JUMP_IF_FALSE       30
    
      9          21 LOAD_GLOBAL              1 (StopIteration)
                 24 RAISE_VARARGS            1
                 27 JUMP_FORWARD             0 (to 30)
    
     10     >>   30 LOAD_FAST                1 (x)
                 33 LOAD_CONST               2 (1)
                 36 BINARY_ADD
                 37 STORE_FAST               1 (x)
    
     11          40 LOAD_FAST                1 (x)
                 43 LOAD_CONST               2 (1)
                 46 BINARY_SUBTRACT
                 47 RETURN_VALUE
    

    Now, here we can see that the generator function involves a few less instructions than this simple iterator, mostly related to the differences in implementation and a couple of instructions related to raising the StopIteration exception. Nevertheless, a function using this iterator is exactly equivalent to y above:

    >>> def z():
    ...   for i in G(1):
    ...     pass
    ...
    >>> dis.dis(z)
      2           0 SETUP_LOOP              20 (to 23)
                  3 LOAD_GLOBAL              0 (G)
                  6 LOAD_CONST               1 (1)
                  9 CALL_FUNCTION            1
                 12 GET_ITER
            >>   13 FOR_ITER                 6 (to 22)
                 16 STORE_FAST               0 (i)
    
      3          19 JUMP_ABSOLUTE           13
            >>   22 POP_BLOCK
            >>   23 LOAD_CONST               0 (None)
                 26 RETURN_VALUE
    

    Of course, these results are based around the fact that Python for-loops will optimise iterators to remove the need for explicit handlers for the StopIteration exception. After all, StopIteration exception essentially form a normal part of the operation of a Python for-loop.


    Regarding why it is implemented this way, see PEP-234 which defines iterators. This specifically addresses the issue of the expense of the exception:

    • It has been questioned whether an exception to signal the end of the iteration isn't too expensive. Several alternatives for the StopIteration exception have been proposed: a special value End to signal the end, a function end() to test whether the iterator is finished, even reusing the IndexError exception.

      • A special value has the problem that if a sequence ever contains that special value, a loop over that sequence will end prematurely without any warning. If the experience with null-terminated C strings hasn't taught us the problems this can cause, imagine the trouble a Python introspection tool would have iterating over a list of all built-in names, assuming that the special End value was a built-in name!

      • Calling an end() function would require two calls per iteration. Two calls is much more expensive than one call plus a test for an exception. Especially the time-critical for loop can test very cheaply for an exception.

      • Reusing IndexError can cause confusion because it can be a genuine error, which would be masked by ending the loop prematurely.