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
[...]