A collection of computer, gaming and general nerdy things.

Wednesday, April 22, 2015

Fun With Descriptors

Once you understand their purpose and power, playing with descriptors is a lot of fun.

I recently found myself needing to set an instance of a class as a class attribute on the class in question. I'm not crazy, I swear. It's for pynads' implementation of Monoids. However, doing this:

In [1]:
class Thing(object):
    mempty = Thing()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
<ipython-input-1-cf5ea63fa573> in <module>()
----> 1 class Thing(object):
      2     mempty = Thing()

<ipython-input-1-cf5ea63fa573> in Thing()
      1 class Thing(object):
----> 2     mempty = Thing()

NameError: name 'Thing' is not defined

Doesn't work. Wah wah wah. So my brain began turning, and I first tried this:

In [2]:
class Thing(object):
    @classmethod
    @property
    def mempty(cls):
        return cls()

Thing.mempty
Out[2]:
<bound method type.? of <class '__main__.Thing'>>

At least that didn't blow up..., what if I swapped them?

In [3]:
class Thing(object):
    @property
    @classmethod
    def mempty(cls):
        return cls()

Thing.mempty
Out[3]:
<property at 0x7fd39c1601d8>

Well, that didn't work either. Hm. I had hoped that composing classmethod and property together would work and that Python would magically infer what I wanted. However, Python does no such thing, unsurprisingly. So, what if I made my own classproperty descriptor?

In [4]:
class classproperty(object):
    def __init__(self, method):
        self.method = method
    def __get__(self, _, cls):
        return self.method(cls)

class Thing(object):
    @classproperty
    def mempty(cls):
        return cls()
    
Thing.mempty
Out[4]:
<__main__.Thing at 0x7fd39c159588>

It works! Five lines of code plus another three to use it! Yeah! Except that three line use was repeated at least three times in pynads. I'm not sure about you, but I hate repeating myself. I'm gonna make a mistake somewhere (and did with List). Mainly because I was caching the instance like this:

In [5]:
class Thing(object):
    _mempty = None
    
    @classproperty
    def mempty(cls):
        if cls._mempty is None:
            cls._mempty = cls()
        return cls._mempty

assert Thing.mempty is Thing.mempty

That's great, but that's also six lines of boilerplate, which is one line longer than the implementation. ): After the benadryl wore off and I was able to think straight, what if I just moved the boiler plate into the descriptor?

In [6]:
class Instance(object):
    def __get__(self, _, cls):
        return cls()

class Thing(object):
    mempty = Instance()

print(repr(Thing.mempty))
print(Thing.mempty is Thing.mempty)
<__main__.Thing object at 0x7fd39c166e80>
False

Awesome. That's a much nicer use of it. The caching can also be moved into the descriptor too:

In [7]:
class Instance(object):
    def __init__(self):
        self._inst = None
    def __get__(self, _, cls):
        if self._inst is None:
            self._inst = cls()
        return self._inst

class Thing(object):
    mempty = Instance()

assert Thing.mempty is Thing.mempty

And, if needed, a specific instance can be created and cached:

In [8]:
class Instance(object):
    def __init__(self, *args, **kwargs):
        self._inst = None
        self.args = args
        self.kwargs = kwargs
    def __get__(self, _, cls):
        if self._inst is None:
            self._inst = cls(*self.args, **self.kwargs)
        return self._inst

class Thing(object):
    mempty = Instance(hello='world')
    
    def __init__(self, hello):
        print(hello)

Thing.mempty
world
Out[8]:
<__main__.Thing at 0x7fd39c1f5e10>

And to show it's cached:

In [9]:
Thing.mempty
Out[9]:
<__main__.Thing at 0x7fd39c1f5e10>

Ta-da! Setting an instance of a class on the class as a class attribute -- a sentence that is no less confusing to read now than before.

No comments:

Post a Comment