The Basics Of Object Oriented Programming In Python

19th September 2021 - 22 minutes read time

I have been learning Python recently, and whilst I'm familiar with PHP and other C-like languages, Python has been a bit of a challenge to get used to due to the different syntax. This is especially the case with objects as although Python is object oriented there are a couple of gotchas when creating custom objects.

This article sets out how to use classes and objects, but I will assume that you have a basic understanding of object oriented programming in other languages.

As a (very) quick proceeder, an object is a 'thing' in your program. They are intended to represent a thing that does something, stores information, or both. For example, a User object would be used to store information about a User, but might also do things like print out the user's name or how long they have existed for. A class is a blueprint for an object and is used to create the object. Fundamentally, the object is an encapsulation of something.

Python Objects

The first thing to realise is that everything in Python is an object. Even a class, once parsed by the Python interpreter, is held in memory as an object.

As an example, let's define a simple string and look at the type of that variable using the type() method.

a_string = 'some string'
print(type(a_string)) # prints "<class 'str'>"

As you can see, the result here shows that the a_string variable is actually an instance of the class 'str'. The upshot of doing this is that we can call some methods on the string as if it was a normal object. For example, we can change the string to be uppercase by using the upper() method on the string object. The "." here is how we reference a property or method of an object.

print(a_string.upper()) # prints "SOME STRING"

Another example is if we look at the type of a stand alone function. Doing the same thing by defining a function and finding out the type using the type() method.

def return_a_one():
    return 1

print(type(return_a_one)) # prints "<class 'function'>"

Again, this tells us that the function is an object of the type 'function'. Classes that are built into the Python library 'builtins', which includes methods like print(), type(), as well as classes like str and int.

Incidentally, the type() method I'm using here isn't really a method. It's a class built into Python that can be called statically to find out the type of any passed item.

Creating Classes And Using Objects

In its simplest terms, a class is a blueprint of an object. You define what sort of methods the object has in the class so that when you instantiate the object it carries with it all of those methods.

Note that a method is essentially a function contained in a class. They tend to be called different things in order to show they are different as they behave slightly differently.

The following code creates a class called Car and defines a single method called "beep". All the beep method does is print out the word "beep".

class Car:
    def beep(self):
        print('beep')

To instantiate this class and get a Car object we just call the Car as if it is a method. Once we have the Car as an object we can call the beep method.

a_car = Car()
a_car.beep()

Notice that we defined the beep method with a parameter called "self", but we didn't pass this parameter when we called the method. All methods in Python objects are implicitly passed this "self" property, which is a reference to the object that the method was called in. This is often called "this" in other languages.

If we miss out the self parameter from the beep method definition and try to use it we get the following error.

TypeError: beep() takes 0 positional arguments but 1 was given

This is because although we didn't define the method with any parameters, Python still passes the self parameter to the method when we call it. The error is basically saying that the method received a parameter, even though none were defined.

As a side note, Python doesn't use the "new" keyword to create objects and instead uses what are called "dunder" methods to listen to certain actions being performed on the class. These dunder methods are so called because they have a double underscore in front and behind the method name. In this case the method __new__ is called, which hands us back a new instance of the object. There are several dunder methods available in Python objects, which you only need to override if you need to alter the functionality it provides. The Car class is very simple and so doesn't need to implement any dunder methods.

Object Properties

Objects without properties aren't all that useful as there is nowhere to store data that tells us the state of the object, so let's look at how we can create them. Unfortunately, properties of objects in Python aren't as straightforward as you think. There are a couple of things to be aware of before jumping in.

Take the following code that defines a Shape class and an area property.

class Shape():
    area = 0

This is perfectly valid Python. What we have defined here is a class property. In other words, this property belongs to the class and not the objects we create using the class. Remember at the start of this article I mentioned that classes were stored as objects in Python? This turns out to be key to understanding their use.

To demonstrate what I mean, let's create a couple of Shape objects.

shape_1 = Shape()
shape_2 = Shape()

Now, let's set the value of the area property of shape_1 to be 10.

shape_1.area = 10

We can also set the value of the the area property of the Shape class in the same way.

Shape.area = 20

Finally, let's print out the value of area from both of the Shape objects we have created.

print(shape_1.area) # prints 10
print(shape_2.area) # prints 20

You would expect that this would print out 10 for shape_1 and 0 for shape_2, but what we actually get is the value 20 for shape_2. This is because we didn't set the area property of the shape_2 object and so it inherits the value from the class, even though the value in the class wasn't set to 20 when we created the object. Whilst it is possible to do this and does have some uses, it's probably not a great idea as it can lead to some interesting bugs.

The alternate approach to this is to use a get and set method to set the property in the object at runtime.

class Shape:
    def get_area(self):
        return self._area

    def set_area(self, value):
        self._area = value

We can then use the property to set and get the area property.

shape = Shape()
shape.set_area(3)
print(shape.get_area()) # prints "3"

The downside to this approach is that the property can be accessed without going through the getter or setter methods. In other words, the property is essentially public and so you have no control over how the property is accessed. It is possible to do the following and smash a value into the _area property.

shape._area = 4
print(shape.get_area()) # prints "4"

This might be fine for your circumstances, but the correct way to create a property in Python is by using the '@property' decorator. This is used to assign a method that will act as the getter method for the property. This is combined with a '@x.setter' decorator (where x is the name of the property) that tells Python what method is to be used to change the property.

class Shape:
    @property
    def area(self):
        return self._area

    @area.setter
    def area(self, value):
        self._area = value

With this in place, the Shape objects can set their own values of the area property independent of the class.

shape_1 = Shape()
shape_2 = Shape()

shape_1.area = 10
shape_2.area = 15

print(shape_1.area) # prints "10"
print(shape_2.area) # prints "15"

This approach creates a private property. It is not possible to access the property directly without going through the getter and setter methods we have defined here.

Note that Python will allow you to create properties for objects on the fly. That is, you can just create a property and assign a value to it without having to set up anything else. The following code sets a property called width to be 10 and then prints out that value.

shape_1 = Shape()
shape_1.width = 10
print(shape_1.width) # prints "10"

This isn't best practice though and should be avoided, there's a good reason why this approach is called "monkey patching". You'll also notice that your IDE won't pick up this property as it will see that is only exists for the one object.

Constructors

A constructor is a special method in a class that is called as the object is created. This method can have many functions, but is generally used to set up the new object in the correct way. In Python this is the dunder method __init__.

Let's modify the Shape class above to have a constructor so that we can set the default value of the area property.

class Shape:
    def __init__(self, area = 0):
        self._area=area

    @property
    def area(self):
        return self._area

    @area.setter
    def area(self, value):
        self._area = value

Note that the area parameter in the __init__ method has been given a default value of 0. This means that if we don't pass any value to the constructor then a value will be set.

To use the constructor we just need to create the object in the same way, but this time we pass in the value of area to the name of the class we are creating. Here is the new Shape class being used and also setting the value of area.

shape_1 = Shape(10)
print(shape_1.area) # prints "10"

I did mention the __new__ dunder earlier, and whilst that is used when creating a new object it only needs to be overridden if you are doing special things with the class. Most of the time you'll want to use this __init__ configuration to set your parameters..

Inheritance

One of the most useful things about object oriented programming is the ability to create general classes and extend them into specific sub-classes. This allows you to write code that can be used by all classes that extend it so a method created in the parent class can be used by any child classes.

Inheritance is done in Python by adding the parent class name to the child class definition. Here is a trivial example of a Parent class that is extended into a Child class.

class Parent:
    pass

class Child(Parent):
    pass

By the way, the "pass" in the above code is a Python command that does nothing. This is normally used as a placeholder for sketching out code and allowing it to be valid syntax. It should be avoided in production code as it produces no errors or output at all and can therefore be difficult to track down.

A good example of a class that can be extended is a Person class. The base Person class defines a single parameter of "name" with the same parameter methods that I described earlier.

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

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value

If we create this object and use the constructor to set the name property of the person object.

person = Person('Testing Testerson')
print(person.name) # prints "Testing Testerson"

Having a Person class is useful for representing a person within a system, but what if we want to represent a real user within the system? A user will have the basic information of a person, along with information about their identification within the system. If we extend the Person class into a User class we automatically get the name property. In the new User class we can then define more properties that the user might have.

In the example below we are extending the Person class into the User class. We also call the Person constructor in the User constructor to pass the name argument upstream so that the name is properly setup.

class User(Person):
    def __init__(self, username, name = ''):
        self._username = username
        Person.__init__(self, name)

    @property
    def username(self):
        return self._username

    @username.setter
    def username(self, value):
        self._username = value

With that class in place we can then instantiate the User object by passing the username and name arguments to the constructor. After this, the new user object has access to both the username and name properties.

user = User('t.testerson', 'Testing Testerson')
print(user.username) # prints "t.testerson"
print(user.name) # prints "Testing Testerson"

This technique can be used in different ways to create hierarchies of classes. Having a single parent to child relationship like this is a simple example. Classes can be extended multiple times to provide different forms of functionality for their children. To continue the example above, we could extend the User class to become an Administrator class that special users are given in order to allow them to access special parts of the system.

Python also allows for multiple inheritance where a single class has more than one parent. This would look something like this using Python syntax.

class Parent1:
    pass

class Parent2:
    pass

class Child(Parent1, Parent2):
    pass

Multiple inheritance is not normally used, and is actually not supported by many languages. It does allow for classes to be structured differently though. For example, the Administrator class, instead of extending only the User class, could extend the Person and User class at the same time. This would give it the same functionality.

Static Methods

It is possible to use classes without instantiating them into objects. When used in this way the methods are called static methods and do not have any knowledge of the class or object they are in. The following example shows a class containing a static method.

class StaticClass:
    def static_method():
        print('static')

This is used by calling the method of the class as if the class were a fully created object.

StaticClass.static_method() # prints "static"

Be sure not to use the static method of the class in a non static manner. The following creates the StaticClass object and attempts to use the static_method() in a non static way.

non_static = StaticClass()
non_static.static_method()

This results in the following error.

TypeError: static_method() takes 0 positional arguments but 1 was given

The error is caused because all object methods are passed a reference to the object as the first parameter, meaning that although we gave out method no parameters Python has passed the object as the first parameter automatically.

Interfaces

An interface is a contract that a class must adhere to, meaning that any class created that implements the interface must contain all of the needed methods. This allows different types of classes (not necessarily related classes) to be passed around and used in the same way.

Interfaces are common practice used in many languages but as there is no interface keyword in Python they need to work in a slightly different way. The solution to this in Python is the metaclass, which is a special class that is used as an instruction set for other classes. They have a few other uses in Python, but I will concentrate on them being used as interfaces here.

The metaclass has a special setup and must use the dunder methods __instancecheck__() and __subclasscheck__(). These are used by Python to inform if a subclass adheres to the interface we setup. Let's go over these two dunder methods.

  • __instancecheck__() - This method returns true if the instance (i.e. an object) should be considered the instance of a class.
  • __subclasscheck__() - This method returns true if the subclass should be considered a subclass of this class.

This means that these methods are used to check if the object is an instance of this interface. What we need to do is check the subclass for the presence of a specific method and return true if the method exists. The __instancecheck__() method should call the __subclasscheck__() method, passing the type of class.

As an example, let's revisit the Shape class. It didn't make sense to extend the Shape class into different types of shapes to show inheritance as all shapes have different implementations of the area method. An interface is perfect for this situation as we just want to ensure that Shape classes adhere to our interface.

The metaclass for the shapes looks like this.

class ShapeMeta(type):
    def __instancecheck__(self, instance):
        return self.__subclasscheck__(type(instance))

    def __subclasscheck__(self, subclass):
        return (hasattr(subclass, 'area') and callable(subclass.area))

All we are doing is ensuring that the area method exists and is callable within the class itself. The __instancecheck__() method forwards the type of class that the object is to the __subclasscheck__() method, which ties everything together.

The interface itself accepts the metaclass as an argument to the class setup. It doesn't need to do anything else as the metaclass handles the actual checks.

class ShapeInterface(metaclass=ShapeMeta):
    pass

Now, we can extend the ShapeInterface class to make a Square class. I will leave our the area method for now to show how the interface works.

class Square(ShapeInterface):
    pass

In order to check if this class adheres to our ShapeInterface interface we use the issubclass() method, passing in the Square class and the interface we want to check it against. This calls the __subclasscheck__() method in our metaclass. Of course, without the area() method this returns false as the class doesn't adhere to the interface.

print(issubclass(Square, ShapeInterface)) # prints "False"

If we instantiate the Square class we can then check the object using the isinstance() method. This calls the __instancecheck__() method in the metaclass. Again, this returns false as our object also does not adhere to the interface.

square_object = Square()
print(isinstance(square_object, ShapeInterface)) # prints "False"

Let's finish off the Square class by adding the area() method, which just calculates the area of the square.

class Square(ShapeInterface):
    def area(self) -> float:
        return self.width * 2

We can now re-run the same checks as before.

print(issubclass(Square, ShapeInterface)) # prints "True"

square_object = Square()
print(isinstance(square_object, ShapeInterface)) # prints "True"

Both of these checks now return true as our Square class conforms to the interface we setup in the ShapeMeta metaclass.

Even though this creates a mechanism to setup an interface, the actual implementation is a kind of "soft" interface. We can still create invalid classes without any errors and so we need to check that the class (and object) adhere to the interface before using them. Thankfully, it is possible to use type hinting to automatically detect the adherence to the interface as the object is passed into a function or method.

The following calculate() function is used to call the area() method of our shape objects in order to calculate the area. Through type hinting we are saying that the single parameter must be a ShapeInterface.

def calculate(shape: ShapeInterface):
    return shape.area()

If we pass an invalid class to the function then it will error with the following output.

AttributeError: 'Square' object has no attribute 'area'

This error is easily solved by adding the area() method to the Square class.

Conclusion

I have covered the very basics of object oriented programming in Python here, but you should have enough to create and use objects of your own.

If you want to get deeper into object oriented programming then take a look at design patterns. These are ways of putting objects together in order to provide solutions to common problems. Simple patterns like the factory, collection or decorator are easy enough to implement and will give a better understanding of this form of programming.

Add new comment

The content of this field is kept private and will not be shown publicly.