Python's magic methods
Tags: python, oop, programming
Published: 2024-02-02 13:56:48

Python's magic methods, or dunder methods (short for "double underscore," because they start and end with double underscores) are special methods that enable certain functionalities within Python's classes, such as operator overloading.
So here are some of Python's most important dunder methods:

### 1- `__init__` 
**syntax**: `object.__init__(self[, ...])`

We will start with the constructor itself, Consider a class called Car:
```python
class Car:
	pass
```
To add the attribute 'color' to this class, you can do it after instantiating an object like this:
```python
obj = Car()

obj.color = "red"

print(obj.color)  ##red
```
Or, you can override the `__init__` method:

```python
class Car:

def __init__(self, color) -> None:

	self.color = color

obj = Car("red")

print(obj.color) #red
```
Now, you can instantiate an object with the attribute value set from the start.


### 2- `__str__`:
**syntax**: `object.__str__(self)`

By default, printing an object of our class Car yields:
```python
print(obj) # <__main__.Car object at 0x10445ee90>
```
This output, which combines the module where the class is defined (`__main__`) and the object's memory address in hexadecimal (`0x10445ee90`), is not very helpful. To modify it for readability, override the `__str__` method:
```python
...
def __str__(self) -> str:
	return f'a {self.color} car'
```
Now, the print statement outputs:
```python
print(obj) # a red car
```


### 3- `__eq__`:
**syntax**: `object.__eq__(self, other)`

Creating two objects with the same color attribute and checking if they're equal yields:
```python
obj = Car("red")

obj2 = Car("red")

print(obj == obj2) # False
```
the default value of their equality is False because Python compares the memory address of the objects, to base the equality on another metric like their color override the `__eq__` method:
```python
def __eq__(self, other: object) -> bool:

	if (isinstance(other, Car)): # this to make sure the other object is of the same class first
		if other.color == self.color:
			return True
		return False
```
### 4- `__len__`:
**syntax**: `object.__len__(self)`

By default, calling the `len` built-in function on an object of our class results in a TypeError, stating: _object of type 'Car' has no len()_. 
To give our class a `len()` implementation, define `__len__`:
```python
def __len__(self) -> None:
	return len(self.color) 
```
Now, the length of my object corresponds to its color attribute's length:
```python
obj = Car("red")
print(len(obj)) # 3
```


### 5- `__add__`:
**syntax**: `object.__add__(self, other)`

By default, attempting to add two objects of type Car results in a TypeError, stating that the operation is unsupported. To define specific behavior for the `+` operator, implement `__add__`:
```python
def __add__(self, other):
	if (isinstance(other, Car)):
		return f"{str(self)} and {str(other)}" #the return value of str() when called upon an object of our class is the same as the __str__ method
	raise TypeError("objects are not of the same type") # raising a typeError if the objects are not both instances of Car
```
Now, adding two Car objects looks like this:
```python
obj = Car("red")

obj2 = Car("blue")

print(obj + obj2) # a red car and a blue car  
```
 the same goes for the other operators:
- `__ne__(self, other)`: Defines behavior for the inequality operator, `!=`
- `__lt__(self, other)`: Defines behavior for the less than operator, `<`
- `__gt__(self, other)`: Defines behavior for the greater than operator, `>`
- `__le__(self, other)`: Defines behavior for the less than or equal to operator, `<=`
- `__ge__(self, other)`: Defines behavior for the greater than or equal to operator, `>=`
- `__add__(self, other)`: Defines behavior for the addition operator, `+`
- `__sub__(self, other)`: Defines behavior for the subtraction operator, `-`
- `__mul__(self, other)`: Defines behavior for the multiplication operator, `*`
- `__truediv__(self, other)`: Defines behavior for the division operator, `/`


### 6- `__getitem__` and `__setitem__`:
**syntax**:
- `object.__getitem__(self, key)`
- `object.__setitem__(self, key, value)`

to add and access attributes using the syntax `obj[attribute]`
use `__getitem__` and `__setitem__`.

First, we'll modify the `__init__` method to include an `attributes` dictionary where we can store additional attributes:
```python
class Car:
	def __init__(self, color) -> None:
		self.color = color
		self.attributes = {}
```
`__setitem__` will create a key-value pair inside our attributes dictionary:

 ```python
  def __setitem__(self, key, value):
	self.attributes[key] = value
```
and `__getitem__` will return the value for the key if found, or None if not:
```python
def __getitem__(self, key):
	try:
		value = self.attributes[key] # try catch block to return a value of the attributes dictionary contains key, else return none

		return value
	except:
		return None
```
Now, operations like these are possible:

```python
obj = Car("red")

obj["model"] = "Honda civic"

print(obj["model"]) # Honda civic
```

For more information on dunder methods and specifications, here's Python's official documentation: [https://docs.python.org/3/reference/datamodel.html#special-method-names](https://docs.python.org/3/reference/datamodel.html#special-method-names)