2
\$\begingroup\$

The code below shows the pattern I use to manage properties in my classes when I have the following requirements:

  • Some properties are slow to calculate and are rarely used, so I want them to be lazy
  • Some methods change the value of a property

The properties are defined the first time they are requested and are deleted when the methods are executed. Calculating the length in the Line class shown in the example is fairly fast, but calculating the Length of a shape made of many Lines would be slow and must be avoided unless required. The full code include many more properties and the list in _reset_attributes([...]) can have dozens of items.

Is this the correct/pythonic/fast way of managing properties?

class Line:
    @property
    def angle(self):
        try:
            return self._angle
        except AttributeError:
            self._angle = math.atan2(self.end.y - self.start.y, self.end.x - self.start.x)
            return self._angle

    @property
    def length(self):
        try:
            return self._length
        except AttributeError:
            self._length = math.sqrt((self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2)
            return self._length

    def rotate(self, pivot_point, angle):
        self.start.rotate(pivot_point, angle)
        self.end.rotate(pivot_point, angle)
        self._reset_attributes(['_angle'])

    def scale(self, pivot_point, factor):
        self.start.scale(pivot_point, factor)
        self.end.scale(pivot_point, factor)
        self._reset_attributes(['_length'])

    def _reset_attributes(self, attributes):
        for attribute in attributes:
            try:
                delattr(self, attribute)
            except:
                pass
\$\endgroup\$
  • \$\begingroup\$ @200_success: why was the title of my post changed, and with it also the meaning, which created confusion? My question was about "Lazy properties in Python when the values can be changed by a method", not about "Lazy properties for the angle and length of a line segment" \$\endgroup\$ – stenci Mar 20 '15 at 21:34
  • \$\begingroup\$ Site standards require the title to reflect the purpose of your code — see How to Ask. Despite the title change, it should still be apparent that start and end are mutable, based on statements like self.start.rotate(pivot_point, factor) and self.end.scale(pivot_point, factor). \$\endgroup\$ – 200_success Mar 20 '15 at 21:46
  • \$\begingroup\$ I did read the how to ask. If you search for [design-patterns] you can see 621 questions similar to mine. I was asking about a design-pattern, as described by the title and by the tag. My question has nothing to do with lines and angles, has to do with properties. I think that the editing was just wrong and misleading, infact it attracted the wrong answer \$\endgroup\$ – stenci Mar 20 '15 at 21:54
  • 2
    \$\begingroup\$ If you are asking about generic best practices (where your Line class is nothing but a hypothetical illustration) rather than seeking a code review, then Software Engineering would be a more appropriate place to ask. \$\endgroup\$ – 200_success Mar 20 '15 at 21:55
  • 2
    \$\begingroup\$ Thanks for the suggestion. It would have been nicer this suggestion than assigning the wrong title and the wrong tags. \$\endgroup\$ – stenci Mar 20 '15 at 21:58
1
\$\begingroup\$

You can create a custom decorator to cache the values, with the help of this decorator we can also remove the unnecessary repetitive if-else or try-except checks present in each of the properties. Hence, the methods will only contain the required code.

The trick here is to use each method's name and store an additional attribute on the instance itself with _ prefixed to it. So, when we look for a value we are first going to check if it is present on that instance, if yes then return the value otherwise call the function and store the value on the instance and return it.

def cached_property(func):
    attr = '_' + func.__name__

    class Descriptor(object):
        def __get__(self, ins, type):
            if hasattr(ins, attr):
                return getattr(ins, attr)
            else:
                value = func(ins)
                setattr(ins, attr, value)
                return value
    return Descriptor()


class Test(object):

    def __init__(self, name):
        self.name = name

    @cached_property
    def find_max(self):
        print ('property max of {!s} accessed'.format(self))
        lst = range(10**5)
        return max(lst)

    @cached_property
    def find_min(self):
        print ('property min of {!s} accessed'.format(self))
        lst = range(10**5, -1, -1)
        return min(lst)

    def __str__(self):
        return self.name


if __name__ == '__main__':
    t1 = Test('t1')
    t2 = Test('t2')
    print(t1.find_max)
    print(t1.find_min)
    print(t2.find_max)
    print(t2.find_min)
    print(t1.find_max,
    t1.find_min,
    t2.find_max,
    t2.find_min)

    t1.remove_value('_find_max')
    t2.remove_value('_find_min')

    print(t1.find_max)
    print(t1.find_min)
    print(t2.find_max)
    print(t2.find_min)

Output:

property max of t1 accessed
99999
property min of t1 accessed
0
property max of t2 accessed
99999
property min of t2 accessed
0
99999 0 99999 0
property max of t1 accessed
99999
0
99999
property min of t2 accessed
0

For removing a single value I would recommend defining __delete__ method on the descriptor itself:

def cached_property(func):
    attr = '_' + func.__name__

    class Descriptor(object):
        def __get__(self, ins, type):
            if hasattr(ins, attr):
                return getattr(ins, attr)
            else:
                value = func(ins)
                setattr(ins, attr, value)
                return value

        def __delete__(self, ins):
            try:
                delattr(ins, attr)
            except AttributeError:
                pass

    return Descriptor()

Now these two calls:

t1.remove_value('_find_max')
t2.remove_value('_find_min')

Can be replaced with:

del t1.find_max
del t2.find_min
\$\endgroup\$
0
\$\begingroup\$

Just changed the properties so it's more obvious what you're doing in them, but other than that this is a fine way of doing it!

import math


class Line:
    @property
    def angle(self):
        if not hasattr(self, '_angle'):
            self._angle = math.atan2(self.end.y - self.start.y, self.end.x - self.start.x)
        return self._angle

    @property
    def length(self):
        if not hasattr(self, '_length'):
            self._length = math.sqrt((self.end.x - self.start.x) ** 2 + (self.end.y - self.start.y) ** 2)
        return self._length

    def rotate(self, pivot_point, angle):
        self.start.rotate(pivot_point, angle)
        self.end.rotate(pivot_point, angle)
        self._reset_attributes(['_angle'])

    def scale(self, pivot_point, factor):
        self.start.scale(pivot_point, factor)
        self.end.scale(pivot_point, factor)
        self._reset_attributes(['_length'])

    def _reset_attribute(self, attribute):
        if hasattr(self, attribute):
            delattr(self, attribute)

    def _reset_attributes(self, attributes):
        for attribute in attributes:
            self._reset_attribute(attribute)
\$\endgroup\$
  • \$\begingroup\$ You also changed from EAFP to LBYL. I thought EAFP was the pythonic way. Any comment on that? (see docs.python.org/3.3/glossary.html#term-eafp) \$\endgroup\$ – stenci Mar 20 '15 at 21:40
  • \$\begingroup\$ While in many cases I find EAFP useful, in this case I think LBYL improved readability. In my experience, EAFP is used when you're catching a case in which you've made a false assumption, like a key should be there, but it's not, so you catch the error it throws. In this case, it shouldn't be there initially, we know it won't be the first time each property is accessed, in my mind it makes more sense to explicitly check in that case. In this case I'm trading the microseconds it takes to make that check, for more readable and understandable code. \$\endgroup\$ – Echocage Mar 20 '15 at 21:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.