Object-oriented Programming in Python: An Introduction


In this article, we’ll dig into object-oriented programming (OOP) in Python. We won’t go too deeply into the theoretical aspects of OOP. The main goal here is to demonstrate how we can use the object-oriented paradigm with Python.

According to Statista, Python is the fourth most used programming language among developers. Why is that? Well, some say that it’s because of Python’s simplified syntax; others say it’s because of Python’s versatility. Whatever the reason is, if we want to study a trending programming language, Python should be one of our choices.

Contents:

  1. The Fundamentals of OOP
  2. Classes and Objects
  3. Defining a New Method
  4. Access Modifiers: Public, Protected and Private
  5. Inheritance
  6. Polymorphism
  7. Method Overloading
  8. Method Overriding

The Fundamentals of OOP

Let’s start with a gentle summary of object-oriented programming. Object-oriented programming is a programming paradigm — a group of ideas that set a standard for how things must be done.

The idea behind OOP is to model a system using objects. An object is a component of our system of interest, and it usually has a specific purpose and behavior. Each object contains methods and data. Methods are procedures that perform actions on data. Methods might require some parameters as arguments.

Java, C++, C#, Go, and Swift are all examples of object-oriented programming languages. The implementation of the OOP principles in all of those languages is different, of course. Every language has its syntax, and in this article, we’ll see how Python implements the object-oriented paradigm.

To learn more about OOP in general, it’s worth reading this article from MDN, or this interesting discussion about why OOP is so widespread.

Classes and Objects

The first important concept of OOP is the definition of an object. Let’s say you have two dogs, called Max and Pax. What do they have in common? They are dogs and they represent the idea of a dog. Even if they are of a different breed or color, they still are dogs. In this example, we can model Max and Pax as objects or, in other words, as instances of a dog.

But wait, what is a dog? How can I model the idea of a dog? Using classes.

Diagram of a class and two objects

As we can see in the picture above, a class is a template that defines the data and the behavior. Then, starting from the template provided by the class, we create the objects. Objects are instances of the class.

Let’s have a look at this Python code:

class Dog():
  def __init__(self, name, breed):
    self.name = name
    self.breed = breed

  def __repr__(self):
    return f"Dog(name={self.name}, breed={self.breed})"

max = Dog("Max", "Golden Retriever")
pax = Dog("Pax", "Labrador")
print(max)
print(pax)

On line 1, we declare a new class using the name Dog. Then we bump into a method called __init__. Every Python class has this, because it’s the default constructor. This method is used to initialize the object’s state, so it assigns values to the variables of the newly created object. As arguments of the constructor, we have the name, the breed, and a special keyword called self. It’s not a coincidence that this is the first argument of the method.

Inside the class code, the self keyword represents the current instance of the class. This means that each time we want to access a certain method or variable that belongs to an instance of the class (max or pax are two different instances), we must use the self keyword. Don’t worry if it’s not completely clear now; it will be in the next sections.

Look at the first line of the __init__ method — self.name = name. In words, this says to the Python interpreter: “Okay, this object that we’re creating will have a name (self.name), and this name is inside the name argument”. The same thing happens for the breed argument. Okay, so we could have stopped here. This is the basic blueprint used to define a class. Before jumping to the execution of this snippet, let’s look at the method that was added after the __init__.

The second method is called __repr__. In Python, the __repr__ method represents the class object as a string. Usually, if we don’t explicitly define it, Python implements it in its own way, and we’ll now see the difference. By default, if we don’t explicitly define a __repr__ method, when calling the function print() or str(), Python will return the memory pointer of the object. Not quite human-readable. Instead, if we define a custom __repr__ method, we have a nice version of our object in a stringed fashion, which can also be used to construct the object again.

Let’s make a change to the code above:

class Dog:
  def __init__(self, name, breed):
    self.name = name
    self.breed = breed

max = Dog("Max", "Golden Retriever")
pax = Dog("Max", "Golden Retriever")

print(max)
print(pax)
print(max == pax)

If we save and run this code, this is what we get:

<__main__.Dog object at 0x0000026BD792CF08>
<__main__.Dog object at 0x0000026BD792CFC8>
False

Wait, how can it be possible that they aren’t two equal dogs, if they have the same name and the same breed? Let’s visualize it using the diagram we made before.

Diagram of a class and two objects showing the memory addresses

First, when we execute print(max), Python will see that there’s no custom definition of a __repr__ method, and it will use the default implementation of the __repr__ method. The two objects, max and pax, are two different objects. Yes, they have the same name and the same breed, but they’re different instances of the class Dog. In fact, they point to different memory locations, as we can see from the first two lines of the output. This fact is crucial for understanding the difference between an object and a class.

If we now execute the first code example, we can see the difference in the output when we implement a custom __repr__ method:

Dog(name=Max, breed=Golden Retriever)
Dog(name=Pax, breed=Labrador)

Defining a New Method

Let’s say we want to get the name of the max object. Since in this case the name attribute is public, we can simply get it by accessing the attribute using max.name. But what if we want to return a nickname for the object?

Well, in that case, we create a method called get_nickname() inside our class. Then, outside the definition of the class, we simply call the method with max.get_nickname():

class Dog:
  def __init__(self, name, breed):
    self.name = name
    self.breed = breed

  def get_nickname(self):
    return f"{self.name}, the {self.breed}"

  def __repr__(self):
    return f"Dog(name={self.name}, breed={self.breed})"

max = Dog("Max", "Golden Retriever")
pax = Dog("Pax", "Labrador")

print(max.name)
print(max.get_nickname())

If we run this snippet, we get the following output:

> python snippet.py
Max
Max, the Golden Retriever

Access Modifiers: Public, Protected and Private

Let’s now consider access modifiers. In OOP languages, access modifiers are keywords used to set the accessibility of classes, methods or attributes. It’s a different situation in C++ and Java, where access modifiers are explicit keywords defined by the language. In Python, there’s no such thing. Access modifiers in Python are a convention rather than a guarantee over access control.

Let’s look at what this means with a code sample:

class BankAccount:
  def __init__(self, number, openingDate):
    
    self.number = number
    
    self._openingDate = openingDate
    
    self.__deposit = 0

In this snippet, we create a class called BankAccount. Any new BankAccount object must have three attributes: a number, an opening date and an initial deposit set to 0. Notice the single underscore (_) before openingDate and the double underscore (__) before deposit.

Great! According to Python’s convention, the single underscore is used as a prefix for protected members, while the double underscore is for private members. What does this mean in practice? Let’s try to add the code below under the class definition:

account = BankAccount("ABXX", "01/01/2022")
print(account.number)
print(account._openingDate)
print(account.__deposit)

If we try to execute this code, we’ll get something like this:

> python snippet.py
ABXX
01/01/2022
Traceback (most recent call last):
  File "snippet.py", line 14, in <module>
    print(account.__deposit)
AttributeError: 'BankAccount' object has no attribute '__deposit'

We can print the account number because it’s a public attribute. We can print the openingDate, even if, according to the convention, it’s not advised. We can’t print the deposit.

In the case of the deposit attribute, the proper way to read or modify its value should be through get() and set() methods. Let’s see an example of this:

class BankAccount:
  def __init__(self, number, openingDate):
    self.number = number
    self._openingDate = openingDate
    self.__deposit = 0

  def getDeposit(self):
    return self.__deposit

  def setDeposit(self, deposit):
    self.__deposit = deposit
    return True

account = BankAccount("ABXX", "01/01/2022")
print(account.getDeposit())
print(account.setDeposit(100))
print(account.getDeposit())

In the code above, we define two new methods. The first one is called getDeposit, and the second one is setDeposit. As their names imply, they’re used to get or set the deposit. It’s a convention in OOP to create get and set methods for all of the attributes that need to be read or modified. So, instead of directly accessing them from outside the class, we implement methods to do that.

As we can easily guess, executing this code gives the following output:

> python snippet.py
0
True
100

Inheritance

DRY. Don’t repeat yourself. Object-oriented programming encourages the DRY principle, and inheritance is one of the strategies used to enforce the DRY principle. In this section, we’ll see how inheritance works in Python. Please note that we’ll use the terms parent class and child class. Other aliases might include base class for the parent and derived class for the children. Since inheritance defines a hierarchy of classes, it’s pretty convenient to differentiate between the parent and all the children.

Okay, so let’s start with an example. Let’s say we want to model a classroom. A classroom is made by a professor and a number of students. What do they all have in common? What relationship do they all share? Well, they’re certainly all humans. As such, they share a certain number of features. For simplicity here, we define a class Person as having two private attributes, name and surname. This class also contains the get() and set() methods.

The image below shows a parent class and two children.

Image diagram showing a parent class and two children

As we can see, in both Student and Professor classes we have all the methods and attributes defined for the Person class, because they inherit them from Person. Additionally, there are other attributes and methods highlighted in bold that are specific to the child class.

Here’s the code for this example:

class Person:
  def __init__(self, name, surname):
    self.__name = name
    self.__surname = surname

  def getName(self):
    return self.__name

  def getSurname(self):
    return self.__surname

  def setName(self, newName):
    self.__name = newName

  def setSurname(self, newSurname):
    self.__surname = newSurname

Then, we have two entities to model, the Student and the Professor. There’s no need to define all the things we define above in the Person class for Student and Professor also. Python allows us to make the Student and the Professor class inherit a bunch of features from the Person class (parent).

Here’s how we can do that:

class Student(Person):
  def __init__(self, name, surname, grade):
    super().__init__(name, surname)
    self.__grade = grade

  def getGrade(self):
    return self.__grade

  def setGrade(self, newGrade):
    self.__grade = newGrade

In the first line, we define a class using the usual class Student() syntax, but inside the parentheses we put Person. This tells the Python interpreter that this is a new class called Student that inherits attributes and methods from a parent class called Person. To differentiate this class a bit, there’s an additional attribute called grade. This attribute represents the grade the student is attending.

The same thing happens for the Professor class:

class Professor(Person):
  def __init__(self, name, surname, teachings):
    super().__init__(name,surname)
    self.__teachings = teachings

  def getTeachings(self):
    return self.__teachings

  def setTeachings(self, newTeachings):
    self.__teachings = newTeachings

There’s a new element we haven’t seen before. On line 3 of the snippet above, there’s a strange function called super().__init__(name,surname).

The super() function in Python is used to give the child access to members of a parent class. In this case, we’re calling the __init__ method of the class Person.

Polymorphism

The example introduced above shows a powerful idea. Objects can inherit behaviors and data from other objects in their hierarchy. The Student and Professor classes were both subclasses of the Person class. The idea of polymorphism, as the word says, is to allow objects to have many shapes. Polymorphism is a pattern used in OOP languages in which classes have different functionalities while sharing the same interface.

Speaking of the example above, if we say that a Person object can have many shapes, we mean that it can be a Student, a Professor or whatever class we create as a subclass of Person.

Let’s see some other interesting things about polymorphism:

class Vehicle:
  def __init__(self, brand, color):
    self.brand = brand
    self.color = color

  def __repr__(self):
    return f"{self.__class__.__name__}(brand={self.brand}, color={self.color})"

class Car(Vehicle):
  pass

tractor = Vehicle("John Deere", "green")
red_ferrari = Car("Ferrari", "red")

print(tractor)
print(red_ferrari)

So, let’s have a look. We define a class Vehicle. Then, we create another class called Car as a subclass of Vehicle. Nothing new here. To test this code, we create two different objects and store them in two separate variables called tractor and red_ferrari. Note here that the class Car doesn’t have anything inside. It’s just defined as a different class, but till now it has had no different behavior from its parent. Don’t bother about what’s inside the __repr__ method for now, as we’ll come back to it later.

Can you guess the output of this code snippet? Well, the output is the following:

Vehicle(brand=John Deere, color=green)
Car(brand=Ferrari, color=red)

Note the magic happening here. The __repr__ method is defined inside the Vehicle class. Any instance of Car will adopt it, since Car is a subclass of Vehicle. But Car doesn’t define a custom implementation of __repr__. It’s the same as its parent.

So the question here is why the behavior is different. Why does the print show two different things?

The reason is that, at runtime, the Python interpreter recognizes that the class of red_ferrari is Car. self.__class__.__name__ will give the name of the class of an object, which in this case is the self object. But remember, we have two different objects here, created from two different classes.

If we want to check whether an object is an instance of a certain class, we could use the following functions:

print(isinstance(tractor, Vehicle)) 
print(isinstance(tractor, Car)) 

On the first line, we’re asking the following question: is tractor an instance of the class Vehicle?

On the second line, we’re instead asking: is tractor an instance of the class Car?

Method Overloading

In Python, like in any other OOP language, we can call the same method in different ways — for example, with a different number of parameters. That might be useful when we want to design a default behavior but don’t want to prevent the user from customizing it.

Let’s see an example:

class Overloading:
  def sayHello(self, i=1):
    for times in range(i):
      print("Nice to meet you!")

a = Overloading()
print("Running a.sayHello():")
a.sayHello()
print("Running a.sayHello(5):")
a.sayHello(5)

Here, we define a method called sayHello. This method has only one argument, which is i. By default, i has a value of 1. In the code above, when we call a.sayHello for the first time without passing any argument, i will assume its default value. The second time, we instead pass 5 as a parameter. This means i=5.

What is the expected behavior then? This is the expected output:

> python snippet.py
Running a.sayHello():
Nice to meet you!
Running a.sayHello(5):
Nice to meet you!
Nice to meet you!
Nice to meet you!
Nice to meet you!
Nice to meet you!

The first call to a.sayHello() will print the message "Nice to meet you!" only once. The second call to a.sayHello() will print "Nice to meet you!" five times.

Method Overriding

Method overriding happens when we have a method with the same name defined both in the parent and in the child class. In this case, we say that the child is doing method overriding.

Basically, it can be demonstrated as shown below. The following diagram shows a child class overriding a method.

Diagram showing a child class overriding a method

The sayHello() method in Student is overriding the sayHello() method of the parent class.

To show this idea in practice, we can modify a bit the snippet we introduced at the beginning of this article:

class Person:
  def __init__(self, name, surname):
    self.name = name
    self.surname = surname

  def sayHello(self):
    return ("Hello, my name is {} and I am a person".format(self.name))

class Student(Person):
  def __init__(self, name, surname, grade):
    super().__init__(name,surname)
    self.grade = grade

  def sayHello(self):
    return ("Hello, my name is {} and I am a student".format(self.name))

a = Person("john", "doe")
b = Student("joseph", "doe", "8th")
print(a.sayHello())
print(b.sayHello())

In this example, we have the method sayHello(), which is defined in both classes. The Student implementation of sayHello() is different, though, because the student says hello in another way. This approach is flexible, because the parent is exposing not only an interface but also a form of the default behavior of sayHello, while still allowing the children to modify it according to their needs.

If we run the code above, this is the output we get:

> python snippet.py
Hello, my name is john and I am a person
Hello, my name is joseph and I am a student

Conclusion

By now, the basics of OOP in Python should be pretty clear. In this article, we saw how to create classes and how to instantiate them. We addressed how to create attributes and methods with different visibility criteria. We also discovered fundamental properties of OOP languages like inheritance and polymorphism, and most importantly how to use them in Python.



Source link

Share

Leave a Reply

Your email address will not be published. Required fields are marked *