The Ultimate Guide to Object-Oriented Programming in Python
Tags: oop, python, programming, advance
Published: 2023-07-11 12:53:50

Object-Oriented Programming, also known as OOP, is one of those important topics that any programmer needs to have full knowledge of. Before diving any deeper into this topic, let us define what OOP is in general and why it is a must-have skill for any programmer. OOP in Python is a method of structuring a program by bundling related properties and behaviors into individual objects. 

## Why OOP?
Object Oriented Programming comes with so many benefits for the programmer, some of these benefits are listed below:

- Makes your code reusable
- Makes your code clean and gives it a clear structure
- Makes your code easy to maintain

## The structure of OOP
1. Classes
-These are user-defined data types that act as a blueprint for objects, methods, and attributes or they are containers of objects, methods, and attributes 
2. Objects
-These are instances of a class created with specifically defined data
3. Methods 
-These are functions defined inside a class describing the behavior of objects
4. Attributes
-These represent the state of objects in a class

## Creating our first class
We can create a class using the **class** keyword followed by the class name and a colon at the end.
**Syntax:**
```python
class ClassName:
    statement1
        .
        .
    statementN
```

**Example1: Creating a Car class**
```python
class Car:
    pass
```

If you do `print(type(Car))` you will get the below output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6ltu84r2d0gxuejc4pim.png)

## Class objects
An object is an instance of a class and it must be known that we can have as many objects as possible from a class. The process of creating a class object is also called object instantiating, you can only create an object for a class that exists.
**Syntax:**
```python
object_name = ClassName()
```
**Example2: Creating a class object**
```python
audi = Car()
```
`audi` is an object for the class Car

## Creating a class and its objects
```python
# creating a class Car
class Car:
    pass
# instantiating the Car class
car1 = Car()
car2 = Car()
# creating attributes for instances car1 and car2
car1.name = "Audi A7"
car2.name = "Nissan Demio"
car1.year_made = "2000"
car2.year_made = "1994"
# printing the output
print(car1.name, "was built in", car1.year_made)
print(car2.name, "was built in", car2.year_made)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5stz5hu1qxad2vrtu4hq.png)

## Methods
These are functions defined inside a class describing the behavior of objects tied to that class. Methods inside a class will always take self as the first parameter.
**Example3: Creating methods inside a class**
```python
# creating a class Car
class Car:
    # initializing method(one of the special methods)
    # called everytime an instance is created
    # takes self always
    def __init__(self, name, year_made):
        self.name = name
        self.year_made = year_made
    # normal method to return the car info   
    def carInformation(self):
        return self.name + " was built in " + self.year_made

#instantiating class objects 
car1 = Car("Audi A7", "2000")
car2 = Car("Nissan Demio", "1994")
# calling the carInformation() method
print(car1.carInformation())
print(car2.carInformation())
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uv6evbeazft8k2ievthy.png)
**Note:**
Any object has access to everything contained in the class, for example, object `car1` has access to method `carInformation()`.

Let us now talk about the `__init__` or dunder init method also known as the **constructor**, this method is used to initialize an instance, and the name is fixed (cannot be renamed to anything). It is inside the `__init__` method that we define or instantiate the class attributes, apart from attributes `self` is another parameter that `__init__` method takes.

```python
def __init__(self, name, year_made):
    self.name = name
    self.year_made = year_made
```

## Other special methods used in classes
●`__str__` is used for displaying data in a beautiful way
**Example:**
```python
# creating a class Car
class Car:
    def __init__(self, name, year_made):
        self.name = name
        self.year_made = year_made
    #the __str__ function for display pretty output 
    def __str__(self):
        return self.name
# instantiating an object
car1 = Car("Range Rover", "2019")
# calling the whole object
print(car1)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vze6mwflpfk29wb6x6wb.png)

●`__repr__` is used for logging and debugging
**Example:**
```python
# creating a class Car
class Car:
    def __init__(self, name, year_made):
        self.name = name
        self.year_made = year_made
    #the __repr__ function for display pretty output 
    def __repr__(self):
        return self.name
# instantiating an object
car1 = Car("Range Rover", "2019")
# calling the whole object
print(car1)
```
Output:
![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b4f991cubx62wo4axd59.png)
**Note:**
The `__str__` and `__repr__` do not differ much in nature

## Class and instance variables
As you are creating your classes you might want to work with class variables or instance variables or work with both, so how do these two differ then? The below table explains everything:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0lf4xunovr74lg0umrjj.png)
**Example4: Class variables**

```python
# creating a class Car
class Car:
    # creating the class variable after class definition
    maximum_speed = "210 km/hr"
    # initializing method(one of the special methods)
    # called everytime an instance is created
    # takes self always
    def __init__(self, name, year_made):
        self.name = name
        self.year_made = year_made
    # normal method to return the car info   
    def carInformation(self):
        return self.name + " was built in " + self.year_made

#instantiating class objects 
car1 = Car("Audi A7", "2000")
# calling the carInformation() method
print(car1.carInformation())
# accessing class and instance variable using the object
print(car1.name, "has maximum speed of", car1.maximum_speed)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i24fbr8w3pnfch1xskkr.png)

**Example5: Instance variables**

```python
# creating the class
class Car:
    # initializing method
    def __init__(self, name, year_made):
        # 2 instance variables name and year_made
        self.name = name
        self.year_made = year_made
#instantiating the class object   
car1 = Car("Audi A7", "2000")
# car1.name and car1.year_made accesses instance variables
print("Car name:", car1.name)
print("Year made:", car1.year_made)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8cdl3lahbzepd5rbwnva.png)


## Inheritance
Inheritance in Object Oriented Programming is a concept where a new class is created using an already existing class. As you are inheriting from a class, it means you are getting the methods, and attributes of that class, in short, you are getting everything contained in that class.

## Why inheritance?
Inheritance allows the reusability of code, this means that we do not have to write the same code over and over again, and we can easily reuse a method with the same or modified data.

## Types of inheritance

- Single inheritance - a child class inherits from only one parent class
**Syntax:**
```python
class ParentClass:
    statement(s)
     
class ChildClass(ParentClass):
    statement(s)
```
- Multiple inheritance - a child class inherits from multiple parent classes
**Syntax:**
```python
class ParentClass:
    statement(s)
    
class AnotherParentClass:
    statement(s)
     
class ChildClass(ParentClass, AnotherParentClass):
    statement(s)
```

**Example1: Using inheritance**
```python
class Car:
    # creating class variables
    maximum_speed = "210 km/hr"
    number_of_tyres = 4
    # the dunder init method/initializer
    def __init__(self, name, year_made):
        self.name = name
        self.year_made = year_made
        
# class Minibus inheriting from class Car
class Minibus(Car):
    # __init__ method of class Minibus
    def __init__(self, name, year_made, price):
        self.name = name
        self.year_made = year_made
        self.price = price
    # method to return price
    def carPrice(self):
        return self.price
# instantiating the object for class Minibus
minbus1 = Minibus("Vanetti", "2013", "MWK4500000")
# printing data
print("Name:", minbus1.name)#name for Minibus class
print("Year made:", minbus1.year_made)#year for Minibus class
print("Maximum speeed:", minbus1.maximum_speed)#max_speed inherited from Car
print("Number of tyres:", minbus1.number_of_tyres)#number_of_tyres inherited from Car
print("Price:", minbus1.carPrice())#calling the carPrice() method in Minibus
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ma7kob5o98s2drkyirrq.png)

## Encapsulation
As you are writing your programs in an Object Oriented Programming way, situations arise where you want your class data not to be modified in any way, to solve this encapsulation comes into play. Encapsulation is defined as a way of restricting access to methods and variables available in a class. Using encapsulation, you can make your class attributes private or protected.

- Private - These attributes are invisible and inaccessible from outside the class itself

**Syntax:**
```python
def __init__(self, name, year_made):
    self.__year_made = year_made#this attribute is private
```
We use double underscores `__` to denote a private attribute

**Example2: Creating a private attribute**

```python
# creating our class Car
class Car:
    # initializing attribute name via __init__
    def __init__(self, name):
        self.__name = name#this is a private attribute
        
#instantiating an object   
car1 = Car("BMW")
# printing the private attribute
print(car1.name)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nyznjvj46f27oh33yhc8.png)
We are getting an error because the private attribute is invisible and inaccessible outside the class

- Protected - This attribute can only be accessed via a subclass but it’s possible to access them outside the class

**Syntax:**
```python
def __init__(self, name, year_made):
    self._name = name#this attribute is protected
```
We use a single underscore `_` to denote a protected attribute
**Example3: creating a protected attribute**
```python
# creating our class Car
class Car:
    # initializing attribute name via __init__
    def __init__(self, name):
        self._name = name#this is a protected attribute
        
#instantiating an object   
car1 = Car("BMW")
# printing the protected attribute
print(car1._name)
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6vse7wkl47tt3o223530.png)
To access the protected attribute outside the class, we use a single underscore before the attribute name e.g. `car1._name`

**Note:**
Attributes without any underscores are said to be public, this means they are visible and accessible even from outside the class itself using an object.

## Polymorphism
Poly means many and morphism means forms, so polymorphism means having many forms. In Python, polymorphism means the same function name is used in different ways.
**Example4: Using polymorphism**
```python
# creating class Square
class Square:
    # initializer
    def __init__(self, side):
        self.side = side 
    # common method
    def calculate_area(self):
        return self.side * self.side
# creating class Triangle
class Triangle:
    # initializer
    def __init__(self, base, height):
        self.base = base
        self.height = height
    # common method
    def calculate_area(self):
        return 0.5 * self.base * self.height
# instantiating class objects
sq = Square(6)
tri = Triangle(2, 7)
# printing results
print("Area of square: ", sq.calculate_area())
print("Area of triangle: ", tri.calculate_area())
```
Output:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ygrn6j9dsc1o1fflutk7.png)
In our two classes, we have created a common method `calculate_area()` that is being used in different ways, that is calculating the area of a square and a triangle. 

## Conclusion
In summary, mastering object-oriented concepts is a must-have skill for any developer. Following an object-oriented paradigm when writing your code makes it cleaner, maintainable, and reusable.