Skip to content

magic methods

Python's magic methods are methods that are defined in classes and are used to provide functionality for certain built-in operations. These methods start and end with double underscores, such as __init__ and __add__. Magic methods allow objects to behave like built-in objects, such as numbers and sequences, and enable operators to be overloaded. For example, the `add method allows objects to use the + operator to add two instances of a class. Magic methods also provide a way to customize the behavior of objects in Python. They are called "magic" because they are used behind the scenes to make things work, and are not typically called directly by the programmer. In other words, they're a great way of creating clean and easy to use objects, and hide internal logic. Magic methods are a powerful tool in Python, and understanding them is crucial for creating well-designed and flexible classes.

There are a few different cartegories of magic methods: - creation - representation - comparison - attribute access - descriptors - container operations - numeric operations - context managers - type conversion - sequences - callables

Creation

__new__(cls, ...) This is the constructor method; the first method called on instantiation. The arguments it received are passed to the __init__ function. Using this method directly is uncommon.

__init__(self, ...)
This is the initializer and used to initialize an object with some values.

__del__(self) This is the destructor and is used in garbage collection. Since Python handles this automagically, there is almost no use for implementing this method yourself.

Representation
__str__

String representation of a object. This is basically a nice way to display a useful name of an object.

__repr__

Same as __str__, but mostly used for development. When a list of objects is printed, it looks for repr

__doc__

get the docstring of the class (or method)

__bytes__

Returns the bytes representation when bytes() is called.

__hash__

In Python, a dictionary is a hash-map, the use case of __hash__ in Python is simply as a hash function for hashing the key in hash tables. Any fast hash function can be used, but there are some rules: - it should return a integer - the hash of an object should be immutable - when using the __eq__ method, it compares the result of __hash__ , so that method should exist, or it returns a error.

class Product:
    '''Unique Product'''
    amount = []
    name = ''

    def __init__(self, a, name):
        self.a = a
        self.name = name

    def __str__(self) -> str:
        return f"Product name: {self.name or 'Empty'}"

    def __repr__(self) -> str:
        return f':{self.name}::'

    def __bytes__(self) -> bytes:
        return b'42'

    def __hash__(self) -> int:
        return reduce(ixor, list(self.name.encode('utf8')), 0)

    def __eq__(self, other) -> bool:
        return hash(self) == hash(other)


p1 = Product(a = 1, name ='')
p2 = Product(a = 2, name ='')
p1.__doc__  # __doc__
p1          # __str__
repr(p1)    # __repr__
bytes(p1)   # __bytes__
hash(p2)    # __hash__

if p2 == p1: # __hash  __eq__
    ...

[p1,p2]     # __repr__

attribute access

__getattr__(self, name)

This method is called whenever a attribute does not exist. This can be useful for catching deprecated functions, or setting default behaviour.

__setattr__(self, name, value)

This method sets the values in the instance dictionary and is called everytime a attribute is set (including on initialization).

__delattr__(self, name)

This method deletes the values in the instance dictionary.

__getattribute__(self, name)

This method is called whenever a attribute is directly accessed, even when it does not exist.

class Item:

    def __getattr__(self, name):
        print(f'attribute does not exist: {name}')

    def __setattr__(self, key, value):
        logger(key, value)

numeric operations

You can define your own numeric type in Python by defining methods like __add__, __sub__ , ... There are many of them, so only a few are listed here.

class Complex:
    def __init__(self, real, imag):
        self.real = real
        self.imag = imag

    def __add__(self, other):
        return Complex(self.real + other.real, self.imag + other.imag)

    def __sub__(self, other):
        return Complex(self.real - other.real, self.imag - other.imag)

    def __mul__(self, other):
        return Complex(self.real*other.real - self.imag*other.imag,
                       self.real*other.imag + self.imag*other.real)

    def __truediv__(self, other):
        denominator = other.real**2 + other.imag**2
        return Complex((self.real*other.real + self.imag*other.imag) / denominator,
                       (self.imag*other.real - self.real*other.imag) / denominator)

In this example, we define a custom Complex class with real and imag attributes. We implement the __add__, __sub__, __mul__, __truediv__ methods to define addition, subtraction, multiplication, and division operations for complex numbers.

operator chaining

In the following we define a custom Vector class with x and y attributes. We implement the __mul__ method to define a dot product operation for vectors. We then chain multiple operator calls together to calculate the dot product of a and b, add it to the dot product of c and b, and store the result in the result variable.

class Vector:
def __init__(self, x, y):
    self.x = x
    self.y = y

def __mul__(self, other):
    return self.x*other.x + self.y*other.y

a = Vector(1, 2)
b = Vector(3, 4)
c = Vector(5, 6)

result = a*b + c*b
print(result)  # Output: 17
in-place operators
class RunningAverage:
    def __init__(self):
        self.count = 0
        self.total = 0

    def __iadd__(self, other):
        self.count += 1
        self.total += other
        return self

    def __float__(self):
        return self.total / self.count

avg = RunningAverage()
avg += 1
avg += 2
avg += 3
result = float(avg)
print(result)  # Output: 2.0

RunningAverage class with count and total attributes. We implement the __iadd__ method to update the count and total attributes in-place when a new value is added.

Sequence

Use the __contains__ method to define whether an item is in a sequence. For example, you could create a custom class that represents a deck of cards:

class Card:
def __init__(self, rank, suit):
    self.rank = rank
    self.suit = suit

def __repr__(self):
    return f"{self.rank} of {self.suit}"

class Deck:
    ranks = [str(n) for n in range(2, 11)] + list("JQKA")
    suits = ["hearts", "diamonds", "clubs", "spades"]

    def __init__(self):
        self.cards = [Card(rank, suit) for suit in self.suits for rank in self.ranks]

    def __len__(self):
        return len(self.cards)

    def __getitem__(self, index):
        return self.cards[index]

    def __contains__(self, card):
        return card in self.cards

my_deck = Deck()
print(len(my_deck))  # Output: 52
print(Card("10", "hearts") in my_deck)  # Output: True
Containers

__contains__(self, item) allows you to define custom behavior for the in operator on an object. For example:

class MyList:
    def __init__(self, items):
        self.items = items

    def __contains__(self, item):
        return item in self.items

    def __len__(self):
        return len(self.items)

my_list = MyList([1, 2, 3])
print(2 in my_list)  # Output: True
print(4 in my_list)  # Output: False

[...]