Don't forbid `map(nullary_func)`

Is it the infinite loop that makes the difference to other changes? For example, max('abc', key=None) also used to raise a TypeError, but that was changed (in 3.8).

To me, yes.

2 Likes

I was surprised by those results of all([]) and any([]), and then I read the document:

all(iterable)
Return True if all elements of the iterable are true (or if the iterable is empty). Equivalent to:

def all(iterable):
    for element in iterable:
        if not element:
            return False
    return True

Then I realized that:
functions like all() and any() only care about state from all elements of iterable, not the state of iterable object itself. That comforts semantically all and any.

  • all([]): no element is False, finally return the default True
  • any([]): no element is True, finally return the default False

It may help.

That plus the fact that any breaking change needs a better justification than you’ve given so far.

3 Likes

If one assumes that the universe of indexes is \mathbb{N}, then the empty intersection of sets of indexes is \mathbb{N}. It is a choice. Other ordinals could also be the choice for that universe, infinite or also finite.

This change would give map new useful functionality (or rather unleash the functionality it already has and is just forbidden to provide). And it would sync the API with others, it’s the only function I see that takes an arbitrary number of iterables except forbids zero, unlike zip, chain, product, zip_longest, and roundrobin (that’s all the built-ins and itertools / recipes taking iterables). A code search for iterable, *iterables also shows map as the only result.

That change with max was about less, that was just done for API syncing (make key=None behave like in sorted etc). Reading the issue I feel like I missed the golden era where you could post these things there and just a few hardcore devs would discuss and get it done in a few comments…

None of those functions create an infinite loop with zero iterables, though. So a better sync would be for map(func) to be an empty iterable, right?

1 Like

No. It would also be a sync, but not better. As shown earlier, zip is only empty because it is artificially prevented from doing its natural thing, and I even argued why that’s better. Similarly zip_longest. The others are empty because that is their natural thing to do. And I’m asking to let map do its natural thing, too. Allowing it to take zero iterables but still artificially preventing it from doing anything wouldn’t make it more useful. That would only be neutering it in a different way.

It seems pretty clear that what’s “natural” here is a matter of opinion. Different people have different mental models for how these functions behave, and that model determines what is considered “natural”.

Like others in this thread, I don’t see any use-case to justify the change, and it doesn’t strike me as any more natural[1].


  1. I’d be a lot more receptive to list(map(func)) == [], that is the natural result in my mind ↩︎

1 Like

Why do you think empty would be the natural thing? I don’t remember any reasoning shown for that, it seems like a gut reaction not thought through. Whereas for infinite we have the fact that that is already what it would do if it weren’t artificially forbidden (remember all it would take is to change “if len(args) < 2: raise error” to “if len(args) < 1: raise error”) and we have the math. Generally, giving map more iterables can lead to fewer results, not to more results. The extra iterables can only make it stop earlier, can’t magically compensate for earlier iterables being exhausted. Giving it fewer iterables is the opposite, can lead to more results but not to fewer. How do you feel it’s natural to break that pattern between zero and one iterables?

As someone who DOESN’T start from a massive background in mathematical set theory, I see “raise error” as the natural thing, with “empty” as a viable alternative. Yielding an infinite supply of empty calls seems debatably valid, but it isn’t fundamentally the natural thing to do.

But even if “call the function forever” is the most natural behaviour for map(func), the converse is most definitely not true. I would be VERY hard-pressed to believe that map(func) is the most obvious spelling of “call the function forever”. This proposal fails hard in my opinion on the fundamental point of usefulness. There are a very very few situations where a decidedly non-obvious spelling is useful (collections.deque(it, maxlen=0) is faster than for _ in it: pass and that’s some justification), but in this case, what’s the benefit of the non-obvious spelling? Why should anyone ever write map(func) in their code?

4 Likes

You’d be adding the ability to get an unexpected infinite loop, just so you can use map for something it wasn’t designed for and can easily be done in other ways if you want it. Nowhere near justified, IMO.

1 Like

You don’t need to. That math is really just an elaborate way to discuss the simple fact that regarding the number of outputs, each iterable is nothing but a limitation. Thus “no iterables” means “no limitations”, i.e., infinitely many outputs. That’s all there is to it.

For me, everything has been about practicality.

So what tools are useful?

I think generality and reliability adds to practicality. I prefer tools which apply to more general situations in a reliable manner.

Consistency helps for these. You don’t want inconsistency which unnecessarily creates cases to which the tools stop applying normally (for generality). You want tools which work in a consistent and hence predictable manner (for reliability).

On the subject at hand, I would prefer being able to use expressions like map(f, *iterables) without unnecessarily worrying about the signature of f, as I have discussed before.

1 Like

Similarly to all, btw, which I guess people are more used to. There, each value is likewise nothing but a potential counterexample, a limitation of the truth value. Thus likewise “no values” means “no counterexamples”, and thus the result is True. Not False.

I think it’s more about consistency than about truth. For instance, note that zip(*i0, *i1) is equivalent to

map(operator.add, zip(*i0), zip(*i1))

when tuples of iterables i0 and i1 are non-empty. It’s then a practically motivated desire that these be always equivalent since similarly looking constructions with sometimes disagreeing results can be a source of problems.

If i1 is empty, then it says map(add, zip(*i0), zip()) must be equivalent to zip(*i0), and hence zip() couldn’t stop. (You are really wishing zip to be a homomorphism with respect to ways of concatenation, and the unit is wished to be preserved by zip in particular.)

This seems to be the only real reason for why I’d like zip() to never stop.

I basically agree with you on how actually map should depend on zip. This is again for consistency.

Because my mental model for map(func, *iterables) is

for args in zip(*iterables):
    yield func(*args)

And my model for zip with no arguments is an empty iterable, so map is too. I know that you disagree with that too, but that proverbial ship has sailed even farther over the horizon than map has.

I agree with all of this, but the conclusion I reach from it is that map(f) should be empty, just like the other functions that take *iterables when they receive no arguments. I can see how extending that general pattern to map might be useful in some cases, but not the infinite-iterator version.

2 Likes

Just for anyone’s fun, here’s another representation of the zip function through functools.reduce, based on the observation above.

from operator import add
from functools import partial, reduce
from itertools import repeat

def zip_(*iterables):
    return reduce(concatenate,
                  map(zip, iterables),
                  unit)

unit = repeat(())
concatenate = partial(map, add)

print(*zip_('ab', 'cde'))
1 Like

Ok, that at least explains it. Although I believe the reasons for artificially making zip empty (at least the three I presented when I argued why that’s good) don’t apply to map. And thus I think trying to justify a neutering of map with that neutering of zip is inappropriate.

We have agreed on a great part. I think we are almost there.

I have explained how my model for zip works consistently with other basic operations. Can you explain how consistent your model may be?