Python Object Oriented Programming (OOP) - with examples

As you start your journey in Python programming, you might wonder, "If we already have functions in Python for bundling and reusing code, why do we even need Objects and Classes?" The answer lies in the added level of organization and abstraction that classes and objects provide, which is especially beneficial in larger, more complex programs.

Let's use an analogy, building houses. If functions are like individual tasks involved in building a house, such as laying bricks or installing windows, then a Class is like having a blueprint for the house, and Objects are the houses built from that blueprint. While each task, or function, is essential, without a blueprint (Class) to organize and coordinate these tasks, it would be very difficult to construct a cohesive and well-structured house (Object). By defining a class (blueprint), we can create as many objects (houses) as we want, all with the same basic structure but with their own individual attributes.

Overview

Think of Classes as the blueprint for a house. This blueprint defines all the characteristics a house can have, like the number of rooms, the colour of the walls, the type of flooring, and so on. Similarly, Classes in Python serve as a blueprint for creating Objects. They define properties (attributes) and behaviours (methods) that every instance of the class should have.

Objects are individual houses built using the blueprint. For instance, if 'House' is a Class, an Object might be 'myHouse' or 'yourHouse', each built using the 'House' blueprint but perhaps differing in the number of rooms or colour of walls. Likewise, in Python, an Object is an Instance of a Class, carrying its own set of attributes and methods defined by the Class.

Music Player Application

In this post, we'll be building a simple Music Player in Python. This MusicPlayer Class will act as a basic model for a music player application. It will maintain a playlist of songs and will allow us to add and remove songs. We will build upon this foundation and explore the concepts of Classes, Objects, Methods, and attributes in Python's object-oriented programming. This hands-on approach will help us understand how these pieces work together to form a cohesive, functioning application.

In the next section, we'll dive into the creation of our MusicPlayer class, and begin to explore the process of instantiation, which allows us to create individual music players, each with its own unique playlist. Let's get started!

Classes

In Python, a class is defined using the keyword class, followed by the class name. Here's a simplified version of our MusicPlayer class.

class MusicPlayer:
    def __init__(self):
        self.playlist = []

In the code above, MusicPlayer is our class. The __init__ method in Python is a special method that's automatically called when you create a new instance of a Class. It's often referred to as a constructor because it's used to set up or initialize attributes for a new object.

When we define the __init__ method in a class, we typically use it to set the initial state of an instance. For example, in our MusicPlayer class, we have an __init__ method that initializes the playlist attribute as an empty list. (New music players come with an empty playlist, right?)

Objects and Instantiation

To create an Object (or an instance) from a Class, we call the Class using its name followed by parentheses.

player = MusicPlayer()

In this case, player is an Instance or Object of the MusicPlayer class. The term "Instance" refers to an individual object of a certain Class. Instantiation is the process of creating an Object from a Class. Each object created this way is an Instance of that Class, possessing its own set of attributes and methods as defined by the Class blueprint.

Within a Class, we can define functions which are typically referred to as methods. Here's how we add a method to our MusicPlayer class.

class MusicPlayer:
    def __init__(self):
        self.playlist = []

    def add_track(self, track):
        self.playlist.append(track)

The add_track method takes a track name as an argument and appends it to the music player's playlist. This allows us to dynamically add new tracks to any instance of the MusicPlayer class.

Scope and Instance Variables

You may already be familiar with two types of scope in Python - Global and Local. Global scope refers to variables defined outside all functions, and these variables can be accessed from anywhere in the code. Conversely, local scope refers to variables that are defined within a function, and these variables are only accessible within that function.

However, object-oriented programming (OOP) in Python introduces a third level of scope, known as Instance Scope. An instance variable, also called an attribute, is a variable that belongs to one instance of a class. Each object has its own copy of the instance variable, so changes to the variable in one object have no effect on other objects of the same class.

In a class definition, instance variables are initiated with self, like self.playlist in our MusicPlayer class. These variables are accessible within any method in the class, giving them object-level scope. For instance, self.playlist could be accessed and modified within any method of our MusicPlayer Class.

Methods in a Class can also have local variables, just like in a standalone function. These are temporary and exist only as long as the method is running. Unlike instance variables, they aren't prefixed with self. Once the method exits, these variables go away.

Instance Variable Example

class MusicPlayer:
    def __init__(self):
        self.playlist = []  # Instance variable

    def add_track(self, track):
        self.playlist.append(track)

player = MusicPlayer()  # Create a MusicPlayer instance
player.add_track("Track 1")  # Call the add_track method

print(player.playlist)  # Prints: ["Track 1"]

In this example, self.playlist is an instance variable. It's defined in the __init__ method, and it's accessed and modified in the add_track method. This variable is tied to the instance of the MusicPlayer class, represented by self.

When we create an instance of MusicPlayer with player = MusicPlayer(), player has its own playlist attribute. We can add tracks to player's playlist by calling player.add_track("Track 1"), which adds "Track 1" to the playlist.

The playlist variable belongs to the player object, and its value is preserved even after the add_track method has been called. We can verify this by printing player.playlist after the method call, which returns ["Track 1"].

This is the key feature of instance variables: they belong to an instance of the class, and their values are preserved for as long as the instance exists.

Local Variable Example

class MusicPlayer:
    def __init__(self):
        self.playlist = []  # Instance variable

    def add_track(self, track):
        track_to_add = track + " (added)"  # Local variable
        self.playlist.append(track_to_add)

player = MusicPlayer()  # Create a MusicPlayer instance
player.add_track("Track 1")  # Call the add_track method

print(player.playlist)  # Prints: ["Track 1 (added)"]

In this version of the add_track method, track_to_add is a local variable. We create and use this variable within the add_track method, and it goes out of scope (i.e., ceases to exist) when the method finishes running. If you try to print track_to_add outside the method, you will get an error because it's not defined in that scope.

Functions vs Methods

You might be wondering about the terms 'functions' and 'methods' that have been used so far, here is how they differ from each other.

  • Definition: A function is a piece of code that is called by its name and can be independent of a class, while a method is associated with an object and belongs to a class.
  • Access to Data: A function operates on its input arguments and has access to the global scope, while a method has access to the instance and its associated attributes.
  • Invocation: A function is called by its name independently, while a method is called on an instance of a class, such as instance.method().

Keep in mind that these differences mainly pertain to the concept of OOP, and "function" and "method" can sometimes be used interchangeably in general coding conversations.

Build the Music Player

Now that we've established a solid understanding of the core principles of Python's object-oriented programming, let's put theory into practice. We're going to work with our MusicPlayer class, a simplistic representation of a music player, allowing us to add and remove tracks, and control playback. This real-world example will help us visualize the concepts we've learned and see how they interact in a functional piece of code. Let's look at the entire code first and then we will break it down bit by bit.

class MusicPlayer:
    def __init__(self):
        self.playlist = []
        self.isPlaying = False
        self.currentTrack = None

    def add_track(self, track):
        self.playlist.append(track)

    def remove_track(self, track):
        if track in self.playlist:
            self.playlist.remove(track)

    def play(self):
        if self.playlist:
            self.currentTrack = self.playlist[0]
            self.isPlaying = True

    def next_track(self):
        if self.playlist and self.currentTrack in self.playlist[:-1]:
            currentIndex = self.playlist.index(self.currentTrack)
            self.currentTrack = self.playlist[currentIndex + 1]

    def stop(self):
        self.isPlaying = False
        self.currentTrack = None
class MusicPlayer:
    def __init__(self):
        self.playlist = []
        self.isPlaying = False
        self.currentTrack = None

Here we're defining a class named MusicPlayer. The __init__ method is called when an instance of MusicPlayer is created. It sets up the initial state of a MusicPlayer object with three attributes: playlist, isPlaying, and currentTrack. These are instance variables, each MusicPlayer object will have its own set of these variables.

Next, we have a set of methods that define the behaviors of a MusicPlayer instance.

def add_track(self, track):
    self.playlist.append(track)

The add_track method adds a track to the playlist. It accesses the instance's playlist attribute with self.playlist and appends the new track to it.

Similarly, the remove_track method removes a track from the playlist.

def remove_track(self, track):
    if track in self.playlist:
        self.playlist.remove(track)

The play, next_track, and stop methods control the playback.

def play(self):
    if self.playlist:
        self.currentTrack = self.playlist[0]
        self.isPlaying = True

The play method starts the playback if there are any tracks in the playlist, sets the currentTrack to the first track in the playlist, and changes isPlaying to True.

The next_track method switches the current track to the next one in the playlist.

def next_track(self):
    if self.playlist and self.currentTrack in self.playlist[:-1]:
        currentIndex = self.playlist.index(self.currentTrack)
        self.currentTrack = self.playlist[currentIndex + 1]

self.currentTrack in self.playlist[:-1] - This checks if the current track is in the list of all tracks except the last one. self.playlist[:-1] is a slicing operation that returns all elements in the playlist except the last one. This check is necessary because if the current track is the last one in the playlist, there is no "next track" to move to.

The stop method stops the playback.

def stop(self):
    self.isPlaying = False
    self.currentTrack = None

Let's create an instance of MusicPlayer and use these methods.

player = MusicPlayer()  # Create a MusicPlayer instance
player.add_track("Track 1")  # Add "Track 1" to the playlist
player.add_track("Track 2")  # Add "Track 2" to the playlist
player.play()  # Start playing
player.next_track()  # Switch to the next track
player.stop()  # Stop playing

This example demonstrates the core concepts we've discussed: classes as blueprints for objects, instantiation of classes into objects, instance variables, and methods. Each MusicPlayer object has its own state and behavior, and we can manipulate these through the methods defined in the class.

Creating Multiple Instances from the Same Class

One of the powerful features of Classes is that they allow us to create multiple objects, each with its own set of attributes. Let's illustrate this with our MusicPlayer class.

# Creating two instances of MusicPlayer class
player1 = MusicPlayer()
player2 = MusicPlayer()

# Adding tracks to the players
player1.add_track("Track 1")
player2.add_track("Track A")

# Print the playlist of each player
print(player1.playlist)  # Prints: ["Track 1"]
print(player2.playlist)  # Prints: ["Track A"]

In the above code, player1 and player2 are distinct instances of the MusicPlayer class. Even though they are created from the same class, they have separate sets of attributes. When we add a track to player1, it doesn't affect the playlist of player2 and vice versa.

This ability to create multiple, independent instances from a class is a fundamental part of object-oriented programming and allows for great flexibility and reuse of code.

Passing Arguments to a Method

If you are familiar with functions, you might be wondering about the add_track method in our MusicPlayer class, which is defined with two parameters: self and track. However, when we call this method, we only pass in one argument. Here's how it works.

When we call a method on an instance of a class, Python automatically passes the instance as the first argument. This argument is conventionally named self. So, when we call player1.add_track("Track 1"), Python is actually calling add_track(player1, "Track 1") behind the scenes.

The self parameter allows the method to access or modify the attributes of the instance on which it's called. That's why we use self.playlist in the method definition. It refers to the playlist of the specific MusicPlayer instance that we're adding a track to.

In essence, self is the bridge between the instance of the class and its class methods, allowing the methods to operate on the correct instance's attributes. So, even though it looks like we're passing only one argument when calling a method, Python is handling self for us, ensuring the method works correctly on the instance it's called on.

Initialization Parameters

When we create objects in real-world applications, it's common that each object needs to start with a unique state. Initialization parameters are the perfect tool for this.

In our MusicPlayer class, let's say we want each music player to be associated with a brand right when it's created. We can achieve this by modifying the __init__ method to accept a brand parameter.

class MusicPlayer:
    def __init__(self, brand):
        self.brand = brand
        self.playlist = []
        self.isPlaying = False
        self.currentTrack = None

    def add_track(self, track):
        self.playlist.append(track)
    # ... rest of the code ...

Now, when we instantiate our MusicPlayer class, we pass in the brand name.

apple_player = MusicPlayer("Apple")
sony_player = MusicPlayer("Sony")

Here, apple_player and sony_player are instances of the MusicPlayer class, each associated with a different brand. The brand is passed as an argument during instantiation and is used to set the brand attribute in the __init__ method.

Conclusion

In conclusion, I hope that this journey through Python's object-oriented programming using the MusicPlayer class has helped to clarify these concepts. We've covered a lot of ground, from understanding classes, objects, and instance variables, to exploring methods, initialization parameters, and the important self keyword.