@property defrating(self): """Getter for _rating""" return self._rating
@rating.setter defrating(self, value): """Setter for _rating""" ifnot isinstance(value, int): raise ValueError('Rating must be an integer!') ifnot (0 <= value <= 10): raise ValueError('Rating must between 0 ~ 10!') self._rating = value self._reviewer_count += 1
try: movie.rating = 11 except ValueError as e: print(str(e))
print('Reviewer count:', movie.reviewer_count)
try: movie.reviewer_count = 5 except AttributeError as e: print(str(e))
>>> Before rating: 0 After rating: 8 Rating must between 0 ~ 10! Reviewer count: 1 can't set attribute
Use descriptors for reusable @property methods
If you want to reuse the logic in @property methods, you have to use a descriptor. The descriptor protocol defines how attribute access is interpreted by the language.
Descriptor: an object attribute with “binding behavior”, one whose attribute access has been overridden by methods in the descriptor protocol
def__set__(self, instance, value): ifnot (0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') self._instance_value_map[instance] = value
classExam: # Descriptors only works with class attributes math_grade = Grade() writing_grade = Grade() science_grade = Grade()
>>> Grade must be between 0and100 First writing grade: 82 Second writing grade: 75
Under the hood
When you assign a property as in line 28, it will be interpreted as: Exam.__dict__['writing_grade'].__set__(first_exam, 120) When you retrieve a property as in line 35, first_exam.writing_grade will be interpreted as: Exam.__dict__['writing_grade'].__get__(first_exam, Exam)
What drives this behavior is the __getattribute__ method of object. In short, when an Exam instance doesn’t have an attribute named writing_grade, Python will fall back to the Exam class’s attribute instead. If this class attribute is an object that has __get__ and __set__ methods, Python will assume you want to follow the descriptor protocol.
Note: Descriptor protocol only works with class attributes, so don’t use it with instance attributes. It’s reasonable to keep object behavior in the class definition. Otherwise, the mere act of assigning a descriptor to an instance attribute would change the object behavior. (more discussion on StackOverflow)
Why using _instance_value_map? A single Grade instance is shared across all Exam instances for the class attribute writing_grade, so we need the Grade class to keep track of its value for each unique Exam instance.
Why using WeakKeyDictionary instead of just {}? Use {} will leak memory.
The _instance_value_map will hold a reference to every instance of Exam ever passed to __set__ over the lifetime of the program, preventing cleanup by the garbage collector.
WeakKeyDictionary will remove Exam instances from its set of keys when the runtime knows it’s holding the instance’s last remaining reference in the program.
Annotate class attributes with metaclasses
Continue with the above descriptor section. You can avoid both memory leaks and the weakref module by using metaclasses along with descriptors.
def__set__(self, instance, value): ifnot (0 <= value <= 100): raise ValueError('Grade must be between 0 and 100') setattr(instance, self.internal_attr_name, value)
classMeta(type): def__new__(meta_cls, name, bases, class_dict): for key, value in class_dict.items(): if isinstance(value, Grade): value.internal_attr_name = f'_{key}' return type.__new__(meta_cls, name, bases, class_dict)
classBaseExam(metaclass=Meta): pass
classExam(BaseExam): # Descriptors only works with class attributes math_grade = Grade() writing_grade = Grade() science_grade = Grade()
Note: Another use case of metaclasses is to validate class attributes of subclasses. We can do the validation in the __new__ method and detect improper subclasses before their usage.