Change PEP 532 to propose a new operator

- if-else, and, or are now left alone
- else is introduced as a new short-circuiting binary operator
- "circuit breaking" is introduced as the operator and protocol name
- exists() builtin checks for existence
- missing() builtin checks for non-existence
- "not exists" gives a missing instance and vice-versa
- chained comparison is adjusted to use the new protocol when available
This commit is contained in:
Nick Coghlan 2016-11-03 15:13:00 +10:00
parent 38371b4ece
commit 8f095cf8c0
1 changed files with 324 additions and 305 deletions

View File

@ -1,5 +1,5 @@
PEP: 532
Title: Defining a conditional result management protocol
Title: Defining "else" as a protocol based circuit breaking operator
Version: $Revision$
Last-Modified: $Date$
Author: Nick Coghlan <ncoghlan@gmail.com>
@ -13,60 +13,60 @@ Abstract
========
Inspired by PEP 335, PEP 505, PEP 531, and the related discussions, this PEP
proposes the addition of a new conditional result management protocol to Python
that allows objects to customise the behaviour of the following expressions:
proposes the addition of a new protocol-driven circuit breaking operator to
Python that allows the left operand to decide whether or not the expression
should short circuit and return a result immediately, or else continue
on with evaluation of the right operand::
* ``if-else`` conditional expressions
* the ``and`` logical conjunction operator
* the ``or`` logical disjunction operator
* chained comparisons (which implicitly invoke ``and``)
* the ``not`` logical negation operator
exists(foo) else bar
missing(foo) else foo.bar()
Each of these expressions is ultimately a variant on the underlying pattern::
These two expressions can be read as:
THEN_RESULT if CONDITION else ELSE_RESULT
* "the expression result is 'foo' if it exists, otherwise it is 'bar'"
* "the expression result is 'foo' if it is missing, otherwise it is 'foo.bar()'"
Currently, the ``CONDITION`` expression can control *which* branch is taken
(based on whether it evaluates to ``True`` or ``False`` in a boolean context),
but it can't influence the *result* of taking that branch.
Execution of these expressions relies on a new circuit breaking protocol that
implicitly avoids repeated evaluation of the left operand while letting
that operand fully control the result of the expression, regardless of whether
it skips evaluation of the right operand or not::
This PEP proposes the addition of two new "conditional result management"
protocol methods that allow conditional result managers to influence the
results of each branch directly:
_lhs = LHS
type(_lhs).__then__(_lhs) if _lhs else type(_lhs).__else__(_lhs, RHS)
* ``__then__(self, result)``, to alter the result when the condition is ``True``
* ``__else__(self, result)``, to alter the result when the condition is ``False``
While there are some practical complexities arising from the current handling
of single-valued arrays in NumPy, this should be sufficient to allow elementwise
chained comparison operations for matrices, where the result is a matrix of
boolean values, rather than tautologically returning ``True`` or raising
``ValueError``.
To properly support logical negation of conditional result managers, a new
``__not__`` protocol methro would also be introduced allowing objects to control
To properly support logical negation of circuit breakers, a new ``__not__``
protocol method would also be introduced allowing objects to control
the result of ``not obj`` expressions.
The PEP further proposes the addition of new ``exists`` and ``missing`` builtins
that allow conditional branching based on whether or not an object is ``None``,
but return the original object rather than the existence checking wrapper as
the result of any conditional expressions. In addition to being usable as
a simple boolean operator (e.g. as in ``assert all(exists, items)``), this
allows existence checking fallback operations (aka null-coalescing operations)
to be written as::
As shown in the basic example above, the PEP further proposes the addition of
builtin ``exists`` and ``missing`` circuit breakers that provide conditional
branching based on whether or not an object is ``None``, but return the
original object rather than the existence checking wrapper when the expression
evaluation short circuits.
value = exists(expr1) or exists(expr2) or expr3
In addition to being usable as simple boolean operators (e.g. as in
``assert all(exists, items)`` or ``if any(missing, items):``), these circuit
breakers will allow existence checking fallback operations (aka None-coalescing
operations) to be written as::
and existence checking precondition operations (aka null-propagating
or null-severing operations) can be written as either::
value = exists(expr1) else exists(expr2) else expr3
value = exists(obj) and obj.field.of.interest
value = exists(obj) and obj["field"]["of"]["interest"]
and existence checking precondition operations (aka None-propagating
or None-severing operations) to be written as::
or::
value = missing(obj) else obj.field.of.interest
value = missing(obj) else obj["field"]["of"]["interest"]
value = missing(obj) or obj.field.of.interest
value = missing(obj) or obj["field"]["of"]["interest"]
A change to the definition of chained comparisons is also proposed, where
the comparison chaining will be updated to use the circuit breaking operator
rather than the logical disjunction (``and``) operator if the left hand
comparison returns a circuit breaker as its result.
While there are some practical complexities arising from the current handling
of single-valued arrays in NumPy, this change should be sufficient to allow
elementwise chained comparison operations for matrices, where the result
is a matrix of boolean values, rather than tautologically returning ``True``
or raising ``ValueError``.
Relationship with other PEPs
@ -74,127 +74,116 @@ Relationship with other PEPs
This PEP is a direct successor to PEP 531, replacing the existence checking
protocol and the new ``?then`` and ``?else`` syntactic operators defined there
with the ability to customise the behaviour of the established ``not``,
``and`` and ``or`` operators. The existence checking use case is taken from
that PEP.
with a single protocol driven ``else`` operator and adjustments to the ``not``
operator. The existence checking use cases are taken from that PEP.
It is also a direct successor to PEP 335, which proposed the ability to
overload the ``and`` and ``or`` operators directly, rather than indirectly
via interpretation as variants of the more general ``if-else`` conditional
expressions. The discussion of the element-wise comparison use case is
drawn from Guido's rejection of that PEP.
overload the ``and`` and ``or`` operators directly, with the ability to
overload the semantics of comparison chaining being one of the consequences
of that change. The proposal in this PEP to instead handle the element-wise
comparison use case by changing the semantic definition of comparison chaining
is drawn from Guido's rejection of PEP 335.
This PEP competes with a number of aspects of PEP 505, proposing that improved
support for null-coalescing operations be offered through a new protocol and
new builtin, rather than through new syntax. It doesn't compete specifically
with the proposed shorthands for existence checking attribute access and
subscripting, but instead offers an alternative underlying semantic framework
for defining them:
This PEP competes with the dedicated null-coalescing operator in PEP 505,
proposing that improved support for null-coalescing operations be offered
through a more general protocol-driven short circuiting operator and related
builtins, rather than through a dedicated single-purpose operator.
* ``LHS ?? RHS`` would mean ``exists(LHS) or RHS``
* ``EXPR?.attr`` would mean ``missing(EXPR) or EXPR.attr``
* ``EXPR?[key]`` would mean ``missing(EXPR) or EXPR[key]``
It doesn't compete with PEP 505's proposed shorthands for existence checking
attribute access and subscripting, but instead offers an alternative underlying
semantic framework for defining them:
* ``EXPR?.attr`` would be syntactic sugar for ``missing(EXPR) else EXPR.attr``
* ``EXPR?[key]`` would be syntactic sugar for ``missing(EXPR) else EXPR[key]``
In both cases, the dedicated syntactic form could be optimised to avoid
actually creating the circuit breaker instance.
Specification
=============
Conditional expressions (``if-else``)
-------------------------------------
The circuit breaking operator (``else``)
----------------------------------------
The conditional expression ``THEN_RESULT if CONDITION else ELSE_RESULT`` is
currently approximately equivalent to the following code::
Circuit breaking expressions would be written using ``else`` as a new binary
operator, akin to the existing ``and`` and ``or`` logical operators::
if CONDITION:
_expr_result = THEN_RESULT
else:
_expr_result = ELSE_RESULT
LHS else RHS
The new protocol proposed in this PEP would change that to::
Ignoring the hidden variable assignment, this is semantically equivalent to::
_condition = CONDITION
_condition_type = type(CONDITION)
if _condition:
_then_result = THEN_RESULT
if hasattr(_condition_type, "__then__"):
_then_result = _condition_type.__then__(_condition, _then_result)
_expr_result = _then_result
else:
_else_result = ELSE_RESULT
if hasattr(_condition_type, "__else__"):
_else_result = _condition_type.__else__(_condition, _else_result)
_expr_result = _else_result
_lhs = LHS
type(_lhs).__then__(_lhs) if _lhs else type(_lhs).__else__(_lhs, RHS)
The key change is that the value determining which branch of the conditional
expression gets executed *also* gets a chance to postprocess the results of
the expressions on each of the branches.
The key difference relative to the existing ``or`` operator is that the value
determining which branch of the conditional expression gets executed *also*
gets a chance to postprocess the results of the expressions on each of the
branches.
Interpreter implementations may check eagerly for the new protocol methods
on condition objects in order to retain an optimised fast path for the great
many objects that support use in a boolean context, but don't implement the new
protocol.
As part of the short-circuiting behaviour, interpreter implementations
are expected to access only the protocol method needed for the branch
that is actually executed, but it is still recommended that circuit
breaker authors that always return ``True`` or always return ``False`` from
``__bool__`` explicitly raise ``NotImplementedError`` with a suitable
message from branch methods that are never expected to be executed (see the
comparison chaining use case in the Rationale section below for an example
of that).
It is proposed that the ``else`` operator use the same precedence level as
the existing ``and`` and ``or`` operators.
The precedence/associativity in the otherwise ambiguous case of
``expr1 if cond else expr2 else epxr3`` is TBD based on ease of implementation
(a guideline will be added to PEP 8 to say "don't do that", as this construct
will be inherently confusing for readers, regardless of how the interpreter
executes it).
Logical conjunction (``and``)
-----------------------------
Overloading logical inversion (``not``)
---------------------------------------
Logical conjunction is affected by this proposal as if::
Any circuit breaker definition will have a logical inverse that is still a
circuit breaker, but inverts the answer as to whether or not to short circuit
the expression evaluation. For example, the ``exists`` and ``missing`` circuit
breakers proposed in this PEP are each other's logical inverse.
LHS and RHS
A new protocol method, ``__not__(self)``, will be introduced to permit circuit
breakers and other types to override ``not`` expressions to return their
logical inverse rather than a coerced boolean result.
was internally implemented by the interpreter as::
To preserve the semantics of existing language optimisations, ``__not__``
implementations will be obliged to respect the following invariant::
_lhs_result = LHS
_expr_result = RHS if _lhs_result else _lhs_result
Conditional result managers can force non-shortcircuiting evaluation under
logical conjunction by always returning ``True`` from ``__bool__`` and
enforce this at runtime by raising ``NotImplementedError`` by raising
``NotImplementedError`` in ``__else__``.
Alternatively, conditional result managers can detect short-circuited evaluation
of logical conjunction in ``__else__`` implementations by looking for cases
where ``self`` and ``result`` are the exact same object.
Logical disjunction (``or``)
-----------------------------
Logical disjunction is affected by this proposal as if::
LHS or RHS
was internally implemented by the interpreter as::
_lhs_result = LHS
_expr_result = _lhs_result if _lhs_result else RHS
Conditional result managers can force non-shortcircuiting evaluation under
logical disjunction by always returning ``False`` from ``__bool__`` and
enforce this at runtime by raising ``NotImplementedError`` by raising
``NotImplementedError`` in ``__then__``.
Alternatively, conditional result managers can detect short-circuited evaluation
of logical disjunction in ``__then__`` implementations by looking for cases
where ``self`` and ``result`` are the exact same object.
assert not bool(obj) == bool(not obj)
Chained comparisons
-------------------
Chained comparisons are affected by this proposal as if::
A chained comparison like ``0 < x < 10`` written as::
LEFT_BOUND left_op EXPR right_op RIGHT_BOUND
was internally implemented by the interpreter as::
is currently roughly semantically equivalent to::
_expr = EXPR
_lhs_result = LEFT_BOUND left_op EXPR
_expr_result = _lhs_result if _lhs_result else (_expr right_op RIGHT_BOUND)
_lhs_result = LEFT_BOUND left_op _expr
_expr_result = _lhs_result and (_expr right_op RIGHT_BOUND)
As with any logical conjunction, conditional result managers returned by
comparison operations can force non-shortcircuiting evaluating in these
cases by always returning ``True`` from ``__bool__``.
This PEP proposes that this be changed to explicitly check if the left
comparison returns a circuit breaker, and if so, use ``else`` rather than
``and`` to implement the comparison chaining::
_expr = EXPR
_lhs_result = LEFT_BOUND left_op _expr
if hasattr(type(_lhs_result), "__then__"):
_expr_result = _lhs_result else (_expr right_op RIGHT_BOUND)
else:
_expr_result = _lhs_result and (_expr right_op RIGHT_BOUND)
This allows types like NumPy arrays to control the behaviour of chained
comparisons by returning circuit breakers from comparison operations.
Existence checking comparisons
@ -208,76 +197,167 @@ known as "None-severing" or "None-propagating").
These builtins would be defined as follows::
class exists:
"""Conditional result manager for 'EXPR is not None' checks"""
def __init__(self, value):
class CircuitBreaker:
"""Base circuit breaker type (available as types.CircuitBreaker)"""
def __init__(self, value, condition, inverse_type):
self.value = value
def __not__(self):
return missing(self.value)
self._condition = condition
self._inverse_type = inverse_type
def __bool__(self):
return self.value is not None
def __then__(self, result):
if result is self:
return result.value
return result
def __else__(self, result):
if result is self:
return result.value
return result
return self._condition
def __not__(self):
return self._inverse_type(self.value)
def __then__(self):
return self.value
def __else__(self, other):
if other is self:
return self.value
return other
class missing:
"""Conditional result manager for 'EXPR is None' checks"""
class exists(types.CircuitBreaker):
"""Circuit breaker for 'EXPR is not None' checks"""
def __init__(self, value):
self.value = value
def __not__(self):
return exists(self.value)
def __bool__(self):
return self.value is None
def __then__(self, result):
if result is self:
return result.value
return result
def __else__(self, result):
if result is self:
return result.value
return result
super().__init__(value, value is not None, missing)
class missing(types.CircuitBreaker):
"""Circuit breaker for 'EXPR is None' checks"""
def __init__(self, value):
super().__init__(value, value is None, exists)
Aside from changing the definition of ``__bool__`` to be based on
``is not None`` rather than normal truth checking, the key characteristic of
``exists`` is that when it is used as a conditional result manager, it is
*ephemeral*: when it detects that short circuiting has taken place, it returns
the original value, rather than the existence checking wrapper.
``exists`` is that when it is used as a circuit breaker, it is *ephemeral*:
when it is told that short circuiting has taken place, it returns the original
value, rather than the existence checking wrapper.
``missing`` is defined as the logically inverted counterpart of ``exists``:
``not exists(obj)`` is semantically equivalent to ``missing(obj)``.
The ``__else__`` implementations for both builtin circuit breakers are defined
such that the wrapper will always be removed even if you explicitly pass the
circuit breaker to both sides of the ``else`` expression::
breaker = exists(foo)
assert (breaker else breaker) is foo
breaker = missing(foo)
assert (breaker else breaker) is foo
Other conditional constructs
----------------------------
No changes are proposed to if statements, while statements, comprehensions, or
generator expressions, as the boolean clauses they contain are purely used for
control flow purposes and don't have programmatically accessible "results".
No changes are proposed to if statements, while statements, conditional
expressions, comprehensions, or generator expressions, as the boolean clauses
they contain are already used for control flow purposes.
However, it's worth noting that while such proposals are outside the scope of
this PEP, the conditional result management protocol defined here would be
sufficient to support constructs like::
this PEP, the circuit breaking protocol defined here would be sufficient to
support constructs like::
while exists(dynamic_query()) as result:
... # Code using result
and:
if exists(re.search(pattern, text)) as match:
... # Code using match
Leaving the door open to such a future extension is the main reason for
recommending that circuit breaker implementations handle the ``self is other``
case in ``__else__`` implementations the same way as they handle the
short-circuiting behaviour in ``__then__``.
Style guide recommendations
---------------------------
The following additions to PEP 8 are proposed in relation to the new features
introduced by this PEP:
* In the absence of other considerations, prefer the use of the builtin
circuit breakers ``exists`` and ``missing`` over the corresponding
conditional expressions
* Do not combine conditional expressions (``if-else``) and circuit breaking
expressions (the ``else`` operator) in a single expression - use one or the
other depending on the situation, but not both.
Rationale
=========
Avoiding new syntax
-------------------
Adding a new operator
---------------------
Adding new syntax to Python to make particular software design problems easier
to handle is considered a solution of last resort. As a successor to PEP 335,
this PEP focuses on making the existing ``and`` and ``or`` operators less rigid
in their interpretation, rather than on proposing new operators.
Similar to PEP 335, early drafts of this PEP focused on making the existing
``and`` and ``or`` operators less rigid in their interpretation, rather than
proposing new operators. However, this proved to be problematic for a few
reasons:
* defining a shared protocol for both ``and`` and ``or`` was confusing, as
``__then__`` was the short-circuiting outcome for ``or``, while ``__else__``
was the short-circuiting outcome for ``__and__``
* the ``and`` and ``or`` operators have a long established and stable meaning,
so readers would inevitably be surprised if their meaning now became
dependent on the type of the left operand. Even new users would be confused
by this change due to 25+ years of teaching material that assumes the
current well-known semantics for these operators
* Python interpreter implementations, including CPython, have taken advantage
of the existing semantics of ``and`` and ``or`` when defining runtime and
compile time optimisations, which would all need to be reviewed and
potentially discarded if the semantics of those operations changed
Proposing a single new operator instead resolves all of those issues -
``__then__`` always indicates short circuiting, ``__else__`` only indicates
"short circuiting" if the circuit breaker itself is also passed in as the
right operand, and the semantics of ``and`` and ``or`` remain entirely
unchanged. While the semantics of the unary ``not`` operator do change, the
invariant required of ``__not__`` implementations means that existing
expression optimisations in boolean contexts will remain valid.
As a result of that design simplification, the new protocol and operator would
even allow us to expose ``operator.logical_and`` and ``operator.logical_or``
as circuit breaker definitions if we chose to do so::
class logical_or(types.CircuitBreaker):
"""Circuit breaker for 'bool(EXPR)' checks"""
def __init__(self, value):
super().__init__(value, bool(value), logical_and)
class logical_and(types.CircuitBreaker):
"""Circuit breaker for 'not bool(EXPR)' checks"""
def __init__(self, value):
super().__init__(value, not bool(value), logical_or)
Naming the operator and protocol
--------------------------------
The names "circuit breaking operator", "circuit breaking protocol" and
"circuit breaker" are all inspired by the phrase "short circuiting operator":
the general language design term for operators that only conditionally
evaluate their right operand.
The electrical analogy is that circuit breakers in Python detect and handle
short circuits in expressions before they trigger any exceptions similar to the
way that circuit breakers detect and handle short circuits in electrical
systems before they damage any equipment or harm any humans.
The Python level analogy is that just as a ``break`` statement lets you
terminate a loop before it reaches its natural conclusion, a circuit breaking
expression lets you terminate evaluation of the expression and produce a result
immediately.
Using an existing keyword
-------------------------
Using an existing keyword has the benefit of allowing the new expression to
be introduced without a ``__future__`` statement.
``else`` is semantically appropriate for the proposed new protocol, and the
only syntactic ambiguity introduced arises when the new operator is combined
with the explicit ``if-else`` conditional expression syntax.
Element-wise chained comparisons
@ -344,21 +424,23 @@ result::
The proposal in this PEP would allow this situation to be changed by updating
the definition of element-wise comparison operations in NumPy to return a
dedicated subclass that both implements the new protocol methods and also
dedicated subclass that implements the new circuit breaking protocol and also
changes the result array's interpretation in a boolean context to always
return true and hence avoid Python's default short-circuiting behaviour::
return ``False`` and hence never trigger the short-circuiting behaviour::
class ComparisonResultArray(np.ndarray):
def __bool__(self):
return True
def __then__(self, result):
if result is self:
msg = ("Comparison array truth values are ambiguous outside "
"chained comparisons. Use a.any() or a.all()")
raise ValueError(msg)
return np.logical_and(self, result.view(ComparisonResultArray))
def __else__(self, result):
raise NotImplementedError("Comparison result arrays are never False")
return False
def _raise_NotImplementedError(self):
msg = ("Comparison array truth values are ambiguous outside "
"chained comparisons. Use a.any() or a.all()")
raise NotImplementedError(msg)
def __not__(self):
self._raise_NotImplementedError()
def __then__(self):
self._raise_NotImplementedError()
def __else__(self, other):
return np.logical_and(self, other.view(ComparisonResultArray))
With this change, the chained comparison example above would be able to return::
@ -395,115 +477,52 @@ potentially missing content is the norm rather than the exception.
The combined impact of the proposals in this PEP is to allow the above sample
expressions to instead be written as:
* ``value1 = exists(expr1) and expr1.field.of.interest``
* ``value2 = exists(expr2) and expr2.["field"]["of"]["interest"]``
* ``value3 = exists(expr3) or exists(expr4) or expr5``
* ``value1 = missing(expr1) else expr1.field.of.interest``
* ``value2 = missing(expr2) else expr2.["field"]["of"]["interest"]``
* ``value3 = exists(expr3) else exists(expr4) else expr5``
In these forms, significantly more of the text presented to the reader is
immediately relevant to the question "What does this code do?", while the
boilerplate code to handle missing data by passing it through to the output
or falling back to an alternative input, has shrunk to four uses of the new
``exists`` builtin, two uses of the ``and`` keyword, and two uses of the
``or`` keyword.
or falling back to an alternative input, has shrunk to two uses of the new
``missing`` builtin, and two uses of the new ``exists`` builtin.
In the first two examples, the 31 character boilerplate suffix
``if exprN is not None else None`` (minimally 27 characters for a single letter
variable name) has been replaced by a 20 character `exists(expr1) and``
prefix (minimally 16 characters with a single letter variable name), somewhat
variable name) has been replaced by a 19 character ``missing(expr1) else``
prefix (minimally 15 characters with a single letter variable name), markedly
improving the signal-to-pattern-noise ratio of the lines (especially if it
encourages the use of more meaningful variable and field names rather than
making them shorter purely for the sake of expression brevity).
making them shorter purely for the sake of expression brevity). The additional
syntactic sugar proposals in PEP 505 would further reduce this boilerplate to
a single ``?`` character that also eliminated the repetition of the expession
being checked for existence.
In the last example, not only are two instances of the 26 character boilerplate,
``if exprN is not None else`` (minimally 22 characters) replaced with the
14 character function call ``exists() or``, with that function call being
placed directly around the original expression, eliminating the need to
duplicate it in the conditional existence check.
In the last example, not only are two instances of the 21 character boilerplate,
`` if exprN is not None`` (minimally 17 characters) replaced with the
8 character function call ``exists()``, but that function call is placed
directly around the original expression, eliminating the need to duplicate it
in the conditional existence check.
Risks and concerns
==================
Readability
-----------
This PEP has been designed specifically to address the risks and concerns
raised when discussing PEPs 335, 505 and 531.
Python has a long history of disallowing customisation of the control flow
operators, and overloading them isn't particularly common in other languages
either. Even languages which do permit overloading may lose the property of
short-circuiting evaluation when overloaded (e.g. that happens when overloading
``&&`` and ``||`` in C++).
This history means that the idea of ``and`` and ``or`` suddenly gaining the
ability to be interpreted differently based on the type of the left-hand
operand is a potentially controversial one from a readability and
maintainability perspective, to the point where it may be *less* controversial
to define a single new ``??`` operator as proposed in PEP 505, or separate
``?then`` and ``?else`` operators as suggested in PEP 531 than it would be to
redefine the existing operators (as currently proposed in this PEP).
Such an approach would also address one of Guido's key concerns with PEP 335
[1_] that would also apply to this PEP as currently written:
Amongst other reasons, I really dislike that the PEP adds to the bytecode
for all uses of these operators even though almost no call sites will ever
need the feature.
If the protocol in this PEP was combined with the core syntactic proposals in
PEP 531, then the end result would look something like:
* ``value1 = exists(expr1) ?then expr1.field.of.interest``
* ``value2 = exists(expr2) ?then expr2["field"]["of"]["interest"]``
* ``value3 = exists(expr3) ?else exists(expr4) ?else expr5``
Rather than indicating use of the existence protocol as suggested in PEP 531,
the ``?`` here would indicate use of the conditional result management protocol,
and hence the fact the result may be something other than the LHS as written
when the short-circuiting path is executed.
Alternatively, if only a single new operator was added as proposed in PEP
505, but it used the semantics proposed for ``or`` in this PEP, then the end
result would look something like:
* ``value1 = missing(expr1) ?? expr1.field.of.interest``
* ``value2 = missing(expr2) ?? expr2["field"]["of"]["interest"]``
* ``value3 = exists(expr3) ?? exists(expr4) ?? expr5``
If new operators were added rather than redefining the semantics of ``and``,
``or`` and ``if-else``, then it would make sense to *require* that their left
hand operand be a conditional result manager that defines both ``__then__``
and ``__else__``, rather than accepting arbitrary objects as ``and`` and ``or``
do.
With that approach, chained comparisons would be conditionally redefined in
terms of the new protocol when the left comparison produces a conditional result
manager, while continuing to be defined in terms of ``and`` for any other
left comparison result.
Compatibility
-------------
At least CPython's peephole optimizer, and presumably other Python optimizers,
include a lot of assumptions about the semantics of ``and`` and ``or``
expressions. This means that any changes to those semantics are likely to
require interpreter implementors to closely review a whole lot of code
related not only to the way those operations are implemented, but also to the
way they're optimized.
By contrast, new operators would be substantially lower risk, as existing
optimizers couldn't be making any assumptions about how they work.
Speed of execution
------------------
Making relatively common operations like ``and`` and ``or`` check for additional
protocol methods is likely to slow them down in the common case. The additional
overhead should be small relative to the cost of boolean truth checking, but
it won't be zero.
Defining new operators rather than reusing existing ones would address this
concern as well.
* it defines a new operator and adjusts the definition of chained comparison
rather than impacting the existing ``and`` and ``or`` operators
* the changes to the ``not`` unary operator are defined in such a way that
control flow optimisations based on the existing semantics remain valid
* rather than the cryptic ``??``, it uses ``else`` as the operator keyword in
exactly the same sense as it is already used in conditional expressions
* it defines a general purpose short-circuiting binary operator that can even
be used to express the existing semantics of ``and`` and ``or`` rather than
focusing solely and inflexibly on existence checking
* it names the proposed builtins in such a way that they provide a strong
mnemonic hint as to when the expression containing them will short-circuit
and skip evaluating the right operand
Design Discussion
@ -512,48 +531,48 @@ Design Discussion
Arbitrary sentinel objects
--------------------------
Unlike PEP 531, this proposal readily handles custom sentinel objects::
Unlike PEPs 505 and 531, this proposal readily handles custom sentinel objects::
# Definition of a base configurable sentinel check that defaults to None
class SentinelCheck:
sentinel = None
class defined(types.CircuitBreaker):
MISSING = object()
def __init__(self, value):
self.value = value
def __bool__(self):
return self.value is not self.sentinel
def __then__(self, result):
if result is self:
return result.value
return result
def __else__(self, result):
if result is self:
return result.value
return result
super().__init__(self, value is not self.MISSING, not_defined)
# Local subclass using a custom sentinel object
class if_defined(SentinelCheck):
sentinel=object()
class not_defined(types.CircuitBreaker):
def __init__(self, value):
super().__init__(self, value is defined.MISSING, defined)
# Using the sentinel to check whether or not an argument was supplied
def my_func(arg=if_defined.sentinel):
arg = if_defined(arg) or calculate_default()
def my_func(arg=defined.MISSING):
arg = defined(arg) else calculate_default()
Implementation
==============
As with PEP 505, actual implementation has been deferred pending in-principle
interest in the idea of making these changes - aside from the compatibility
concerns noted above, the implementation isn't really the hard part of these
proposals, the hard part is deciding whether or not this is a change where the
long term benefits for new and existing Python users outweigh the short term
costs involved in the wider ecosystem (including developers of other
implementations, language curriculum developers, and authors of other Python
related educational material) adjusting to the change.
interest in the idea of making these changes - aside from the possible
syntactic ambiguity concerns noted above, the implementation isn't really the
hard part of these proposals, the hard part is deciding whether or not this is
a change where the long term benefits for new and existing Python users
outweigh the short term costs involved in the wider ecosystem (including
developers of other implementations, language curriculum developers, and
authors of other Python related educational material) adjusting to the change.
...TBD...
Acknowledgements
================
Thanks go to Mark E. Haase for feedback on and contributions to earlier drafts
of this proposal. However, his clear and exhaustive explanation of the original
protocol design that modified the semantics of ``if-else`` conditional
expressions to use an underlying ``__then__``/``__else__`` protocol helped
convince me it was too complicated to keep, so this iteration contains neither
that version of the protocol, nor Mark's explanation of it.
References
==========