Home > Software design >  Which variables are allowed in a __str__ implementation?
Which variables are allowed in a __str__ implementation?

Time:12-14

I'm learning python @property annotation using this. My understanding is that it is a built-in property to facilitate accessing and modifying class properties. I created a class using this annotation on some properties, then tried implementing str to display everything.

Below, everything displays fine using the str() method. However, when I substitute self._area for self.area, I get AttributeError: Circle object has no attribute _area

In the str() implementation, why am I allowed to use self._diameter but not self._area?

class Circle(object):
    def __init__(self, radius): 
        self._radius=radius
    
    @property
    def radius(self):
        return self._radius
        
    @radius.setter
    def radius(self, radius):
        self._radius=radius
    
    @property 
    def diameter(self):
        return self._diameter
    
    @diameter.setter
    def diameter(self, diameter):
        self._diameter=diameter
    
    @diameter.deleter
    def diameter(self):
        del self._diameter
    
    @property
    def area(self):
        self._area = self._radius**2*3.14
        return self._area

        
    def __str__(self):
        return f'Circle has radius of {self._radius}, diameter of {self._diameter}, and area of {self.area}'    
        
c = Circle(4)
c.diameter=3
print(str(c))

CodePudding user response:

This doesn't work because you (rather pointlessly) compute self._area lazily, only when self.area is accessed. So your __str__ implementation will work just fine if the user accesses c.area ahead of time, but doesn't work when it hasn't been accessed.

Simple solution: Use an eagerly computed attribute or a lazily computed property, don't mix the two in confusing ways. As a rule, you should define (even if it's just with None for lazily computed attributes) all attributes in __init__; it's maintainer-friendly (they don't have to search the whole class to figure out the set of attributes) and modern CPython rewards you for it (by using key-sharing dictionaries for attribute storage, reducing the per-instance memory overhead by a significant amount, very roughly halving memory overhead).


As an aside, properties that provide full access to the underlying attribute are pointless in Python; if you're giving the user full access anyway, just make it a regular attribute, saving a bunch of boilerplate and making the code run faster. This is the case with both radius and diameter in your code. In the case of diameter though, having it manage a separate attribute makes no sense (diameter is definitionally twice the radius, so they should use common state, _diameter should never exist), and area is cheap enough to recompute that it should probably just be an uncached property.

A simplified, idiomatic version of your class would look like:

class Circle:  # (object) only necessary on Py2; hope you're not developing for it
    def __init__(self, radius):
        self.radius = radius  # Use a public attribute without bothering with
                              # pointless property wrapping that protects nothing
                              # and just slows things down

    # diameter is just another "view" of the radius, so make the property
    # perform converting mutations to the radius
    @property
    def diameter(self):
        return self.radius * 2  # Computable from radius

    @diameter.setter
    def diameter(self, diameter):
        self.radius = diameter / 2  # Convert to radius and store

    @property
    def area(self):
        # This is cheap enough to compute that it's probably not worth
        # caching (if the radius changes, you'd have to invalidate/recompute the cached value, it's just a pain)
        return math.pi * self.radius ** 2  # Computable from radius

    def __str__(self):
        # Use the accessors; they're accurate, and duplicating the code to compute
        # them is silly
        return f'Circle has radius of {self.radius}, diameter of {self.diameter}, and area of {self.area}'


c = Circle(4)
c.diameter = 3
print(c)  # print already stringifies; calling str on it is redundant

Side-note: You always want to implement __repr__ on a class, just so tracebacks and the like referencing it are useful; it's pretty easy to write (using type(self).__name__ makes it subclass friendly when the __init__ arguments don't change):

def __repr__(self):
    return f'{type(self).__name__}({self._radius!r})'  # !r meaningless for int/float, but it's good to use it as a habit for when it matters, e.g. str arguments
  • Related