Description
We've been having a debate in python/cpython#102433 about how isinstance()
should work at runtime when comparing instances of classes with properties against runtime-checkable protocols. There's a few interrelated issues here, so I'm interested in knowing what the wider community thinks about the best way forward.
Current behaviour
The current behaviour at runtime is that a property x
on an object obj
will be evaluated if you call isinstance()
on obj
against a runtime-checkable protocol that specifies x
as part of the interface:
>>> from typing import Protocol, runtime_checkable
>>> @runtime_checkable
... class HasX(Protocol):
... x: int
...
>>> class Foo:
... @property
... def x(self):
... print('Called!')
... return 42
...
>>> obj = Foo()
>>> isinstance(obj, HasX)
Called!
True
The reason for this is that calling isinstance()
on obj
against HasX
performs a structural check comparing obj
to HasX
. If all attributes and methods specified in the HasX
protocol are present on obj
, the isinstance()
check returns True
. This is taken care of by _ProtocolMeta.__instancecheck__
, which just calls hasattr
on obj
for every attribute specified in the HasX
protocol; calling hasattr(obj, x)
will result in the x
property on obj
being evaluated as part of the isinstance()
check.
An alternative to the current behaviour at runtime
An alternative to the current implementation would be to change the hasattr
call in _ProtocolMeta.__instancecheck__
to use inspect.getattr_static
(or similar):
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -30,6 +30,7 @@
import sys
import types
import warnings
+from inspect import getattr_static
from types import WrapperDescriptorType, MethodWrapperType, MethodDescriptorType, GenericAlias
@@ -1990,7 +1991,9 @@ def __instancecheck__(cls, instance):
issubclass(instance.__class__, cls)):
return True
if cls._is_protocol:
- if all(hasattr(instance, attr) and
+ sentinel = object()
+ if all(
+ (getattr_static(instance, attr, sentinel) is not sentinel) and
# All *methods* can be blocked by setting them to None.
(not callable(getattr(cls, attr, None)) or
getattr(instance, attr) is not None)
This would mean that properties would not be evaluated against instances, since getattr_static
avoids evaluating descriptors at runtime:
>>> class Foo:
... @property
... def x(self):
... print('Called!')
... return 42
...
>>> getattr(Foo(), 'x')
Called!
42
>>> import inspect
>>> inspect.getattr_static(Foo(), 'x')
<property object at 0x0000015A789F5120>
Criticisms of the current behaviour at runtime
The current behaviour at runtime has been criticised due to the fact that it can result in unexpected behaviour from isinstance()
checks, which don't usually result in properties being evaluated on instances:
>>> class Bar:
... @property
... def x(self):
... raise RuntimeError("what were you thinking?")
...
>>> isinstance(Bar(), HasX)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "C:\Users\alexw\AppData\Local\Programs\Python\Python311\Lib\typing.py", line 1968, in __instancecheck__
if all(hasattr(instance, attr) and
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\Users\alexw\AppData\Local\Programs\Python\Python311\Lib\typing.py", line 1968, in <genexpr>
if all(hasattr(instance, attr) and
^^^^^^^^^^^^^^^^^^^^^^^
File "<stdin>", line 4, in x
RuntimeError: what were you thinking?
>>> import time
>>> class Baz:
... @property
... def x(self):
... time.sleep(3600)
... return 42
...
>>> isinstance(Baz(), HasX) # oh no, now we have to wait for an hour
These criticisms have been levied in the CPython repo more than once (a previous instance of this being reported was python/cpython#89445), so this is clearly pretty surprising behaviour to some people.
Possible rebuttal to the criticisms of the current behaviour
You could say that the whole point of runtime-checkable protocols is that they do something "a little different" when you call isinstance()
against them. Arguing "I didn't expect this because it's not what isinstance()
usually does" may not be the strongest case here.
When it comes to raising exceptions inside property getter methods, it may also be unwise in general to raise exceptions that aren't subclasses of AttributeError
-- doing so will generally lead to unexpected things happening with hasattr()
or getattr()
calls.
Defence of the current behaviour at runtime
The current behaviour has things to recommend it. Firstly, using hasattr()
here is probably faster than an alternative implementation using getattr_static
. _ProtocolMeta.__instancecheck__
has previously been criticised for on performance grounds. This could make things even worse.
Moreover, while the current implementation leads to surprising outcomes for some users, it's possible that changing the implementation here could also lead to surprising outcomes. getattr_static
has different semantics to hasattr
: sometimes it finds attributes where hasattr
doesn't, and sometimes it doesn't find attributes when hasattr
does. It's hard to predict exactly what the effect of changing this would be, and it could be a breaking change for some users. The semantics of _ProtocolMeta.__instancecheck__
have been the same for a long time now; it's possible that the better course of action might be to simply document that calling isinstance
on an instance obj
against a runtime-checkable protocol might result in properties on obj
being evaluated.
Possible rebuttal to the defence of the current behaviour
We probably shouldn't prioritise speed over correctness here; users who care deeply about performance probably shouldn't be using runtime-checkable protocols anyway.
Just because the semantics have been the same for several years doesn't mean we should necessarily stick with them if they're bad; sometimes breaking changes are necessary.
A specific case where changing the implementation would lead to different semantics
One reason why I lean towards keeping the current implementation is because I'd like to keep the invariant where the isinstance
guard in the following snippet is enough to ensure that accessing the x
attribute is safe:
from typing import Protocol, runtime_checkable
@runtime_checkable
class HasX(Protocol):
x: int
def do_something(obj: object) -> None:
if isinstance(obj, HasX):
print(obj.x)
If we changed the implementation of _ProtocolMeta.__instancecheck__
to use getattr_static
or similar, this wouldn't necessarily be the case. Consider:
class Foo:
SWITCH: bool = False
@property
def x(self) -> int:
if self.SWITCH:
return 42
raise AttributeError
obj: object = Foo()
# At runtime this is currently a safe call:
# - `isinstance(obj, HasX)` returns True if `self.SWITCH` is True, (in which case accessing `x` is safe)
# - `isinstance(obj, HasX)` returns False if `self.SWITCH` is False (in which case accessing `x` is unsafe)
#
# If we changed the implementation to use getattr_static, `isinstance(obj, HasX)` would be True
# regardless of the value of `self.SWITCH`,
# so the `isinstance()` guard in the `do_something` function would be ineffective
# at ensuring that the `x` attribute can be safely accessed
do_something(obj)
To me, being able to use isinstance()
guards in this way -- to provide structural narrowing at runtime as well as statically, ensuring attributes can always be accessed safely -- is kind of the whole point of the runtime_checkable
decorator.
Others have argued, however, that they would find it more intuitive if the runtime isinstance()
check was closer to how static type checkers understood the above snippet. (Static type checkers have no special understanding of the fact that properties can sometimes raise AttributeError
, so will always consider the Foo
class above as being a valid subtype of HasX
, contradicting the current behaviour at runtime.)