Learn Python in 7 days: Day 6 - Introduction to OOP

Shashank Shetty
Shashank Shetty

Comment section

You need to be a subscriber to comment


Introduction to OOP

What is OOP?

OOP stands for Object Oriented Programming. Like most modern programming languages, Python is also an object oriented language, in fact, it is a characteristic feature of the language. This might be a little tricky to understand for beginners, but I'll try my best. Buckle up! Because this is gonna be a bit challenging. If you are not confident enough, you can practice the previous lessons to get hold of the syntax.

Everything in Python is an object, and I mean EVERYTHING (almost). Now, when you create a variable called fruit: str = "Apple", an apple won't be produced in the computer, obviously. They are treated as objects in the sense that they have certain attributes/properties or qualities which describe that particular object. This is the core of Python and is the underlying mechanism of everything you can do in Python.

Classes

Then, we have classes. Classes are blueprints which are like frameworks for different types of objects. If 'Car' is a class, it will have certain properties such as brand, model name, colour, seats, fuel capacity, mileage or range etc.. And every single car is an object; not "Mahindra Thar is an object", "every individual car is an object": if it is a Mahindra Thar, then it's brand will be Mahindra and model will be Thar. I hope you understand and this analogy will be used often in this course. Each car is an "instance" of the class Car. So, objects are instances of a particular class.

You might have noticed the word 'class' in result of the type() function:


>>> print(type(777))
< class 'int' >
    

Yes! 777 is an instance of the class int. That's what I meant by "everything in Python is an object". That "Apple" you created was an object of type string; meaning all the data types are classes! Even functions are objects of the class function(). Now let's see how we can create our own classes.

Classes are defined using the class key word. Every class must have the __init__ method in them. Speaking of methods, objects have not only attributes, but also methods which are defined in the respective class. Essentially, methods are functions specific to that object; they act in some way on the object itself. If you convert 45 into a string with str(45), that string function is a method of class int. The .lower() function is also a method, of type str. (Note the dot notation of the method)


class Car:  # A class with the name Car is defined
    def __init__(self, brand: str, model: str, year: int, colour: str):  #The parameters necessary to declare all the attributes
        self.brand: str = brand      #The attribute self.brand is declared and its value is set to the argument brand
        self.model: str = model      #The attribute self.model is declared and its value is set to the argument model
        self.year: int = year        #The attribute self.year is declared and its value is set to the argument year
        self.colour: str = colour    #The attribute self.colour is declared and its value is set to the argument colour

# Creating an instance of the class Car
my_car: Car = Car("Hyundai", "Venue", 2022, "White")

print(my_car)
print(type(my_car))
# As you might have seen in the type hint,
# 'Car' is now its own type!
# The object my_car is of the type Car

print(my_car.model)
# You can access specific attributes with the 'object.attribute' syntax


# You can also change attributes with this syntax
my_car.brand = "Rolls Royce"
my_car.model = "Spectre"
print(my_car.model)
    

What is 'self'?

Now, you must be asking, what the heck is self? Self is the parameter that refers to a particular instance of a class. In the above example, 'Car' is a class and 'my_car' is an instance of that class, as you must have understood by now. Every instance can have different values for an attribute. E.g., neighbour_car can have the brand 'Ford'. The self parameter just tells the method or the attribute to refer to that instance. When you printed my_car.model, the my_car object was passed to the self parameter automatically and it returned the model of my_car. If you had another car called neighbour_car, and you asked for neighbour_car.model, it would return something like Ford; because self would then become neighbour_car. A simple way to think about it is that a specific object replaces the self keyword. You don't need to pass it when calling a method or accessing an attribute, it is automatically passed. The first parameter is always self, and it is just a convention to call it 'self'; you can call it anything but I recommend not to.

Methods

Apart from attributes, classes can also have methods, which are just class-specific functions. The __init__() function is also a method which runs when an instance of the class is created or initialised. Let's see an example:


from datetime import datetime

# I will not include the complete context every time
# The following function should be placed under the class after __init__()

    def age(self) -> int:
        return datetime.now().year - self.year
    # Gets the current year and subtracts the purchase year of the car

print(my_car.age(), "years")
    

We just created a method called age() which returns the age of the car in years. Here is another example to make you understand the concept:


# A Fruit class with an __init__() method
# and a price() method
class Fruit:
    def __init__(self, name: str, calories: int, rate: float) -> None:
        self.name: str = name
        self.calories: int = calories
        self.rate: float = rate
    
    def price(self, weight) -> int:
        return self.rate * weight  # Returns weight times rate

apple: Fruit = Fruit("Apple", 530, 150.00)
banana: Fruit = Fruit("Banana", 800, 70.00)

print("₹", apple.price(2))  # Price of 2 kilos of apples
print("₹", banana.price(5))  # Price of 5 kilos of apples
    

Dunder methods

There are special methods called as 'dunder methods' which is short for 'Double Underscore methods'. They are also called as magic methods in Python because they provide most of the functionality to the language. There are numerous dunder methods which you can look up on the Internet. You will be surprised to know even basic arithmetic operations like addition and multiplication are also dunder methods. Integer and float objects have a method called __add__ which called when the + operator is used. Here is an example:


# A money class that has attributes amount and currency
class Money:
    def __init__(self, amount: float, currency: str):
        self.amount: float = amount
        self.currency: str = currency
    
    # Checks whether the currency is the same and returns a new Money object
    def __add__(self, other):
        if self.currency != other.currency:
            raise ValueError("Can't add amounts with different currencies.")
            # Oh yes, you can raise exceptions on-demand!
        return Money(self.amount + other.amount, self.currency)

balance: Money = Money(35000, "INR")
deposit: Money = Money(6000, "INR")

balance = balance + deposit
# The + operator calls the __add__ method
# The object to the right is passed as the second argument, 'other'
print(balance.amount)

# You can directly call the add method as well
deposit: Money = Money(2500, "INR")
balance = balance.__add__(deposit)
print(balance.amount)

# Create another object of a different currency and try to use the + operator
      

One of the most commonly used dunder method is the __str__() method. Without this, when you print a custom object, it just says that it is an object of a certain type; but nothing useful. This method/function is manually defined to output a user-friendly text. This method is automatically called when you print the object or convert it into text. For the Car class used earlier, this can be a string method:


    def __str__(self):
        return f"A/An {self.colour} {self.brand} {self.model}, purchased in {self.year}."
    
    # You can now print the car objects or convert them to text
    

Inheritance of Classes

Just like properties are inherited from generation to generation in real life, attributes and methods can be inherited from one class to another. More specifically, we can create what are 'child classes' which have the attributes and methods of the parent class, as well as some of their own. And example will clarify it:


class Person:
    def __init__(self, name: str, age: int):
        self.name: str = name
        self.age: int = age
    
    def introduce(self):
        print(f"Hello, my name is {self.name} and I am {self.age} years old.")

class Student(Person):  # The Person class is passed as a parameter to the Student class
    # The Student class inherits the attributes and methods of the Person class
      def __init__(self, name: str, age: int, standard: int, roll_no: int):
        super().__init__(name, age)  # Calls the __init__ method of the Person class
        self.standard: int = standard  # The Student class specific attribute
        self.roll_no: int = roll_no
      
      def introduce(self):  # This method overrides the introduce() method of the Person class
        print(f"Hello, my name is {self.name}. I am {self.age} years old and I am studying in standard {self.standard}")

class Teacher(Person):
    def __init__(self, name: str, age: int, subject: str):
        super().__init__(name, age)
        self.subject: str = subject
    
    # Though the introduce() method is not defined here, it is inherited and can be called

    def teach(self):  # A method unique to the Teacher class
        return f"[Teaching {self.subject}...]"
    

In the above example, we first created a Person class with attributes name & age and a method introduce(). We then created two child classes, Student and Teacher. Both the Student and the Teacher are a Person and they inherit the attributes and methods of the parent class, Person. When we defined the __init__() method of the child class and provided some parameters, the parameters of the parent class were actually overridden. But instead of declaring the attributes again, we use the super() function to refer to the parent class and call its initialisation method. After that, we can declare the child class-specific attributes or methods. Similar to attributes, methods will also be overridden if defined again. Let us create some objects.


me: Person = Person("S S Shetty", 16)
rutherford: Student = Student("Ernest Rutherford", 154, 13, 001)
thomson: Teacher = Teacher("J J Thomson", 169, "Physics")

me.introduce()
rutherford.introduce()
thomson.introduce()

print(thomson.teach())
    

Try and analyse the output and figure out which methods are being called. Also, find out the types of the three objects to see how they created

More to know

  • You can also have what are called 'class variables' and 'class methods'. Essentially, these are variables and methods that are common to all instances of that class. You just declare class variables without the self. prefix and class methods with the @classmethod decorator with the cls parameter instead of the self parameter. There are also 'static methods' which are just normal functions that are defined inside a class. They don't have access to the instance or class variables, but they can be called with the class name. Since these are not often used, I'll be skipping the details.
  • If we intend to create a class solely for storing data, which is indeed the most common use case, we can make use of 'dataclasses'. They automate the creation of some methods. They are also more concise.
  • 
    from dataclasses import dataclass
    
    @dataclass
    class Car:
        brand: str
        model: str
        year: int
        colour: str
    
    my_car: Car = Car("Hyundai", "Venue", 2022, "White")
    print(my_car)
          

    That's it! The above code is equivalent to the Car class we created earlier, but with less boilerplate code. And some dunder methods (like __str__, __eq__, etc.)are automatically created. Although you can still define other methods, it is not generally done.