Gradient AtlasML Materials

Data science roadmap › Python for Data Science › Chapter 13

Special Methods, Overloading and Overriding

How user-defined classes hook into Python's built-in behaviour.

13.1 Special (Dunder) Method Concepts

Dunder methods ("double underscore," also called magic methods) are methods named __like_this__ that Python calls automatically, in response to built-in syntax or built-in functions — never by writing obj.__add__(...) directly. a + b is really type(a).__add__(a, b) under the hood; print(obj) is really type(obj).__str__(obj); len(obj) is really type(obj).__len__(obj). Defining the right dunder method is how a user-defined class plugs into that built-in syntax.

The full catalog, grouped by what each group hooks into — not just a handful of examples, but every commonly used one:

Group Dunders Hooked into
Construction / destruction __init__, __new__, __del__ ClassName(...) (10.4), garbage collection
Representation __str__, __repr__ print()/str(), the REPL/repr()
Arithmetic — normal __add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __pow__ __matmul__ + - * / // % ** @
Arithmetic — reflected __radd__ __rsub__ __rmul__ __rtruediv__ __rfloordiv__ __rmod__ __rpow__ __rmatmul__ same operators, operands swapped (13.2)
Arithmetic — in-place __iadd__ __isub__ __imul__ __itruediv__ __ifloordiv__ __imod__ __ipow__ __imatmul__ += -= *= /= //= %= **= @= (13.2)
Comparison __eq__ __ne__ __lt__ __le__ __gt__ __ge__ == != < <= > >=
Hashing __hash__ hash(), use as a dict/set key (5.3.1, 5.4.6)
Container __len__ __getitem__ __setitem__ __delitem__ __contains__ len(), obj[i], obj[i] = x, del obj[i], in
Iteration __iter__, __next__ for loops, iter()/next()
Callable __call__ obj(...)
Context manager __enter__, __exit__ with obj:
Attribute access __getattr__ __setattr__ __delattr__ __getattribute__ obj.attr, obj.attr = x, del obj.attr (13.4)
Truthiness __bool__ if obj:, bool(obj)

Only implement the dunders a class actually needs — a plain data class might only ever need __init__, __repr__, and __eq__; the rest of this catalog exists to be reached for exactly when the matching syntax is genuinely useful on that class.

A class that defines __str__ and __len__ immediately gets print() and len() support, with no other change needed:

In [1]13 lines
class Box:
    def __init__(self, contents):
        self.contents = contents

    def __str__(self):
        return f"Box({self.contents!r})"

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

b = Box([1, 2, 3])
print(b)          # Box([1, 2, 3]) -> print() called __str__() automatically
print(len(b))   # 3 -> len() called __len__() automatically
Box([1, 2, 3])
3

__repr__ vs. __str__. __str__ is for a readable, user-facing display — what print()/str() use. __repr__ is for an unambiguous, developer-facing one — what the REPL and repr() use, ideally something that looks like valid Python that could recreate the object. The fallback relationship between them is one-directional: if only __repr__ is defined (as with Vector in 13.2), str() automatically falls back to it — but if only __str__ is defined, repr() does not fall back to it; it falls back to object's default <Box object at 0x...>-style text instead. And a list containing the object always calls repr() on each element, even when the object has a perfectly good __str__:

In [2]17 lines
class Box:
    def __init__(self, contents):
        self.contents = contents

    def __str__(self):
        return f"Box with {len(self.contents)} item(s)"   # readable, user-facing

    def __repr__(self):
        return f"Box({self.contents!r})"                       # unambiguous, developer-facing

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

b = Box([1, 2, 3])
print(b)          # Box with 3 item(s)   -> print() calls __str__
print(repr(b))   # Box([1, 2, 3])          -> repr() calls __repr__, ignores __str__ entirely
print([b])         # [Box([1, 2, 3])]        -> a list prints repr() of its elements, even though __str__ exists
Box with 3 item(s)
Box([1, 2, 3])
[Box([1, 2, 3])]

In practice — this is what makes a stack trace actually useful. When an Order or User object shows up in a log line or an unhandled exception's traceback, seeing Order(id=42, status='paid') instead of <Order object at 0x7f3a1c> is the difference between debugging a production incident in seconds versus having to go add print statements and reproduce it again — a useful __repr__ on every domain class is a small, cheap habit with an outsized payoff during an outage.

Comparison dunders work the same way — == calls __eq__, < calls __lt__, and so on. By default, == compares identity (2.4) — defining __eq__ replaces that with whatever comparison actually makes sense for the class:

In [3]17 lines
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __eq__(self, other):
        return self.amount == other.amount   # compare values, not identity

    def __lt__(self, other):
        return self.amount < other.amount

a = Money(10)
b = Money(10)
c = Money(20)

print(a == b)   # True -> calls a.__eq__(b), compares amounts
print(a is b)     # False -> still two separate objects (2.4)
print(a < c)      # True -> calls a.__lt__(c)
True
False
True

Common mistake — assuming the other comparisons come for free. Comparison operators pair up as reflections of each other — </>, <=/>=, and ==/!= (each its own reflection) — so a > c actually tries a.__gt__(c) first, and only because that's missing does Python fall back to the reflected form, c.__lt__(a). <= has no such relationship to __lt__ at all — it's paired with __ge__ instead — so it fails outright unless defined:

In [4]8 lines
print(a > c)   # False -> no __gt__ defined, so Python tries c.__lt__(a) instead: 20 < 10 is False

try:
    a <= c   # <= has nothing to do with __lt__ - it needs __le__ (or __ge__ on the other side)
except TypeError as e:
    print("TypeError:", e)   # '<=' not supported between instances of 'Money' and 'Money'

print(a != c)   # True -> __ne__ wasn't defined either, but Python auto-inverts __eq__ for it
False
TypeError: '<=' not supported between instances of 'Money' and 'Money'
True

Common mistake — defining __eq__ silently makes instances unhashable. Python's default object.__hash__ assumes equal objects hash the same, which the identity-based default can no longer promise once a class defines its own value-based __eq__ — so Python responds by setting that class's __hash__ to None automatically. That breaks anything needing a hash: hash() directly, or using the object as a dict key/set member (5.3.1, 5.4.6):

In [5]9 lines
try:
    hash(a)   # a = Money(10), from above - __eq__ was defined, so Python set Money.__hash__ = None
except TypeError as e:
    print("TypeError:", e)   # unhashable type: 'Money'

try:
    {a}   # a set needs to hash its members too
except TypeError as e:
    print("TypeError:", e)   # unhashable type: 'Money'
TypeError: unhashable type: 'Money'
TypeError: unhashable type: 'Money'

The fix — define __hash__ explicitly, alongside __eq__, so that objects considered equal also hash identically (a hard requirement for correct dict/set behavior):

In [6]17 lines
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __eq__(self, other):
        return self.amount == other.amount

    def __lt__(self, other):
        return self.amount < other.amount

    def __hash__(self):             # must be re-defined explicitly - not inherited automatically
        return hash(self.amount)     # equal Money objects now hash identically too

m1 = Money(10)
m2 = Money(10)
print(m1 == m2, hash(m1) == hash(m2))   # True True -> equal objects, equal hashes, as hashing requires
print(len({m1, m2}))                            # 1 -> the set correctly treats them as duplicates
True True
1

In practice — value objects like Money or a geographic Coordinate. A real billing system routinely needs to deduplicate or group transactions by amount, and a mapping/geospatial system needs to use coordinates as dict keys or set members for fast lookup — both require exactly the correctly-paired __eq__/__hash__ shown above; getting only one of the two defined is a real, easy-to-hit bug in this kind of code.

What's allowed: overloading vs. overriding, at a glance. Two ideas get confused constantly — "redefining the same name more than once in one class" (overloading) and "redefining a name a parent class already gave a class" (overriding) are entirely different mechanisms, with entirely different levels of support:

Allowed in Python? Why
Operator overloading ✅ yes implement the matching dunder (__add__, __eq__, ...) — this whole section is about exactly that
Method overloading ❌ no a class body just runs top to bottom; a second def greet(...) replaces the first, it doesn't add a second signature
Constructor overloading ❌ no __init__ is just a method — the same replace-not-add rule applies to it too
Method overriding ✅ yes a subclass redefining a method its parent already had — this is routine, everyday inheritance (12.3)
Constructor overriding ✅ yes a subclass redefining __init__ — also routine, typically paired with super().__init__(...) (12.4)

The next two sections dive into each half of that table in detail: 13.2 covers operator overloading (the "yes" case above involving operators specifically), and 13.3 covers why method/constructor overloading fails and confirms that overriding, by contrast, works exactly as expected.

13.2 Operator Overloading

Python doesn't let you change what + does for built-in types, but operator overloading is fully supported for your own classes: implement the matching dunder method, and that operator works on your objects too. v1 + v2 calls v1.__add__(v2):

In [7]14 lines
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)   # Vector(4, 6) -> calls v1.__add__(v2)
Vector(4, 6)

Reflected methods handle the operator with the operand order flipped. 5 + v1 first tries (5).__add__(v1) — but int has no idea what a Vector is, so it backs off (returns NotImplemented), and Python then tries v1.__radd__(5) instead. Without __radd__ defined, both sides give up and it's a TypeError:

In [8]4 lines
try:
    5 + v1   # int.__add__(5, v1) -> NotImplemented, and Vector has no __radd__ either
except TypeError as e:
    print("TypeError:", e)   # unsupported operand type(s) for +: 'int' and 'Vector'
TypeError: unsupported operand type(s) for +: 'int' and 'Vector'

This is exactly why sum() silently fails on a list of custom objects — sum() always starts from 0 and computes 0 + first_item before adding the rest, which hits this exact int + Vector case:

In [9]6 lines
vectors = [Vector(1, 1), Vector(2, 2), Vector(3, 3)]

try:
    total = sum(vectors)   # internally: 0 + Vector(1,1) + Vector(2,2) + Vector(3,3)
except TypeError as e:
    print("TypeError:", e)   # unsupported operand type(s) for +: 'int' and 'Vector'
TypeError: unsupported operand type(s) for +: 'int' and 'Vector'

The fix: implement __radd__ and special-case 0 — the one value sum() will ever add from the left that isn't a real Vector:

In [10]18 lines
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __radd__(self, other):   # called for "other + self" when other's __add__ gave up
        if other == 0:                 # handles sum()'s implicit "0 + first_item" start
            return self
        return NotImplemented

    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

vectors = [Vector(1, 1), Vector(2, 2), Vector(3, 3)]
print(sum(vectors))   # Vector(6, 6) -> works now
Vector(6, 6)

In practice — this is literally how NumPy arrays support +/*. Writing array1 + array2 to add two NumPy arrays element-wise, or Money(10) + Money(5) in a billing system so + respects currency rules, works through exactly this mechanism — a library or domain class implementing __add__/__radd__ (and friends) so its objects behave naturally with Python's own arithmetic syntax instead of needing a separate .add() method call.

Common mistake — confusing NotImplemented with NotImplementedError. They read almost identically but are entirely different things. NotImplemented (returned above, in __radd__) is a sentinel object — returning it tells Python's operator dispatch to try the other operand instead, exactly how 5 + v1 above ended up trying v1.__radd__(5). NotImplementedError (raised in 12.6's Shape.area) is an ordinary exception — raising it stops execution immediately, signaling "this method genuinely isn't written yet." Returning NotImplementedError instead of NotImplemented (or raising NotImplemented instead of NotImplementedError) is a real, easy-to-make mix-up that silently breaks the mechanism each one is meant to support:

In [11]11 lines
class Oops:
    def __add__(self, other):
        return NotImplementedError   # BUG: the EXCEPTION CLASS, not the NotImplemented sentinel

o = Oops()
print(o + 1)   # <class 'NotImplementedError'> -> no error at all, just garbage output instead of a clean TypeError

try:
    raise NotImplemented   # BUG: NotImplemented is a plain object, not an exception - it can't be raised
except TypeError as e:
    print("TypeError:", e)   # exceptions must derive from BaseException
<class 'NotImplementedError'>
TypeError: exceptions must derive from BaseException

In-place methods handle the augmented form directly — obj += x calls obj.__iadd__(x) if it exists, and unlike __add__ it's expected to mutate the object itself and return it, rather than building a new one:

In [12]16 lines
class Basket:
    def __init__(self, items):
        self.items = items

    def __iadd__(self, other):
        self.items.extend(other)   # mutate in place...
        return self                       # ...and return self, instead of a new object

    def __repr__(self):
        return f"Basket({self.items})"

basket = Basket([1, 2])
original_id = id(basket)
basket += [3, 4]   # calls basket.__iadd__([3, 4])
print(basket)                             # Basket([1, 2, 3, 4])
print(id(basket) == original_id)   # True -> same object, mutated in place
Basket([1, 2, 3, 4])
True

Without __iadd__, += just falls back to obj = obj.__add__(x) — which builds a new object and rebinds the name to it:

In [13]15 lines
class NoInPlace:
    def __init__(self, items):
        self.items = items

    def __add__(self, other):
        return NoInPlace(self.items + other)   # always builds a NEW object

    def __repr__(self):
        return f"NoInPlace({self.items})"

n = NoInPlace([1, 2])
original_id = id(n)
n += [3, 4]   # no __iadd__ -> falls back to n = n + [3, 4]
print(n)                             # NoInPlace([1, 2, 3, 4])
print(id(n) == original_id)   # False -> a different object now
NoInPlace([1, 2, 3, 4])
False

The full arithmetic operator family — every operator follows the same three-part pattern just demonstrated (normal, reflected, in-place):

Operator Normal Reflected In-place
+ __add__ __radd__ __iadd__
- __sub__ __rsub__ __isub__
* __mul__ __rmul__ __imul__
/ __truediv__ __rtruediv__ __itruediv__
// __floordiv__ __rfloordiv__ __ifloordiv__
% __mod__ __rmod__ __imod__
** __pow__ __rpow__ __ipow__
@ (matrix multiplication, 13.1) __matmul__ __rmatmul__ __imatmul__
& __and__ __rand__ __iand__
\| __or__ __ror__ __ior__
^ __xor__ __rxor__ __ixor__
<< __lshift__ __rlshift__ __ilshift__
>> __rshift__ __rrshift__ __irshift__

Only implement the ones an operation actually makes sense for — Vector above only needed __add__/__radd__/__iadd__; a class with no sensible % or << behavior simply leaves those dunders undefined, and Python raises TypeError for that operator on its own, exactly like it did for 5 + v1 above.

13.3 Method Overloading Behaviour in Python

Overloading — defining multiple versions of the same name, distinguished by signature (different parameter counts/types) — is not supported in Python, for either ordinary methods or constructors. A class body just executes top to bottom (9.1's "code that runs" idea, applied to a class, per 10.4): a second def with the same name doesn't add an overload, it simply replaces the first definition, which is now gone entirely:

In [14]14 lines
class Greeter:
    def greet(self):                # this definition...
        return "Hello!"

    def greet(self, name):        # ...is completely replaced by this one
        return f"Hello, {name}!"

g = Greeter()
print(g.greet("Ada"))   # Hello, Ada! -> only the one-argument version exists at all now

try:
    g.greet()   # the zero-argument version is simply gone
except TypeError as e:
    print("TypeError:", e)   # Greeter.greet() missing 1 required positional argument: 'name'
Hello, Ada!
TypeError: Greeter.greet() missing 1 required positional argument: 'name'

The exact same thing happens to __init__ — it's just another method name, with no special exemption (10.4 first showed this exact gotcha):

In [15]16 lines
class Point:
    def __init__(self, x):          # this constructor...
        self.x = x
        self.y = 0

    def __init__(self, x, y):     # ...is completely replaced by this one
        self.x = x
        self.y = y

p = Point(1, 2)
print(p.x, p.y)   # 1 2 -> only the two-argument version exists at all

try:
    Point(1)   # the one-argument version is simply gone
except TypeError as e:
    print("TypeError:", e)   # Point.__init__() missing 1 required positional argument: 'y'
1 2
TypeError: Point.__init__() missing 1 required positional argument: 'y'

The idiomatic alternative is a single method with default/variable arguments (6.2) — one definition that handles every call shape, instead of several competing definitions:

In [16]9 lines
class Greeter:
    def greet(self, name=None):   # one method, optional parameter, instead of two overloads
        if name is None:
            return "Hello!"
        return f"Hello, {name}!"

g = Greeter()
print(g.greet())          # Hello!
print(g.greet("Ada"))   # Hello, Ada!
Hello!
Hello, Ada!

Going deeper — default/variable arguments (above) handle one function with optional pieces, but that's not true type-based dispatch. For genuinely different logic per type of argument, functools.singledispatch (for plain functions) and functools.singledispatchmethod (for methods) are the standard-library, real-world way professional Python code gets overload-like behavior — registering one implementation per type instead of branching manually inside a single function body.

Overriding is a completely different thing, and it is fully supported — both for ordinary methods and for __init__. The distinction:

Overloading Overriding
Where same class, same name, different signatures parent and child class, same name
Supported in Python? no — later definition just replaces the earlier one yes — this is routine, everyday inheritance
Covered in this section 12.3 (methods), 12.4/10.4 (__init__, via super())

A subclass redefining __init__ or any other method — in a different class from where it was first defined — works exactly as expected, with no ambiguity at all:

In [17]17 lines
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "some sound"

class Dog(Animal):
    def __init__(self, name, breed):   # overriding __init__ - a DIFFERENT class, allowed
        super().__init__(name)
        self.breed = breed

    def speak(self):                       # overriding speak() - allowed
        return "Woof!"

d = Dog("Rex", "Labrador")
print(d.name, d.breed, d.speak())   # Rex Labrador Woof!
Rex Labrador Woof!

13.4 Getter and Setter Methods

The classic OOP pattern from other languages — a plain get_x()/set_x() method pair guarding a "private" field — works fine in Python too:

In [18]21 lines
class Person:
    def __init__(self, name):
        self._name = name

    def get_name(self):             # traditional getter method
        return self._name

    def set_name(self, value):   # traditional setter method
        if not value:
            raise ValueError("name cannot be empty")
        self._name = value

p = Person("Ada")
print(p.get_name())      # Ada
p.set_name("Grace")
print(p.get_name())      # Grace

try:
    p.set_name("")
except ValueError as e:
    print("ValueError:", e)   # name cannot be empty
Ada
Grace
ValueError: name cannot be empty

This works, but it's not the idiomatic Python style — @property (10.6) does the same job while keeping the caller-facing syntax as plain attribute access (p.name, not p.get_name()). Both get_name()/set_name() and a @property handle one named attribute. The dunders below are different in kind: each one intercepts every attribute on the object at once, generically.

__getattr__ is a fallback — it only runs when normal attribute lookup already failed. It's the tool for attributes that don't really exist as stored data, but can be computed on demand:

In [19]18 lines
class Circle:
    def __init__(self, radius):
        self.radius = radius

    def __getattr__(self, name):
        # only called when normal lookup FAILS - "radius" itself never reaches here
        if name == "area":
            return 3.14159 * self.radius ** 2
        raise AttributeError(f"'Circle' object has no attribute {name!r}")

c = Circle(2)
print(c.radius)   # 2 -> found normally, __getattr__ never even runs
print(c.area)      # 12.56636 -> "area" lookup failed, __getattr__ computed it on the fly

try:
    c.diameter
except AttributeError as e:
    print("AttributeError:", e)   # 'Circle' object has no attribute 'diameter'
2
12.56636
AttributeError: 'Circle' object has no attribute 'diameter'

In practice — lazy-loading related data, the way an ORM does. An ORM object representing a database row often defers fetching a related record (e.g. an Order's customer) until the attribute is actually accessed for the first time — implemented with exactly this __getattr__ fallback, running the extra query only when the data is genuinely needed instead of eagerly loading everything up front.

__setattr__ is not a fallback — it runs on every single attribute assignment, letting one method act like a setter for the whole object at once:

In [20]9 lines
class Logged:
    def __setattr__(self, name, value):
        print(f"setting {name} = {value!r}")
        super().__setattr__(name, value)   # delegate to the real assignment

l = Logged()
l.x = 10        # setting x = 10
l.y = "hello"   # setting y = 'hello'
print(vars(l))   # {'x': 10, 'y': 'hello'}
setting x = 10
setting y = 'hello'
{'x': 10, 'y': 'hello'}

Going deeper — everything above assumes instances keep their attributes in a per-instance __dict__, which is exactly what vars(obj) reads (vars() is literally shorthand for obj.__dict__). A class built with __slots__ (10.2) has no instance __dict__ at all: vars() raises TypeError on such an instance, and a __getattr__ fallback can't lean on scanning a dict that was never created.

Common mistake — infinite recursion. self.x = value inside __setattr__ would call __setattr__ again to perform that very assignment — forever. The super().__setattr__(name, value) call above is what actually stores it, by reaching past this override to the real, underlying assignment mechanism:

In [21]12 lines
import sys
sys.setrecursionlimit(50)   # fail fast for this demo, instead of hanging

class Broken:
    def __setattr__(self, name, value):
        self.name = value   # BUG: this assignment calls __setattr__ again -> infinite recursion

try:
    broken = Broken()
    broken.x = 1
except RecursionError as e:
    print("RecursionError:", e)   # maximum recursion depth exceeded
RecursionError: maximum recursion depth exceeded

__delattr__ is the same idea, for del obj.attr (2.5) — and needs the same super() delegation to avoid the same recursion trap:

In [22]9 lines
class LoggedDelete:
    def __delattr__(self, name):
        print(f"deleting {name}")
        super().__delattr__(name)

ld = LoggedDelete()
ld.x = 10
del ld.x   # deleting x
print(vars(ld))   # {}
deleting x
{}

__getattribute__ is the most powerful — and most dangerous — of the four. Unlike __getattr__, it runs on every attribute read, even ones that already exist, which means it's rarely overridden directly; almost any use of self.anything inside it would trigger itself again. Delegating to super().__getattribute__(name) for the actual lookup is what keeps it from recursing:

In [23]12 lines
class Watched:
    def __init__(self, x):
        self.x = x

    def __getattribute__(self, name):
        print(f"accessing {name!r}")
        return super().__getattribute__(name)   # delegate to the real lookup, avoid recursion

w = Watched(5)
print(w.x)
# accessing 'x'   -> printed even though x already exists - __getattribute__ runs for EVERY access
# 5
accessing 'x'
5

13.5 Container and Callable Protocols

13.1's catalog table listed the container protocol (__len__ __getitem__ __setitem__ __delitem__ __contains__) and the callable protocol (__call__) without ever actually implementing most of them — Box (13.1) only defined __str__ and __len__ so far. Filling in the rest turns it into something that supports box[i], box[i] = x, del box[i] and x in box, exactly like a list:

In [24]30 lines
class Box:
    def __init__(self, contents):
        self.contents = list(contents)

    def __str__(self):
        return f"Box({self.contents!r})"

    def __len__(self):
        return len(self.contents)          # len(box)

    def __getitem__(self, index):
        return self.contents[index]         # box[i]

    def __setitem__(self, index, value):
        self.contents[index] = value        # box[i] = value

    def __delitem__(self, index):
        del self.contents[index]            # del box[i]

    def __contains__(self, item):
        return item in self.contents        # item in box

box = Box([1, 2, 3])
print(len(box))              # 3           -> __len__
print(box[1])                  # 2           -> __getitem__
box[1] = 99                   # __setitem__
print(box[1])                  # 99
del box[0]                     # __delitem__
print(box.contents)          # [99, 3]
print(99 in box, 5 in box)   # True False  -> __contains__
3
2
99
[99, 3]
True False

In practice — a paginated API results wrapper. A class wrapping one page of API results so callers can write len(results), results[0], and "item-42" in results exactly as if it were a plain list — while it might actually be lazily fetching more pages behind the scenes — is a very common real use of the container protocol, alongside game-development classes like a Deck of cards or a Hand of tiles.

The callable protocol — __call__ makes instances directly callable with obj(...), just like a function. A minimal example: a Multiplier whose instances remember a factor and apply it when called:

In [25]10 lines
class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, value):
        return value * self.factor

double = Multiplier(2)
print(double(5))          # 10 -> double(5) is really double.__call__(5)
print(callable(double))   # True -> callable() checks whether __call__ exists
10
True