Dunder methods ("double underscore," like __init__) let a class customize how built-in operators and functions behave with its instances — Python's mechanism for operator overloading.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other): # defines what + does for Vector + Vector
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other): # defines what == does
return self.x == other.x and self.y == other.y
def __repr__(self): # defines what print()/repr() shows
return f"Vector({self.x}, {self.y})"
def __len__(self): # defines what len() returns
return int((self.x ** 2 + self.y ** 2) ** 0.5)
v1, v2 = Vector(1, 2), Vector(3, 4)
print(v1 + v2) # Vector(4, 6) — calls __add__, then __repr__ to display it
print(v1 == Vector(1, 2)) # True — calls __eq__
__init__ (constructor) and __repr__ (how an object prints) are the two dunder methods you'll write the most often — without __repr__, print(v1) would show something unhelpful like <__main__.Vector object at 0x...> instead of a readable representation.
Other common dunder methods: __str__ (used by str()/print() specifically, falls back to __repr__ if absent), __len__ (used by len()), __getitem__ (enables obj[key] indexing), __iter__ (makes an object iterable in a for loop), and __call__ (lets an instance be called like a function, instance()). Nearly every "magic" behavior of built-in types — why [1,2] + [3,4] concatenates, why len("abc") works, why for x in my_list: iterates — is implemented via exactly these same dunder methods on the built-in types themselves, meaning custom classes can participate in the same protocols as built-ins by implementing the matching dunders.