Compiled on: 2025-06-30 — printable version
(cf. https://en.wikipedia.org/wiki/Programming_paradigm)
Mainstream programming languages are actually blending multiple paradigms
(cf. https://en.wikipedia.org/wiki/Object-oriented_programming)
OOP nicely addresses all three dimensions for non-distributed systems
Everything is an object
A program is a bunch of objects telling each other what to do by sending messages
Every object can have its own memory (state) composed by other objects
Every object is an instance of a class
Every object has an interface, which defines what messages it can receive
An object is a container of attributes. These can be of 3 sorts:
Classes are blueprints for objects
Objects are created as instances of classes
Objects can use other objects, by calling their methods or accessing their fields or properties
Light
classclass Light:
def __init__(self, initially_on: bool):
self.state = initially_on
@property
def is_off(self) -> bool:
return not self.state
@is_off.setter
def is_off(self, value: bool):
self.state = not value
def switch(self):
self.state = not self.state
def to_string(self) -> str:
return f"Light(state={'on' if self.state else 'off'})"
l = Light(True)
print(l.to_string()) # Light(state=on)
l.switch()
print(l.to_string()) # Light(state=off)
l.state = True
print(l.to_string()) # Light(state=on)
l.is_off = True
print(l.to_string()) # Light(state=off)
print(l.is_off) # True
Object of type Light
have:
a field state
storing a boolean
True
if that light is on, False
otherwisea settable property is_off
corresponding to the opposite of state
a method switch
that toggles the state
of the light
a constructor __init__
that initializes the state
of the light to its initial value (initially_on
)
Light)
self
?You can think about a class as group of functions, which all have an implicit first argument, self
Via self
, you can access the other attributes of an object, from within a method of that object
self.state
in the switch
method of the Light
classThe name self
is a convention, but you can use any name you want
self
for readabilityLight
class above, by simply renaming self
to this
:
class Light:
def __init__(this, initially_on: bool):
this.state = initially_on
@property
def is_off(this) -> bool:
return not this.state
# etc.
You can imagine that variable self is automatically passed to the method when it is called
l.switch()
is equivalent to Light.switch(l)
class MemoryCell:
def __init__(self, initial_value: int):
self.value = 0
self.assign(initial_value)
def assign(self, value: int):
if not in range(0, 256):
raise ValueError("Value must in the [0, 255] range")
self.value = value
class Counter:
def __init__(self, initial_value: int = 0):
self.value = initial_value
def increment(self, delta: int = 1):
self.value += delta
def decrement(self, delta: int = 1):
self.increment(-delta)
class Calculator:
def __init__(self):
self.expression = ""
def __ensure_is_digit(self, value: int | str):
if isinstance(value, str):
value = int(value)
if value not in range(10):
raise ValueError("Value must a digit in [0, 9]: " + value)
return value
def __append(self, value):
self.expression += str(value)
def digit(self, value: int | str):
value = self.__ensure_is_digit(value)
self.__append(value)
def input(self, symbol):
self.__append(symbol[0])
def clear(self):
self.expression = ""
def compute_result(self) -> int | float:
try:
from math import sqrt, cos, sin
result = eval(self.expression)
if isinstance(result, Number):
self.expression = str(result)
return result
else:
raise ValueError("Result is not a number: " + str(result))
except Exception as e:
expression = self.expression
self.expression = ""
raise ValueError("Invalid expression: " + expression) from e
The idea of presenting a consistent interface [of an object, to its users] that is independent of its internal implementation
A means to achieve encapsulation, by restricting/controlling access to the internal details of an object
Commonly achieved by separating how an object is used from how it is implemented
Commonly involves enforcing some invariants on the object’s state
Light
classLet’s suppose that
0
to 255
) where:
0.0
to 100.0
) by the user0—100%
range when setting the intensity100%
Light
classclass Light:
def __init__(self, initially_on: bool):
self.__intensity: int = int(initially_on * 255)
self.__last_intensity: int = 255
@property
def is_on(self) -> bool:
return self.__intensity > 0
@property
def intensity(self) -> float:
return self.__intensity / 255 * 100
@intensity.setter
def intensity(self, value: float):
if not 0 <= value <= 100:
raise ValueError("Intensity must be in the [0, 100] range")
self.__intensity = int(value / 100 * 255)
def switch(self):
if self.__intensity > 0:
self.__last_intensity = self.__intensity
self.__intensity = 0
else:
self.__intensity = self.__last_intensity
def to_string(self) -> str:
return f"Light(intensity={self.__intensity})"
l = Light(initially_on=False)
print(l.to_string()) # Light(intensity=0)
l.intensity = 50
print(l.to_string()) # Light(intensity=127)
l.switch()
print(l.to_string()) # Light(intensity=0)
print(l.is_on) # False
print(l.intensity) # 0.0
l.switch()
print(l.to_string()) # Light(intensity=127)
l.intensity = 150 # ValueError: Intensity must be in the [0, 100] range
print(l.__intensity) # AttributeError: 'Light' object has no attribute '__intensity'
The intensity
and last_intensity
attributes are private (i.e., their names start with __
)
The technical detail “intensity is represented as a byte” is hidden from the user
The invariant “intensity must be in the [0, 100] range” is enforced by the setter of the intensity
property
ValueError
is raisedTwo objects are identical if they are the same object in memory
This is checked using the is
operator
a is b
returns True
if a
and b
are the same objectTwo objects are equal if they have the same value
This is checked using the ==
operator
__eq__
method which defines what equality means for that classclass Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
a = Complex(1, 2) # (1 + i * 2)
b = a
c = Complex(1, 2) # (1 + i * 2)
d = Complex(2, 1) # (2 + i * 1)
print(a is a) # True
print(a is b) # True
print(a is c) # False
print(a is d) # False
print(a == a) # True
print(a == b) # True
print(a == c) # True
print(a == d) # False
__eq__
methodsNo object is equal to None
(x == None
should always return False
)
None
, returning False
if it is
NoneType
errorsCheck whether the other object is an instance of the same class of the current object
AttributeError
errorsisinstance(other, CurrentClass)
or type(other) == type(self)
or isinstance(other, type(self))
Alternatively, check whether the other object has the same attributes as the current object
AttributeError
errorshasattr(other, 'attribute')
for each attribute of the current object that you want to compareCompare the attributes of the other object with the attributes of the current object
self.attribute == other.attribute
for each attribute of the current object that you want to comparea = Complex(1, 2) # (1 + i * 2)
b = a
c = Complex(1, 2) # (1 + i * 2)
d = Complex(2, 1) # (2 + i * 1)
s = {a, b, c, d}
print(len(s)) # TypeError: unhashable type: 'Complex'
(cf. https://www.pythonmorsels.com/what-are-hashable-objects/)
Long and complex discussion
Put simply, you need objects to be hashable in order for them to be used within a set
or a dict
An object is hashable if it has a __hash__
method that returns a semi-unique hash value for that object
__hash__
method__hash__
method if you implement the __eq__
method (and vice versa)hash()
function, containing all and only the attributes that you compared in the __eq__
method
hash((self.attribute1, self.attribute2))
class Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
def __hash__(self):
return hash((self.re, self.im))
a = Complex(1, 2) # (1 + i * 2)
b = a
c = Complex(1, 2) # (1 + i * 2)
d = Complex(2, 1) # (2 + i * 1)
s = {a, b, c, d}
print(len(s)) # 2
print(s) # {<__main__.Complex object at 0x78d202e09520>, <__main__.Complex object at 0x78d202e09580>}
What’s <__main__.Complex object at 0x78d202e09520>
?
By default, Python represents objects as <package-name.ClassName object at 0x78d202e09520>
Better would be to represent objects as strings that are meaningful to the user
Complex(1, 2)
instead of <__main__.Complex object at 0x78d202e09520>
There are two more magic methods to be implemented in Python classes, for the sake of string representation
__str__
: returns a user-friendly string representation of the object__repr__
: returns a developer-friendly string representation of the objectIf __str__
is not implemented, Python will use __repr__
as a fallback
__repr__
is not implemented, Python will use the default representation
__repr__
__str__
is used by the print()
and the str()
functions, as well as in f-strings
print(obj)
is equivalent to print(str(obj))
, which is equivalent to print(obj.__str__())
f"{obj}"
is equivalent to f"{str(obj)}"
, which is equivalent to f"{obj.__str__()}"
__repr__
is used by the repr()
function, as well as when the object is represented as part of a collection
repr(obj)
is equivalent to obj.__repr__()
f"{obj!r}"
is equivalent to f"{obj.__repr__()}"
print([obj1, obj2, ...])
is equivalent to print('[', obj1.__repr__(), ', ', obj2.__repr__(), ', ...]')
Long and complex discussion, see: https://realpython.com/python-repr-vs-str
__repr__
should return a string that is unambiguous and complete
Complex(1, 2)
instead of <__main__.Complex object at 0x78d202e09520>
__str__
should return a string that is readable and informative
1 + i * 2
instead of Complex(1, 2)
class Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
def __hash__(self):
return hash((self.re, self.im))
def __repr__(self):
return f"Complex({self.re}, {self.im})"
def __str__(self):
sign = '+' if self.im >= 0.0 else '-'
im = abs(self.im)
im = str(im) if im != 1.0 else ""
return f"{self.re} {sign} i{im}" if self.im != 0.0 else str(self.re)
a = Complex(2, 2)
b = Complex(-1, 1)
c = Complex(1, -1)
d = Complex(-2, -2)
e = Complex(3, 0)
print(a) # 2 + i2
print(b) # -1 + i
print(c) # 1 - i
print(d) # -2 - i2
print(e) # 3
s = {a, b, c, d, e}
print(len(s)) # 5
print(s) # {Complex(-2.0, -2.0), Complex(-1.0, 1.0), Complex(1.0, -1.0), Complex(3.0, 0.0), Complex(2.0, 2.0)}
Objects are immutable if their state cannot be changed after they are created
int
, float
, str
, tuple
, frozenset
Objects are mutable if their state can be changed after they are created
list
, dict
, set
Immutability is a good thing, as it makes objects easier to reason about, and safer to use
Yet, immutability can be inconvenient in some cases
When designing a class, you should decide whether it should be immutable or mutable design
The attributes of an immutable object should be read-only
The attributes of an immutable object should be immutable themselves
list
, it should be replaced by a tuple
State-changing methods should apply the copy-on-write pattern
Make classes immutable by default, and mutable only when necessary
Alternatively, support both mutability and immutability
(cf. https://en.wikipedia.org/wiki/Complex_number)
class Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def add(self, other):
return Complex(self.re + other.re, self.im + other.im)
def subtract(self, other):
return Complex(self.re - other.re, self.im - other.im)
def multiply(self, other):
return Complex(self.re * other.re - self.im * other.im, self.re * other.im + self.im * other.re)
def divide(self, other):
if other.re == 0 and other.im == 0:
raise ZeroDivisionError("Cannot divide by zero: " + str(self))
denominator = other.re ** 2 + other.im ** 2
return Complex((self.re * other.re + self.im * other.im) / denominator, (self.im * other.re - self.re * other.im) / denominator)
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
def __hash__(self):
return hash((self.re, self.im))
def __repr__(self):
return f"Complex({self.re}, {self.im})"
def __str__(self):
sign = '+' if self.im >= 0.0 else '-'
im = abs(self.im)
im = str(im) if im != 1.0 else ""
return f"{self.re} {sign} i{im}" if self.im != 0.0 else str(self.re)
a = Complex(3, 2)
b = Complex(-1, 1)
print(a.add(b)) # 2.0 + i3.0
print(a.subtract(b)) # 4.0 + i
print(a.multiply(b)) # -5.0 + i
print(a.divide(b)) # -0.5 - i2.5
class Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def add(self, other):
self.re += other.re
self.im += other.im
def subtract(self, other):
self.re -= other.re
self.im -= other.im
def multiply(self, other):
re = self.re * other.re - self.im * other.im
im = self.re * other.im + self.im * other.re
self.re, self.im = re, im
def divide(self, other):
if other.re == 0 and other.im == 0:
raise ZeroDivisionError("Cannot divide by zero: " + str(self))
denominator = other.re ** 2 + other.im ** 2
re = (self.re * other.re + self.im * other.im) / denominator
im = (self.im * other.re - self.re * other.im) / denominator
self.re, self.im = re, im
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
def __hash__(self):
return hash((self.re, self.im))
def __repr__(self):
return f"Complex({self.re}, {self.im})"
def __str__(self):
sign = '+' if self.im >= 0.0 else '-'
im = abs(self.im)
im = str(im) if im != 1.0 else ""
return f"{self.re} {sign} i{im}" if self.im != 0.0 else str(self.re)
(notice the lack of return statements)
a = Complex(3, 2)
b = Complex(-1, 1)
print(a.add(b)) # None
print(f'a={a}, b={b}') # a=2.0 + i3.0, b=-1.0 + i
print(a.subtract(b)) # None
print(f'a={a}, b={b}') # a=3.0 + i2.0, b=-1.0 + i
print(a.multiply(b)) # None
print(f'a={a}, b={b}') # a=-5.0 + i, b=-1.0 + i
print(a.divide(b)) # None
print(f'a={a}, b={b}') # a=3.0 + i2.0, b=-1.0 + i
(notice the lack of return values, and that b
never changes while a
does)
class Complex:
def __init__(self, real: float, imag: float):
self.re = float(real)
self.im = float(imag)
def __update_or_create_new(self, re, im, in_place):
if in_place:
self.re, self.im = re, im
else:
return Complex(re, im)
def add(self, other, in_place: bool = False):
re = self.re + other.re
im = self.im + other.im
return self.__update_or_create_new(re, im, in_place)
def subtract(self, other, in_place: bool = False):
re = self.re - other.re
im = self.im - other.im
return self.__update_or_create_new(re, im, in_place)
def multiply(self, other, in_place: bool = False):
re = self.re * other.re - self.im * other.im
im = self.re * other.im + self.im * other.re
return self.__update_or_create_new(re, im, in_place)
def divide(self, other, in_place: bool = False):
if other.re == 0 and other.im == 0:
raise ZeroDivisionError("Cannot divide by zero: " + str(self))
denominator = other.re ** 2 + other.im ** 2
re = (self.re * other.re + self.im * other.im) / denominator
im = (self.im * other.re - self.re * other.im) / denominator
return self.__update_or_create_new(re, im, in_place)
def __eq__(self, other):
return other is not None and hasattr(other, 're') and self.re == other.re and hasattr(other, 'im') and self.im == other.im
def __hash__(self):
return hash((self.re, self.im))
def __repr__(self):
return f"Complex({self.re}, {self.im})"
def __str__(self):
sign = '+' if self.im >= 0.0 else '-'
im = abs(self.im)
im = str(im) if im != 1.0 else ""
return f"{self.re} {sign} i{im}" if self.im != 0.0 else str(self.re)
a = Complex(3, 2)
b = Complex(-1, 1)
print(a.add(b)) # 2.0 + i3.0
print(f'a={a}, b={b}') # a=3.0 + i2.0, b=-1.0 + i
print(a.subtract(b, in_place=True)) # None
print(f'a={a}, b={b}') # a=4.0 + i, b=-1.0 + i
print(a.multiply(b)) # -5.0 + i3.0
print(f'a={a}, b={b}') # a=4.0 + i, b=-1.0 + i
print(a.divide(b, in_place=True)) # None
print(f'a={a}, b={b}') # a=-1.5 - i2.5, b=-1.0 + i
OOP software designer will model interactions between objects
To do so, they will design classes that use other classes
The “single responsibility principle” suggests that each class should have exactly one responsibility
Good design is art: designers learn how decompose a problem into smaller, more manageable sub-problems…
Let’s say we want to design a calculator that can compute arithmetic expressions of complex numbers
class ComplexCalculator:
def __init__(self):
self.__memory = []
def __memorize(self, value):
self.__memory.append(value)
def clear(self):
self.__memory.clear()
def number(self, value: int | float | Complex):
if not isinstance(value, Complex):
value = Complex(value, 0)
self.__memorize(value)
def operation(self, operator: str):
if operator not in {'+', '-', '*', '/*'}:
raise ValueError("Invalid operator: " + operator)
self.__memorize(operator)
def compute_result(self) -> Complex:
if len(self.__memory) == 0:
raise ValueError("Nothing to compute")
result = self.__memory[0]
for i in range(1, len(self.__memory), 2):
operator = self.__memory[i]
operand = self.__memory[i + 1]
match operator:
case '+':
result = result.add(operand)
case '-':
result = result.subtract(operand)
case '*':
result = result.multiply(operand)
case '/':
result = result.divide(operand)
self.clear()
self.number(result)
return result
c = ComplexCalculator()
c.number(3)
c.operation('+')
c.number(Complex(2, 1))
print(c.compute_result()) # 5.0 + i1.0
c.operation('*')
c.number(Complex(1, 1))
print(c.compute_result()) # 4.0 + i6.0
When creating your own data types (i.e. classes, in Python), you can define how they behave with arithmetic operators
__eq__
method for the Complex
class to support the ==
operatorRedefining the behavior of an operator for a class is called operator overloading
+
operator for the Complex
class to support the addition of two complex numbersNotice that you cannot add new operators to the language, but just redefine the behavior of the existing operators
Not all programming languages support operator overloading, and the ones who do it, do it with different syntaxes, yet the idea is similar
Beware: when reading code in a language which supports operator overloading, you should not assume that you know what an operator does, unless you know the types of the operands, and have read the documentation of those types
Python supports operator overloading via magic methods
(complete specification here: https://docs.python.org/3/reference/datamodel.html#special-method-names)
+
$\leftrightarrow$ def __add__(self, other)
a + b
$\leftrightarrow$ a.__add__(b)
-
$\leftrightarrow$ def __sub__(self, other)
a - b
$\leftrightarrow$ a.__sub__(b)
*
$\leftrightarrow$ def __mul__(self, other)
a * b
$\leftrightarrow$ a.__mul__(b)
/
$\leftrightarrow$ def __truediv__(self, other)
a / b
$\leftrightarrow$ a.__truediv__(b)
//
$\leftrightarrow$ def __floordiv__(self, other)
a // b
$\leftrightarrow$ a.__floordiv__(b)
%
$\leftrightarrow$ def __mod__(self, other)
a % b
$\leftrightarrow$ a.__mod__(b)
==
$\leftrightarrow$ def __eq__(self, other)
a == b
$\leftrightarrow$ a.__eq__(b)
!=
$\leftrightarrow$ def __ne__(self, other)
a != b
$\leftrightarrow$ a.__ne__(b)
<
$\leftrightarrow$ def __lt__(self, other)
a < b
$\leftrightarrow$ a.__lt__(b)
<=
$\leftrightarrow$ def __le__(self, other)
a <= b
$\leftrightarrow$ a.__le__(b)
>
$\leftrightarrow$ def __gt__(self, other)
a > b
$\leftrightarrow$ a.__gt__(b)
>=
$\leftrightarrow$ def __ge__(self, other)
a >= b
$\leftrightarrow$ a.__ge__(b)
**
$\leftrightarrow$ def __pow__(self, other)
a ** b
$\leftrightarrow$ a.__pow__(b)
Python supports operator overloading via magic methods
(complete specification here: https://docs.python.org/3/reference/datamodel.html#special-method-names)
&
$\leftrightarrow$ def __and__(self, other)
a & b
$\leftrightarrow$ a.__and__(b)
|
$\leftrightarrow$ def __or__(self, other)
a | b
$\leftrightarrow$ a.__or__(b)
^
$\leftrightarrow$ def __xor__(self, other)
a ^ b
$\leftrightarrow$ a.__xor__(b)
<<
$\leftrightarrow$ def __lshift__(self, other)
a << b
$\leftrightarrow$ a.__lshift__(b)
>>
$\leftrightarrow$ def __rshift__(self, other)
a >> b
$\leftrightarrow$ a.__rshift__(b)
~
$\leftrightarrow$ def __invert__(self)
~a
$\leftrightarrow$ a.__invert__()
Get item $\leftrightarrow$ def __getitem__(self, i)
a[i]
$\leftrightarrow$ a.__getitem__(i)
Set item $\leftrightarrow$ def __setitem__(self, i, x)
a[i] = x
$\leftrightarrow$ a.__setitem__(i, x)
Del item $\leftrightarrow$ def __delitem__(self, i)
del a[i]
$\leftrightarrow$ a.__delitem__(i)
Length $\leftrightarrow$ def __len__(self)
len(a)
$\leftrightarrow$ a.__len__()
Contains $\leftrightarrow$ def __contains__(self, x)
x in a
$\leftrightarrow$ a.__contains__(x)
Unary -
$\leftrightarrow$ def __neg__(self)
-a
$\leftrightarrow$ a.__neg__()
Unary +
$\leftrightarrow$ def __pos__(self)
+a
$\leftrightarrow$ a.__pos__()
class Point2D:
def __init__(self, x, y):
self.x = float(x)
self.y = float(y)
def __str__(self):
return f"({self.x}, {self.y})"
def __repr__(self):
return f"Point2D({self.x}, {self.y})"
def __eq__(self, other): # equality: p1 == p2
return other is not None and all(hasattr(a, other) for a in ['x', 'y']) and self.x == other.x and self.y == other.y
def __hash__(self):
return hash((self.x, self.y))
def __abs__(self): # magnitude: abs(p)
return math.sqrt(self.x ** 2 + self.y ** 2)
def __neg__(self): # negation: -p
return Point2D(-self.x, -self.y)
def __add__(self, other) -> 'Point2D': # addition: p1 + p2
if not isinstance(other, Point2D):
other = Point2D(other, other) # if other is a scalar, convert it to a point of equal coordinates
return Point2D(self.x + other.x, self.y + other.y)
def __sub__(self, other) -> 'Point2D': # subtraction: p1 - p2
return self + (-other) # implemented via addition and negation
def __mul__(self, other): # multiplication: p1 * p2 or p1 * s
if isinstance(other, Point2D):
return self.x * other.x + self.y * other.y # dot product
return Point2D(self.x * other, self.y * other) # scalar product
def __truediv__(self, other: float) -> 'Point2D': # division: p1 / s
return self * (1 / other) # implemented via multiplication
def distance(self, other: 'Point2D') -> float:
return abs(self - other) # implemented via subtraction and magnitude
def angle(self, other: 'Point2D') -> float:
diff = other - self
return math.atan2(diff.y, diff.x) # requires import math
p1 = Point2D(1, 2)
p2 = Point2D(3, 4)
print(p1 + p2) # (4.0, 6.0)
print(p1 - p2) # (-2.0, -2.0)
print(p1 * p2) # 11.0
print(p1 * 2) # (2.0, 4.0)
print(p1 / 2) # (0.5, 1.0)
print(p1.distance(p2)) # 2.8284271247461903
print(p1.angle(p2)) # 0.7853981633974483
So far, functions written inside classes were related to the instances of the class
they are called “instance” methods are they are called on and for the instances, and they had the self
parameter
class MyClass:
def instance_method(self, formal_arg):
...
obj = MyClass()
obj.instance_method("actual arg")
# notice that the function is called on **an instance** of the class
One may also define static methods, which are not related to the instances of the class
they are called “static” methods as they work the same for all instances,
they are tagged by the @staticmethod
decorator
they do not have the self
parameter
class MyClass:
@staticmethod
def static_method(formal_arg): # notice the lack of the `self` parameter
...
MyClass.static_method("actual arg")
# notice that the function is called on **the class itself**
One may also define class methods, which are like static methods, but they have a reference to the class itself
they are tagged by the @classmethod
decorator
they have the cls
parameter, which is a reference to the class itself
class MyClass:
@classmethod
def class_method_1(cls, formal_arg): # notice the `cls` parameter
...
@classmethod
def class_method_2(cls, formal_arg): # notice the `cls` parameter
return cls.class_method_1(formal_arg) # the cls parameter is usefull to call other class methods
MyClass.class_method_2("actual arg")
# notice that the function is called on **the class itself**, as if it were a static method
One may also define module functions, which are not related to any class
they are just functions defined in a module, not inside a class
they are called “module” functions as they are defined at the module level
class MyClass:
...
def module_function(arg: MyClass):
...
module_function(MyClass())
# notice that the function is called with no prefix
# notice that the function can still use classes defined above them as data types
If a function operates at the instance level, and it is generally potentially useful for each instance, it should be an instance method
If the function involves a variable amount of instances of a class at once, it should be a static method
If the function could be static, but it needs to use other class methods, it should be a class method
If the function uses the class to do something which is not general, but application specific, it could be a module function
This can be supported via 2 class functions:
Point2D.centroid(points: list[Point2D]) -> Point2D
: computes the centroid of a list of pointsPoint2D.sort_anti_clockwise(points: list[Point2D]) -> list[Point2D]
: orders a list of points anti-clockwise w.r.t. their centroidclass Point2D:
# rest of the class is unchanged
@staticmethod
def centroid(points: list['Point2D']) -> 'Point2D':
return sum(points, Point2D(0, 0)) / len(points)
@classmethod
def sort_anti_clockwise(cls, points: list['Point2D']) -> list['Point2D']:
centroid = cls.centroid(points)
def angle_wrt_center(p):
return (p - centroid).angle(Point2D(1, 0))
points = list(points)
points.sort(key=angle_wrt_center)
return points
triangle = [Point2D(1, -1), Point2D(-1, -1), Point2D(0, 1)]
print(Point2D.centroid(triangle)) # (0.0, -0.3333333333333333)
print(Point2D.sort_anti_clockwise(triangle)) # [Point2D(0.0, 1.0), Point2D(-1.0, -1.0), Point2D(1.0, -1.0)]
rectangle = [Point2D(-2, 1), Point2D(2, 1)]
rectangle = rectangle + [-p for p in rectangle]
print(Point2D.centroid(rectangle)) # (0.0, 0.0)
print(Point2D.sort_anti_clockwise(rectangle)) # [Point2D(2.0, 1.0), Point2D(-2.0, 1.0), Point2D(-2.0, -1.0), Point2D(2.0, -1.0)]
Inheritance is a way to reuse and specialize classes, without repeating code
Inheritance is a way to factorize common attributes and methods into a base class
Inheritance is a way to extend the functionality of a class
Inheritance creates a sub-typing relationship between classes, which are subject to the Liskov Substitution Principle
Animal
is a base class, Dog
and Cat
are derived classesShape
is a base class, Circle
and Rectangle
are derived classesVehicle
is a base class, Car
and Bicycle
are derived classesIn Python, a class can inherit from one or more classes
class Derived(Base1, Base2, ...):
If no base class is specified, the class implicitly inherits from the object
class
class Derived:
is equivalent to class Derived(object):
All classes are (either directly or indirectly) derived from the object
class
object
class
Inside derived classes, one can:
super()
instead of self
Some classes are abstract, meaning that they are incomplete and should not be instantiated
Animal
Class Hierarchy (v. 1)class Animal:
def __init__(self, name: str):
self.__name: str = name # private field
@property
def name(self) -> str: # public, read-only property
return self.__name
def speak(self):
raise NotImplementedError("Subclasses must override this method")
class Dog(Animal):
def speak(self):
print(f"[{self.name}]", "Woof!")
class Cat(Animal):
def speak(self):
print(f"[{self.name}]", "Meow!")
class Crocodile(Animal):
def speak(self):
print(f"[{self.name}]", "...")
animals = [Dog("Rex"), Cat("Garfield"), Crocodile("Dingodile")]
for animal in animals:
animal.speak()
# [Rex] Woof!
# [Garfield] Meow!
# [Dingodile] ...
Notice that all three classes:
name
property from the base class__name
field from the base classspeak
method from the base classPrivate attributes and methods are not accessible from outside the class
Private attributes are the ones whose names start (and does not end) with __
self.__name
__
def __init__(self):
Protected attributes and methods are not accessible from outside the class, but are accessible from the derived classes
_
self._name
Protected attributes can be used to factorize some functionality in a base class, and reuse it only in the derived classes
Public attributes and methods are accessible from outside the class
Animal
Class Hierarchy (v. 2)class Animal:
def __init__(self, name: str):
self.__name: str = name # private field
@property
def name(self) -> str: # public, read-only property
return self.__name
def _say_something(self, sound: str): # protected method
print(f"[{self.name}]", sound)
def speak(self):
raise NotImplementedError("Subclasses must override this method")
class Dog(Animal):
def speak(self):
self._say_something("Woof!")
class Cat(Animal):
def speak(self):
self._say_something("Meow!")
class Crocodile(Animal):
def speak(self):
self._say_something("...")
(the classes’ behaviour is unchanged, see slide of v. 1)
Animal
now has a protected method _say_something
to factorize the printing of the name before the sound…Dog
, Cat
, and Crocodile
Animal
Class Hierarchy (v. 3)class Animal:
def __init__(self, name: str, sound: str):
self.__name: str = name # private field
self.__sound: str = sound # private field
@property
def name(self) -> str: # public, read-only property
return self.__name
def speak(self):
print(f"[{self.name}]", self.__sound)
class Dog(Animal):
def __init__(self, name: str):
super().__init__(name, sound="Woof!")
class Cat(Animal):
def __init__(self, name: str):
super().__init__(name, sound="Meow!")
class Crocodile(Animal):
def __init__(self, name: str):
super().__init__(name, sound="Meow!")
(the classes’ behaviour is unchanged, see slide of v. 1)
speak
method is now implemented once and for all in the base class Animal
…Dog
, Cat
, and Crocodile
Animal
now has a constructor that accepts the name and the sound of the animal
speak
methodDog
, Cat
, and Crocodile
override the constructor to initialize the sound of the animal
super()
Shape
Class Hierarchy (pt. 1)A Shape
is a geometric figure, characterized by its surface and perimeter
float
), but how to compute them depends on the shape itselfA Circle
is a particular case of Shape
characterized by its center and radius
Point2D
, the radius is a float
A Polygon
is a particular case of Shape
characterized by its vertices
Point2D
instances, in anti-clockwise orderA Triangle
is a particular case of Polygon
characterized by having 3 vertices
a
, b
, c
A Rectangle
is a particular case of Polygon
characterized by having 4 vertices which are pairwise aligned
bottom_left
, bottom_right
, top_left
, top_right
width
and a height
, which can be computed from the verticeswidth
and height
width
and height
A Square
is a particular case of Rectangle
characterized by having equal width
and height
Rectangle
Shape
Class Hierarchy (pt. 2)class Shape:
def surface(self) -> float:
raise NotImplementedError()
def perimeter(self) -> float:
raise NotImplementedError()
class Circle(Shape):
def __init__(self, center: Point2D, radius: float):
self.center = center
self.radius = float(radius)
def surface(self) -> float:
return math.pi * self.radius ** 2
def perimeter(self) -> float:
return 2 * math.pi * self.radius
def __repr__(self):
return f"Circle(center={self.center}, radius={self.radius})"
def __eq__(self, other):
return isinstance(other, Circle) and self.center == other.center and self.radius == other.radius
def __hash__(self):
return hash((self.center, self.radius))
class Polygon(Shape):
def __init__(self, points: list):
self.vertices = tuple(point if isinstance(point, Point2D) else Point2D(*point) for point in points)
if len(self.vertices) < 3:
raise ValueError("A polygon must have at least 3 vertices")
def __repr__(self):
return f"Polygon(vertices=[{', '.join(str(p) for p in self.vertices)}])"
def __eq__(self, other):
return isinstance(other, Polygon) and self.vertices == other.vertices
def __hash__(self):
return hash(self.vertices)
class Triangle(Polygon):
def __init__(self, fst: Point2D, snd: Point2D, trd: Point2D):
super().__init__(Point2D.sort_anti_clockwise([fst, snd, trd]))
@property
def a(self):
fst, snd = self.vertices[:2]
return fst.distance(snd)
@property
def b(self):
snd, trd = self.vertices[1:]
return snd.distance(trd)
@property
def c(self):
fst, trd = self.vertices[::2]
return fst.distance(trd)
def __repr__(self):
return super().__repr__().replace("Polygon", "Triangle")
def perimeter(self):
return self.a + self.b + self.c
def surface(self):
# cf. https://en.wikipedia.org/wiki/Heron%27s_formula
p = self.perimeter() / 2
return math.sqrt(p * (p - self.a) * (p - self.b) * (p - self.c))
class Rectangle(Polygon):
def __init__(self, a: Point2D, b: Point2D):
bl = Point2D(min(a.x, b.x), min(a.y, b.y))
tr = Point2D(max(a.x, b.x), max(a.y, b.y))
super().__init__([bl, Point2D(tr.x, bl.y), tr, Point2D(bl.x, tr.y)])
@property
def bottom_left(self):
return self.vertices[0]
@property
def bottom_right(self):
return self.vertices[1]
@property
def top_right(self):
return self.vertices[2]
@property
def top_left(self):
return self.vertices[3]
@property
def width(self):
return self.top_right.x - self.bottom_left.x
@property
def height(self):
return self.top_right.y - self.bottom_left.y
def __repr__(self):
return f"Rectangle(bottom_left={self.bottom_left}, top_right={self.top_right})"
def perimeter(self):
return 2 * (self.width + self.height)
def surface(self):
return self.width * self.height
class Square(Rectangle):
def __init__(self, corner: Point2D, side: float):
super().__init__(corner, corner + side)
def __repr__(self):
return f"Square(bottom_left={self.bottom_left}, top_right={self.top_right})"
@property
def side(self):
return self.width
c = Circle(Point2D(1, 1), 2)
print(c, c.surface(), c.perimeter())
# should print: Circle(center=(1.0, 1.0), radius=2.0) 12.566370614359172 12.566370614359172
t = Triangle(Point2D(1, -1), Point2D(-1, -1), Point2D(0, 1))
print(t, t.surface(), t.perimeter())
# should print: Triangle(vertices=[(0.0, 1.0), (-1.0, -1.0), (1.0, -1.0)]) 2.0 6.47213595499958
r = Rectangle(Point2D(2, -1), Point2D(-2, 1))
print(r, r.surface(), r.perimeter())
# should print: Rectangle(bottom_left=(-2.0, -1.0), top_right=(2.0, 1.0)) 8.0 12.0
s = Square(Point2D(-1, -1), 2)
print(s, s.surface(), s.perimeter())
# should print: Square(bottom_left=(-1.0, -1.0), top_right=(1.0, 1.0)) 4.0 8.0
Shape
Class Hierarchy (pt. 3)We may write even less code:
Polygon
given its vertices (cf. Shoelace formula)Polygon
given its vertices (just sum up the lengths of the edges)We may implement the surface
and perimeter
methods in the Polygon
class
class Shape: # unchanged
class Circle(Shape): # unchanged
class Polygon(Shape):
# rest of the class is unchanged
def perimeter(self) -> float:
result = self.vertices[-1].distance(self.vertices[0])
for i in range(1, len(self.vertices)):
result += self.vertices[i - 1].distance(self.vertices[i])
return result
def surface(self) -> float:
result = 0
for i in range(len(self.vertices)):
current = self.vertices[i]
next = self.vertices[(i + 1) % len(self.vertices)]
result += abs(current.y + next.y) * abs(current.x - next.x)
return result / 2
class Triangle(Polygon):
# no need to re-implement the surface and perimeter methods
class Rectangle(Polygon):
# no need to re-implement the surface and perimeter methods
class Square(Rectangle):
# no need to re-implement the surface and perimeter methods
(the usage example should work exactly as in the previous slide)
(cf. https://en.wikipedia.org/wiki/Polymorphism_(computer_science))
[In programming languages theory] The use of one symbol to represent multiple different types
[In OOP] The provisioning of a single interface to for using different data types in the same way
Put simply, interfaces are abstract, general, and stable;
whereas implementations are concrete, use-case specific, and subject to change
sort
method of the list
class
sort
method, but your code will still workclass Complex:
@property
def re(self) -> float: ... # a read-only property to get the real part
@property
def im(self) -> float: ... # a read-only property to get the imaginary part
@property
def modulus(self) -> float: ... # a read-only property to get the modulus
@property
def phase(self) -> float: ... # a read-only property to get the phase
def to_polar(self) -> "Complex": ... # a method to convert to polar form
def to_cartesian(self) -> "Complex": ... # a method to convert to Cartesian form
def conjugate(self) -> "Complex": ... # a method to get the conjugate
def __add__(self, other: "Complex") -> "Complex": ... # addition
def __neg__(self) -> "Complex": ... # negation
def __sub__(self, other: "Complex") -> "Complex": ... # subtraction
def __mul__(self, other: "Complex") -> "Complex": ... # multiplication
def __truediv__(self, other: "Complex") -> "Complex": ... # division
def __abs__(self) -> float: # magnitude
@classmethod
def polar(cls, modulus: float, phase: float) -> "Complex": ... # class method to create a polar complex number
@classmethod
def cartesian(cls, modulus: float, phase: float) -> "Complex": ... # class method to create a Cartesian complex number
def __eq__(self, other): ... # equality
def __hash__(self): ... # hashing
def __repr__(self): ... # string representation
z = Complex.polar(2, math.pi / 4)
print(z) # 2.0 * e^i0.7853981633974483
print(z.modulus) # 2.0
print(z.phase) # 0.7853981633974483
print(z.to_cartesian()) # 1.4142135623730951 + i1.4142135623730951
print(z.re) # 1.4142135623730951
print(z.im) # 1.4142135623730951
print(z is z.to_polar()) # True
print(z.conjugate()) # 1.4142135623730951 - i1.4142135623730951
print(z + z) # 2.8284271247461903 + i2.8284271247461903
print(z - z) # 0.0
print(z * z) # 4.0 * e^i1.5707963267948966
print(z / z) # 1.0
print(abs(z)) # 2.0
print(z + z == z * Complex.cartesian(2, 0)) # True
class Complex:
def __init__(self, fst: float, snd: float): # constructor accepts two scalar coordinates...
self.__coords = (float(fst), float(snd)) # ... to be memorized in the __coords field
@property
def re(self) -> float: # read-only property to get the first coordinate
return self.__coords[0] # (good for Cartesian class, to be overridden in the Polar class)
@property
def im(self) -> float: # read-only property to get the second coordinate
return self.__coords[1] # (good for Cartesian class, to be overridden in the Polar class)
@property
def modulus(self) -> float: # read-only property to get the modulus
return self.__coords[0] # (good for Polar class, to be overridden in the Cartesian class)
@property
def phase(self) -> float: # read-only property to get the phase
return self.__coords[1] # (good for Polar class, to be overridden in the Cartesian class)
def to_polar(self) -> "Complex":
return self # (good for Polar class, to be overridden in the Cartesian class)
def to_cartesian(self) -> "Complex":
return self # (good for Cartesian class, to be overridden in the Polar class)
# subsequent methods are good for both implementations and can be inherited with no change
def conjugate(self) -> "Complex":
return self.cartesian(self.re, -self.im)
def __add__(self, other: "Complex") -> "Complex":
return self.cartesian(self.re + other.re, self.im + other.im)
def __neg__(self) -> "Complex":
return self.cartesian(-self.re, -self.im)
def __sub__(self, other: "Complex") -> "Complex":
return self + (-other)
def __mul__(self, other: "Complex") -> "Complex":
return self.polar(self.modulus * other.modulus, self.phase + other.phase)
def __truediv__(self, other: "Complex") -> "Complex":
return self.polar(self.modulus / other.modulus, self.phase - other.phase)
def __abs__(self) -> float:
return self.modulus
@classmethod
def polar(cls, modulus: float, phase: float) -> "Complex":
return PolarComplex(modulus, phase)
@classmethod
def cartesian(cls, modulus: float, phase: float) -> "Complex":
return CartesianComplex(modulus, phase)
def __eq__(self, other):
return isinstance(other, Complex) and \
((self.re == other.re and self.im == other.im) or \
(self.modulus == other.modulus and self.phase == other.phase))
def __hash__(self):
return hash(self.__coords)
def __repr__(self): # represents as string which reports the name of the base class and the coordinates
return f"{type(self).__name__}({', '.join(str(c) for c in self.__coords)})"
class CartesianComplex(Complex):
def __init__(self, re: float, im: float): # constructor accepts two scalar coordinates...
super().__init__(re, im) # ... to be interpreted as Cartesian coordinates
@property
def modulus(self) -> float: # overrides the modulus property
return math.hypot(self.re, self.im) # computes the modulus from the coordinates
@property
def phase(self) -> float: # overrides the phase property
return math.atan2(self.im, self.re) # computes the phase from the coordinates
def to_polar(self):
self.polar(self.modulus, self.phase) # converts to polar form
def __str__(self): # represents as string in the Cartesian form
sign = '+' if self.im >= 0.0 else '-'
im = abs(self.im)
im = str(im) if im != 1.0 else ""
return f"{self.re} {sign} i{im}" if self.im != 0.0 else str(self.re)
class PolarComplex(Complex):
@staticmethod
def __normalize_angle(angle: float) -> float: # normalizes the angle to the [-pi, pi] range
return (angle + math.pi) % (2 * math.pi) - math.pi
def __init__(self, modulus: float, phase: float): # constructor accepts two scalar coordinates...
phase = PolarComplex.__normalize_angle(phase)
super().__init__(modulus, phase) # ... to be interpreted as polar coordinates
@property
def re(self) -> float: # overrides the re property
return self.modulus * math.cos(self.phase) # computes the real part from the coordinates
@property
def im(self) -> float: # overrides the im property
return self.modulus * math.cos(self.phase) # computes the imaginary part from the coordinates
def to_cartesian(self):
return self.cartesian(self.re, self.im) # converts to Cartesian form
def __str__(self): # represents as string in exponential notation
sign = '' if self.phase >= 0.0 else '-'
return f"{self.modulus} * e^{sign}i{abs(self.phase)}" if self.phase != 0.0 else str(self.modulus)
Interface $\approx$ type name + public attributes’ signatures (i.e. names + types or formal arguments)
We first design an interface that is good for all relevant use cases
Complex
has
re
, im
, modulus
, phase
read-only properties+
, -
, *
, /
, etc.), plus the conjugate
oneto_polar()
and to_cartesian()
)Complex.polar()
and Complex.cartesian()
)We then identify all potential implementations:
PolarComplex
: which implements the polar form of a complex number
CartesianComplex
: which implements the Cartesian form of a complex number
We define a base class, partially implementing the aforementioned interface with shared code
We define derived classes, completing the implementation of the interface with specific code
Design and implement a Python module for performing 2D matrix computations
In particular the module should provide a Matrix
class with the following interface:
shape
returning a tuple with the number of rows and columnstranspose
that returns the transpose of the matrixis_square
returning True
has the same amount of rows and columns, False
otherwiseFurther sub-classes should be defined for specific types of matrices, such as:
Compiled on: 2025-06-30 — printable version