In order to develop applications that are easier to read, modify, and scale, it helps to use an object-oriented approach. Object-Oriented Programming (OOP) allows you to group related pieces of data and functionality together inside objects. These objects contain methods (functions that belong to the object) which define specific behaviors that act on their internal data.
What is Object Oriented Programming (OOP)?
Object-Oriented Programming is a programming paradigm based on the concept of “objects.” These objects gather together attributes (data) and methods (functions) that define the object’s behavior. This approach makes it easier to model real-world entities, organize code, and build more maintainable applications.
Classes
A class is kind of like diagram for creating an object. It’s not the object itself — it just tells you what the structure looks like and what kind of data and behavior the object will have. When we call a class, we’re basically calling its constructor, which is what creates a new object based on that blueprint.
Instances
An instance is just a specific object created from a class. For example, if we have a Game class, when we create a new instance, we’re making a new game object that follows the structure of the Game class. Each instance is its own separate object, even if it comes from the same class.
An example of class and instances:
class Game:
pass
g1 = Game()
g1
<__main__.Game object at 0x7f87d8f642b0>
Here, g1 is an instance of the Game class. The output shows its memory location, which is how Python represents the object by default.
Most methods you define inside a class are actually instance methods. They use self to access and modify the object’s data, and they’re called on specific instances of the class.
Instance Methods
When you have a class defined, you usually want it to do something. To make that happen, you can create functions inside the class to customize its behavior. These functions defined within the class are called instance methods, and they always have self (I’ll dig more into this later) as one of their arguments.
class Game:
def __init__(self, name, genre):
self.name = name
self.genre = genre
def shout_game(self):
return f"I love playing {self.name} because it's an {self.genre} game"
>>> g1 = Game("Metal Gear", "action")
>>> g1
<__main__.Game object at 0x7f6a069652b0>
>>> g1.shout_game()
'I love playing Metal Gear because is an action game'
As we can see here, we have a Game class that is initialized with a name and genre. Then we have an instance method that returns a string based on the object’s data.
When we call the class like this:
g1 = Game("Metal Gear", "action")
We’re initializing a new object using the __init__
method. This special method runs automatically when a new instance is created.
The self
keyword represents the specific instance being created or used. Inside __init__
and other instance methods, self lets us access or modify the object’s own attributes. For example, self.name
refers to the name attribute of the g1
object.
Also, when we want to add extra attributes that might not always be provided, Python lets us use default arguments in the __init__
method.
>>> class Game:
... def __init__(self, name, genre, style="red"):
... self.name = name
... self.genre = genre
... self.style = style
...
>>> g1 = Game("Metal Gear", "action")
>>> g1.style
'red'
>>> g2 = Game("Metal Gear", "action", "blue")
>>> g2.style
'blue'
Attributes
Attributes are unique variables that belong to objects. In some programming languages, attributes and methods are often protected using different access levels: public, private, and protected.
- Public: accessible from anywhere.
- Private: accessible only within the class itself.
- Protected: accessible within the class and by classes that inherit from it.
Python doesn’t strictly enforce these rules like some other languages do, but it uses naming conventions (like prefixing with _ or __) to signal the intended level of access.
There are also class attributes, which are defined outside any method, directly inside the class. These attributes belong to the class itself and are shared by all instances. They’re typically used to store information that applies to the entire class.
Similarly, class methods are methods that belong to the class rather than to instances. They’re usually used to define behaviors that apply to the class as a whole, not to any specific object.
class Game:
# This is a class attribute — shared across all instances
all = {}
# These are instance attributes — unique to each object
def __init__(self, title, genre, console_id, id = None):
self.title = title
self.genre = genre
self.console_id = console_id
self.id = id
g1 = Game("Metal Gear", "Action", "PS2", 1)
g2 = Game("God of War", "Action", "PS2", 2)
print(g1.title) # Metal Gear (instance attribute)
print(g2.title) # God of War (instance attribute)
print(Game.all) # Accessing the class attribute
Properties
Sometimes, we want to make sure that our attributes follow certain rules or validations. Python’s @property decorator lets us use methods to control how attributes are accessed and modified, while still letting us use them like normal attributes.
class Game:
all = {}
def __init__(self, title, genre, console_id, id = None):
self.title = title
self.genre = genre
self.console_id = console_id
self.id = id
def __repr__(self):
return(
f"<Game {self.id}: {self.title}, {self.genre}, " +
f"Console ID: {self.console_id}>"
)
@property
def title(self):
return self._title
@title.setter
def title(self, value):
if not isinstance(value, str):
raise ValueError("Title must be a string")
if len(value.strip()) <= 0:
raise ValueError("Title must be longer than 0")
self._title = value
@property
def genre(self):
return self._genre
@genre.setter
def genre(self, value):
if not isinstance(value, str):
raise ValueError("Genre must be a string")
if len(value.strip()) <= 0:
raise ValueError("Genre must be longer than 0")
self._genre = value
@property
def console_id(self):
return self._console_id
@console_id.setter
def console_id(self, console_id):
# Assume Console.find_by_id(console_id) checks if the console exists in the database
if type(console_id) is int and Console.find_by_id(console_id):
self._console_id = console_id
else:
raise ValueError("console_id must reference a console in the database")
Here, the @property decorator lets us write validation logic inside methods, but access the values like regular attributes. This gives us more control without changing how the object is used.
Decorators
Earlier, I mentioned that @classmethod belongs to the class itself. Now, let’s see how it differs from a regular method and how these are implemented as decorators.
There are two other common decorators you’ll see often: the @property decorator, which we already used in the previous section, and @staticmethod.
In Python, decorators are basically functions that take another function as a parameter and return a new one with extra functionality. This allows us to modify or extend how methods work without changing their actual code.
A class method it differs from an instance method because it works with the class itself, not a specific object. Instead of using self as the first parameter, it uses cls, which represents the class. Class methods are useful when you want to create or change data that applies to the whole class rather than individual instances.
A static method is different from both instance and class methods because it doesn’t take self or cls as its first parameter. It behaves just like a regular function, but it’s located inside a class because it’s associated to the class logically. Static methods are often used for utility functions that don’t need to access or modify instance or class data.
class Game:
all = []
def __init__(self, title):
self.title = title
Game.all.append(self)
@classmethod
def total_games(cls):
return len(cls.all)
g1 = Game("Metal Gear")
g2 = Game("God of War")
print(Game.total_games()) # 2
Here, total_games is a class method that uses cls to access the class-level all list and return the total number of games created.
class Game:
@staticmethod
def greet():
return "Welcome to the Game database!"
print(Game.greet())
The greet method doesn’t use self or cls. It’s just a utility function that logically belongs to the class, so we keep it inside for organization.
Inheritance
Just like in the real world, some entities are related to each other and share certain characteristics or behaviors. In Python, we can represent these relationships through inheritance, which lets one class reuse the attributes and methods of another.
Without inheritance, you might end up repeating a lot of code just to represent small differences between related objects. Inheritance helps avoid that repetition by letting child classes build on top of a parent class.
To use inheritance, we start by creating a parent (or superclass) with shared attributes and methods. Then, we define a child (or subclass) that inherits from the parent by passing the parent class as an argument when defining the child. This way, the child class automatically has access to everything from the parent class.
class Game:
def __init__(self, title):
self.title = title
def play(self):
print(f"Playing {self.title}")
class ActionGame(Game):
def __init__(self, title, difficulty):
super().__init__(title)
self.difficulty = difficulty
g1 = ActionGame("Metal Gear", "Hard")
g1.play() # Inherits play() from Game
'Playing Metal Gear'
Implementing inheritance helps us to have a cleaner code while avoiding repetition of code and helps to represent real world relationships between objects.
Python has a lot of powerful tools, and here we just scratched the surface. However, this gives us a clear idea of how these concepts work in Python.
Top comments (0)