Fluent Python—A Pythonic Object

1. Vector Class Redux

  • Thanks to the Python Data Model, your user-defined types can behave as naturally as the built-in types. And this can be accomplished without inheritance, in the spirit of duck typing: you just implement the methods needed for your objects to behave as expected
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
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
from array import array
import math
class Vector2d:
# class attribute
typecode = 'd'
# support positional pattern matching (does not need to include all public instance attributes)
__match_args__ = ('x', 'y')
# save memory
#__slots__ = ('__x', '__y')
# instance attribute
def __init__(self, x, y):
# leading __ can make attribute private
self.__x = float(x)
self.__y = float(y)
# making x and y read-only properties (getter)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
# make class iterable, support unpacking, *self, etc.
def __iter__(self):
# yield self.x; yield self.y
return (i for i in (self.x, self.y)) # <class 'generator'>
# support python console or debugger
def __repr__(self):
# support subclasses
class_name = type(self).__name__
# !r tells Python to use the repr() function on the value before inserting it into the string
return '{}({!r}, {!r})'.format(class_name, *self)
# support print()
def __str__(self):
return str(tuple(self))
# get the object represented as a byte sequence
def __bytes__(self):
# convert the typecode to bytes and concatenate bytes converted from an array built by iterating over the instance
return (bytes([ord(self.typecode)]) + bytes(array(self.typecode, self)))
# support ==
def __eq__(self, other):
return tuple(self) == tuple(other)
# support abs()
def __abs__(self):
return math.hypot(self.x, self.y)
# support bool()
def __bool__(self):
return bool(abs(self))
# create instance from a binary sequence
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
# create a memoryview from the binary sequence and use the typecode to cast it
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv)
def angle(self):
return math.atan2(self.y, self.x)
# support f-strings, format(), str.format()
def __format__(self, fmt_spec=''):
if fmt_spec.endswith('p'):
fmt_spec = fmt_spec[:-1]
coords = (abs(self), self.angle())
outer_fmt = '<{}, {}>'
else:
coords = self
outer_fmt = '({}, {})'
components = (format(c, fmt_spec) for c in coords)
return outer_fmt.format(*components)
# hashable: 1. __hash__; 2. __eq__; [3. immutable instance;]
def __hash__(self):
# take into account the hashes of the object attributes that are also used in the __eq__ method
return hash((self.x, self.y))

v1 = Vector2d(3, 4)
#v1.x = 7 # AttributeError: property 'x' of 'Vector2d' object has no setter
print(v1.x, v1.y) # 3.0 4.0
x, y = v1
print(x, y) # 3.0 4.0
print(repr(v1)) # Vector2d(3.0, 4.0)
v1_clone = eval(repr(v1))
print(v1 == v1_clone) # True
print(v1 == [3, 4]) # True
print(v1) # (3.0, 4.0)
print(bytes(v1)) # b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
print(abs(v1)) # 5.0
print(bool(v1), bool(Vector2d(0, 0))) # True False
print(Vector2d.frombytes(bytes(v1))) # (3.0, 4.0)
print(format(v1, '.3f')) # (3.000, 4.000)
print(format(v1, 'p')) # <5.0, 0.9272952180016122>
print(format(v1, '.3ep')) # <5.000e+00, 9.273e-01>
print(format(v1, '0.5fp')) # <5.00000, 0.92730>
print(hash(v1)) # 1079245023883434373
def pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} is null')
case Vector2d(0):
print(f'{v!r} is vertical')
case Vector2d(_, 0):
print(f'{v!r} is horizontal')
case Vector2d(x, y) if x==y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
pattern_demo(Vector2d(0, 0)) # Vector2d(0.0, 0.0) is null
pattern_demo(Vector2d(0, 1)) # Vector2d(0.0, 1.0) is vertical
pattern_demo(Vector2d(1, 0)) # Vector2d(1.0, 0.0) is horizontal
pattern_demo(Vector2d(1, 1)) # Vector2d(1.0, 1.0) is diagonal
pattern_demo(Vector2d(1, 2)) # Vector2d(1.0, 2.0) is awesome

2. classmethod Versus staticmethod

  • classmethod decorator defines a method that operates on the class and not on instances. classmethod changes the way the method is called, so it receives the class itself as the first argument, instead of an instance. Its most common use is for alternative constructors
  • staticmethod decorator changes a method so that it receives no special first argument. In essence, a static method is just like a plain function that happens to live in a class body, instead of being defined at the module level
1
2
3
4
5
6
7
8
9
10
11
class Demo:
@classmethod
def klassmeth(*args):
return args
@staticmethod
def statmeth(*args):
return args
print(Demo.klassmeth()) # (<class '__main__.Demo'>,)
print(Demo.klassmeth('spam')) # (<class '__main__.Demo'>, 'spam')
print(Demo.statmeth()) # ()
print(Demo.statmeth('spam')) # ('spam',)

3. Formatted Displays

  • The f-strings, the format() built-in function, and the str.format() method delegate the actual formatting to each type by calling their .__format__(format_spec) method. The format_spec is a formatting specifier, which is either:
    • The second argument in format(my_obj, format_spec)
    • Whatever appears after the colon in a replacement field delimited with {} inside an f-string or the fmt in fmt.str.format()
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
# https://docs.python.org/3/library/string.html#formatspec

brl = 1 / 4.82 # BRL to USD currency conversion rate
print(brl) # 0.20746887966804978
print(format(brl, '0.4f')) # 0.2075
print('1 BRL = {rate:0.2f} USD'.format(rate=brl)) # 1 BRL = 0.21 USD
# evaluates the expression first, then formats the result
print(f'1 USD = {1 / brl:0.2f} BRL') # 1 USD = 4.82 BRL

# a few built-in types have their own presentation codes
# integers use the codes 'bcdoxXn', floats use 'eEfFgGn%', and strings use 's'
print(format(42, 'b')) # 101010
print(format(2 / 3, '.1%')) # 66.7%
# extensible format_spec
from datetime import datetime
now = datetime.now()
print(format(now, '%H:%M:%S')) # 11:30:06
print("It's now {:%I:%M %p}".format(now)) # It's now 11:30 AM

# if a class has no __format__, the method inherited from object returns str(my_object)
class Pi:
def __str__(self):
return "3.14159265359"
u = Pi()
print(format(u)) # 3.14159265359
#print(format(u, '.3f')) # TypeError: unsupported format string passed to Pi.__format__

4. Private and “Protected” Attributes

  • In Python, there is no way to create private variables like there is with the private modifier in Java. What we have in Python is a simple mechanism to prevent accidental overwriting of a “private” attribute in a subclass
  • If you name an instance attribute with two leading underscores and zero or at most one trailing underscore, Python stores the name in the instance __dict__ prefixed with a leading underscore and the class name. This language feature goes by the lovely name of name mangling
1
2
3
4
5
v1 = Vector2d(3, 4)
print(v1.__dict__) # {'_Vector2d__x': 3.0, '_Vector2d__y': 4.0}
print(v1._Vector2d__x) # 3.0
v1._Vector2d__x = 7
print(v1) # (7, 4.0)
  • The name mangling functionality is not loved by all Pythonistas, and neither is the skewed look of names written as self.__x. Some prefer to avoid this syntax and use just one underscore prefix to “protect” attributes by convention (e.g., self._x)
  • The single underscore prefix has no special meaning to the Python interpreter when used in attribute names, but it’s a very strong convention among Python programmers that you should not access such attributes from outside the class. It’s easy to respect the privacy of an object that marks its attributes with a single _, just as it’s easy respect the convention that variables in ALL_CAPS should be treated as constants
  • In modules, a single _ in front of a top-level name does have an effect: if you write from mymod import *, the names with a _ prefix are not imported from mymod. However, you can still write from mymod import _privatefunc

5. Saving Memory with __slots__

  • By default, Python stores the attributes of each instance in a dict named __dict__. A dict has a significant memory overhead. But if you define a class attribute named __slots__ holding a sequence of attribute names, Python uses an alternative storage model for the instance attributes: the attributes named in __slots__ are stored in a hidden array or references that use less memory than a dict
  • The __slots__ class attribute may provide significant memory savings if properly used, but there are a few caveats:
    • You must remember to redeclare __slots__ in each subclass to prevent their instances from having __dict__
    • Instances will only be able to have the attributes listed in __slots__, unless you include ‘__dict__‘ in __slots__ (but doing so may negate the memory savings)
    • Classes using __slots__ cannot use the @cached_property decorator, unless they explicitly name ‘__dict__‘ in __slots__
    • Instances cannot be targets of weak references, unless you add ‘__weakref__‘ in __slots__
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
class Pixel:
# must be present when the class is created; adding or changing it later has no effect
# the attribute names may be in a tuple or list
__slots__ = ('x', 'y')
p = Pixel()
#print(p.__dict__) # AttributeError: 'Pixel' object has no attribute '__dict__'
p.x = 10
p.y = 20
#p.color = 'red' # AttributeError: 'Pixel' object has no attribute 'color'

# the effect of __slots__ is only partially inherited by a subclass
class OpenPixel(Pixel):
pass
op = OpenPixel()
print(op.__dict__) # {}
op.x = 8
print(op.__dict__) # {}
# x is stored in the hidden array of references in the instance
print(op.x) # 8
op.color = 'green'
print(op.__dict__) # {'color': 'green'}

# declare __slots__ again in the subclass
class ColorPixel(Pixel):
__slots__ = ('color',)
cp = ColorPixel()
#print(cp.__dict__) # AttributeError: 'ColorPixel' object has no attribute '__dict__'
cp.x = 2
cp.color = 'blue'
#cp.flavor = 'banana' # AttributeError: 'ColorPixel' object has no attribute 'flavor'

# use both feature
class DynamicPixel(Pixel):
__slots__ = ('__dict__',)
dp = DynamicPixel()
print(dp.__dict__) # {}
dp.x = 2
dp.color = 'blue'
print(dp.__dict__) # {'color': 'blue'}

6. Overriding Class Attributes

  • A distinctive feature of Python is how class attributes can be used as default values for instance attributes. In Vector2d there is the typecode class attribute, we read it as self.typecode. Because Vector2d instances are created without a typecode attribute of their own, self.typecode will get the Vector2d.typecode class attribute by default. But if you write to an instance attribute that does not exist, you create a new instance attribute, and the class attribute by the same name is untouched. However, from then on, whenever the code handling that instance reads self.typecode, the instance typecode will be retrieved
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
v1 = Vector2d(1.1, 2.2)
dumpd = bytes(v1)
print(dumpd) # b'd\x9a\x99\x99\x99\x99\x99\xf1?\x9a\x99\x99\x99\x99\x99\x01@'
print(len(dumpd)) # 17
v1.typecode = 'f'
dumpf = bytes(v1)
print(dumpf) # b'f\xcd\xcc\x8c?\xcd\xcc\x0c@'
print(len(dumpf)) # 9
print(Vector2d.typecode) # d

#Vector2d.typecode = 'f'
# class attributes are public, they are inherited by subclasses
class ShortVector2d(Vector2d):
typecode = 'f'
sv = ShortVector2d(1/11, 1/27)
print(repr(sv)) # ShortVector2d(0.09090909090909091, 0.037037037037037035)
print(len(bytes(sv))) # 9

References

  • Fluent Python