2

I have a mutable class in Python which I would like to be able to "freeze", at that point its immutable, therefor can have a __hash__ function.

My concern is, will having the __hash__ function present will make Python behave strangely because it may check for the existence of a hash function.

I realize I could use a subclass that has a hash function, copy the class to a subtype. But I'm interested to know if having an optional hash function is supported by Python.

In the example below it works in basic cases (but may fail in others).

Note: This assumes you don't touch _var or _is_frozen directly and only use access methods.

Note: its probably more Pythonic not to use this method and instead have a FrozenMyVar class, but Im curious if this can be considered to be supported in Python or not.

class MyVar:
    __slots__ = ("_var", "_is_frozen")

    def __init__(self, var):
        self._var = var
        self._is_frozen = False

    def freeze(self):
        self._is_frozen = True

    def __hash__(self):
        if not self._is_frozen:
            raise TypeError("%r not hashable (freeze first)" % type(self))
        return hash(self._var)

    def __eq__(self, other):
        try:
            return self.val == other.val
        except:
            return NotImplemented

    @property
    def var(self):
        return self._var

    @var.setter
    def var(self, value):
        if self._is_frozen:
            raise AttributeError("%r is frozen" % type(self))
        self._var = value


# ------------
# Verify Usage

v = MyVar(10)
v.var = 9

try:
    hash(v)
except:
    print("Hash fails on un-frozen instance")

v.freeze()

try:
    v.var = 11
except:
    print("Assignment fails on frozen instance")

print("Hash is", hash(v))

Adding a note on the real-world use-case, We have some linear math module with Vector/Matrix/Quaternion/Euler classes. In some cases we want to have for eg, a "set of matrices" or a "dict with vector keys". Its always possible to expand them into tuples but they take up more memory & loose their abilities to behave a our own math types - so the ability to freeze them is attractive.

1 Answer 1

4

The original example didn't quite work "sensibly", because the class had __hash__ but not __eq__, and as https://docs.python.org/3/reference/datamodel.html#object.hash says "If a class does not define an eq() method it should not define a hash() operation either". But the OP's edit fixed that side issue.

This done, if the class and its instances are indeed used with the discipline outlined, behavior should comply with the specs: instances are "born unhashable" but "become hashable" -- "irreversibly" given said discipline, and only, of course, if their self.val is in turn hashable -- once their freeze method is called.

Of course collections.Hashable will "mis-classify" unfrozen instances (as it only checks for the presence of __hash__, not its actual working), but that is hardly unique behavior:

>>> import collections
>>> isinstance((1, [2,3], 4), collections.Hashable)
True
>>> hash((1, [2,3], 4))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

That tuple does appear "hashable", like all tuples (since its type does define __hash__) -- but if you in fact try hashing it, you nevertheless get a TypeError, as one of the items is a list (making the whole not actually hashable!-). Not-yet-frozen instances of the OP's class would behave similarly to such a tuple.

An alternative which does avoid this little glitch (yet doesn't require potentially onerous copies of data) is to model the "freezing" as the instance "changing type in-place", e.g...:

class MyVar(object):
    _is_frozen = False

    def __init__(self, var):
        self._var = var

    def freeze(self):
        self.__class__ = FrozenMyVar

    def __eq__(self, other):
        try:
            return self.val == other.val
        except:
            return NotImplemented

    __hash__ = None

    @property
    def var(self):
        return self._var

    @var.setter
    def var(self, value):
        if self._is_frozen:
            raise AttributeError("%r is frozen" % type(self))
        self._var = value


class FrozenMyVar(MyVar):
    _is_frozen = True

    def __hash__(self):
        return hash(self._var)

This behaves essentially like the original example (I've removed the "slots" to avoid issues with object layout differs errors on __class__ assignment) but may be considered an improved object model since "changing type in-place" models well such irreversible changes in behavior (and as a small side effect collections.Hashable now behaves impeccably:-).

The concept of an object "changing type in-place" freaks some out because few languages indeed would even tolerate it, and even in Python of course it's a rare thing to have a practical use case for such an obscure feature of the language. However, use cases do exist -- which is why __class__ assignment is indeed supported!-)

Sign up to request clarification or add additional context in comments.

3 Comments

Well spotted, __eq__ part was an oversight (corrected), actual example is much longer (and has an __eq__).
Good to know its supported, is this documented anywhere?
@ideasman42, see docs.python.org/3/reference/datamodel.html#object.__hash__ : "The only required property is that objects which compare equal have the same hash value" (followed but many instances of "should", but by definition "should" in a standard is strongly advisory, not rigidly normative). A sea lawyer might dispute whether the required behavior is perfectly matched (it surely would if equality of _is_frozen was also required, rather than just equality of _val) but I would argue it still is (too long a discussion for here:-).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.