3

I'm working on a class representing on object with numerous associated data. I'm storing these data in a dictionary class attribute called metadata. A representation could be:

{'key1':slowToComputeValue, 'key2':evenSlowerToComputeValue}

The calculating of the values is in some cases very slow, so what I want to do is, using "getter" functions, first try and get the value from the metadata dict. Only on a KeyError (i.e. when the getter tries to get a value for a key which doesn't exist yet) should the value be calculated (and added to the dictionary for fast access next time the getter is called).

I began with a simple:

try:
    return self.metadata[requested_key]
except KeyError:
    #Implementation of function

As there are many getters in the class, I started thought that these first 3 lines of code could be handled by a decorator. However I'm having problems making this work. The problem is that I need to pass the metadata dictionary from the class instance to the decorator. I've found several tutorials and posts like this one which show that it is possible to send a parameter to an enclosing function but the difficulty I'm having is sending a class instantiation attribute metadata to it (if I send a string value it works).

Some example code from my attempt is here:

def get_existing_value_from_metadata_if_exists(metadata):
    def decorator(function):
        @wraps(function)
        def decorated(*args, **kwargs):
            function_name = function.__name__
            if function_name in metadata.keys():
                return metadata[function_name]
            else:
                function(*args, **kwargs)
        return decorated
    return decorator

class my_class():
    @get_existing_value_from_metadata_if_exists(metadata)
    def get_key1(self):
        #Costly value calculation and add to metadata

    @get_existing_value_from_metadata_if_exists(metadata)
    def get_key2(self):
        #Costly value calculation and add to metadata

    def __init__(self):
        self.metadata = {}

The errors I'm getting are generally self not defined but I've tried various combinations of parameter placement, decorator placement etc. without success.

So my questions are:

  1. How can I make this work?
  2. Are decorators a suitable way to achieve what I'm trying to do?
Ashwini Chaudhary
  • 244,495
  • 58
  • 464
  • 504
user3535074
  • 1,268
  • 8
  • 26
  • 48

2 Answers2

3

Yes, a decorator is a good use case for this. Django for example has something similar already included with it, it's called cached_property.

Basically all it does is that when the property is accessed first time it will store the data in instance's dict(__dict__) by the same name as the function. When we fetch the same property later on it simple fetches the value from the instance dictionary.

A cached_property is a non-data descriptor. Hence once the key is set in instance's dictionary, the access to property would always get the value from there.

class cached_property(object):
    """
    Decorator that converts a method with a single self argument into a
    property cached on the instance.

    Optional ``name`` argument allows you to make cached properties of other
    methods. (e.g.  url = cached_property(get_absolute_url, name='url') )
    """
    def __init__(self, func, name=None):
        self.func = func
        self.__doc__ = getattr(func, '__doc__')
        self.name = name or func.__name__

    def __get__(self, instance, cls=None):
        if instance is None:
            return self
        res = instance.__dict__[self.name] = self.func(instance)
        return res

In your case:

class MyClass:
    @cached_property
    def key1(self):
        #Costly value calculation and add to metadata

    @cached_property
    def key2(self):
        #Costly value calculation and add to metadata

    def __init__(self):
        # self.metadata not required

Use the name argument to convert an existing method to cached property.

class MyClass:
    def __init__(self, data):
        self.data = data

    def get_total(self):
        print('Processing...')
        return sum(self.data)

    total = cached_property(get_total, 'total')

Demo:

>>> m = MyClass(list(range(10**5)))

>>> m.get_total()
Processing...
4999950000

>>> m.total
Processing...
4999950000

>>> m.total
4999950000

>>> m.data.append(1000)

>>> m.total  # This is now invalid
4999950000

>>> m.get_total()  # This still works
Processing...
4999951000

>>> m.total
4999950000

Based on the example above we can see that we can use total as long as we know the internal data hasn't been updated yet, hence saving processing time. But it doesn't make get_total() redundant, as it can get the correct total based on the data.

Another example could be that our public facing client was using something(say get_full_name()) as method so far but we realised that it would be more appropriate to use it as a property(just full_name), in that case it makes sense to keep the method intact but mark it as deprecated and start suggesting the users to use the new property from now on.

Ashwini Chaudhary
  • 244,495
  • 58
  • 464
  • 504
1

Another way to go about this is to use class "properties" like so:

class MyClass():
    def __init__():
        self._slowToComputeValue = None
    @property
    def slowToComputeValue(self):
        if self._slowToComputeValue is None:
            self._slowToComputeValue = self.ComputeValue()
        return self._slowToComputeValue
    def ComputeValue(self):
        pass

Now you can access this as though it were a class attribute:

myclass = MyClass()
print(myclass.slowToComputeValue)
David Marx
  • 8,172
  • 3
  • 45
  • 66