Effective Python Note 2

This article is composed of some notes from book Effective Python.

Use @property to define special behavior when attributes are accessed/set on your objects, if necessary

Another use case is to only specify getter to make that attribute read-only.

property_decorator_example.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class Movie:
def __init__(self):
super().__init__()
self._rating = 0
self._reviewer_count = 0 # read-only attribute

@property
def rating(self):
"""Getter for _rating"""

return self._rating

@rating.setter
def rating(self, value):
"""Setter for _rating"""

if not isinstance(value, int):
raise ValueError('Rating must be an integer!')
if not (0 <= value <= 10):
raise ValueError('Rating must between 0 ~ 10!')
self._rating = value
self._reviewer_count += 1

@property
def reviewer_count(self):
return self._reviewer_count


movie = Movie()
print('Before rating:', movie.rating)
movie.rating = 8
print('After rating:', movie.rating)

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

Descriptor Protocol:
descriptor.__get__(self, obj, type=None) -> value
descriptor.__set__(self, obj, value) -> None
descriptor.__delete__(self, obj) -> None

descriptor_example.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
from weakref import WeakKeyDictionary


class Grade:
def __init__(self):
self._instance_value_map = WeakKeyDictionary()

def __get__(self, instance, owner):
if instance is None:
return self
return self._instance_value_map.get(instance, 0)

def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError('Grade must be between 0 and 100')
self._instance_value_map[instance] = value


class Exam:
# Descriptors only works with class attributes
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()


first_exam = Exam()
try:
first_exam.writing_grade = 120
except ValueError as e:
print(str(e))
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75

print('First writing grade:', first_exam.writing_grade)
print('Second writing grade:', second_exam.writing_grade)


>>>
Grade must be between 0 and 100
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.

descriptor_with_metaclass_example.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Grade:
def __init__(self):
self.internal_attr_name = None # will be assigned by the metaclass

def __get__(self, instance, owner):
if instance is None:
return self
return getattr(instance, self.internal_attr_name, 0)

def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError('Grade must be between 0 and 100')
setattr(instance, self.internal_attr_name, value)


class Meta(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)


class BaseExam(metaclass=Meta):
pass


class Exam(BaseExam):
# Descriptors only works with class attributes
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()


first_exam = Exam()
try:
first_exam.writing_grade = 120
except ValueError as e:
print(str(e))
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75

print('First writing grade:', first_exam.writing_grade)
print('Second writing grade:', second_exam.writing_grade)


>>>
Grade must be between 0 and 100
First writing grade: 82
Second writing grade: 75

The __new__ method of metaclasses
It’s run immediately after the class statement’s entire body has been processed.

If using print(meta_cls, name, bases, class_dict, sep='\n', end='\n\n') in the first line of __new__ in the above program:

1
2
3
4
5
6
7
8
<class '__main__.Meta'>
BaseExam
()
{'__module__': '__main__', '__qualname__': 'BaseExam'}
<class '__main__.Meta'>
Exam
(<class '__main__.BaseExam'>,)
{'__module__': '__main__', '__qualname__': 'Exam', 'math_grade': <__main__.Grade object at 0x1021855c0>, 'writing_grade': <__main__.Grade object at 0x1021855f8>, 'science_grade': <__main__.Grade object at 0x102185630>}

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.

#

評論

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×