A Pythonic Object🔗
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.
But if you are writing a library or a framework, the programmers who will use your classes may expect them to behave like the classes that Python provides. Fulfilling that expectation is one way of being “Pythonic.”
Object Representation🔗
Every object-oriented language has at least one standard way of getting a string representation from any object. Python has 2
repr()
: return a string representing the object as the developer want to see it. Its what we get using python console or debugger.str()
: returns a string representing the object as user want to see it.print()
calls it.
There are two additional special methods to support alternative representations of objects: __bytes__
and __format__
.
Vector Class Redux🔗
from array import array
import math
class Vector2d:
typecode = 'd' # Class Attribute
def __init__(self, x, y):
self.x = float(x) # early conversion to prevent bugs
self.y = float(y)
def __iter__(self):
return (i for i in (self.x, self.y)) # makes vector iterable
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self) # builds string
def __str__(self):
return str(tuple(self)) # tuple for display
def __bytes__(self): # to generate `bytes` we convert typecode to bytes and concatenate
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other) # comparison
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
An Alternative Contructor🔗
Since we can export a Vector2d
as bytes, naturally we need a method that imports a Vector2d
from a binary sequence. Looking at the standard library for inspiration, we find that array.array
has a class method named .frombytes
that suits our purpose
@classmethod # modifies method to be directly callable on a class
def frombytes(cls, octets): # note we pass cls args rather than self.
typecode = chr(octets[0]) # read typecode from first byte
memv = memoryview(octets[1:]).cast(typecode) # create a memory view
return cls(*memv) # unpack memory view resulting from the cast
classmethod v/s staticmethod🔗
The classmethod
decorator is not mentioned in the Python tutorial, and neither is staticmethod
. Anyone who has learned OO in Java may wonder why Python has both of these decorators and not just one of them.
classmethod
: to define 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- the
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.
class Demo:
@classmethod
def Klassmeth(*args):
return args
@staticmethod
def statmeth(*args):
return args
Demo.klassmeth() # (<class '__main__.Demo'>,)
Demo.klassmeth('spam') # (<class '__main__.Demo'>, 'spam')
Demo.statmeth() # ()
Demo.statmeth('spam') # ('spam',)
The classmethod
decorator is clearly useful, but good use cases for staticmethod
are very rare in my experience.
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)
, or - Whatever appears after the colon in a replacement field delimited with
{}
inside an f-string or thefmt
infmt.str.format()
brl = 1/4.82
brl # 0.20746887966804978
format(brl, '0.4f') # 0.2075
"1 BRL = {rate:0.2f} USD".format(rate=brl)
f"1 USD = {1/brl:0.2f} BRL" # f string syntax
The notation used in the formatting specifier is called the Format Specification Mini-Language.
The Format Specification Mini-Language is extensible because each class gets to interpret the format_spec
argument as it likes. For instance, the classes in the datetime
module use the same format codes in the strftime()
functions and in their __format__
methods.
from datetime import datetime
now = datetime.now()
format(now, "%H%M:%s")
"It's now {:%I:%M %p}".format(now)
# inside the Vector2d class
def __format__(self, fmt_spec=''):
components = (format(c, fmt_spec) for c in self) # apply spec to each component
return '({}, {})'.format(*components) # plug formatted string in (x,y)
# more improved version that support polar co-ordinates using p
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)
# output
format(Vector2d(1, 1), 'p')
format(Vector2d(1, 1), '.3ep')
format(Vector2d(1, 1), '0.5fp')
A Hashable Vector2d🔗
So far Vector2d
instances are not hashable so we can’t put them in a set
To make it hashable we need to implement __hash__
and __eq__
(already done) and make vector instances immutable. Because anyone can change them right now using v1.x = 0 and there is nothing in code to prevent that.
class Vector2d:
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x) # use __ to make attribute private
self.__y = float(y)
@property # marks the getter method of a property
def x(self): # getter must be named after the public property it exposes
return self.__x # just return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __hash__(self):
return hash((self.x, self.y)) # hashed the tuple of co-ordinates
# remaining methods: same as previous Vector2d
Supporting Positional Pattern Matching🔗
So far, Vector2d instances are compatible with keyword class patterns—covered in “Keyword Class Patterns”.
def keyword_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(x=0, y=0):
print(f'{v!r} is null')
case Vector2d(x=0):
print(f'{v!r} is vertical')
case Vector2d(y=0):
print(f'{v!r} is horizontal')
case Vector2d(x=x, y=y) if x==y:
print(f'{v!r} is diagonal')
case _:
print(f'{v!r} is awesome')
However if try to use a position pattern like this: it fails
To make Vector2d
work with positional patterns, we need to add a class attribute named __match_args__
, listing the instance attributes in the order they will be used for positional pattern matching:
Now we can use positional patterns
def positional_pattern_demo(v: Vector2d) -> None:
match v:
case Vector2d(0, 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')
Complete Listing of Vector2d, version3🔗
from array import array
import math
class Vector2d:
__match_args__ = ('x', 'y')
typecode = 'd'
def __init__(self, x, y):
self.__x = float(x)
self.__y = float(y)
@property
def x(self):
return self.__x
@property
def y(self):
return self.__y
def __iter__(self):
return (i for i in (self.x, self.y))
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
def __str__(self):
return str(tuple(self))
def __bytes__(self):
return (bytes([ord(self.typecode)]) +
bytes(array(self.typecode, self)))
def __eq__(self, other):
return tuple(self) == tuple(other)
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return bool(abs(self))
def angle(self):
return math.atan2(self.y, self.x)
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)
@classmethod
def frombytes(cls, octets):
typecode = chr(octets[0])
memv = memoryview(octets[1:]).cast(typecode)
return cls(*memv)
Private and “Protected” Attributes in Python🔗
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.
Consider this scenario: someone wrote a class named Dog
that uses a mood
instance attribute internally, without exposing it. You need to subclass Dog
as Beagle
. If you create your own mood
instance attribute without being aware of the name clash, you will clobber the mood
attribute used by the methods inherited from Dog
. This would be a pain to debug.
To prevent this, if you name an instance attribute in the form __mood
(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, so in the Dog
class, __mood
becomes _Dog__mood
, and in Beagle
it’s _Beagle__mood
. This language feature goes by the lovely name of name mangling.
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.
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 - even with the optimizations implemented.
- 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 adict
class Pixel:
__slots__ = ('x', 'y')
p = Pixel()
p.__dict__ # throws attribute error
p.x = 10
p.y = 20
p.color = 'Red' # throws attribute error
# slots are not inherited
class OpenPixel(Pixel):
pass
op = OpenPixel()
op.__dict__ # works
op.color = 'green' # works
class ColorPixel(Pixel):
__slots__ = ('color',)
cp = ColorPixel()
cp.__dict__ # raises attribute error
cp.x = 10
cp.color = 'red'
cp.flavor = 'banana' # raises attribute error
Issues with __slots__
🔗
- you must remember to redeclare
__slots__
in each subclass to prevent their instance having__dict__
- instances will only be able to have attributes listed in
__slots__
, unless we include__dict__
in slots. - Classes using
__slots__
can’t use@chached_property
decorator, unless they explicitly name__dict__
in__slots__
- Instances can’t be targets of weak references unles you add
__weakref__
in__slots__
Overriding Class Attributes🔗
- class attributes can be used as default values for instance attributes
- we can access them as
self.attribute_name
- if we declare a instance attribute then the class attribute is left untouched and
self.attribute_name
will always reference to instance attribute. - If you want to change a class attribute, you must set it on the class directly, not through an instance.
- In python more idiomatic way to achieve it how Django does it. Create subclasses which change class attributes.
- Another thing to note is following function which doesn’t hardcodes class name in its representation
# inside class Vector2d:
def __repr__(self):
class_name = type(self).__name__
return '{}({!r}, {!r})'.format(class_name, *self)
This way f
typecodes work fine in representation as well.