A Primer on Classes in Python

A Primer on Classes in Python

A practical approach to working with classes in python
Aquiles Carattino 2018-05-22 Classes Object oriented beginner

Python is an object-oriented programming (OOP) language. Object-oriented programming is a programming design that allows developers not only to define the type of data of a variable but also the operations that can act on that data. For example, a variable can be of type integer, float, string, etc. We know that we can multiply an integer to another, or divide a float by another, but that we cannot add an integer to a string. Objects allow programmers to define operations both between different objects as with themselves. For example, we can define an object person, add a birthday and have a function that returns the person's age.

At the beginning it will not be clear why objects are useful, but over time it becomes impossible not to think with objects in mind. Python takes the objects ideas one step further, and considers every variable an object. Even if you didn't realize, it is possible that you have already encountered some of these ideas when working with numpy arrays, for example. In this chapter we are going to cover from the very basics of object design to slightly more advanced topics in which we can define a custom behavior for most of the common operations.

Defining a Class

Let's dive straight into how to work with classes in Python. Defining a class is as simple as doing:

class Person:
    pass

When speaking it is very hard not to interchange the words Class and Object. The reality is that the difference between them is very subtle: an object is an instance of a class. This means that we will use the word classes when referring to the type of variable, while we will use object to the variable itself. It is going to become clearer later on.

In the example above, we've defined a class called Person that doesn't do anything, that is why it says pass. We can add more functionality to this class by declaring a function that belongs to it. Create a file called person.py and add the following code to it:

class Person:
    def echo_name(self, name):
        return name

In Python, the functions that belong to classes are called methods. For using the class, we have to create a variable of type person. Back in the Python Interactive Console, you can, for example, do:

>>> from person import Person
>>> me = Person()
>>> me.echo_name("John Snow")
John Snow

The first line imports the code into the interactive console. For this to work, it is important that you trigger python directly from the same folder where the file person.py is located. When you run the code above, you should see as output John Snow. There is also an important detail that was omitted this far, the presence of self in the declaration of the method. All the methods in python take a first input variable called self, referring to the class itself. For the time being don't stress yourself about it, but bear in mind that when you define a new method, you should always include the self, but when calling the method you should never include it. You can also write methods that don't take any input, but still will have the self in them, for example:

def echo_True(self):
    return "True"

that can be used by doing:

>>> me.echo_True()

So far, defining a function within a class has no advantage at all. The main difference, and the point where methods become handy is because they have access to all the information stored within the object itself. The self argument that we are passing as first argument of the function is exactly that. For example, we can add the following two methods to our class Person:

def store_name(self, name):
    self.stored_name = name

def get_name(self):
    return self.stored_name

And then we can execute this:

>>> me = Person()
>>> me.store_name('John Snow')
>>> print(me.get_name())
John Snow
>>> print(me.stored_name)
John Snow

What you can see in this example is that the method store_name takes one argument, name and stores it into the class variable stored_name. Variables in the context of classes are called attributes in the context of a class. The method get_name just returns the stored property. What we showed in the last line is that we can access the property directly, without the need to call the get_name method. In the same way, we don't need to use the store_name method if we do:

>>> me.stored_name = 'Jane Doe'
>>> print(me.get_name())
Jane Doe

One of the advantages of the attributes of classes is that they can be of any type, even other classes. Imagine that you have acquired a time trace of an analog sensor and you have also recorded the temperature of the room when the measurement started. You can easily store that information in an object:

measurement.temperature = '20 degrees'
measurement.timetrace = np.array([...])

What you have so far is a vague idea of how classes behave, and maybe you are starting to imagine some places where you can use a class to make your daily life easier and your code more reusable. However, this is just the tip of the iceberg. Classes are very powerful tools.

Initializing classes

Instantiating a class is the moment in which we call the class and pass it to a variable. In the previous example, the instantiation of the class happened at the line reading me = Person(). You may have noticed that the property stored_name does not exist in the object until we assign a value to it. This can give very serious headaches if someone calls the method get_name before actually having a name stored (you can give it a try to see what happens!) Therefore it is very useful to run a default method when the class is first called. This method is called __init__, and you can use it like this:

class Person():
    def __init__(self):
        self.stored_name = ""

    [...]

If you go ahead and run the get_name without actually storing a name beforehand, now there will be no error, just an empty string being returned. While initializing you can also force the execution of other methods, for example:

def __init__(self):
    self.store_name('')

[...]

Will have the same final effect. It is however common (and smart) practice, to declare all the variables of your class at the beginning, inside your __init__. In this way you don't depend on specific methods being called to create the variables.

As with any other method, you can have an __init__ method with more arguments than just self. For example you can define it like this:

def __init__(self, name):
    self.stored_name = name

Now the way you instantiate the class is different, you will have to do it like this:

me = Person('John Snow')
print(me.get_name())

When you do this, your previous code will stop working, because now you have to set the name explicitly. If there is any other code that does Person(), it will fail. The proper way of altering the functioning of a method is to add a default value in case no explicit value is passed. The __init__ would become:

def __init__(self, name=''):
    self.stored_name = name

With this modification, if you don't explicitly specify a name when instantiating the class, it will default to '', i.e., an empty string.

Defining default values for parameters in methods has to be handled with care. They are very useful when you expect people to always use the same values and only occasionally to change them. Trying to keep backwards compatibility by declaring default values can make your code look chaotic, so you have to do it only when it is worth doing, and not all the time. When developing, it is impossible not to refactor code.

Defining class attributes

So far, if you wanted to have properties available right after the instantiation of a class, you had to include them in the __init__ method. However, this is not the only possibility. You can define attributes that belong to the class itself. Doing it is as simple as declaring them before the __init__ method. For example, we could do this:

class Person():
    birthday = '2010-10-10'
    def __init__(self, name=''):
        [...]

If you use the new Person class, you will have an attribute called birthday available, but with some interesting behavior. First, let's start as always:

>>> from person import Person
>>> guy = Person('John Snow')
>>> print(guy.birthday)
2010-10-10

What you see above is that it doesn't matter if you define the birthday within the __init__ method or before, when you instantiate the class, you access the property in the same way. The main difference is what happens before instantiating the class:

>>> from person import Person
>>> print(Person.birthday)
2010-10-10
>>> Person.birthday = '2011-11-11'
>>> new_guy = Person('Cersei Lannister')
>>> print(new_guy.birthday)
2011-11-11

What you see in the code above is that you can access class attributes before you instantiate anything. That is why they are class and not object attributes. Subtleties apart, once you change the class attribute, in the example above, the birthday, next time you create an object with that class, it will receive the new property. At the beginning it is hard to understand why it is useful, but one day you will need it and it will save you a lot of time.

Inheritance

One of the advantages of working with classes in Python is that it allows you to use the code from other developers and expand or change its behavior without modifying the original code. The best idea is to see it in action. So far we have a class called Person, which is general but not too useful. Let's assume we want to define a new class, called Teacher, that has the same properties as a Person (i.e., name and birthday) plus it is able to teach a class. You can add the following code to the file person.py:

class Teacher(Person):
    def __init__(self, course):
        self.course = course

    def get_course(self):
        return self.course

    def set_course(self, new_course):
        self.course = new_course

Note that in the definition of the new Teacher class, we have added the Person class. In Python jargon, this means that the class Teacher is a child of the class Person, or the opposite, that Person is the parent of Teacher. This is called inheritance and you will notice that a lot of different projects take advantage of it. You can use the class Teacher in the same way as you have used the class Person:

>>> from person import Teacher
>>> me = Teacher('math')
>>> print(me.get_course)
math
>>> print(me.birthday)
2010-10-10

However, if you try to use the teacher's name it is going to fail:

>>> print(me.get_name())
[...]
AttributeError: 'Teacher' object has no attribute 'stored_name'

The reason behind this error is that get_name returns stored_name in the class Person. However, the property stored_name is created when running the __init__ method of Person, which didn't happen. You could have changed the code above slightly to make it work:

>>> from person import Teacher
>>> me = Teacher('math')
>>> me.store_name('J.J.R.T.')
>>> print(me.get_course)
math
>>> print(me.get_name())
J.J.R.T.

However, there is also another approach to avoid the error. You could simply run the __init__ method of the parent class (i.e. the base class), you need to add the following:

class Teacher(Person):
    def __init__(self, course):
        super().__init__()
        self.course = course
    [...]

When you use super(), you are going to have access directly to the class from which you are inheriting. In the example above, you explicitly called the __init__ method of the parent class. If you try again to run the method me.get_name(), you will see that no error appears, but also that nothing is printed to screen. This is because you triggered the super().__init__() without any arguments and therefore the name defaulted to the empty string. You could change the code like this:

class Teacher(Person):
    def __init__(self, name, course):
        super().__init__(name)
        self.course = course
    [...]

which you would use combining both examples above:

>>> from person import Teacher
>>> me = Teacher('John', 'math')
>>> print(me.get_name())
John

It is important to note that when importing the class, you only import the one you want to use, you don't need to import the parent, that is the responsibility of whoever developed the Teacher class.

Finer details of classes

With what you have learned up to here, you can achieve a lot of things, it is just a matter of thinking how to connect different methods when it is useful to inherit. Without doubts, it will help you to understand the code developed by others. There are, however, some details that are worth mentioning, because you can improve how your classes look and behave.

Printing objects

Let's see, for example, what happens if you print an object:

>>> from person import Person
>>> guy = Person('John Snow')
>>> print(guy)
<__main__.Student object at 0x7f0fcd52c7b8>

The output of printing guy is quite ugly and is not particularly useful. Fortunately, you can control what appears on the screen. You have to update the Person class. Add the following method to the end:

def __str__(self):
    return "Person class with name {}".format(self.stored_name)

If you run the code above, you will get the following:

>>> print(guy)
Person class with name John Snow

You can get very creative. It is also important to point out that the method __str__ will be used also when you want to transform an object into a string, for example like this:

>>> class_str = str(guy)
>>> print(class_str)
Person class with name John Snow

Which also works if you do this:

>>> print('My class is {}.'.format(guy))

Something that is important to point out is that this method is inherited. Therefore, if you, instead of printing a Person, print a Student, you will see the same output, which may or may not be the desired behavior.

Defining complex properties

When you are developing complex classes, sometimes you would like to alter the behavior of assigning values to an attribute. For example you would like to change the age of a person when you store the year of birth:

>>> person.year_of_birth = 1980
>>> print(person.age)
38

There is a way of doing this in Python which can be easily implemented even if you don't fully understand the syntax. Working again in the class Person, we can do the following:

class Person():
    def __init__(self, name=None):
        self.stored_name = name
        self._year_of_birth = 0
        self.age = 0

    @property
    def year_of_birth(self):
        return self._year_of_birth

    @year_of_birth.setter
    def year_of_birth(self, year)
        self.age = 2018 - year
        self._year_of_birth = year

Which can be used like this:

>>> from people import Person
>>> me = Person('Me')
>>> me.age
0
>>> me.year_of_birth = 1980
>>> me.age
32

What is happening is that Python gives you control over everything, including what does the = do when you assign a value to an attribute of a class. The first time you create a @property, you need to specify a function that returns a value. In the case above, we are returning self._year_of_birth. Just doing that will allow you to use me.year_of_birth as an attribute, but it will fail if you try to change its value. This is called a read-only property. If you are working in the lab, it is useful to define methods as read-only properties when you can't change the value. For example, a method for reading the serial number of a device would be read-only.

If you want to change the value of a property, you have to define a new method. This method is going to be called a setter. That is why you can see the line @year_of_birth.setter. The method takes an argument that triggers two actions. On the one hand, it updates the age, on the other it stores the year in an attribute. It takes a while to get used to, but it can be very handy.

Conclusions

This article is a very short primer on how to start working with classes in Python. You are not supposed to be an expert after such a brief walk-through, but it should be enough for getting you started with your own developments, and, more importantly, to be able to read other developers code and understand what they are doing.

The series of primer articles are thought as a go-to destination when you need to refresh a specific concept. If you find anything missing, you can always leave a comment below and we will expand the article according to your needs.

Header photo by Daniel Cheung on Unsplash

Article written by Aquiles Carattino
Join our newsletter!
If you liked the content, sign up to never miss an update.

Share your thoughts with us!

Support Us

If you like the content of this website, consider buying a copy of the book Python For The Lab