A semi-baked idea that has been kicking around in my brain the past few days is something I’ve been thinking about as “query expressions” (assorted potential enhancements omitted in the sketch below, like being able to capture the same code location information on the various query result instances as we capture when raising SyntaxError
in the compiler, as well as offering common has_value
, is_missing
, and is_error
properties for easier runtime introspection. Distinguishing between raised exceptions and returned exceptions is also a topic that would need further consideration):
class Value:
"""Any Python object that isn't missing or an error. Considered true."""
def __init__(self, value):
self._value = value
@property
def value(self):
return self._value
def __bool__(self):
return True
class Missing:
"""A missing value. Considered false."""
def __init__(self, sentinel=None):
self._sentinel = sentinel
@property
def sentinel(self):
return self._sentinel
def __bool__(self):
return False
class Error:
"""An error value. Considered false."""
def __init__(self, exception):
self._exception = exception
@property
def exception(self):
return self._exception
def __bool__(self):
return False
def query(obj: Any, *, sentinel=None) -> Value|Missing|Error:
"""Classify a Python object as missing, an error, or some other value"""
if obj is sentinel:
# Do the fastest check first
return Missing(sentinel)
if isinstance(obj, (Value, Missing, Error)):
# Avoid nesting the result wrapper objects
return obj
if isinstance(obj, BaseException):
# Exceptions are always considered errors,
# even when returned instead of being raised
return Error(obj)
# Wrap anything else as a regular value
return Value(obj)
Given the above foundation, ?
would then be defined as a unary prefix operator that was just a shorthand for operator.query
, so ?expr
would give the same result as query(expr)
(aside from the latter lacking code location information).
On its own, that wouldn’t be interesting (aside from potentially being a way to capture code location information for arbitrary expressions), but where I think it has more potential is as a concept that underlies safe navigation in a way that handles the x is not None
vs hasattr(x, "attr")
discrepancy by saying it means both (and represents those potential results differently).
Firstly though, ?try
could be an exception catching query expression, such that:
result = ?try some_call()
translated to:
try:
_inner_value = some_call()
except Exception as _e:
_inner_value = _e
_query_result = ?_inner_value
result = _query_result
and ?await some_call()
(along with ?yield
and ?yield from
) could be defined as a way to avoid a common bug where coroutine and generator exception handlers inadvertently cover more than just the exceptions thrown in when the frame resumes execution:
_awaitable = some_call() # Note: outside the scope of the try block!
try:
_inner_value = await _awaitable
except Exception as _e:
_inner_value = _e
_query_result = ?_inner_value
result = _query_result
(If you do want to cover both parts of the expression for some reason, then ?try await ...
, ?try yield ...
, and ?try yield from ...
would all remain available)
Returning to the original topic of safe navigation (in this conceptual framework: “attribute query expressions” and “item query expressions”):
result = obj?.attr.subattr
would translate to:
_lhs = obj
if _lhs is None:
_inner_value = _lhs
else:
try:
_inner_value = _lhs.attr
except AttributeError as _e:
_query_result = _e
else:
# Resolve any trailing parts of the expression
# This clause would be omitted when not needed
_inner_value = _inner_value.subattr
_query_result = ?_inner_value
result = _query_result
Item lookup (such as result = obj?[some_calculated_item()].attr
) would translate to:
_lhs = obj
if _lhs is None:
_inner_value = _lhs
else:
_lookup_key = some_calculated_item() # Outside the try/catch!
try:
_inner_value = _lhs[_lookup_key]
except LookupError as _e:
_query_result = _e
else:
# Resolve any trailing parts of the expression
# This clause would be omitted when not needed
_inner_value = _inner_value.attr
_query_result = ?_inner_value
result = _query_result
With these definitions, obj?.attr
would potentially replace a lot of hasattr
and getattr
usage with a type-safe alternative, and obj?[item]
would provide a convenient way to attempt optional item lookups without having to handle KeyError
or IndexError
yourself (LookupError
is their common parent exception).
Due to the way __bool__
is defined in the query result objects, ??
wouldn’t be needed - you would use or
in combination with query expressions instead.
This approach would presumably be a bit slower than the simpler definition in PEP 505, but not that much slower:
try
/except
is essentially free these days when no exception is thrown- the actual implementation would presumably avoid querying objects when it already knows the result (such as for caught exceptions, or values that have just been checked against
None
)
Edit: I started a dedicated thread for further iteration on this idea: Safe navigation operators by way of expression result queries