[Python-ideas] How assignment should work with generators?

Steven D'Aprano steve at pearwood.info
Mon Nov 27 08:55:05 EST 2017


On Mon, Nov 27, 2017 at 12:17:31PM +0300, Kirill Balunov wrote:
> Currently during assignment, when target list is a comma-separated list of
> targets (*without "starred" target*) the rule is that the object (rhs) must
> be an iterable with the same number of items as there are targets in the
> target list. That is, no check is performed on the number of targets
> present, and if something goes wrong the  ValueError is raised.

That's a misleading description: ValueError is raised when the number of 
targets is different from the number of items. I consider that to be 
performing a check on the number of targets.


> To show this on simple example:
> 
> >>> from itertools import count, islice
> >>> it = count()
> >>> x, y = it
> >>> it
> count(3)

For everyone else who was confused by this, as I was, that's not 
actually a copy and paste from the REPL. There should be a ValueError 
raised after the x, y assignment. As given, it is confusing because it 
looks like the assignment succeeded, when in fact it didn't.


> Here the count was advanced two times but assignment did not happen.

Correct, because there was an exception raised.


> I found that in some cases it is too much restricting that rhs must 
> have the same number of items as targets. It is proposed that if the 
> rhs is a generator or an iterator (better some object that yields 
> values on demand), the assignmenet should be lazy and dependent on the 
> number of targets.

I think that's problematic. How do you know what objects that yields 
values on demand? Not all lazy iterables are iterators: there are also 
lazy sequences like range.

But even if we decide on a simple rule like "iterator unpacking depends 
on the number of targets, all other iterables don't",  I think that will 
be a bug magnet. It will mean that you can't rely on this special 
behaviour unless you surround each call with a type check:


if isinstance(it, collections.abc.Iterator):
    # special case for iterators
    x, y = it
else:
    # sequences keep the old behaviour
    x, y = it[:2]



> I find this feature to be very convenient for 
> interactive use,

There are many things which would be convenient for interactive use that 
are a bad idea outside of the interactive environment. Errors which pass 
silently are one of them. Unpacking a sequence of 3 items into 2 
assignment targets should be an error, unless you explicitly limit it to 
only two items.

Sure, sometimes it would be convenient to unpack just two items out of 
some arbitrarily large iterator just be writing `x, y = it`. But 
other times that would be an error, even in the interactive interpreter.

I don't want Python trying to *guess* whether I want to unpack the 
entire iteratable or just two items. Whatever tiny convenience there is 
from when Python guesses correctly will be outweighed by the nuisance 
value of when it guesses wrongly.


> while it remains readable, expected, and expressed in a more compact 
> code.

I don't think it is expected behaviour. It is different from the current 
behaviour, so it will be surprising to everyone used to the current 
behaviour, annoying to those who like the current behaviour, and a 
general inconvenience to those writing code that runs under multiple 
versions of Python.

Personally, I would not expect this suggested behaviour. I would be very 
surprised, and annoyed, if a simple instruction like:

x, y = some_iterable

behaved differently for iterators and sequences.


> There are some Pros:
>     1. No overhead

No overhead compared to what?


>     2. Readable and not so verbose code
>     3. Optimized case for x,y,*z = iterator

The semantics of that are already set: the first two items are assigned 
to x and y, with all subsequent items assigned to z as a list. How will 
this change optimize this case? It still needs to run through the 
iterator to generate the list.


>     4. Clear way to assign values partially from infinite generators.

It isn't clear at all. If I have a non-generator lazy sequence like:

# Toy example
class EvenNumbers:
    def __getitem__(self, i):
        return 2*i

it = EvenNumbers()  # A lazy, infinite sequence

then `x, y = it` will keep the current behaviour and raise an exception 
(since it isn't an iterator), but `x, y = iter(it)` will use the new 
behaviour.

So in general, when I'm reading code and I see:

x, y = some_iterable

I have very little idea of which behaviour will apply. Will it be the 
special iterator behaviour that stops at two items, or the current 
sequence behaviour that raises if there are more than two items?


> Cons:
>     1. A special case of how assignment works
>     2. As with any implicit behavior, hard-to-find bugs

Right. Hard-to-find bugs beats any amount of convenience in the 
interactive interpreter. To use an analogy:

"Sure, sometimes my car suddenly shifts into reverse while I'm driving 
at 60 kph, sometimes the engine falls out when I go around the corner, 
and occasionally the brakes catch fire, but gosh the cup holder makes it 
really convenient to drink coffee while I'm stopped at traffic lights!"


> There several cases with "undefined" behavior:
> 1. Because the items are assigned, from left to right to the corresponding
> targets, should rhs see side effects during assignment or not?

I don't understand what you mean by this. Surely the behaviour should be 
exactly the same as if you wrote:

x, y = islice(it, 2)


What would you do differently, and why?


> 2. Should this work only for generators or for any iterators?

I don't understand why you are even considering singling out *only* 
generators. A generator is a particular implementation of an iterator. I 
can write:

def gen():
   yield 1; yield 2; yield 3

it = gen()

or I can write:

it = iter([1, 2, 3])

and the behaviour of `it` should be identical.


> 3. Is it Pythonic to distinguish what is on the rhs during assignment, or
> it contradicts with duck typing (goose typing)?

I don't understand this question.


> In many cases it is possible to do this right now, but in too verbose way:
> 
> >>> x, y = islice(gen(), 2)

I don't think that is excessively verbose.

But maybe we should consider allowing slice notation on arbitrary 
iterators:

x, y = it[:2]


I have not thought this through in any serious detail, but it seems to 
me that if the only problem here is the inconvenience of using islice(), 
we could add slicing to iterators. I think that would be better than 
having iterators and other iterables behave differently.

Perhaps a better idea might be special syntax to tell the interpreter 
you don't want to run the right-hand side to completion. "Explicit is 
better than implicit" -- maybe something special like:

x, y, * = iterable

will attempt to extract exactly two items from iterable, without 
advancing past the second item. And it could work the same for 
sequences, iterators, lazy sequences like range, and any other iterable.

I don't love having yet another meaning for * but that would be better 
than changing the standard behaviour of iterator unpacking.



-- 
Steve


More information about the Python-ideas mailing list