# 25. Python's \_\_magic\_\_ Methods

Here Python code snippets that showcase the use of Python's **dunder methods** (a.k.a. magic methods) like `__init__`, `__str__`, `__repr__`, and others, to customize class behavior.

***

**1. `__init__`: Constructor Method**

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

p = Person("Alice", 30)
print(p.name)  # Alice
print(p.age)   # 30
```

***

**2. `__str__`: String Representation for Humans**

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return f"{self.name} is {self.age} years old."

p = Person("Alice", 30)
print(p)  # Alice is 30 years old.
```

***

**3. `__repr__`: String Representation for Debugging**

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __repr__(self):
        return f"Person(name={self.name}, age={self.age})"

p = Person("Alice", 30)
print(repr(p))  # Person(name=Alice, age=30)
```

***

**4. `__add__`: Overloading the `+` Operator**

```python
class Number:
    def __init__(self, value):
        self.value = value

    def __add__(self, other):
        return Number(self.value + other.value)

    def __repr__(self):
        return f"Number({self.value})"

n1 = Number(5)
n2 = Number(10)
print(n1 + n2)  # Number(15)
```

***

**5. `__getitem__`: Index Access for Custom Objects**

```python
class CustomList:
    def __init__(self, items):
        self.items = items

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

cl = CustomList([10, 20, 30])
print(cl[1])  # 20
```

***

**6. `__len__`: Custom Length Calculation**

```python
class CustomList:
    def __init__(self, items):
        self.items = items

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

cl = CustomList([1, 2, 3, 4])
print(len(cl))  # 4
```

***

**7. `__eq__`: Overloading `==` for Equality Check**

```python
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, other):
        return self.name == other.name and self.age == other.age

p1 = Person("Alice", 30)
p2 = Person("Alice", 30)
p3 = Person("Bob", 25)
print(p1 == p2)  # True
print(p1 == p3)  # False
```

***

**8. `__call__`: Making Objects Callable**

```python
class Greeter:
    def __init__(self, greeting):
        self.greeting = greeting

    def __call__(self, name):
        return f"{self.greeting}, {name}!"

greet = Greeter("Hello")
print(greet("Alice"))  # Hello, Alice!
```

***

**9. `__del__`: Destructor Method**

```python
class Resource:
    def __init__(self, name):
        self.name = name
        print(f"Resource {self.name} created.")

    def __del__(self):
        print(f"Resource {self.name} destroyed.")

res = Resource("FileHandler")
del res  # Resource FileHandler destroyed.
```

***

**10. `__contains__`: Custom Behavior for `in`**

```python
class CustomContainer:
    def __init__(self, items):
        self.items = items

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

container = CustomContainer([1, 2, 3])
print(2 in container)  # True
print(5 in container)  # False
```

***

These examples demonstrate how dunder methods let you override and customize the behavior of built-in Python operations, allowing you to create intuitive, powerful, and expressive classes.
