object.__hash__
object.__eq__
and Hashable
are currently underspecified in the type system. object.__hash__
and object.__eq__
are currently assumed to exist on object
, and the types they have do not match the intended use
This becomes a problem for LSP as the design of the language is that these are removable disableable qualities that happen to have a default behavior, not that the default behavior is intended for all types inheriting from object.
- The data model specifically allows for overriding
__eq__
to return non-bool objects (see ORM use) - defining
__eq__
without defining an updated__hash__
removessets__hash__
toNone
instead - hash can be directly set to
None
to remove hashability.
Proposed changes:
- The type of
__hash__
considered on object isNone | ((self) -> int)
- The type of
__eq__
on object is(self, other: Any, /) -> Any
- It is determined when declaring a type by the type system if a type would be Hashable by following the rules of the language. (the presence of a definition of
__eq__
without a corresponding definition of__hash__
, as well as explicitly setting__hash__ = None
remove hashability) - It is an error to remove hashability when subclassing any type other than object which has hashability.
- It is an error to add hashability when subclassing a type that does not have it.
- type checkers should determine that instances of the exact type
object
are bothobject
andHashable
[1] (ie. in the case ofx = object()
, type checkers should preserve the knowledge that x is Hashable in that local scope.)
This results in a consistent interface, at the cost of the type system not accepting a few extremely suspect design choices that someone could make (such as emoving hashability in one type, then re-adding it in a further subtype, then removing it in yet a further subtype while then expecting all of those types to automatically work with subtyping based rules for Hashable), and at the cost of a limited amount of extra tracking using an existing protocol (Hashable) to handle cases where object()
is used
The special casing on object is limited and defined in such a way that it limits the impact to cases that can already cause issues such as:
def problem(x: object, mapping: dict[Hashable, Any]):
mapping[x] = x
Which could be immediately fixable in those cases to:
def fixed(x: Hashable, mapping: dict[Hashable, Any]):
mapping[x] = x
Below are substantial additions taken from the discussion below for a one-place summary, these are intentionally tacked on at the bottom for convenience but not adding things which mailing list users would be unaware of here.
Why is adding hashability also an LSP violation?
It’s not possible to not have a definition of __hash__
on a type. The interpreter removal of it sets it to None
, the supported user removal directly requires setting to None. It’s also not possible even for types defined in C-extensions, __hash__
is a required implementation detail.
Adding hashability to an unhashable type therefore looks like:
class Unhashable:
__hash__ = None
class Problem(Unhashable):
def __hash__(self) -> int: ...
Leaving the special casing to object simplifies the number of cases type checkers need to consider, and there are not practical reasons to allow this, subclassing should be cooperative in nature, building off of types that are appropriate to build onto for your use.
Handling of Any
(Thanks @Liz , see comment below)
In the case where subclassing has happened involving Any, if any non-object, non-Any type’s Hashability can be determined, including the definition of the type being defined, Any is assumed compatible and that definition is used.
In the indeterminable case, the assumption is that the type is hashable, preserving compatibility with Hashable. This is a type safety hole, but not a hole in consistent definitions of Any as a compatible type. It is trivially fixable by setting hash to None when subclassing untyped unhashable types.
while intersections are not denotable, the ability of type checkers to track such a type is required already by
typing.TypeIs
, and would only need to be tracked in the local scope where this happened, as the non-denotability meansobject & Hashable
is not a valid parameter or return type ↩︎