Revisiting PEP 505 – None-aware operators

Yes, it’s clearly spelled out in Boolean Operations — and, or, not:

Operation Result Notes
x or y if x is true, then x, else y (1)
x and y if x is false, then x, else y (2)
  1. This is a short-circuit operator, so it only evaluates the second argument if the first one is false.
  2. This is a short-circuit operator, so it only evaluates the second argument if the first one is true.

This doesn’t pin it down without saying whether and are left or right associative. I can’t find that specified anywhere.

1 Like

Operator associativity is documented together with operator precedence:

Operators in the same box group left to right (except for exponentiation and conditional expressions, which group from right to left).

Assuming “group left to right” means “left associative”, that would mean
that

a and b and c

would be grouped as

(a and b) and c

which is not correct – to get the required short-circuiting behaviour it
would need to be

a and (b and c)
1 Like

a and b and c being grouped as (a and b) and c is correct.

When a is falsey, a and b is evaluated to a first without evaluating b, and then a and c is evaluated to a without evaluating c.

1 Like

I’m aware of that. That is not the behavior in question. That is describing the evaluation of a single boolean operator. The question is whether the short-circuiting applies to multiple boolean operators, or whether each separate boolean operator is evaluated separately.

As Guido mentioned, the difference is in how many times __bool__ is evaluated. On my reading of the docs the expression a and b and c should evaluate bool(a) twice. The first a and b evaluates bool(a). It is false. The expression then reduces to a and c, which should again evaluate bool(a). This is just like how when you do a + b + c, even if a + b is still just a, a.__add__ is still called twice. If it doesn’t do that, then there is a lookahead going on where the behavior of a chain of boolean operators is not a simple composition of the behavior of its constituent boolean operators.

The behavior is not unreasonable, given that only in pathological cases would bool(a) change in the middle of evaluating an expression. All I’m saying is, if that’s the behavior, it should be documented as such. This double-short-circuit can’t be derived just from the knowledge that a single and short-circuits.

This is all a tangent from the main thread, but it doesn’t change my opinion that the ?. operator should not do this kind of double-short-circuit and skip the entire rest of the expression; my position is that the behavior of an expression involving ?. (if we want it at all) should only depend on the direct operands of the ?..

1 Like

That’s what I thought at first, too. But as Guido (somewhat cryptically) pointed out,would call the method of twice, whereasonly calls it once. The latter is what the bytecode actually implements.

1 Like

(Reposting this because I replied to the wrong thread.)

The docs also don’t describe the behaviour when explicit grouping is used, which turns out to be a bit weird. The following two functions produce identical bytecode:

def f1(): return (a and b) and c

def f2(): return a and (b and c)

Both of these are compiled as:

  1           0 LOAD_GLOBAL              0 (a)
              2 JUMP_IF_FALSE_OR_POP    10
              4 LOAD_GLOBAL              1 (b)
              6 JUMP_IF_FALSE_OR_POP    10
              8 LOAD_GLOBAL              2 (c)
        >>   10 RETURN_VALUE

This is despite the fact that the ASTs are different:

>>> ast.dump(ast.parse("(a and b) and c"))
"Module(body=[Expr(value=BoolOp(op=And(), values=[BoolOp(op=And(), values=[Name(id='a', ctx=Load()), Name(id='b', ctx=Load())]), Name(id='c', ctx=Load())]))], type_ignores=[])"
>>> ast.dump(ast.parse("a and (b and c)"))
"Module(body=[Expr(value=BoolOp(op=And(), values=[Name(id='a', ctx=Load()), BoolOp(op=And(), values=[Name(id='b', ctx=Load()), Name(id='c', ctx=Load())])]))], type_ignores=[])"

So the and operator (and presumably or too) actually has the same kind of parentheses-escaping behaviour that we’re arguing against with ?.. It’s just that nobody has noticed before, probably because it’s rare for a bool method to have a side effect.

1 Like

Ah I see what you mean now. Indeed the compiler optimization does break the semantics Guido mentioned, that and should theoretically be strictly a binary operator, when in fact the compiler looks ahead and makes a chain of ands effectively a multi-operand operator that skips calls to __bool__ when convenient.

This however can be viewed as a precedent for ?.+.s to be implemented as a multi-operand operator just as well, which I find reasonable.

1 Like

I just experimented a bit with a newer and older Python version.

In Python 3.13, a and b and c is evaluated as a and (b and c). Moreover, writing (a and b) and c calls a.__bool__() twice (if a is truthy), so you can tell that this is the case (in fact that’s how I concluded this).

I also experimented with Python 3.8, and there a.__bool__() is only called once regardless of where you put parentheses. So apparently the behavior used to be that X and Y and Z evaluates the sub-expressions one at a time until a falsy value is found and then it stops the entire chain; and somehow (X and Y) and Z ended up only evaluating X’s truthiness once (perhaps through clever jumps?).

But it looks like this was changed (my suspicion is in 3.11 but I’m too lazy to bisect). The grammar in both cases treats A and A and A as the conjunction of a list of As, but the bytecode generation or optimization must be doing something different. (Again I haven’t inspected any of it.)

But I don’t see this specified in the language reference – it doesn’t seem to say anything about chains of multiple ands other than (falsely) claiming that and is left-associative.

I’ve got to say that I like the 3.8 behavior better, so perhaps we need to file two bugs:
(1) This isn’t specified; the spec should probably follow what CPython 3.8 used to do.
(2) Newer versions evaluate a.__bool__() twice in (a and b) and c, violating the (ideal) spec.

It’s quite off-topic though for PEP 505. :slight_smile:

3 Likes

It’s fixed in 3.14 though:

class Bool:
    def __init__(self, name, value):
        self.name = name
        self.value = bool(value)
    def __bool__(self):
        print(self.name)
        return self.value

(Bool('a', 0) and Bool('b', 1)) and Bool('c', 1)

which outputs one a in 3.14 but two as in 3.13.

4 Likes

On which Python version did you try this? In 3.13 they produce different bytecode!

>>> dis.dis(f1)
  1           RESUME                   0
              LOAD_GLOBAL              0 (a)
              COPY                     1
              TO_BOOL
              POP_JUMP_IF_FALSE        6 (to L1)
              POP_TOP
              LOAD_GLOBAL              2 (b)
      L1:     COPY                     1
              TO_BOOL
              POP_JUMP_IF_FALSE        6 (to L2)
              POP_TOP
              LOAD_GLOBAL              4 (c)
      L2:     RETURN_VALUE
>>> dis.dis(f2)
  1           RESUME                   0
              LOAD_GLOBAL              0 (a)
              COPY                     1
              TO_BOOL
              POP_JUMP_IF_FALSE       19 (to L1)
              POP_TOP
              LOAD_GLOBAL              2 (b)
              COPY                     1
              TO_BOOL
              POP_JUMP_IF_FALSE        6 (to L1)
              POP_TOP
              LOAD_GLOBAL              4 (c)
      L1:     RETURN_VALUE
>>> 

A version without parentheses produces the same bytecode as f2.

Oh, thanks for checking!

If the 3.8 behavior of evaluating a.__bool__ only once is the ideal spec then (to my surprise) treating a chain of ands as a multi-operand operator is done so by an undocumented design.

And what’s even more surprising is that even explicitly grouping (a and b) and c can’t force a.__bool__ to be invoked twice, even though it makes sense purely from an optimization perspective.

This again does make making ?.+.s a multi-operand short-circuiting operator more plausible.

It is a bit hard to follow but…

Do we all agree that
a?.b.c should behave like a and a.b.c,
(a?.b).c should behave like (a and a.b).c
(with None-awareness) ?

6 Likes

Yes, that’s a good summary of the behavior I’m hoping for.

2 Likes

It was 3.8.2. Seems it changed between there and 3.13 and then changed
back again in 3.14.

I’m suspecting these changes were side effects of changes being made for
other reasons rather than deliberate choices about hte short-circutiing
semantics.

Another data point is that (at least in 3.8) a and b and c without parens gets parsed as a single and operator with three operands:

>>> ast.dump(ast.parse("a and b and c"))
"Module(body=[Expr(value=BoolOp(op=And(), values=[Name(id='a', ctx=Load()), Name(id='b', ctx=Load()), Name(id='c', ctx=Load())]))], type_ignores=[])"

This is analogous to what Guido suggested for parsing attribute access chains.

2 Likes

Yes, that’s my position.

1 Like

Very interesting. So this isn’t just a compiler optimization, but a deliberate choice of design at the parser level (even though the grammar does not say so).

By the way both 3.13 and 3.14 output the same as above.