classification
Title: Make NotImplemented unusable in boolean context
Type: enhancement Stage: resolved
Components: Versions: Python 3.9
process
Status: closed Resolution: fixed
Dependencies: Superseder:
Assigned To: Nosy List: ethan.furman, gvanrossum, josh.r, serhiy.storchaka, steven.daprano, terry.reedy, veky, xtreak
Priority: normal Keywords: patch

Created on 2019-01-10 21:55 by josh.r, last changed 2020-03-13 02:15 by ethan.furman. This issue is now closed.

Pull Requests
URL Status Linked Edit
PR 13195 merged josh.r, 2019-05-08 16:20
Messages (17)
msg333421 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2019-01-10 21:55
I don't really expect this to go anywhere until Python 4 (*maybe* 3.9 after a deprecation period), but it seems like it would have been a good idea to make NotImplementedType's __bool__ explicitly raise a TypeError (rather than leaving it unset, so NotImplemented evaluates as truthy). Any correct use of NotImplemented per its documented intent would never evaluate it in a boolean context, but rather use identity testing, e.g. back in the Py2 days, the canonical __ne__ delegation to __eq__ for any class should be implemented as something like:

    def __ne__(self, other):
        equal = self.__eq__(other)
        return equal if equal is NotImplemented else not equal

Problem is, a lot of folks would make mistakes like doing:

    def __ne__(self, other):
        return not self.__eq__(other)

which silently returns False when __eq__ returns NotImplemented, rather than returning NotImplemented and allowing Python to check the mirrored operation. Similar issues arise when hand-writing the other rich comparison operators in terms of each other.

It seems like, given NotImplemented is a sentinel value that should never be evaluated in a boolean context, at some point it might be nice to explicitly prevent it, to avoid errors like this.

Main argument against it is that I don't know of any other type/object that explicitly makes itself unevaluable in a boolean context, so this could be surprising if someone uses NotImplemented as a sentinel unrelated to its intended purpose and suffers the problem.
msg333426 - (view) Author: Karthikeyan Singaravelan (xtreak) * (Python committer) Date: 2019-01-10 22:54
Seems related issue22978
msg333433 - (view) Author: Steven D'Aprano (steven.daprano) * (Python committer) Date: 2019-01-11 01:44
> the canonical __ne__ delegation to __eq__ for any class should be implemented as something like

I disagree that your code snippet is the canonical way to write __ne__. I'd write it like this:

    def __ne__(self, other):
        return not (self == other)

Or just not write it at all. Python will automatically call __eq__ going back to Python 2.4 (and possibly older).

Delegating to __eq__ directly is the wrong way to do it.

> NotImplemented is a sentinel value that should never be evaluated in a boolean context

I don't see why not. I can't think of any use-cases where I would want to *specifically* use NotImplemented in a boolean context, but I see no good reason why I would want it to fail if one happened to do so.
msg333440 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2019-01-11 07:24
This is a common mistake. Even the default implementation of object.__ne__ had a bug, fixed only 4 years ago in issue21408. There were several errors in stdlib. I am sure there is a lot of occurrences of this errors in a third party code.

So I like this idea. This can help to fix hidden errors in existing code. But I share also Josh's concerns.

There is related common mistake. In C code, the result of PyObject_IsTrue() often is treated as just a boolean, without checking for errors. Fortunately, in the current CPython code it is handled properly, but I am sure this error still is occurred in third-party extensions.

When these two errors (using NotImplemented in the boolean context and ignoring the error in PyObject_IsTrue() in the C code) meet, this can lead to manifestation of more weird bugs than treating NotImplemented as true: from a crash in debug build to raising an exception in the following unrelated code.

I suggest to start emitting a DeprecationWarning or a FutureWarning in NotImplemented.__bool__.
msg333483 - (view) Author: Guido van Rossum (gvanrossum) * (Python committer) Date: 2019-01-11 16:07
I agree.

On Thu, Jan 10, 2019 at 11:24 PM Serhiy Storchaka <report@bugs.python.org>
wrote:

>
> Serhiy Storchaka <storchaka+cpython@gmail.com> added the comment:
>
> This is a common mistake. Even the default implementation of object.__ne__
> had a bug, fixed only 4 years ago in issue21408. There were several errors
> in stdlib. I am sure there is a lot of occurrences of this errors in a
> third party code.
>
> So I like this idea. This can help to fix hidden errors in existing code.
> But I share also Josh's concerns.
>
> There is related common mistake. In C code, the result of
> PyObject_IsTrue() often is treated as just a boolean, without checking for
> errors. Fortunately, in the current CPython code it is handled properly,
> but I am sure this error still is occurred in third-party extensions.
>
> When these two errors (using NotImplemented in the boolean context and
> ignoring the error in PyObject_IsTrue() in the C code) meet, this can lead
> to manifestation of more weird bugs than treating NotImplemented as true:
> from a crash in debug build to raising an exception in the following
> unrelated code.
>
> I suggest to start emitting a DeprecationWarning or a FutureWarning in
> NotImplemented.__bool__.
>
> ----------
> nosy: +gvanrossum, serhiy.storchaka
>
> _______________________________________
> Python tracker <report@bugs.python.org>
> <https://bugs.python.org/issue35712>
> _______________________________________
>
-- 
--Guido (mobile)
msg333508 - (view) Author: Terry J. Reedy (terry.reedy) * (Python committer) Date: 2019-01-11 21:49
I consider it a nice feature of Python that all builtin objects, and, AFAIK (and Josh, apparently), all stdlib class instances, have a boolean value.  (I am aware of numpy's element-wise behavior.)  I hate to give this up.  This is part of Python's general avoidance of singular exceptions and exceptions to exceptions.  This proposal would be the latter: "An object is truthy, unless its class makes it false, unless it is NotImplemented and a TypeError."

If this exception is made, I expect that there will be proposals to extend the exception to other objects, such as Ellipsis.
msg341909 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2019-05-08 16:44
I've submitted a PR for this. I did discover a use case for boolean evaluation while doing this in the functools.total_ordering helpers. While it's legitimate, it *looks* wrong (the code looks like it didn't consider the possibility of NotImplemented even though it did), and really only applies to the case total_ordering handles on behalf of most folks (implementing one comparator in terms of two others).

The specific case was:

def _le_from_lt(self, other, NotImplemented=NotImplemented):
     'Return a <= b.  Computed by @total_ordering from (a < b) or (a == b).'
     op_result = self.__lt__(other)
     return op_result or self == other

(with a similar implementation for _ge_from_gt). It happens to work because the return value of __lt__ is used directly for both True and NotImplemented cases, with only False delegating to equality. It's not at all clear that that's correct at first glance though, and the fix to avoid the warning is simple, matching the other 10 comparator helpers by explicit checking for NotImplemented via:

    if op_result is NotImplemented:
        return NotImplemented

That's about the strongest case I can find for "legitimate" use of NotImplemented in a boolean context, so I figured I'd point it out explicitly so people knew it existed.

If that makes an eventual TypeError a non-starter, I'd still like that usage to emit a warning (e.g. a RuntimeWarning) simply because the utility of that one little shortcut pales in comparison to the damage done by the *many* misuses that occur.
msg348529 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2019-07-26 21:34
Moving to 3.9 target, per Serhiy's request. PR has been rebased against master, including updating the What's New info to be in the 3.9 version, not 3.8.
msg349203 - (view) Author: Josh Rosenberg (josh.r) * (Python triager) Date: 2019-08-07 23:00
In the docs for my PR, I mention using NotImplemented in a boolean context is deprecated, raising DeprecationWarning, and will raise TypeError in a future version of Python. Serhiy has suggested the end state might be RuntimeWarning instead of TypeError. I'm in favor of it ending up as an error, but not rigid on it. Does anyone else have a strong opinion among the following options?

1. DeprecationWarning, documented as eventual TypeError (current PR)
2. DeprecationWarning, documented as eventual RuntimeWarning
3. Immediate RuntimeWarning, no DeprecationWarning period (since a warning only causes stderr output by default, it doesn't prevent working code from working unless warnings are explicitly configured to become exceptions)
msg349215 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2019-08-08 05:17
I do not have a strong opinion, so I suggest to not mention neither TypeError, nor RuntimeWarning. After some time of using a DeprecationWarning we can have have some information about whether there is a code which can't be fixed if bool(NotImplemented) will raise an error.
msg349303 - (view) Author: Vedran Čačić (veky) * Date: 2019-08-09 17:52
Another reason why current behavior is confusing: what do you think

    filter(2 .__eq__, 'text')

should yield? :-o

(Yes, I know this isn't the right way to do it, but

    (element for element in 'text' if element == 2)

is twice longer.:)
msg363293 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2020-03-03 18:50
New changeset 469325c30e147680543b2f5118b83fd95055a499 by MojoVampire in branch 'master':
bpo-35712: Make using NotImplemented in a boolean context issue a deprecation warning (GH-13195)
https://github.com/python/cpython/commit/469325c30e147680543b2f5118b83fd95055a499
msg363956 - (view) Author: Ethan Furman (ethan.furman) * (Python committer) Date: 2020-03-11 18:53
I know I'm late to the party, but if

  bool(NotImplemented)

returned `NotImplemented` wouldn't that solve the problem?

    def __ge__(self, other):
        return not self.__lt__(other)

then

   if __lt__ returns   then __gt__ returns

     NotImplemented      NotImplemented
     True                False
     False               True

Correct code (which checks for NotImplemented) would still work, and buggy code (which just returns the bool() of NotImplemented), would then be correct.
msg363961 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2020-03-11 19:50
No.

First, it is impossible. nb_bool and PyObject_IsTrue() can return only three value: 1 for true, 0 for false, and -1 for error. It is not possible to represent NotImplemented without breaking all extensions.

Currently

    if not a:
        b()
    else:
        c()

is equivalent to

    if a:
        c()
    else:
        b()

If a is NotImplemented, what branch be executed in every case?

Second, it would not help. Because real-world examples are not always so trivial as "return not self.__lt__(other)". It may be a part of more complex expression, e.g.:

    return super().__eq__(other) and self.attr == other.attr

So it may help in some examples, but make bugs in other examples even more mystical.
msg363969 - (view) Author: Ethan Furman (ethan.furman) * (Python committer) Date: 2020-03-11 20:49
Serhiy:
------
> First, it is impossible. nb_bool and PyObject_IsTrue() can return
> only three value: 1 for true, 0 for false, and -1 for error.

Huh.  How is -1 interpreted?  Does it become a TypeError?


> It is not possible to represent NotImplemented without breaking all
> extensions.
> 
> Currently
> 
>     if not a:
>         b()
>     else:
>         c()
> 
> is equivalent to
> 
>    if a:
>        c()
>     else:
>         b()
> 
> If a is NotImplemented, what branch be executed in every case?

Side-stepping to other __dunder__ methods for a moment: if, for example, both __add__ and __radd__ return NotImplemented then the interpreter will convert that into a TypeError.

So my thinking is that when the interpreter gets the `NotImplemented` returned by either `if a` or by `if not a` that it would be converted to a `TypeError`, meaning none of the branches would be executed as an exception would be raised instead.

> Second, it would not help. Because real-world examples are not always so
> trivial as "return not self.__lt__(other)". It may be a part of more
> complex expression, e.g.:
> 
>     return super().__eq__(other) and self.attr == other.attr

I don't see the problem -- breaking it down:

    return super().__eq__(other) and self.attr == other.attr

becomes

    return NotImplemented and ...

and the `and` machinery sees it has a `NotImplemented` and raises a `TypeError`.  The same would be true if the `__eq__` operation returned `NotImplemented`.

So to make that work, `and` and `or` would have to check if one of the operands is `NotImplemented`.  Are there others that would need to have that check?

The reason I would prefer this solution is that if it could behave as I described above, the `TypeError` would point at the user's line of code as being the problem, and not inside the __dunder__:

    class NoAdd:
        #
        def __add__(self, other):
            return NotImplemented
        #
        def __radd__(self, other):
            # fake a NotImplemented TypeError from bool(NotImplemented)
            raise TypeError("cannot boolean NotImplemented")

    # what we should see, since the error is in the users' code
    --> NoAdd() + 7
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    TypeError: unsupported operand type(s) for +: 'NoAdd' and 'int'

    # what we will see -- a leaky implementation detail
    --> 7 + NoAdd()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 6, in __radd__
    TypeError: cannot boolean NotImplemented
msg363973 - (view) Author: Serhiy Storchaka (serhiy.storchaka) * (Python committer) Date: 2020-03-11 21:41
> How is -1 interpreted?  Does it become a TypeError?

It is interpreted as error. It requires an exception be set. If you return -1 without setting an exception you will usually get a SystemError or crash. Oh, and this may happen during executing other code, so you will search a bug in wrong place.

You cannot change this without breaking the C and Python API in hard way.

> So my thinking is that when the interpreter gets the `NotImplemented` returned by either `if a` or by `if not a` that it would be converted to a `TypeError`

> and the `and` machinery sees it has a `NotImplemented` and raises a `TypeError`.

So you literally want NotImplemented raising a TypeError in boolean context except the "not" operator, and redefine `not a` from

    False if a else True

to

    NotImplemented if a is NotImplemented else False if a else True

You can do this, but this is a different issue, and I doubt that it will solve many problems.
msg364065 - (view) Author: Ethan Furman (ethan.furman) * (Python committer) Date: 2020-03-13 02:15
Hmm.  Okay, I'm happy with just raising a TypeError in NotImplemented.__bool__.
History
Date User Action Args
2020-03-13 02:15:50ethan.furmansetmessages: + msg364065
2020-03-11 21:41:19serhiy.storchakasetmessages: + msg363973
2020-03-11 20:49:03ethan.furmansetmessages: + msg363969
2020-03-11 19:50:30serhiy.storchakasetmessages: + msg363961
2020-03-11 18:53:44ethan.furmansetnosy: + ethan.furman
messages: + msg363956
2020-03-03 18:52:02serhiy.storchakasetstatus: open -> closed
resolution: fixed
stage: patch review -> resolved
2020-03-03 18:50:21serhiy.storchakasetmessages: + msg363293
2019-08-09 17:52:50vekysetnosy: + veky
messages: + msg349303
2019-08-08 05:17:54serhiy.storchakasetmessages: + msg349215
2019-08-07 23:00:18josh.rsetmessages: + msg349203
2019-07-26 21:34:07josh.rsetmessages: + msg348529
versions: + Python 3.9, - Python 3.8
2019-05-08 16:44:03josh.rsetmessages: + msg341909
2019-05-08 16:20:46josh.rsetkeywords: + patch
stage: test needed -> patch review
pull_requests: + pull_request13106
2019-01-11 21:49:58terry.reedysetversions: + Python 3.8
nosy: + terry.reedy

messages: + msg333508

type: behavior -> enhancement
stage: test needed
2019-01-11 16:07:42gvanrossumsetmessages: + msg333483
2019-01-11 07:24:07serhiy.storchakasetnosy: + gvanrossum, serhiy.storchaka
messages: + msg333440
2019-01-11 01:44:01steven.dapranosetnosy: + steven.daprano
messages: + msg333433
2019-01-10 22:54:18xtreaksetnosy: + xtreak
messages: + msg333426
2019-01-10 21:55:06josh.rcreate