Object Oriented Programming using Python

Object-oriented programming

Object-oriented programming is a paradigm of programming that models software code after real-life objects. Just like objects, you can have instances of objects and objects can have attributes. Let's get into it.

Figure [1] - OOP

Creating an object:

class Car:
    pass

car1 = Car()
print(car1)

In the script above, we first defined a class Car, then instantiated Car class on variable 'car1' and we received the following output for our print statement.

<__main__.Car object at 0x0124E490>

This refers to the object stored at a particular memory location. We will see how to make this output more user friendly later on.

Adding attributes to an object:

All cars would have a make, model, and year of manufacture, so we can set those as attributes for our car1. In the script below, we have done just that.

class Car:
    pass

car1 = Car()
car1.make = 'Volkswagen'
car1.model = 'Golf'
car1.year = 2016
print(f'{car1.year} {car1.make} {car1.model}')

Methods:

Say we want to ouput a simple print statement saying that a car has been added to the garage, we can do this using a 'method' as in the code below:

class Car:
    def add_garage(self, x, y, z):
        return str(x)+' '+ y +' '+ z + ' has been added to the garage.'

car1 = Car()
car1.make = 'Volkswagen'
car1.model = 'Golf'
car1.year = 2016

print(car1.add_garage(car1.year, car1.make, car1.model))

We add the 'self' argument here since Python passes the object itself as the first argument when it calls the methods. For example, when we call the method add_garage from the car1 instance, the car1 instance itself is passed to the method and this is represented by the 'self' argument. The output of the print statement looks like the following:

2016 Volkswagen Golf has been added to the garage.

__init__ method:

We need a set of rules to instantiate an object, we can do that by setting the '__init__' method. This 'init' method is called for every instance of the class. 

class Car:
    def __init__(self):
        print('Car instance created.')
        return None

car1 = Car()
car2 = Car()

We see the following output:

Car instance created.

Car instance created.

We can use this method to set up a Car instance such that every time it is called, we have a model, make, and year input.

class Car:
    def __init__(self, make, model, year):
        self.make = make
        self.model =model
        self.year = year

    def add_garage(self, x, y, z):
        return str(x)+' '+ y +' '+ z + ' has been added to the garage.'

car1 = Car('Volkswagen', 'Golf', 2016)

print(car1.add_garage(car1.year, car1.make, car1.model))

The output for the above code looks like the following:

2016 Volkswagen Golf has been added to the garage.

Let's see what happens if we only enter the make and model:

car1 = Car('Volkswagen', 'Golf')

The output: 

Traceback (most recent call last):

  File "c:\Users\user\Desktop\temp.py", line 10, in <module>

    car1 = Car('Volkswagen', 'Golf')

TypeError: __init__() missing 1 required positional argument: 'year'

As we can see, we get an error saying that we missed a positional argument.

Input Validation:

Let's say that we want to assume that the year is 2022 if no specific model year is specified, then we can set a default value for the variable 'year' that can be over-written if required while calling the class object. Also, we can use the variables in the 'init' method anywhere in the class definition, so let's update the 'add_garage' method as well. The implementation for that looks like the follows:

class Car:
    def __init__(self, make, model, year=2022):
        self.make = make
        self.model =model
        self.year = year

    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

car1 = Car('Volkswagen', 'Golf')

print(car1.add_garage())

Now, the output looks like the following:

2022 Volkswagen Golf has been added to the garage.

If we want to specify the object data type, for e.g., we want to make and model to be strings and year to be an integer. We can do that with a ':' operator. Also, since the first car model came out in 1886, we want 'year' to be greater than 1886, we can use an 'assert' statement for this. Let's take a look at how to how to implement this and what happens when we input an incorrect value:

class Car:
    def __init__(self, make: str, model: str, year=2022):
        # Validating received arguments
        assert year >= 1886, f"Model year {year} is not greater than 1886."

        # Assigning to self object
        self.make = make
        self.model =model
        self.year = year
 
    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

car1 = Car('Volkswagen', 'Golf', 1510)

The output looks like the following:

Traceback (most recent call last):

  File "c:\Users\user\Desktop\temp.py", line 14, in <module>

    car1 = Car('Volkswagen', 'Golf', 1885)

  File "c:\Users\user\Desktop\temp.py", line 4, in __init__

    assert year >= 1886, f"Model year {year} is not greater than 1886."

AssertionError: Model year 1885 is not greater than 1886.

As we can see, we now have an 'AssertionError'.

Class attributes:

So far we have seen instance attributes such as make, model, and year that are specific to every Car object but what if we have attributes such as the number of wheels that are common among all Car objects? In this case, we can use Class attributes.

class Car:
    num_wheels = 4
    def __init__(self, make: str, model: str, year=2022):
        # Validating received arguments
        assert year >= 1886, f"Model year {year} is not greater than 1886."

        # Assigning to self object
        self.make = make
        self.model =model
        self.year = year
 
    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

car1 = Car('Volkswagen', 'Golf', 2022)
print(car1.num_wheels)
print(Car.num_wheels)

From the example above, we see the output of '4' twice. For the instance attribute (car1.num_wheels), the value was obtained from the class definition.

Here's a special method to see all attributes (__dict__):

print(car1.__dict__)

print(Car.__dict__)

The output looks like the following:

{'make': 'Volkswagen', 'model': 'Golf', 'year': 2022}

{'__module__': '__main__', 'num_wheels': 4, '__init__': <function Car.__init__ at 0x021A9E38>, 'add_garage': <function Car.add_garage at 0x021A9DF0>, '__dict__': <attribute '__dict__' of 'Car' objects>, '__weakref__': <attribute '__weakref__' of 'Car' objects>, '__doc__': None}

Let's see how to call the class attribute in one of our instance methods.

class Car:
    num_wheels = 4
    def __init__(self, make: str, model: str, year=2022):
        # Validating received arguments
        assert year >= 1886, f"Model year {year} is not greater than 1886."

        # Assigning to self object
        self.make = make
        self.model =model
        self.year = year
 
    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

    def add_wheel(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has ' + str(Car.num_wheels) + ' wheels put on.'

car1 = Car('Volkswagen', 'Golf', 2022)

print(car1.add_wheel())

Here the 'add_wheel' method takes the 'num_wheels' attribute from the class 'Car'.

The problem with this approach is that we will not be able to set num_wheels at the instance level to override the class level attribute, to overcome this, the best practice would be to use 'self.num_wheels' instead of 'Car.num_wheels'.

class Car:
    num_wheels = 4
    car_list = []
    def __init__(self, make: str, model: str, year=2022):
        # Validating received arguments
        assert year >= 1886, f"Model year {year} is not greater than 1886."

        # Assigning to self object
        self.make = make
        self.model =model
        self.year = year

        # Adding each instance to the car_list
        Car.car_list.append(self)
 
    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

    def add_wheel(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has ' + str(Car.num_wheels) + ' wheels put on.'

car1 = Car('Volkswagen', 'Golf', 2016)
car2 = Car('Volvo', 'XC90', 2022)
car3 = Car('Porsche', '911', 2022)
car4 = Car('Audi', 'R8', 2022)

print(Car.car_list)

The output looks like the following:

[<__main__.Car object at 0x01ABE610>, <__main__.Car object at 0x01B2BA78>, <__main__.Car object at 0x01B2BA30>, <__main__.Car object at 0x01B50490>]

The output above shows us 4 car objects. This can be very useful, for e.g. if we would like to print all the car models, we can use the following code:

for car in Car.car_list:
    print(car.model)

The output for which looks like the following:

Golf

XC90

911

R8

__repr__ method:

This method is similar to '__str__' method. So far, we have been seeing output such as '<__main__.Car object at 0x01ABE610>'. It would be more useful to return a string representing the object, we can do that using the following code:

class Car:
    num_wheels = 4
    car_list = []
    def __init__(self, make: str, model: str, year=2022):
        # Validating received arguments
        assert year >= 1886, f"Model year {year} is not greater than 1886."

        # Assigning to self object
        self.make = make
        self.model =model
        self.year = year

        # Adding each instance to the car_list
        Car.car_list.append(self)
 
    def add_garage(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has been added to the garage.'

    def add_wheel(self):
        return str(self.year)+' '+ self.model +' '+ self.make + ' has ' + str(Car.num_wheels) + ' wheels put on.'

    def __repr__(self) -> str:
        return f"Car('{self.make}', '{self.model}', '{self.year}')"

car1 = Car('Volkswagen', 'Golf', 2016)
car2 = Car('Volvo', 'XC90', 2022)
car3 = Car('Porsche', '911', 2022)
car4 = Car('Audi', 'R8', 2022)

print(Car.car_list)

Now, the output is much more user-friendly and looks like the following:

[Car('Volkswagen', 'Golf', '2016'), Car('Volvo', 'XC90', '2022'), Car('Porsche', '911', '2022'), Car('Audi', 'R8', '2022')]

It is also a best practice to return a string that represents our input string.

Conclusion:

Hopefully, this blog has presented a clear and concise understanding of Object Oriented Programming using Python. In a future blog, I hope to cover topics such as static and class methods, inheritance, getters & setters.

If you prefer to learn from a video format, I highly recommend the freeCodeCamp video (ref [4]) below. Thank you for reading! 

---

References:

[1] https://www.udacity.com/blog/2021/11/__init__-in-python-an-overview.html#:~:text=The%20__init__%20method%20is%20the%20Python%20equivalent%20of,is%20only%20used%20within%20classes.

[2] Python special/magic methods -> https://www.tutorialsteacher.com/python/magic-methods-in-python

[3] https://docs.python.org/3/reference/datamodel.html

[4] Highly recommended -> https://www.youtube.com/watch?v=Ej_02ICOIgs&t=1960s&ab_channel=freeCodeCamp.org

[5] https://docs.python.org/3/tutorial/classes.html

Comments

Popular posts from this blog

Playing around with Dell R520 server

Experience Interviewing for an Infrastructure Engineer - Continuous Delivery position

2023 Summer Reading List Summary