**Python is a multi-paradigm programming language**. Python supports many different programming paradigms including procedural (imperative), functional, and object-oriented programming.

Object-oriented programming is a programming paradigm *based on the concept of “objects”, which can contain data and code*: data in the form of fields, attributes, or properties, and code, in the form of procedures or methods. Objects are bundles of properties (data) and methods (behaviours). In this programming paradigm, computer programs are designed by making them out of objects that interact with one another.

**Classes are blueprints for objects. They provide a means of bundling data and functionality together**. They define a set of attributes that describe or characterize its objects and methods. Data is represented as properties and behaviors are represented as methods.

For example, if we have a Person class, its attributes could be name, address, gender, age, and phone number, and its methods could be getName, getResidence, setResidence, setAge, getAge, greet, etc.

```
# It is necessary to overload /
from __future__ import division
def gcd(a, b):
while b:
a, b = b, a % b
return a
```

‘‘‘The simplest form of class definition looks like this:

```
class ClassName:
statement-1
.
.
.
statement-N
```

```
class Fraction:
'A simple implementation of fractions in Python'
## We create a class with the name Fraction. It models a rational number. Next, we need to define its attributes or properties.
# @param n is the numerator of the fraction (default is 0).
# @param d is the denominator of the fraction (default is 1).
def __init__(self, n = 0, d = 1): # __init__ is the constructor. It is immediately called when an object is created.
if (not isinstance(n, int)): # The isinstance() function returns True if the specified object is of the specified type or class, otherwise it returns False. We want to check that all its arguments are of the correct type.
raise TypeError("The numerator of a Fraction must be an integer") # Read our article "Handling Exceptions."
if (not isinstance(d, int)):
raise TypeError("The denominator of a Fraction must be an integer")
self.numerator = int(n / gcd(abs(n), abs(d))) # We are going to represent a fraction in its simplest form. It means that its numerator and denominator are relatively prime, that is, they have no common factors other than 1.
self.denominator = int(d / gcd(abs(n), abs(d)))
if self.denominator < 0: # If the denominator is negative, we just flip the signs of both.
self.denominator = abs(self.denominator)
self.numerator = -1*self.numerator
# The denominator cannot be zero
elif self.denominator == 0:
raise ZeroDivisionError("Denominator cannot be zero")
```

**Polymorphism** or operator overloading is just syntactic sugar. It allows _the same operator name or symbol to be used for multiple operations, that is, the same operator has different implementations depending on its arguments.

For example, the ‘+’ operator is used to add two integers, floats, strings, and lists.

```
user@pc:~$ python
Python 3.9.5 (default, May 11 2021, 08:20:37)
[GCC 10.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> a = 2
>>> type(a)
<class 'int'>
>>> 2+3
5
>>> b = 3.5
>>> type(b)
<class 'float'>
>>> 4.7 + b
8.2
>>> c = "hello"
>>> type(c)
<class 'str'>
>>> c + " bye!"
'hello bye!'
>>> d = [1, 2, 3]
>>> type(d)
<class 'list'>
>>> d + [4, 5, 6]
[1, 2, 3, 4, 5, 6]
>>> d + [4, 5, 6]
[1, 2, 3, 4, 5, 6]
```

```
## Adds a fraction to this fraction.
# @params other, the fraction to be added to this fraction.
# @return the new Fraction object that is the result of self + other.
def __add__(self, other):
```

This is Python’s approach to operator overloading. It allows classes to define their own behavior with respect to language operators. The method **add** is called internally by the plus “+” operator. Thanks to polymorphism, we can personalize the method __add__ to make it work on any particular class we want.

```
num = self.numerator * other.denominator + self.denominator * other.numerator
den = self.denominator * other.denominator
return Fraction (num, den)
## Substracts a fraction from this fraction.
# @params other, our fraction is to be subtracted to "other"
# @return the new Fraction object that is the result of self - other.
def __sub__(self, other): # __sub__ is the special method for overloading the minus "-" operator.
# There is a special or a "magic" method for every operator sign: + (__add__), - (__sub__), * (__mul__), / (__truediv__), etc.
num = self.numerator * other.denominator - self.denominator * other.numerator
den = self.denominator * other.denominator
return Fraction (num, den)
## Determines if this fraction is equal to another fraction.
# @params other, the right-hand fraction to be compared with our fraction.
# @return True if the fractions are equal.
def __eq__(self, other): # the __eq__ method is added to the Fraction class to overload the == operator.
return (self.numerator == other.numerator and self.denominator == other.denominator)
## Multiply this fraction by another fraction.
# @params other, our fraction is to be multiplied by "other".
# @return the new Fraction object that is the result of self * other.
def __mul__(self, other):
return Fraction(self.numerator * other.numerator, self.denominator* other.denominator)
## Divide this fraction by another fraction.
# @params other, our fraction is to be divided by "other".
# @return the new Fraction object that is the result of self / other.
def __truediv__(self, other):
return Fraction(self.numerator * other.denominator, self.denominator * other.numerator)
def __float__(self): # __float__ is going to be called by the built-in float() method to convert our fraction to float.
return self.numerator / self.denominator
## Gets a string representation of the fraction
# @return a string in the format numerator/denominator
# __str__ is going to be called by the built-in str() method.
def __str__(self):
if self.denominator == 1:
return str(self.numerator)
elif abs(self.numerator) > self.denominator: # It handles improper fractions. Remember that an improper fraction is one in which its numerator is greater than or equal to its denominator.
if self.numerator > 0:
a = self.numerator // self.denominator
b = self.numerator % self.denominator
return "{} {}/{}".format(a, b, self.denominator)
else:
a = - (abs(self.numerator) // self.denominator)
b = - (abs(self.numerator) % self.denominator)
return "{} {}/{}".format(a, -b, self.denominator)
else:
return str(self.numerator) + "/" + str(self.denominator)
```

**An object is an instance of a particular class**. For example, an object with its “name” field set to “Joe” might be an instance of the class Person. You can create as many objects as you need or want once you have defined your class.

To create an instance of a class, you just call the class using its class name (e.g., Fraction) and pass in whatever arguments its **init** method accepts or requires.

```
def main():
f1 = Fraction(2,-3) # It creates a new instance of the class and assigns this object to the variable f1. An _instance variable_ is a variable defined in a class (numerator, denominator), for which each instantiated object of the class has a separate copy.
f2 = Fraction(3,-4)
try:
f3 = Fraction(3, 0)
except ZeroDivisionError:
print("Oops! Denominator cannot be zero")
f3 = f1 + f2
print(f1, "+", f2, "=", f3)
f3 = f1 - f2
print(f1, "-", f2, "=", f3)
f3 = f1 * f2
print(f1, "*", f2, "=", f3)
f3 = f1 / f2
print(f1, "/", f2, "=", f3)
f3 = Fraction(4, -6)
print("Are equal ", f3, " and ", f1, "? ", f3==f1)
print(f3, "=", "{:.2f}".format(round(float(f3), 2)), "=", round( float(f3)*100, 2 ), "%")
f4 = Fraction(1, 3)
print(f4, "=", "{:.2f}".format(round(float(f4), 2)), "=", round( float(f4)*100, 2 ), "%")
print(Fraction(8, 5))
print(Fraction(-13, 6))
if __name__ == '__main__':
main()
```

The result is as follows: Oops! Denominator cannot be zero

-2/3 + -3/4 = -1 5/12

-2/3 - -3/4 = 1/12

-2/3 * -3/4 = 1/2

-2/3 / -3/4 = 8/9

Are equal -2/3 and -2/3 ? True

-2/3 = -0.67 = -66.67 %

1/3 = 0.33 = 33.33 %

1 3/5

-2 1/6

**Classes in Python have five predefined attributes: doc, name, module, bases, and dict.** They give us information about the class. We can access them using the dot “.” operator.

```
if __name__ == '__main__':
main()
print("Fraction.__doc__: " + str(Fraction._doc__)) # __doc__ is the class documentation string.
print("Fraction.__name__: " + str(Fraction._name__)) # __name__ is the class name.
print("Fraction.__module__: " + str(Fraction._module__)) # __module__ is the module name in which the class is defined. Its value is "__main__" in interactive mode.
print("Fraction.__bases__: " + str(Fraction._bases__)) # __bases__ are the base classes.
print("Fraction.__dict__: "+ str(Fraction._dict__)) # __dict__ is the dictionary containing the class's namespace.
```

Fraction.__doc__: A simple implementation of fractions in Python

Fraction. __name__: Fraction

Fraction.__module__: **main**

Fraction.__bases__: (

Fraction.__dict__:

```
{'__module__': '__main__', '__doc__': 'A simple implementation of fractions in Python', '__init__': <function Fraction._init__ at 0x7fa068e42e50>, '__add__': <function Fraction._add__ at 0x7fa068e55160>, '__sub__': <function Fraction._sub__ at 0x7fa068e551f0>, '__eq__': <function Fraction._eq__ at 0x7fa068e55280>, '__mul__': <function Fraction._mul__ at 0x7fa068e55310>, '__truediv__': <function Fraction._truediv__ at 0x7fa068e553a0>, '__float__': <function Fraction._float__ at 0x7fa068e55430>, '__str__': <function Fraction._str__ at 0x7fa068e554c0>, '__dict__': <attribute '__dict__' of 'Fraction' objects>, '__weakref__': <attribute '__weakref__' of 'Fraction' objects>, '__hash__': None}
```

**Instead of starting from scratch, inheritance allows us to create classes that are built upon existing class**.

It allows us to define a class (child or derived class) that *inherits all the properties and methods from another class* (parent or base class), and by doing so, we can inherit the functionality of an existing class, add extra functionality, overwrite some of its base functionality, and more importantly, reuse existing code. *Reusing code is key to building a maintainable system*.

```
from turtle import * # It imports the turtle module.
class Polygon(Turtle): # To declare a child class, a list of base classes to inherit from is given after the class name.
'A simple implementation of regular polygons in Python'
def __init__(self, nsides=3, pencolor="red", fillcolor="yellow", size=200, pensize=2):
super().__init__() # It calls the parent's constructor.
self.myScreen = Screen() # It creates a turtle screen object.
self.myScreen.setup(800, 600) # It changes the size of the window.
self.myScreen.bgcolor('black') # It changes its background color.
self.color(pencolor, fillcolor) # self.color() is a method that we have inherited from Turtle. It sets the pen color and the fill color.
self.pensize(pensize) # It sets the line thickness.
self.__nsides = nsides # It stores the number of sides.
self.__size = size # It sets the side's size.
self.__interior_angles = (self.__nsides-2)*180 # This is the sum of interior angles of a polygon, [BBC Bitesize](http://bbc.co.uk/bitesize), Angles, lines, and polygons.
self._angle = 180 - (self.__interior_angles/self.__nsides) # The interior angle is equal to self.__interior_angles/self.__nsides.
def draw(self):
self.begin_fill() # It needs to be called just before drawing a shape to be filled.
for i in range(self._nsides): # A regular polygon is a polygon whose sides have the same length. We just use a loop to iterate from 0 to __nsides-1.
self.forward(self._size) # The turtle moves forward.
self.left(self._angle) # The turtle turns left self._angle degrees counter-clockwise.
self.end_fill() # It fills the shape drawn after the last call to begin_fill()
exitonclick() # It pauses the execution of the program and waits for the user to click the mouse to exit.
if __name__ == "__main__":
t = Polygon(6, "red", "yellow", 120)
t.draw()
```

**Encapsulation** is one of the core ideas in the object-oriented programming paradigm. It describes the idea of **bundling or wrapping the data (attributes or variables) and code acting on the data together as a simple unit**, as well as **hiding the internal representation** or state (attributes) of an object from the outside.

If you have an attribute that is not visible from the outside of an object and can be accessed only through the methods of the class, then you can *hide specific information and control access and manipulation of the internal state of the object*. It acts as a protective layer by hiding the data from the outside world.

Do you remember our class Fraction? Try the following code:

```
def main():
f1 = Fraction(1,-3) # Internally, f1.numerator = -1, f1.denominator = 3
f1.numerator = 2 # We access the numerator attribute from the outside world.
print(f1) # It returns 2/3. Opps! Where is the sign?
```

In Python, Encapsulation can be achieved by naming the attributes with a double underscore prefix.

Remember our Polygon class, let’s execute the following code:

```
if __name__ == "__main__":
t = Polygon(6, "red", "yellow", 120)
print(t.__nsides) # You cannot access the __nsides attribute directly from the outside of a class.
t.draw()
```

AttributeError: ‘Polygon’ object has no attribute ‘__nsides’

**Multiple inheritance is the process in which a class inherits attributes or properties and methods from multiple base classes**. It is problematic and can lead to a lot of confusion, but it does not mean that it could not be useful in some cases.

```
class Polygon(Turtle):
[...]
def area(self): # area is a virtual method of an abstract base class. In other words, Polygon does not implement this method. It raises NotImplementedError exception because it requires derived classes to override it.
raise NotImplementedError()
def getSize(self):
return self._size
class Triangle (Polygon, Turtle): # Triangle inherits from Polygon and Turtle.
def __init__(self, nsides=3, pencolor="red", fillcolor="yellow", size=200, pensize=2):
super().__init__(3, pencolor, fillcolor, size, pensize)
def area(self):
return (math.sqrt(3) * math.pow(self.getSize(), 2))/4
# Triangle has no access to private attributes from Polygon (__size). That's why we need to implement a method to retrieve the size's value.
if __name__ == "__main__":
t = Triangle("red", "blue")
t.draw()
print(t.area()) # 17320.508075688773
```

**Class methods are methods that are called on the class itself rather than on a specific object**. Class methods are useful when you need to have methods that aren’t specific to any particular instance or object of the class, but still, involve the class in some way. They take cls as the first parameter which refers to the class itself.

**Static methods are also bound to a class** and not its objects or instances. They are used as *independent functions which don’t access or change the attributes of the class*.

```
class Polygon(Turtle):
__height = 800
__width = 600
def __init__(self, nsides=3, pencolor="red", fillcolor="yellow", size=200, pensize=2):
super().__init__()
self.__myScreen = Screen()
self.__myScreen.setup(self.__height, self.__width)
[...]
def perimeter(self):
return Polygon.aux_perimeter(self.__nsides, self.__size)
@classmethod # We mark changeSizeScreen with a @classmethod decorator to define it as a class method.
def changeSizeScreen(cls, height, width): # It takes a cls parameter that points to the class when the method is called. This method changes the size of the window.
cls.__height = height
cls.__width = width
@staticmethod # We mark aux_perimeter with a @staticmethod decorator to define it as a static method.
def aux_perimeter(nsides, size):
return nsides*size
if __name__ == "__main__":
Polygon.changeSizeScreen(1200, 800) # We can call class methods directly without creating any objects. We change the size of the window by invoking the class method "changeSizeScreen".
p = Polygon(6)
p.draw()
print(str(p.perimeter()))
```