Classes and Object-Oriented Programming in Python#

Object-Oriented Programming (OOP) is a way of programming that organizes code into reusable blocks of code called classes, which bundle data (attributes) and behaviors (methods) together. When you want to use a class in one of your programs, you make an object from that class, which is where the phrase “object-oriented” comes from.

OOP promotes:

  • modularity: making multiple modules or functions and then combining them to form a complete system

  • reusability: shared attributes and functions can be reused without the need of rewriting code

  • better code organization: less code and more reuse

OOP is defined by 4 main concepts (each of which is described below):

  • Encapsulation: concept of bundling data and methods that operate on that data into a single unit called a class. Its purpose is to help in organizing and protecting an object’s internal state and behavior;

  • Inheritance: mechanism that allows a new class to inherit attributes and methods from an existing class. Its purpose is to promote code reusability, extensibility, and the creation of class hierarchies, where subclasses can build upon the functionality of their parent classes;

  • Polymorphism: ability of different objects to respond to the same method name in a way that is specific to their class. It simplifies code by enabling flexibility and dynamic behavior;

  • Abstraction: process of simplifying complex systems by modeling classes based on their essential properties. It reduces complexity, making code more understandable and maintainable.

These concepts may seem abstract for now, but don’t be concerned because you have already been using them quite a lot: everything you do in Python uses objects! This should become obvious as you go through this page, after which you should be able to:

  1. Understand the fundamental concepts of classes and object-oriented programming (OOP) in Python.

  2. Comprehend the key principles of encapsulation, inheritance, and polymorphism in OOP.

  3. Define and create classes in Python.

  4. Create objects from classes and understand the relationship between classes and objects.

Tip

Click –> Live Code in the top-right corner of this screen and the live code feature will be enabled.

There is an interactive exercise at the end of this notebook.

What are classes?#

Classes can be thought of as blueprints or templates for creating objects.

A class is a body of code that defines the attributes and behaviors required to accurately model something you need for your program. Each class encapsulates both properties (attributes) and functions (methods) related to that type of object.

An attribute is a piece of information. In code, an attribute is just a variable that is part of a class.

A method is an action that is defined within a class, i.e., just a function that is defined for the class.

An object is a particular instance of a class. An object has a certain set of values for all of the attributes (variables) in the class. You can have as many objects as you want for any one class.

Let’s consider an example using a rocket ship in a game. When defining the Rocket class, we have to imagine what are properties and actions that are common to all rockets in this hypotethic game. For example, the a very simple rocket will have some x and y coordinates and will be able to move up. Here is what the rocket class can look like in code:

class Rocket:
    # Rocket simulates a rocket ship for a game.
        
    def __init__(self):
        # Each rocket has an (x,y) position.
        self.x = 0
        self.y = 0

    def move_up(self):
        # Each rocket can move along the y-axis.
        self.y += 1

Now let’s examine how we created this class.

The first line, with the keyword class, tells Python that you are about to define a class. The naming convention for classes is the CamelCase, a convention where each letter that starts a word is capitalized, with no underscores in the name. It is recommended that the class name does not have any parentheses after it, unless it inherits from another class (we’ll see this better later).

It is good practice to write a comment at the beginning of your class, describing the class. There is a more formal syntax for documenting your classes, but you can wait a little bit to get that formal. For now, just write a comment at the beginning of your class summarizing what you intend the class to do. Writing more formal documentation for your classes will be easy later if you start by writing simple comments now.

One of the first things you do when creating a class is to define the __init__() method. The __init__() method is called automatically when you create an object from your class and sets the values for any parameters that need to be defined when an object is first created. 0 We call this method a constructor. In this case, The __init__() method initializes the x and y values of the Rocket to 0.

The self part is a syntax that allows you to access variables and methods from anywhere else in the class. The word “self” refers to the current object that you are working with. Basically, all methods in a class need the self object as their first argument, so they can access any attribute that is part of the class.

Methods define a specific action that the class can do. A method is just a function that is part of a class. Since it is just a function, you can do anything with a method that you learned about with functions. Each method generally accepts at least one argument, the value self. This is a reference to the particular object that is calling the method.

With this code, we just defined the class, which is a blueprint. To define the actual object, we have to create an instance of the class. In other words, you create a variable that takes all of the attributes and methods as the Rocket class.

This can be done simply as follows:

class Rocket:
    # Rocket simulates a rocket ship for a game,
    
    def __init__(self):
        # Each rocket has an (x,y) position.
        self.x = 0
        self.y = 0
        
    def move_up(self):
        # Increment the y-position of the rocket.
        self.y += 1

# Create a Rocket object.
my_rocket = Rocket()
print(my_rocket)

Once you have a class, you can define an object and use its methods. Here is how you might define a rocket and have it start to move up:

# Create a Rocket object, and have it start to move up.
my_rocket = Rocket()
print(f"Rocket altitude: {my_rocket.y}", )

my_rocket.move_up()
print(f"Rocket altitude: {my_rocket.y}")

my_rocket.move_up()
print(f"Rocket altitude: {my_rocket.y}")

To access an object’s variables or methods, you give the name of the object and then use dot notation to access the variables and methods. So to get the y-value of my_rocket, you use my_rocket.y.

To use the move_up() method on my_rocket, you write my_rocket.move_up(). This tells Python to apply the method move_up() to the object my_rocket. Python finds the y-value associated with my_rocket and adds 1 to that value. This process is repeated several times, and you can see from the output that the y-value is in fact increasing.

Making multiple objects from a class#

One of the goals of object-oriented programming is to create reusable code. Once you have written the code for a class, you can create as many objects from that class as you need. It is worth mentioning at this point that classes are usually saved in a separate file, and then imported into the program you are working on. So you can build a library of classes, and use those classes over and over again in different programs. Once you know a class works well, you can leave it alone and know that the objects you create in a new program are going to work as they always have.

You can see this “code reusability” already when the Rocket class is used to make more than one Rocket object. Each object is its own instance of that class, with its own separate variables. All of the objects are capable of the same behavior, but each object’s particular actions do not affect any of the other objects. Here is the code that made a fleet of Rocket objects:

class Rocket:
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self):
        # Each rocket has an (x,y) position.
        self.x = 0
        self.y = 0
        
    def move_up(self):
        # Increment the y-position of the rocket.
        self.y += 1
        
# Create a fleet of 3 rockets, and store them in a list.
rocket_1 = Rocket()
rocket_2 = Rocket()
rocket_3 = Rocket()

# Show that each rocket is a separate object.
print(rocket_1, rocket_2, rocket_3)

You can see that each rocket is at a separate place in memory and therefore printed in a different way.

You can show that each rocket has its own x and y values by moving just one of the rockets:

# Move the third rocket up.
rocket_3.move_up()

# Show that only the third rocket has moved.
print(f"Rocket altitudes: {rocket_1.y}, {rocket_2.y}, {rocket_3.y}")

A quick check-in#

If all of this makes sense, then the rest of your work with classes will involve learning a lot of details about how classes can be used in more flexible and powerful ways. If this does not make any sense, you could try a few different things:

  • Reread the previous sections, and see if things start to make any more sense.

  • Type out these examples in your own editor, and run them. Try making some changes, and see what happens.

  • Read on. The next sections are going to add more functionality to the Rocket class. These steps will involve rehashing some of what has already been covered, in a slightly different way.

Classes are a huge topic, and once you understand them you will probably use them for the rest of your life as a programmer. If you are brand new to this, be patient and trust that things will start to sink in.

Adding parameters to the class#

The Rocket class so far is very simple. It can be made a little more interesting with some refinements to the __init__() method, and by the addition of some methods.

Accepting parameters for the __init__() method#

The __init__() method is run automatically one time when you create a new object from a class. The __init__() method for the Rocket class so far is pretty simple. But we can easily add keyword arguments so that new rockets can be initialized at any position:

class Rocket:
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self, x=0, y=0):
        # Each rocket has an (x,y) position.
        self.x = x
        self.y = y
        
    def move_up(self):
        # Increment the y-position of the rocket.
        self.y += 1

Now when you create a new Rocket object you have the choice of passing in arbitrary initial values for x and y:

# Make a series of rockets at different starting places.
rockets = []
rockets.append(Rocket())
rockets.append(Rocket(0,10))
rockets.append(Rocket(100,0))

# Show where each rocket is.
for index, rocket in enumerate(rockets):
    print(f"Rocket {index} is at (x,y)=({rocket.x}, {rocket.y}).")

Accepting parameters in a method#

The __init__() method is just a special method that serves a particular purpose, which is to help create new objects from a class. Any method in a class can accept parameters of any kind. With this in mind, the move_up() method can be made much more flexible. By accepting keyword arguments, the move_up() method can be rewritten as a more general move_rocket() method. This new method will allow the rocket to be moved any amount, in any direction:

class Rocket():
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self, x=0, y=0):
        # Each rocket has an (x,y) position.
        self.x = x
        self.y = y
        
    def move_rocket(self, x_increment=0, y_increment=1):
        # Move the rocket according to the paremeters given.
        #  Default behavior is to move the rocket up one unit.
        self.x += x_increment
        self.y += y_increment

The paremeters for the move() method are named x_increment and y_increment rather than x and y. It’s good to emphasize that these are changes in the x and y position, not new values for the actual position of the rocket. By carefully choosing the right default values, we can define a meaningful default behavior. If someone calls the method move_rocket() with no parameters, the rocket will simply move up one unit in the y-direciton. Note that this method can be given negative values to move the rocket left or right:

class Rocket():
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self, x=0, y=0):
        # Each rocket has an (x,y) position.
        self.x = x
        self.y = y
        
    def move_rocket(self, x_increment=0, y_increment=1):
        # Move the rocket according to the paremeters given.
        #  Default behavior is to move the rocket up one unit.
        self.x += x_increment
        self.y += y_increment

        
# Create three rockets.
rockets = [Rocket() for x in range(0,3)]

# Move each rocket a different amount.
rockets[0].move_rocket()
rockets[1].move_rocket(10,10)
rockets[2].move_rocket(-10,0)
          
# Show where each rocket is.
for index, rocket in enumerate(rockets):
    print(f"Rocket {index} is at ({rocket.x}, {rocket.y}).")

Adding a new method#

One of the strengths of object-oriented programming is the ability to closely model real-world phenomena by adding appropriate attributes and behaviors to classes. One of the jobs of a team piloting a rocket is to make sure the rocket does not get too close to any other rockets. Let’s add a method that will report the distance from one rocket to any other rocket. Note how this method uses another instance of the same class as one of its arguments!

If you are not familiar with distance calculations, there is a fairly simple formula to tell the distance between two points if you know the x and y values of each point. This new method performs that calculation, and then returns the resulting distance.

from math import sqrt

class Rocket:
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self, x=0, y=0):
        # Each rocket has an (x,y) position.
        self.x = x
        self.y = y
        
    def move_rocket(self, x_increment=0, y_increment=1):
        # Move the rocket according to the paremeters given.
        #  Default behavior is to move the rocket up one unit.
        self.x += x_increment
        self.y += y_increment
        
    def get_distance(self, other_rocket):
        # Calculates the distance from this rocket to another rocket,
        #  and returns that value.
        distance = sqrt((self.x-other_rocket.x)**2+(self.y-other_rocket.y)**2)
        return distance
    
# Make two rockets, at different places.
rocket_0 = Rocket()
rocket_1 = Rocket(10,5)

# Show the distance between them.
distance = rocket_0.get_distance(rocket_1)
print(f"The rockets are {distance:.6f} units apart.")

Hopefully these short refinements show that you can extend a class’ attributes and behavior to model the phenomena you are interested in as closely as you want. The rocket could have a name, a crew capacity, a payload, a certain amount of fuel, and any number of other attributes. You could define any behavior you want for the rocket, including interactions with other rockets and launch facilities, gravitational fields, and whatever you need it to! There are techniques for managing these more complex interactions, but what you have just seen is the core of object-oriented programming.

Encapsulation#

As we have mentioned before using dictionaries is not useful to create objects that consist of several attributes. Encapsulation entails the wrapping of attributes and methods within one unit. This is beneficial as it enables engineers to write code that is easy to maintain. For example, if you add a new attribute or a method to a class, you will not need to update all your objects, but only the class itself.

A nice feature that encapsulation provides is private attributes and private methods. Those are units, which are meant to only be used internally in a class and not accessed outside of it. By convention programmers should not access them via the object. Furthermore, in order to create a private attribute or a private method, you need to put 2 leading underscores (_) before their name.

You can think of the __init__ method for an example. You are not supposed to call the method, as it is automatically called when you create a new object. Furthermore, note that it has 2 leading and 2 trailing underscores. This is meant to show that this method is resereved in Python. Therefore, you should not make attributes or methods that have both leading and trailing underscores, because you may mess up how Python works. Besides the __init__ method, there are more built-in methods that start and end with 2 underscores. These are called magic methods, and determine the behaviour when certain operations (for example: +, - or print()) are performed on an object.

Have a look at the Rocket class below, which contains a private attribute creation_date. We will call this attribute __creation_date to tell Python that we want it to be private. This attribute is set inside the __init__ method and should not be accessed outside the class unless we create a method, which returns it:

from math import sqrt
import datetime

class Rocket:
    # Rocket simulates a rocket ship for a game,
    #  or a physics simulation.
    
    def __init__(self, x=0, y=0):
        # Each rocket has an (x,y) position.
        self.x = x
        self.y = y
        self.__creation_time = datetime.datetime.now()
        
    def move_rocket(self, x_increment=0, y_increment=1):
        # Move the rocket according to the paremeters given.
        #  Default behavior is to move the rocket up one unit.
        self.x += x_increment
        self.y += y_increment
        
    def get_distance(self, other_rocket):
        # Calculates the distance from this rocket to another rocket,
        #  and returns that value.
        distance = sqrt((self.x-other_rocket.x)**2+(self.y-other_rocket.y)**2)
        return distance
    
    def get_creation_time(self):
        # Returns the time the Rocket was made.
        return self.__creation_time
    
# Make a rocket.
rocket_0 = Rocket()

# Try to get the creation time via a method.
date = rocket_0.get_creation_time()
print(f"Rocket was made in: {date}")

# Try to get the creation time directly.
date = rocket_0.__creation_time
print(f"Rocket was made in: {date}")

As seen in the example above, we can only access __creation_time via the method get_creation_time and get an AttributeError if we attempt to directly use the dot notation on the object rocket_0.

Inheritance#

Another of the most important goals of the object-oriented approach to programming is the creation of stable, reliable, reusable code. If you had to create a new class for every kind of object you wanted to model, you would hardly have any reusable code. In Python and any other language that supports OOP, one class can inherit from another class. This means you can base a new class on an existing class; the new class inherits all of the attributes and behavior of the class it is based on. A new class can override any undesirable attributes or behavior of the class it inherits from, and it can add any new attributes or behavior that are appropriate. The original class is called the parent class or superclass, and the new class is a child or subclass of the parent class.

The child class inherits all attributes and behavior from the parent class, but any attributes that are defined in the child class are not available to the parent class. This may be obvious to many people, but it is worth stating. This also means a child class can override behavior of the parent class. If a child class defines a method that also appears in the parent class, objects of the child class will use the new method rather than the parent class method.

To better understand inheritance, let’s look at an example of a class that can be based on the Rocket class.

The Shuttle class#

If you wanted to model a space shuttle, you could write an entirely new class. But a space shuttle is just a special kind of rocket. Instead of writing an entirely new class, you can inherit all of the attributes and behavior of a Rocket, and then add a few appropriate attributes and behavior for a Shuttle.

One of the most significant characteristics of a space shuttle is that it can be reused. So the only difference we will add at this point is to record the number of flights the shutttle has completed. Everything else you need to know about a shuttle has already been coded into the Rocket class.

Here is what the Shuttle class looks like:

class Shuttle(Rocket):
    # Shuttle simulates a space shuttle, which is really
    #  just a reusable rocket.
    
    def __init__(self, x=0, y=0, flights_completed=0):
        super().__init__(x, y)
        self.flights_completed = flights_completed
        
shuttle = Shuttle(10,0,3)
print(shuttle.x)

When a new class is based on an existing class, you write the name of the parent class in parentheses when you define the new class:

class NewClass(ParentClass):

The __init__() function of the new class needs to call the __init__() function of the parent class. The __init__() function of the new class needs to accept all of the parameters required to build an object from the parent class, and these parameters need to be passed to the __init__() function of the parent class. The super().__init__() function takes care of this:

class NewClass(ParentClass):
    
    def __init__(self, arguments_parent_class, arguments_new_class):
        super().__init__(arguments_parent_class)
        # Code for initializing an object of the new class.

The super() function passes the self argument to the parent class automatically.

Inheritance is a powerful feature of object-oriented programming. Using just what you have seen so far about classes, you can model an incredible variety of real-world and virtual phenomena with a high degree of accuracy. The code you write has the potential to be stable and reusable in a variety of applications.

Polymorphism#

Another important goal of the object-oriented approach to programming is to provide flexibility of your code. This can be achived by Polymorphism, which entails that an entity is able to take multiple forms. In Python polymorphism allows us to create methods in a child class with the same name as a method in a parent class. This would mean that a method can serve one purpose in a parent class and different one in a child class.

Child classes inherit all the methods of their parent classes, however, sometimes those methods need to be modified to fit the function of the child. This is achieved by reimplementing the parent methods in the child class.

To better understand polymorphism, let’s look at an example of a class that can be based on the Shuttle class and transitively on the Rocket class as well.

The ImprovedShuttle class#

Our Shuttle class already improves the basic Rocket class, however, the information we receive from the Rocket class such as get_distance is very limited. This is because we currently only get information about the absolute distance, but we do not know the direction, which we need to face to get to that place the fastest.

Therefore, we will create an improved Shuttle, which will be based on the initial Shuttle and will provide better distance information such as angle in which we need to rotate. The formula used is based on taking arctangent of the 2-dimension distances and transforming from radians to degrees.

Here is what the ImprovedShuttle class looks like:

from math import atan, pi, sqrt

class ImprovedShuttle(Shuttle):
    # Improved Shuttle that provides better distance information
    #  such as angle.
    
    def __init__(self, x=0, y=0, flights_completed=0):
        super().__init__(x, y)
        self.flights_completed = flights_completed
    
    def get_distance(self, other_rocket):
        # Calculates the distance from this rocket to another rocket,
        #  the angle to rotate to face the other rocket,
        #  and returns those values.
        distance = super().get_distance(other_rocket)
        angle = atan((other_rocket.y - self.y) / (other_rocket.x - self.x)) * (180 / pi)
        return distance, angle
        
improvedShuttle = ImprovedShuttle(10,0,3)
otherShuttle = ImprovedShuttle(13, 3)

# Show the distance between them.
distance, angle = improvedShuttle.get_distance(otherShuttle)
print(f"The shuttles are {distance:.6f} units apart.")
print(f"The angle the initial shuttle needs to rotate in case it needs to go to the other shuttle is {angle:.2f} degrees.")

As you can see in the example above, since ImprovedShuttle inherits Shuttle and Shuttle inherits Rocket, then transitively ImprovedShuttle is a child of Rocket class and has access to the parent get_distance method. It is possible to access that parent method by making a super().get_distance() call.

As a result, class ImprovedShuttle has overridden Rocket’s get_distance. This means that it has reimplemented the parent’s method.

It is important to mention that it is not necessary to override (reimplement) every method in the parent class when using inheritance, but if needed, it is possible.

Note: ImprovedShuttle’s get_distance() now returns two outputs, while the parent class only returns one. Imagine you are looping a list containing a mix of Rockets and ImprovedShuttles to store their distance in an array (with as many elements as the length of the lists); this difference in the output may require some extra lines of code to handle potential problems.

Exercise#

Exercise

It is time to practice what you have learned so far about classes, inheritance, and polymorphism. Given the predefined Person class, create a Student and Teacher classes that inherit from People and have the additional following requirements

  1. Create a class Student:

    1. Make class Student inherit class Person;

    2. Add parameters start year and GPA grade in the __init__ method and reuse the parent constructor for the other attributes;

    3. Create a method change_grade, which sets a new grade for the student;

    4. Override the introduce_yourself method to account for the 2 new fields (start year and GPA grade). In your overriding try to reuse the parent introduce_yourself method implementation by calling super();

  2. Create a class Teacher:

    1. Make class Teacher inherit class Person;

    2. Add an attribute called students of type set in the __init__ method and reuse the parent constructor for the other attributes. Remember that a set is a collection of elements that contains no duplicates. A set can be initialised in multiple ways. For example, my_set = set() or my_set = {};

    3. Create a method add_student, which adds a student to the student set of a teacher. Elements to a set can be added using the method add(). For example, my_set.add(3);

    4. Create a method print_classroom, which prints the introductions of all the students in the classroom of the teacher. (Hint: call introduce_yourself on every Student object in the set).

  3. Similarly to the previous exercise, show your new classes are working properly by creating objects for each of them and calling their respective methods. Furthermore, add the necessary documentation for the classes and methods.

class Person():
    def __init__(self, name, age, country_of_origin):
        """Constructor to initialise a Person object.
        
        Keyword arguments:
        name -- the name of the person
        age -- the age of the person
        country_of_origin -- the country the person was born
        """
        self.__name = name
        self.age = age
        self.country_of_origin = country_of_origin
    
    def introduce_yourself(self):
        """Prints a brief introduction of the person."""
        print(f"Hello, my name is {self.__name}, I am {self.age} and I am from {self.country_of_origin}.")
            
    def get_name(self):
        """Gets the name of a person."""
        return self.__name
"""Use this code cell to type your answer."""

Solution

If you have done the exercise correctly, the following output should be printed when you run the cell above.

Showing how class Student is defined:
New GPA grade of student is 7.2.

Showing how class Teacher is defined:
Hello, my name is Joe, I am 42 and I am from Germany.
The classroom consists of the following students:
Hello, my name is Mike, I am 22 and I am from Italy.
My GPA grade is 7.2 and I started studying in year 2021.

Documentation: Good Practices#

The Python Organization publishes best practices for writing Python code, which covers important aspects like documentation. This section illustrates a few concepts that are important of OOP.

If you would like to know where these concepts come from, try having a look at PEP 8, which is the style guide for writing Python code. Another document, PEP 257, covers conventions for writing docstrings. PEP 257 is covered here, as it has more relevant material related to documentation of classes and methods.

Naming conventions#

Class names should be written in CamelCase, with an initial capital letter and any new word capitalized. There should be no underscores in your class names. For example, if you have a super cool class, you should name it ASuperCoolClass.

Method names should always have an initial lowercase letter, similar to the regulations on naming functions. Furthermore, the first argument of every class method should be the keyword self. For example, the method signature (the same as function signature - the function name and function arguments) of a method move_up() should be move_up(self):. Nevertheless, if a method name contains more than 1 word, then the words should be separated by underscore _. For instance, if you have an important method, you should name it important_method.

Docstrings#

A docstring is a string literal that appears at the start of classes/methods.

By convention, docstrings begin and end with 3 quotation marks: """docstring""" and should be placed right below the signature of a method or the class signature. A rule of thumb is to have 1 line explaning what a method does, followed by 1 blank line, followed by zero/one/multiple lines explaning what each of the parameter does:

class Rocket():
    """Rocket simulates a rocket ship for a game,
    or a physics simulation.
    """
    
    def __init__(self, x=0, y=0):
        """Constructor to initialise a Rocket object.
        
        Keyword arguments:
        x -- x coordinate (default 0)
        y -- y coordinate (default 0)
        """
        self.x = x
        self.y = y

    def move_rocket(self, x_increment=0, y_increment=1):
        """Moves the rocket according to the paremeters given.
        
        Keyword arguments:
        x_increment -- units to move in x dimension (default 0)
        y_increment -- units to move in y dimension (default 1)
        """
        self.x += x_increment
        self.y += y_increment
        
    def get_distance(self, other_rocket):
        """Calculates the distance from this rocket to another rocket
        and returns that value.
        
        Keyword arguments:
        other_rocket -- the other rocket, which distance to compare to
        """
        distance = sqrt((self.x-other_rocket.x)**2+(self.y-other_rocket.y)**2)
        return distance
        
# Check the documentation of Rocket class
help(Rocket)

Note that the self argument is not explained the docstrings, because its use is implicitly known.