What makes a Pythonic Object? Part I
How to Make our Objects Behave as Built-In Python Objects by Implementing Python Magic Methods
The Python Data Model makes it possible for user-defined types to behave as naturally as the built-in types. By implementing the correct methods, we can make sure our objects will act as we’d expect through duck typing.
In the 11th chapter of the book Fluent Python, the author shows us how to build user-defined classes that behave as real Python objects, he develops a class Vector2d to showcase this.
Today we will follow his steps and we will visit these topics:
- How to represent an object with str() and repr() ?
- How to implement an alternative class constructor?
- How to support the object format() method?
- How to make our attributes private or read only?
- How to make our object hashable?
- How to use __slots__ to save memory?
How to generate Object Representations?
Python’s Standard Library, unlike any other programming language, offers us 2 Special Methods to string represent a Pythonic Object:
- The first is the __repr__ Special method called with repr(), returning a string representing of the object as the developer wants to see it, i.e. It is used for debugging.
- The second is the __str__ Special method called with str(), returning a string representation of the object as the end user wants to see it, i.e. It’s what we see when we execute the print().
There are 2 additional methods that we can use to get an alternative object representation:
- The first is the __bytes__ Special method called by bytes() to get the object represented as a byte sequence.
- The second is the __format__ Special method used by f-strings, by the built-in function format(), and by the str.format() method. They call obj.__format__(format_spec) to get string displays of objects using special formatting codes.
In order to demonstrate the many methods used to generate object representations and to make our object Pythonic, we’ll implement the Vector2d class like in the original book.
Use case: Vector2d Class
Here we find different Special Methods implemented including the __bytes__, __repr__ and __str__ used to represent a Python Object.
To generate bytes, we convert the typecode to bytes and concatenate bytes converted from an array built by iterating over the instance.
How to add an alternative constructor?
Our first goal is to build a method for the Vector2d class that takes a binary Vector2d sequence and returns a Vector2d instance, and since we support the bytes() method, this step seems logical to take.
This won’t be a constructor, however, it has a similar purpose as it constructs a Vector2d instance from a binary sequence(Alternative Constructor). To implement this we first have to discuss static and class methods in Python.
Comparing @classmethod and @staticmethod in Python
Pythons offers us 2 ways to declare a class method or what is commonly known as static methods:
- The first is with the @staticmethod Decorator, this is a plain and simple static method, it is a class method that lives in the class body and operates on the class not the instances. It isn’t used so often but if it is, we use it to create utility functions.
- The second is the @classmethod Decorator, this is similar to the @staticmethod Decorator only with the class itself as the first argument, and it's commonly used for alternative constructors or factory methods to build instances.
Now for our Vector2d class, we’re adding a class method frombytes to build an instance from a binary sequence.
How to format Object Representation and String Display?
To format a String, we use the built-in function format(), this calls the Special Method __format__(format_spec) in our class.
The formatting specifier(format_spec) can be:
- 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().
For example:
In Python, formatting itself uses a sub language called Formatting Specification Mini Language, this is basically a syntax that allows modifiers to be placed into a particular placeholder to give it additional meaning and it uses specific type modifiers like 's' for strings.
Few built-in types have their own presentation codes or type modifiers in the Format Specification Mini-Language, and it is extensible, i.e. each class gets to interpret the format_spec argument as it likes.
For instance, the classes in the date time module use the same format codes in the strftime() functions and in their __format__ methods.
It’s important to note that if a class has not implemented the __format__ method, the default behavior would be to return str(obj) but we do not want that for our Vector2d class, we need to support formatting to display the vector in polar coordinates if the format specifier ends with ‘p’ and if not we will simply display the vector components.
To do this we need to extend the Format Specification Mini-Language too and implement our own __format__ method.
The Formatting Specification is quite extensive you can find more about it here.
How to make an Object Hashable?
So far we have learned how to display or represent our object, how to build an object with an alternative constructor and how to customize our object’s formatted display. However, We still can’t use them in a Set or a Dictionary because they have no unique hash. To do this we have to implement the __hash__ and __eq__ Special Method and make our instances Immutable.
How to make an object Immutable?
Currently, a user can create a Vector2d instance and then update the x or the y value, however it is essential to make our properties read-only for the object to be hashable.
What we want is to get this Exception when we try to set a Vector2d instance attributes: AttributeError: can't set attribute.
To do this, we have to change our Vector2d Implementation, i.e. we add the missing Special Methods and make our properties read-only by modifying the __init__ Special Method.
The hash() method should return an integer, and it would be ideal if it takes into account the hashes of the objects components that are also used in the __eq__ method, since objects that compare to be equal should have the same hash. In our case we compute the hash of a tuple with its components.
How to Make our Object Support Positional Pattern Matching?
Pattern Matching in Python can be done with keywords or with attribute positions, and so far we can support Keyword Pattern Matching but Not Positional Pattern Matching, i.e. We can’t do this:
The class attribute __match_args__ is necessary in order to make Vector2d compatible with positional patterns, and this attribute should list the instance attributes in the order they will be used for positional pattern matching.
After adding the missing attribute, we get this Class with everything modified and added so far:
Conclusion of the First Part
Up till now, we have seen some critical Special Methods that we may choose to use to have a complete Python object. There are still a few topics to cover such as optimizing memory consumption with slots and how to make our properties private, but since this has gone for a while, we'll split the work into two different Parts, hence the Part I in the title. Meanwhile, check out these resources that could give you further insight.
Further Reading
- The “Data Model” chapter of The Python Language Reference.
- Python in a Nutshell, covers Special Methods in detail.
- Python Cookbook, Modern Python practices demonstrated through recipes. Chapter 8, “Classes and Objects” in particular is quite useful.
- Python Essential Reference, Covers the data model in detail.
- The 11th chapter of the book Fluent Python.