`x or x or x` versus `(x or x) or x`

As far as I can see, the or operator is documented to group left to right and isn’t documented to be chaining. So based on that, I would expect x or x or x to be equivalent to (x or x) or x. But it isn’t.

Test script (Attempt This Online!):

class X:
    def __bool__(self):
        print('aha!')
        return True

x = X()
x or x or x
print()
(x or x) or x

Output:

aha!

aha!
aha!

How come? Where is that behavior documented?

1 Like

I don’t get this result in a python notebook, I wonder if it’s something to do with the ATO implementation? Maybe it’s not short-circuiting?

What Python version did you use?

Ah, that was in 3.10 and it does happen in 3.12, interesting! To me the second behavior is more confusing because it should short-circuit inside the parentheses and then short-circuit the whole expression, so it only needs to evaluate __bool__ once.

Yes, ATO uses 3.12, too. And I also get this in a recent 3.13.

In any case, the extra calls to __bool__ don’t mean that the logical result isn’t equivalent. And it seems like it might be dependent on the code, e.g. True or 1/0 or 1/0 doesn’t raise anything, so it’s not evaluating the latter 2 expressions.

I can replicate this in 3.12 but not 3.11.

I wonder though, which one should be the correct behavior? Is this documented somewhere? To me it’s more intuitive that bool(x) is evaluated twice in both cases.

In this case the result is the same, but imagine x is an object that alternates between true and false every time it’s asked.

This is definitely a 3.12 change (and thanks for the motivation to build and install it!).

Python 3.12.2 (main, Mar 29 2024, 00:26:08) [GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import dis
>>> dis.dis('x or x or x')
  0           0 RESUME                   0

  1           2 LOAD_NAME                0 (x)
              4 COPY                     1
              6 POP_JUMP_IF_TRUE         8 (to 24)
              8 POP_TOP
             10 LOAD_NAME                0 (x)
             12 COPY                     1
             14 POP_JUMP_IF_TRUE         3 (to 22)
             16 POP_TOP
             18 LOAD_NAME                0 (x)
             20 RETURN_VALUE
        >>   22 RETURN_VALUE
        >>   24 RETURN_VALUE
>>> dis.dis('(x or x) or x')
  0           0 RESUME                   0

  1           2 LOAD_NAME                0 (x)
              4 COPY                     1
              6 POP_JUMP_IF_TRUE         2 (to 12)
              8 POP_TOP
             10 LOAD_NAME                0 (x)
        >>   12 COPY                     1
             14 POP_JUMP_IF_TRUE         3 (to 22)
             16 POP_TOP
             18 LOAD_NAME                0 (x)
             20 RETURN_VALUE
        >>   22 RETURN_VALUE
>>> 
Python 3.11.2 (main, Apr  5 2023, 03:08:14) [GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import dis
>>> dis.dis('x or x or x')
  0           0 RESUME                   0

  1           2 LOAD_NAME                0 (x)
              4 JUMP_IF_TRUE_OR_POP      4 (to 14)
              6 LOAD_NAME                0 (x)
              8 JUMP_IF_TRUE_OR_POP      3 (to 16)
             10 LOAD_NAME                0 (x)
             12 RETURN_VALUE
        >>   14 RETURN_VALUE
        >>   16 RETURN_VALUE
>>> dis.dis('(x or x) or x')
  0           0 RESUME                   0

  1           2 LOAD_NAME                0 (x)
              4 JUMP_IF_TRUE_OR_POP      4 (to 14)
              6 LOAD_NAME                0 (x)
              8 JUMP_IF_TRUE_OR_POP      3 (to 16)
             10 LOAD_NAME                0 (x)
             12 RETURN_VALUE
        >>   14 RETURN_VALUE
        >>   16 RETURN_VALUE
>>> 
3 Likes

For example:

from itertools import cycle

class WasAskedForTruthValueAnEvenNumberOfTimes:
    def __init__(self):
        self.truth = cycle((True, False))
    def __bool__(self):
        return next(self.truth)
    def __str__(self):
        return 'WasAskedForTruthValueAnEvenNumberOfTimes'

x = WasAskedForTruthValueAnEvenNumberOfTimes()
print(x or 2 or 3)

x = WasAskedForTruthValueAnEvenNumberOfTimes()
print((x or 2) or 3)

Output (Attempt This Online!):

WasAskedForTruthValueAnEvenNumberOfTimes
3

Broken objects are not too be taken into account when judging whether semantics are ok. Both behaviours are perfectly fine. You could argue that the evalute only one behavior is faster, so you might want to make a report about that.

Why do you call it “broken”? I see no justification for that, and I don’t think it is. And I think even for example a list could change its truth value between the two evaluations, if another thread clears it in between.

It’s broken because what is supposed to be a side effect free operation isn’t side effect free. Python makes assumptions about the behaviour of a few magic methods. This is one of them.

If an object is changed from a different thread, that’s a race condition then. That is always a danger in threaded code. Use locks.

If (x or x) were assigned to a variable, you wouldn’t expect __bool__ to be only called once, would you?

1 Like

Where does the documentation say that?

You mean var = (x or x)? I certainly would expect that to call __bool__ only once.

But when use it in if,it is normal:

class X:
    def __bool__(self):
        print('aha!')
        return True

x = X()
if x or x or x:
    pass
print()
if (x or x) or x:
    pass

Output(Attempt This Online!):

aha!

aha!
1 Like

Not sure if this is explicitly mentioned somewhere. I would describe this as “common sense”. Similar to how reading an attribute shouldn’t have a side effect. Or calling __str__. Ofcourse, you can break these rules, but don’t be surprised if you get weird behavior.

1 Like

Interesting. Though based on the documentation, I wouldn’t call that normal and would instead expect both cases to print twice.

(Btw I keep saying “based on the documentation” because from experience I expected x or x or x to print only once. Treating it as a “chain” and only evaluating the expressions and the truth values as far as needed. But I realized I don’t know the technical why, checked the docs, and now to me it looks like it should be equivalent to (x or x) or x and that that should check the truth value twice.)

1 Like

There is a paragraph in the document:

Note that neither and nor or restrict the value and type they return to False and True, but rather return the last evaluated argument. This is sometimes useful, e.g., if s is a string that should be replaced by a default value if it is empty, the expression s or ‘foo’ yields the desired value. Because not has to create a new value, it returns a boolean value regardless of the type of its argument (for example, not ‘foo’ produces False rather than ‘’.)

And there are also some things about (),I think when we use (x or x) in expressions, does it first generate an x.

class X:
    def __init__(self, val):
        self.val = val
        self.truth = 1

    def __bool__(self):
        print('val:', self.val)
        return bool(self.truth)


x = X(0)
x.truth = 0
y = X(1)
z = X(2)
print((x or y or x or z).val)
print()
print(((x or y or x) or z).val)
print()
if x or y or x or z:
    pass
print()
if (x or y or x) or z:
    pass
print()

Output(Attempt This Online!):

val: 0
val: 1
1

val: 0
val: 1
val: 1
1

val: 0
val: 1

val: 0
val: 1

So, the current performance may be more reasonable.

1 Like

So, the brackets in (x or y) or z trigger an extra boolean evaluation in CPython 3.12. Is that the case? But this only seems to happen if those variables are instances of classes that define the __bool__ dunder. For builtin types it doesn’t seem to happen, for instance:

3.11

>>> dis.dis('(1 or 2) or 3')
  0           0 RESUME                   0

  1           2 LOAD_CONST               0 (1)
              4 JUMP_IF_TRUE_OR_POP      2 (to 10)
              6 LOAD_CONST               2 (3)
              8 RETURN_VALUE
        >>   10 RETURN_VALUE

3.12

>>> dis.dis('(1 or 2) or 3')
  0           0 RESUME                   0

  1           2 LOAD_CONST               0 (1)
              4 COPY                     1
              6 POP_JUMP_IF_TRUE         3 (to 14)
              8 POP_TOP
             10 LOAD_CONST               1 (3)
             12 RETURN_VALUE
        >>   14 RETURN_VALUE

Or am I reading this wrong? (I don’t understand why there is an extra COPY and POP_TOP in the 3.12 code.)

I didn’t see any comments on this in the “What’s new in Python 3.12” either. Are the changes in Using new 'bool' format character · Issue #60203 · python/cpython · GitHub somehow related to this?