Python is arguably the most famous programming language in the world today. According to the 2022 GitHub research on the most utilized languages amongst its repos, Python showcased an impressive second place, surpassing Java and only behind JavaScript. Undoubtedly, Python’s fame as “Jack of all trades” is one of its reasons for being so popular, alongside its readability and low learning curve.
However, one aspect of Python is sometimes forgotten in the middle of all this hype: its object-oriented language nature. Even though it is, indeed, object-oriented, people tend to link this programming paradigm with compiled, statically typed languages such as Java or C++. So, in this blog post, we will explore the basics of OOP in Python, including classes, objects, inheritance, and polymorphism.
Object-oriented programming (OOP) is a programming paradigm that focuses on objects, which are instances of classes. OOP provides a way of organizing code that is modular, reusable, and easy to understand.
One could say that it is like a toolbox that programmers use to build applications. The tools in this toolbox are objects, which are like building blocks that can be put together to create a larger structure.
Think of Python objects as real-life things that have properties and behaviors. For example, a car is an object that has properties like color, make and model, and behaviors like accelerating and braking.
In programming, objects can be created from classes, which are like blueprints that define the properties and behaviors of the object. This makes it easy to reuse code and create modular programs that are easy to understand and maintain.
OOP is widely used in software development because it helps to reduce complexity and increase efficiency. By breaking down a problem into smaller, more manageable pieces, developers can create easier software to test and debug. Additionally, OOP makes it easy to create complex programs with many moving parts without getting lost in the details.
In Python, a class is a blueprint for creating objects, as we said earlier. It defines a set of attributes (characteristics) and methods (abilities) that the objects will have. To create a class, you use the class keyword, followed by the class name and a colon. Let’s suppose that we work for a bank and that we want to recreate their client info storage software with OOP. First of all, let’s create a class that defines a real client of this bank: it has a name and a SSN (the USA equivalent to Brazil’s CPF):
class Client: def __init__(self, name, ssn): self.name = name self.ssn = ssn def is_angry(self): print("Why is this queue taking so long?")
In this example, we have defined a class called Client. It has two attributes, name and ssn, a constructor method, init, and a method called is_angry that acts as an ability of our object. The init method is a special, so-called “magic method” because of the fact that it is called when an object of the class is created. It initializes the attributes (characteristics) of the object. It’s fair to note that magic methods are defined with two underscores at their beginning and at their end.
It is also important to know what is the self word in that snippet of code. It refers to the specific instance of a class that is being created or called. It’s like a way of referring to the object itself.
In the example given, “self” refers to the specific instance of the Client class that is being created. When a new Client object is created, the init method is called with the arguments “name” and “ssn”. The “self” parameter is automatically passed in and is used to assign the “name” and “ssn” attributes to that specific instance of the Client class. In short, it is a reference to the current object being used or created, and it allows you to access and modify its attributes and methods.
With these important concepts in mind, we can now go ahead and create an object of the Client class. For that, we can simply call the class name and pass in the arguments for the __init__ method. Note that we don’t pass self as an argument here. Python already does that automatically under the hood:
client01 = Client("John Doe", "575-21-9040")
This creates an object called client01 named “John Doe” and SSN “575–21–9040”.
You can access the attributes of an object using dot notation, which will basically have the following syntax: ObjectCreated.DesiredAttribute:
print(client01.name) print(client01.ssn)
This will output:
John Doe 575-21-9040
Likewise, we can also call the methods of an object using dot notation. Note that, as a method is defined as a function that already has a print in it, we don’t need to utilize the print clause outside of it:
client01.is_angry()
This will output:
Why is this queue taking so long?
Now that we defined how the average client of our bank, we can define another class to represent our clients’ accounts. We’ll take some more complex steps here, but it will all make sense quickly!
Let’s go on with it:
class Account: def __init__(self, client, account_num, balance=0): self.balance = balance self.client = client self.account_num = account_num def summary(self): print(f"Account Nº: {self.account_num}; Balance: {self.balance:10.2f}") def withdraw(self, amount): if self.balance >= amount: self.balance -= amount else: print("You do not have enough funds to perform this operation!") print(f"Your new balance is:{self.balance:10.2f}") def deposit(self, amount): self.balance += amount print(f"Your new balance is:{self.balance:10.2f}")
Let’s start breaking our account down and understanding its details.
Our constructor method takes 3 parameters (self not included):
This time, we also have some more specific methods:
We can see this in action through the prompts that will follow.
First of all, let’s create an account object named acc01 that represents the bank account of our client01, Mr. John Doe. Because of a business rule, we are defining the account number parameter as the client’s SSN with a “-5” verifying digit at the end. Note that we can declare an object’s parameters utilizing another’s, as we did here with the account name holder and the account’s number.
acc01 = Account(client01.name, client01.ssn + str(-5), 700)
Now, let’s see this account’s information and perform some banking procedures:
print(acc01.client) print(acc01.account_num) print(acc01.balance)
This will output:
John Doe 575-21-9040-5 700
Let’s withdraw and deposit some money from this account too:
acc01.summary() acc01.withdraw(500) acc01.deposit(100)
This will output:
Account Nº: 575-21-9040-5; Balance: 700.00 Your new balance is: 200 Your new balance is: 300
Another extremely important concept that anyone who wants to go to the next level in OOP needs to know is inheritance.
It is a mechanism in OOP that allows you to create a new class based on an existing class. The new class, called the subclass, inherits the attributes and methods of the existing class, called the superclass. This is a great trick to make your code reusable.
To create a subclass in Python, you use the class keyword, followed by the subclass name and the superclass name in parentheses. Easier said than done, but we’ll figure this out with another practical example from our fictional bank.
Suppose we now want to create a credit card class for our system, given that some clients are now interested in utilizing their credit. We’ll see how that goes here:
class CreditCard(Account): def __init__(self, client, account_num, credit_limit, balance=0): super().__init__(client, account_num, balance) self.credit_limit = credit_limit def make_purchase(self, amount): '''This method represents the situation where the cardholder wants to have a bigger credit limit, that is shown along with the account's balance''' if self.balance + amount > self.credit_limit: print("Purchase declined - you have exceeded your credit limit.") else: self.balance += amount def make_payment(self, amount): self.balance -= amount
So, here’s what happens in this snippet:
In the same way, a child gets its genetic characteristics from its parents, a subclass gets its methods and attributes from its superclass.
To make things more visual, let’s handle our loyal client John Doe a credit card:
cc = CreditCard(acc01.name, acc01.account_num, 5000, acc01.balance)
This creates a credit card object called cc that is affiliated to the client01 information that we defined at the beginning of this article, with the only exception being the credit limit that was created alongside the CreditCard class.
Now it’s time to perform some actions on John Doe’s card:
cc.summary() cc.make_purchase(1000) cc.summary() cc.make_payment(500) cc.summary() cc.make_purchase(5000) cc.summary()
Note that albeit we are utilizing methods of the CreditCard class, we are also calling the Summary method that came from the Account class. If we did not utilize inheritance here, it’d be necessary for us to rewrite the same method, in a repetitive and definitively not optimized coding way:
Those prompts will output the following:
Account Nº: 575-21-9040-5; Balance: 700.00 # $1000 purchase happens here Account Nº: 575-21-9040-5; Balance: 1700.00 # $500 payment happens here Account Nº: 575-21-9040-5; Balance: 1200.00 # $5000 purchase attempt happens here Purchase declined - you have exceeded your credit limit. Account Nº: 575-21-9040-5; Balance: 1200.00
As we’ve seen, inheritance is a simple yet powerful resource that makes a huge difference when implementing an object-oriented solution in python or any other programming language, providing the programmer with an efficient and elegant way of reutilizing code, especially when it comes to scalable projects.
Polymorphism is a concept in OOP that allows objects of different classes to be treated as if they were of the same class. Basically, it is the ability of objects to take on multiple forms. Overall, there are two ways that polymorphism is achieved in Python: through Method Overriding and Method Overloading.
In this section, I’ll utilize the concepts we learned in the last two sections to explain Polymorphism. Instead of going with a banking example, this time we’ll go with dogs, as it’s more intuitive to think of polymorphism analyzing their behavior 🐶 (It’ll all make sense in a moment, I promise).
To kickstart things, let’s create a generic Dog class: it will take name and breed as parameters, as well as a bark method (like — almost — every dog):
class Dog: def __init__(self, name, breed): self.name = name self.breed = breed def bark(self): print("Au Au!")
Method overriding is a type of polymorphic framework that occurs when a subclass provides a different implementation of a method that is already defined in its parent class (the superclass).
This allows the subclass to inherit the method from the parent class but also modify it to suit its own needs. When the method is called on an instance of the subclass, the overridden method in the subclass is executed instead of the original method in the parent class.
Here we can create a practical example of it. As we know, different breeds have different vocalizations. We can modify the bark method to make it adequate for a big or small dog, for example. Instead of a generic ”Au Au!”, we can modify it to whatever we want. So, without further ado, let’s do it:
class GoldenRetriever(Dog): def bark(self): print("Woof woof!") class Shihtzu(Dog): def bark(self): print("Whee Whee!")
In this example, we create two subclasses of Dog with their own implementation of the Bark method. These subclasses represent a Golden Retriever and a Shihtzu.
Let’s now create our objects:
golden = GoldenRetriever("Hunter", "Golden Retriever") shihtzu = Shihtzu("Freddie", "Shihtzu")
With our objects created, we can now test our polymorphic methods:
golden.bark() shihtzu.bark()
When we call it on the GoldenRetriever object, it barks “Woof woof!”, and when we call it on a Shihtzu object, it barks “Whee Whee!”, as follows:
Woof Woof! Whee Whee!
This is a classical example of polymorphism because we can use the same method (bark), inherited from the parent class Dog, with different objects (namely GoldenRetriever and Shihtzu) to achieve different behaviors.
On the other hand, Method Overloading is when a class provides multiple methods with the same name but with different parameters. This allows the class to handle different types of input data in a more flexible manner.
When a method with a specific name is called, the interpreter determines which version of the method to execute based on the arguments that are passed to it. Python does not support method overloading, but it’s possible to create some workarounds to work with it, if that’s the case.
I won’t be covering it here as it would require an introduction to the concept of decorators, but this GeeksForGeeks article does a great job walking through it in Python.
Phew, that was a lot, but this sums up our introduction to OOP in Python!
As we can see, it is a powerful programming paradigm that can greatly improve your coding skills. By using OOP concepts, you can write modular, reusable, and easy-to-understand code to be used throughout your projects, as well as make things easier for teamwork.
Although many programmers may initially feel intimidated by OOP, once mastered, it can take your programming skills and creativity to a whole new level, adding an extra, considerably powerful layer of abstraction to one’s coding realm.
Undoubtedly, the ability to create complex programs that are easy to maintain and extend is an essential tool for any modern developer. An extra tip that I’d give to anyone willing to commit to this world is to study OOP in Java or C++. It may (or not) be harder to comprehend, but it’ll surely provide a broader understanding of it.
So, don’t be afraid to dive into OOP and explore its vast potential in your personal and professional projects!