classification
Title: Introduce exception argument to iter
Type: enhancement Stage: resolved
Components: Interpreter Core Versions: Python 3.6
process
Status: closed Resolution: rejected
Dependencies: Superseder:
Assigned To: Nosy List: cool-RR, josh.r, rhettinger, terry.reedy
Priority: low Keywords:

Created on 2014-02-17 17:43 by cool-RR, last changed 2017-06-18 18:16 by terry.reedy. This issue is now closed.

Messages (11)
msg211431 - (view) Author: Ram Rachum (cool-RR) * Date: 2014-02-17 17:43
See discussion: https://groups.google.com/forum/#!searchin/python-ideas/iter/python-ideas/UCaNfAHkBlQ/5vX7JbpCxDkJ

`iter` has a very cool `sentinel` argument. I suggest an additional argument `exception`; when it's supplied, instead of waiting for a sentinel value, we wait for a sentinel exception to be raised, and then the iteration is finished.

This'll be useful to construct things like this: 

    my_iterator = iter(my_deque.popleft, exception=IndexError)

I also suggest being able to pass multiple exceptions in a tuple to have any of them trigger a `StopIteration`.
msg222169 - (view) Author: Ram Rachum (cool-RR) * Date: 2014-07-03 12:37
Hey-ho... Anyone feels like implementing this? (I don't program in C so I can't.)
msg222215 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2014-07-03 21:16
Your suggestion and an example appears to have been taken directly from the itertools recipes:

def iter_except(func, exception, first=None):
    """ Call a function repeatedly until an exception is raised.

    Converts a call-until-exception interface to an iterator interface.
    Like __builtin__.iter(func, sentinel) but uses an exception instead
    of a sentinel to end the loop.

    Examples:
        bsddbiter = iter_except(db.next, bsddb.error, db.first)
        heapiter = iter_except(functools.partial(heappop, h), IndexError)
        dictiter = iter_except(d.popitem, KeyError)
        dequeiter = iter_except(d.popleft, IndexError)
        queueiter = iter_except(q.get_nowait, Queue.Empty)
        setiter = iter_except(s.pop, KeyError)

    """
    try:
        if first is not None:
            yield first()
        while 1:
            yield func()
    except exception:
        pass

FWIW, this idea was explored before an aside from the examples given in the docstring above, it seems to have very limited application.  Accordingly, it was left as a recipe and not added to itertools or the the iter() function.
msg222221 - (view) Author: Ram Rachum (cool-RR) * Date: 2014-07-03 21:32
I understand. Personally I think it'll be useful enough (and more useful to me than the builtin `sentinel`), but maybe that's just me. And I guess Terry liked it too. I don't know whether other people would like it as well.
msg222227 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2014-07-03 22:02
+1; I've had several cases where I'd have used something like this (for the exact purpose mentioned, to destructively consume an input iterable). I don't think it's more or less useful than the sentinel version, which is convenient for iterating a file by blocks instead of by line, e.g.:

from functools import partial

with open('...', 'rb') as f:
    for block in iter(partial(f.read, 4096), b''):
        ...

But it would still nice to be able to destructively iterate sequences, particularly in CPython, where doing it at the C level can get you atomicity without relying on anything beyond the GIL (and without wrapping infinite while loops in try/except: pass blocks, which is pointlessly verbose).

One issue: You can't just make the second argument allow exception types as well, since it's possible (however unlikely) for an exception type to be a legitimate return type from a function. Making it keyword only would solve that problem though.
msg223094 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2014-07-15 08:20
The recipe has been in the docs for a good while and as far as I can tell, no one ever uses this in real-code.  That suggests that it should remain as a recipe and not become part of the core language (feature creep is not good for learnability or maintainability).

I also don't see people writing simple generators that exhibit this functionality.  It just doesn't seem to come-up in real code.

[Josh]
> I've had several cases where I'd have used something like this 

Please post concrete examples so we can actually assess whether code is better-off with or without the feature.  

FWIW, the standard for expanding the API complexity of built-in functions is very high.  It is not enough to say, "I might have used this a couple of times".  

Unnecessary API expansion is not cost free and can make the language worse off on the balance (does the time spent learning, remembering, and teaching the function payoff warrant the rare occasion where it will save a couple of lines of code?  is code harder to customize or debug with hard-wired functionality rather than general purpose try-excepts?  Do we even want people to write code like this?  If heaps, deques, dicts and queues really needed to be destructively iterated, we would have long since had a feature request for them.  But we haven't and destructive for-loops would be unusual and unexpected for Python.
msg223096 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2014-07-15 10:21
The main example that comes to mind was a variant of functools.lru_cache I wrote that expired cache entries after they reached a staleness threshold. The details elude me (this was a work project from a year ago), but it was basically what I was describing; a case where I wanted to efficiently, destructively iterate a collections.deque, and it would have been nice to be able to do so without needing a lock (at least for that operation) and without (IMO) ugly infinite loops terminated by an exception. (Caveat: Like I said, this was a while ago; iter_except might only have simplified the code a bit, not saved me the lock)

No, it's not critical. But for a lot of stuff like this, the recipe saves nothing over inlining a while True: inside a try/except, and people have to know the recipe is there to even look for it. The only reason my particular example came to mind is that the atomic aspect was mildly important in that particular case, so it stuck in my head (normally I'm not trying to squeeze cycles out of Python, but performance oriented decorators are a special case). I do stuff that would be simplified by this more often, it's just cases where I currently do something else would all be made a little nicer if I could have a single obvious way to accomplish it that didn't feel oddly verbose/ugly.
msg223138 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2014-07-15 19:28
Ram, your opening post here is essentially a copy of your opening post on python-ideas, as if the long discussion there, involving about 6 serious discussants other than you, never happened. Instead of restarting the discussion from scratch, you need to summarize the previous discussion, including a proposed python equivalent for the revised iter() (to exactly pin down the api) and how much support the proposal got.

A couple of notes that might be additions to what I said before: If a collection is fixed during an iteration, then destructive iteration might as well be done, when possible, by normal iteration followed by deletion of the collection.

That leaves as use cases iterations where the collection is mutated during the iteration, as in breadth-first search. For many collections, like deques and hashables, mutation means that direct (normal) iteration with for is prohibited.  The current solution is to interleave exception-raising access and mutation within a try and while-True loop.

The following example is similar to a 'breadth-first search'. It uses a deque rather than a list to limit the maximum length of the collection to the maximum number of live candidates rather than the total number of candidates.

from collections import deque
d = deque((0,))
try:
  while True:
        n = d.popleft()
        print(n, len(d))
        if n < 5:
            d.extend((n+1, n+2))
except IndexError:
    pass

This prints 25 items, with a max len before the pop of 11.

Under one variation of the proposal, the try-while block would be replaced by 

for n in iter(d.popleft, None, IndexError):
        print(n, len(d))
        if n < 5:
            d.extend((n+1, n+2))

Is the difference enough to add a parameter to iter?  Perhaps so. It reduces boilerplate and is a little easier to get right.  It eliminates there question of whether the try should be inside or outside the loop. It also matches

d = [0]
for n in d:
    print(n, len(d))
    if n < 5:
        d.extend((n+1, n+2))

which processes the same items in the same order, but extends the list to 25 rather than a max of 11 items. It makes deques and sets look more like direct replacements for lists.

Ram: If you program in Python, you should be able to write a test. To start, replace the prints above with out = [] ... out.append((n, len(d))) and assert that the out lists of the current and proposed deque loops are the same.

Raymond: I started this post with a recommendation to close but changed my mind after actually writing out the two deque examples. The fact that people rarely relegate the try - while True loop to a separate function (which would often be used just once) does not mean that the pattern itself is rare. Just yesterday or so, someone asked on python-list about how to search a graph when the set of candidate nodes got additions and 'for item in set' would not work. He was given the try - while True pattern as the answer.

I think iter(func, ... exception) would be more useful than iter(func, sentinel) is now. The problem with the latter is that for general collections, the sentinel needs to be a hidden instance of object() so that it cannot be placed in the collection and become a non-special legal return value. It is then inaccessible to pass to iter.  To signal 'no return', Python often raises an exception instead of returning a special object.
msg223140 - (view) Author: Ram Rachum (cool-RR) * Date: 2014-07-15 19:44
Terry: Thanks for your example use case. I hope that Raymond would be convinced.

I take your point regarding summarizing the discussion, sorry about that. 

Regarding me writing a test: I'm only willing to write code for a feature for Python if there's general interest from python-dev in getting said feature into Python. If there's general agreement from core python-dev members that this feature should be implemented, I'll be happy to do my part and write the test. But otherwise... I really have better things to do than spending my time writing code that will never be used, especially when it'll take me 10x more time to write it than a python-dev member because 90% of the work would be making the test case comply to the development practices of CPython, and only 10% of it would be to write the actual simple logic.
msg223152 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2014-07-15 21:37
Here is a test that now fails.
------------
from collections import deque

d = deque((0,))
old = []
try:
    while True:
        n = d.popleft()
        old.append((n, len(d)))
        if n < 5:
            d.extend((n+1, n+2))
except IndexError:
    pass

d = deque((0,))
new = []
for n in iter(d.popleft, exception=IndexError):
        new.append((n, len(d)))
        if n < 5:
            d.extend((n+1, n+2))

assert new == old
--------

Here is Python code, partly from my python-ideas comments, that makes the test pass. This version allows stopping on both a sentinel value and an exception (or tuple thereof, I believe).
-------
__sentinel = object()

class callable_iterator:
    class stop_exception: pass

    def __init__(self, func, sentinel, exception):
        self.func = func
        self.sentinel = sentinel
        if exception is not None:
            self.stop_exception = exception
    def __iter__(self):
        return self
    def __next__(self):
        try:
            x = self.func()
        except self.stop_exception:
            raise StopIteration from None
        if x == self.sentinel:
            raise StopIteration
        else:
            return x

def iter(it_func, sentinel=__sentinel, exception=None):
    if sentinel == __sentinel and exception == None:
        pass  # do as at present
    else:
        return callable_iterator(it_func, sentinel, exception)
msg264236 - (view) Author: Raymond Hettinger (rhettinger) * (Python committer) Date: 2016-04-26 08:39
I really don't think this is worth it.  In my programming career, I may have had an occasion to use this only once every few years.
History
Date User Action Args
2017-06-18 18:16:51terry.reedysetstatus: open -> closed
resolution: rejected
stage: needs patch -> resolved
2016-04-26 08:39:11rhettingersetassignee: rhettinger ->
messages: + msg264236
versions: + Python 3.6, - Python 3.5
2014-07-15 21:37:03terry.reedysetmessages: + msg223152
stage: test needed -> needs patch
2014-07-15 19:44:29cool-RRsetmessages: + msg223140
2014-07-15 19:28:41terry.reedysetmessages: + msg223138
stage: test needed
2014-07-15 10:21:02josh.rsetmessages: + msg223096
2014-07-15 08:20:33rhettingersetpriority: normal -> low

messages: + msg223094
2014-07-03 22:02:32josh.rsetnosy: + josh.r
messages: + msg222227
2014-07-03 21:32:28cool-RRsetmessages: + msg222221
2014-07-03 21:16:55rhettingersetassignee: rhettinger

messages: + msg222215
nosy: + rhettinger
2014-07-03 12:37:24cool-RRsetmessages: + msg222169
2014-02-17 17:43:47cool-RRcreate