Skip to content

Behavior change for foo and 1 or 2: 3.12 newly converts foo to bool twice #124285

Description

@frigus02

Bug report

Bug description:

We noticed a behavior change between 3.11 and 3.12. The following code calls Foo.__bool__ once in 3.11 and twice in 3.12. Consequently for this contrived example, the expression evaluates to different results in 3.11 and 3.12.

class Foo:
    def __init__(self):
        self._a = True
    def __bool__(self):
        self._a = not self._a
        print(f"Foo.__bool__ -> {self._a}")
        return self._a

Foo() and "a string" or 42

In Python 3.11:

>>> Foo() and "a string" or 42
Foo.__bool__ -> False
42

In Python 3.12 (and 3.13.0b2):

>>> Foo() and "a string" or 42
Foo.__bool__ -> False
Foo.__bool__ -> True
<__main__.Foo object at 0x7f0ae554c1a0>

Is this change intentional?

Note that I'm not necessarily asking to change this. We should arguably change the code to "a string" if Foo() else 42, which evaluates the same in 3.11 and 3.12.

CPython versions tested on:

3.12

Operating systems tested on:

Linux

Linked PRs

Activity

  1. ZeroIntensity commented on Sep 20, 2024

    @ZeroIntensity
    Member

    I haven't had a chance to look closer at this, but I will say that flipping the truthiness of something everytime it's used in __bool__ is quite odd. How is this being used in practice?

  2. frigus02 commented on Sep 20, 2024

    @frigus02
    Author

    We noticed this when upgrading pytype to support 3.12. Pytype evaluates the bytecode and reported that this expression may now now be Union[Foo, str, int]. See google/pytype#1777.

    After staring at the bytecode for a while we noticed this is really a runtime change and a specifically crafted __bool__ method could show that. But I don't know of any real example that does that.

    Apparently pyright also encodes that behavior somehow. But it does that independently of the Python version. pyright playground.

    Edit: I was honestly wondering if this is worth filing an issue about. But ultimately I thought there is no harm in reporting.

  3. Eclips4 commented on Sep 20, 2024

    @Eclips4
    Member

    Yeah, this code is quite odd but this is definitely regression, thanks for the report @frigus02. I'm bisecting the bad commit right know

  4. added
    3.12only security fixes
    3.13only security fixes
    3.14bugs and security fixes
    on Sep 20, 2024
  5. tomasr8 commented on Sep 20, 2024

    @tomasr8
    Member

    Confirmed to be happening on current main as well

  6. Eclips4 commented on Sep 20, 2024

    @Eclips4
    Member

    Bisected to 3468c76
    cc @iritkatriel

  7. skirpichev commented on Sep 20, 2024

    @skirpichev
    Member

    Documentation in 3.12 says: "The expression x and y first evaluates x; if x is false, its value is returned; otherwise, y is evaluated and the resulting value is returned.
    The expression x or y first evaluates x; if x is true, its value is returned; otherwise, y is evaluated and the resulting value is returned.
    Note that neither and nor or restrict the value and type they return to False and True, but rather return the last evaluated argument."

    That's exactly how it works in 3.12+:

    1. Compute Foo() and "a string":
    2. Foo() evaluates to False => returns Foo() object
    3. Compute Foo() or "42":
    4. Foo() evaluated to False => returns Foo() object
    # a.py
    class Foo:
        def __init__(self):
            self._a = True
        def __bool__(self):
            print(self)
            self._a = not self._a
            print(f"Foo.__bool__ -> {self._a}")
            return self._a
    print(Foo() and "a string" or 42)
    $ python3.12 a.py
    <__main__.Foo object at 0x7f1b514d6120>
    Foo.__bool__ -> False
    <__main__.Foo object at 0x7f1b514d6120>
    Foo.__bool__ -> True
    <__main__.Foo object at 0x7f1b514d6120>
    

    It looks, that before the result of evaluation Foo() in (2) was "cached". That's ok, assuming that bool(obj) would have no side effects. Which is not our case:

    >>> x = Foo()
    >>> bool(x)
    <a.Foo object at 0x7fb5d7bfbaa0>
    Foo.__bool__ -> False
    False
    >>> bool(x)
    <a.Foo object at 0x7fb5d7bfbaa0>
    Foo.__bool__ -> True
    True

    @Eclips4, thanks for debugging. But I don't think it's a bug. Does documentation says somewhere that __bool__() should have no side effects?

  8. Eclips4 commented on Sep 20, 2024

    @Eclips4
    Member

    It's too late to change something as big as that and say that __bool__ should have no side effects. In fact, it's essential in Python to expect side effects somewhere.. This is not making __bool__ an exclusive here. The fact that __bool__ is called twice (which actually is a reason of the following) is less scary than the fact that the same code has different results on different versions, because the commit which introduced it didn't intend that.

  9. skirpichev commented on Sep 20, 2024

    @skirpichev
    Member

    the commit which introduced it didn't intend that.

    Maybe (let's wait a reply from core dev;)), but IMHO it looks like it fixed a bug (which was mentioned by OP pre-3.12 behaviour).

    If we return rules as in 3.11 - we will have to adjust docs accordingly, because old behaviour doesn't match docs (in 3.11 - too). And yes, __bool__() - will be a special beast!

  10. ZeroIntensity commented on Sep 20, 2024

    @ZeroIntensity
    Member

    If we do decide to fix this, it's probably going to be too complicated to backport :(

  11. ethanfurman commented on Sep 20, 2024

    @ethanfurman
    Member

    It's a bug. Terms should only be evaluated once.

  12. 3 remaining items

  13. added 2 commits that reference this issue on Sep 23, 2024
  14. iritkatriel commented on Sep 25, 2024

    @iritkatriel
    Member

    I merged a fix, but I don't know about back porting it.

  15. ncoghlan commented on Sep 26, 2024

    @ncoghlan
    Contributor

    @JelleZijlstra asked on the 3.14 PR if the idea of doing this in the AST optimiser was worth considering further.

    Duplicating the c node in the generated AST really isn't desirable if we can get the same performance benefit later in the pipeline without introducing any duplication in the generated code.

    I mainly suggested the AST option in case it was hard to restore the optimisation at the opcode generation stage, which doesn't appear to be a relevant concern (since @iritkatriel already restored it).

  16. JelleZijlstra commented on Sep 26, 2024

    @JelleZijlstra
    Member

    @iritkatriel I think we shouldn't backport this, both because the fix is invasive (new opcodes), and because the behavior change feels too big for a bugfix release.

  17. ncoghlan commented on Sep 27, 2024

    @ncoghlan
    Contributor

    This skip coincidentally came up in a recent forum thread.

    The point was made that if compilers are allowed to make this assumption (and they are, since the reference interpreter did it for years, and only stopped due to a bug), then the __bool__ data model entry should mention that.

    Edit: given the bug fix is too invasive to reasonably backport, I think that means the remaining work here is choosing suitable docs wording for 3.12/3.13/3.14, deciding where it should live in the language reference, and then mentioning it in the docs for __bool__ (and maybe __len__?)

  18. added a commit that references this issue on Sep 28, 2024
  19. skirpichev commented on Sep 28, 2024

    @skirpichev
    Member

    Here is an attempt to document things: #124723

    PS: @Eclips4, probably we should clear 3.12-3.14 labels, as the fix is not going to be backported?

  20. removed
    3.12only security fixes
    3.13only security fixes
    on Sep 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.14bugs and security fixesdocsDocumentation in the Doc dirinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions