Make your code more Pythonic with Magic Methods

Jakub BarańskiBy Jakub Barański

Table of Content

    Dunders

    If you spend some time with Python you will eventually hear about something called “dunder”. Even if you haven’t and this name sounds odd to you I can bet that you’ve used it multiple times and just didn’t know what was called. To make it easier — dunder is an abbreviation of two words: “double under”, where “under” is yet another abbreviation, this time of the word “underscore”. Knowing this, you probably already know what I am talking about. Yes, the Python __init__ method is one of many dunders (also sometimes called “magic method”) that we can use in Python.

    Dunders are internal cogs that do work when on the surface we use built-in methods, operators and keywords that make Python so easy to read and write. For a good start, we can look at which dunders are provided with the base object that every other class inherits.

    Straight off the bat we have 15 dunders. Let’s just take some of them and see what they do. If you’ve ever used Django you are probably familiar with the __str__ method, used often when declaring models. This is the one responsible for returning the string representation of… well, anything in Python. Whenever you use the built-in function str(), this dunder will be called and it will decide what string will be returned. Similarly, when you use print to log something in the console, the __str__ dunder will be called.

    Some dunders are called when you use specific keywords in Python. For example, __delattr__ is called when you use the keyword del, as when deleting dictionary entries for instance.

    dictionary = { 'key': value }
    del dictionary['key']

    Of course, these 15 dunders are just a small fraction of what can be used. Some of the more useful are ones that allow us to override operators. This is a concept known for a very long time in other programming languages. In C++ you can overload operators to have a special meaning when used with objects that provide the overload definition. In Python you can do the same by implementing, for example, the __add__ method that is responsible for handling the addition operator. To demonstrate, we can create a simple class that will allow us to “simplify” adding elements to a list.

    So, we implemented the Appender class that overrides the __add__ method to append a new element to a list it keeps inside every time we use a + operator. After that we return the Appender instance so we can do it many times over by chaining operators.

    How about something useful?

    Ok, so Appender shows that you can do some creative stuff with operators in Python to cause non-standard behavior of the code. Some of it is really useful and is even in the Python standard library. For example, have you ever wondered how you add timedelta to the datetime object? Yup, operator overload. Here is the example from the cpython repository, file datetime.py.

    One of the interesting approaches of using dunders can also be found in pathlib. It is a library meant to help you work with filesystem. Before it was included in the standard library (it’s been there since Python 3.4) you would most likely have used os.path for creating path names and opening files. However, if you did it even once you know how cumbersome that was. Say you want to get the parent directory of the current code file. You can use os.path.

    import os
    parent_dir = os.path.dirname(os.path.abspath(__file__))

    Not that bad, right? Well, this is just the directory of the file. Imagine you want to go a bit lower; you have to wrap that thing in another os.path.dirname invocation. Alternatively, you can use pathlib for a smoother approach.

    from pathlib import Path
    print(Path(__file__).resolve().parent)

    Easier, right? But what does it have to do with dunders anyway? Well, pathlib also introduces one neat trick to help you create paths. Let’s say you want to create a path from your working directory to the directory foo that is in the directory bar. In pathlib you can do it like this:

    p = Path('/etc')
    p / 'init.d' / 'redis-server'

    Dividing the Path object by a string will work similarly to our Appender class. It will join path and return a new Path object, ready to be joined again with another string. Thanks to the usage of the __truediv__ (or __div__ if you are still using Python 2.x) we can create paths in a way that is intuitive and similar to how paths are represented in string format. Here is how it looks in the source code:

    ORMs love dunders

    If you’ve ever used any Python web framework, you’ve probably also used ORM for easier database handling. During my work at Profil Software - Python Development Company I usually use Django as my backend web framework and with it - a built-in Django ORM. If you were using other, less feature-complete frameworks (like Flask or Pyramid) you would probably use SQLAlchemy. Both of these libraries heavily use Python magic methods to make database usage as painless as possible.

    1.png

    For example, creating a connection between a model field and an actual database uses something called descriptors. The __get__, __set__, and __delete__ methods are, respectively, three of the dunders that control what happens when values are read, set or deleted. Overriding each of these magic methods controls what happens when you manipulate model fields.

    So, when you have a User object and you try to access a value of the related field, the code in the __get__ descriptor is fired, data is fetched from the database, and you get straight to the value. Behavior like this is possible because of descriptors. The entire descriptor function for Django models is quite long so I won’t place it here, but you can see it directly on GitHub.

    2.png

    This is only one example. ORMs use these mechanics heavily. Let’s see how you would create a query using SQLAlchemy using a NOT statement. You actually have two options of doing that. If we want to get users that don’t start with “bob”, you can do this:

    from sqlalchemy.sql.expression import not_

    User.query.filter(not_(User.username.startswith('bob')))

    Alternatively, you can skip the import of the NOT expression and use a bitwise NOT like so:

    User.query.filter(~User.username.startswith('bob'))

    Both will produce the same outcome thanks to ColumnElement subclasses implementing their own __invert__ dunder that controls operator behavior. With this you can skip the import of the not_ expression and reduce the length of your code line while still preserving the general explicitness of what you want to achieve.

    With great power comes great responsibility…

    3.png

    This is, of course, just a small sample of what can be done with dunder mechanisms. If you know of other interesting implementations of operator overriding, let me know in the comments section, and if you want to read more about Python and coding check out our articles on 2 Questions to Ask Before Choosing Python Frameworks and 10 things you need to know to effectively use Django Rest Framework.

    Python is a language that gives you a lot of freedom in what you do with your code. Dunders allow you to get extremely creative with how you approach various problems and let you to write very intuitive frameworks, ORMs and libraries for you and others to use. It lets you write code that looks natural even for someone that doesn’t have a lot of experience in programming. It also allows you to write an ugly, obfuscated code if you want to be a bad person.

    Dunders are a feature that allow you to do both of these things at the same time and that’s why it is so important to keep in mind that readability, intelligibility and widely accepted good practices should be paramount in your code.

    Profil Software is not only a Python Development Company, we also offer React Native Agency and DevOps Services, and we cover these subjects in our blog as well so feel free to check it out.

    Got an idea and want to launch your product to the market?

    Get in touch with our experts.

    Max length: 1000
    Jakub BarańskiJakub Barański