Object Oriented Programming: introduction

Object-oriented programming (OOP) is a standard feature of many modern programming languages. Learning the main concepts of OOP would occupy one entire lecture at least, and we won't try to cover all the details here. The specific objectives of these two OOP units are:

  • learn the basic OOP concepts syntax so that you are able to understand code and libraries making use of it
  • become familiar with certain semantics associated with OOP: classes, objects, attributes, methods, etc.
  • introduce simple examples where OOP is a useful paradigm, and try to raise your interest in its usage so that you can learn it by yourself when needed.

This first OOP unit introduces the concept of "objects" in the Python language and shows you how to make objects on your own. Next week's unit will tell you what objects are useful for.

Copyright notice: this chapter is partly inspired from RealPython's beginner tutorial on OOP.

Introduction

As stated in the OOP wikipedia page, OOP is a "paradigm" which might or might not be supported by a specific programming language. Although Python is an OOP language at its core, it does not enforce its usage. In fact, many of you will be able to write your master thesis and even your PhD thesis without programming in OOP on your own. You have, however, already made heavy use of Python objects (everything is an object in Python, remember?) and I find it very important that you are able to understand the basics of it in order to make better use of Python.

In short, OOP is simply another way to structure your programs. Until now, you have written modules consisting mainly of functions, sometimes with a short __main__ script which was itself calling one or more functions. OOP will add a new tool to your repertoire by allowing you to bundle data and behaviors into individual objects, possibly helping you to organize your code in a way that feels more natural and clear.

Let's get started with some examples and new semantics! We will talk about the advantages and disadvantages of OOP in the following unit, once you are more familiar with its syntax.

Classes and objects

Classes are used to create new user-defined structures that contain information about something and that come with "services". Let's define a new class called Cat:

In [1]:
class Cat:
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight

There are already a couple of new things in this code snippet:

  • first, the class name definition is happening at the very first line. As per pep8, class names in Python should use "CapWords" per convention
  • the class contains a "function" called __init__, which indeed looks very much like a normal function. Here the __init__ function has three positional arguments: self (which has a special meaning as we are going to see), name and weight. These arguments are used to initialize the attributes of the same name. We'll go back to this in the next section.

A class provides a new structure definition. It's a "blueprint" for how something should be defined, but it doesn't actually provide any real data content itself. To actually use the functionalities defined by the class you'll need to create a new instance of that class. Instantiating is a fancy term for creating a new, unique realization of a class (an object). Let's go for it:

In [2]:
a = Cat('Grumpy', 4)
a
Out[2]:
<__main__.Cat at 0x7fa3500f70b8>

We just created a new instance of the class Cat and assigned it to the variable a. An instance of a class is commonly called an object (this can be used as synonym for "instance"). The variable a stores an object (instance) of the class Cat:

In [3]:
# Ask if a is an instance of Cat or not
isinstance(a, Cat)
Out[3]:
True

In fact, we just created a new datatype called Cat:

In [4]:
type(a)
Out[4]:
__main__.Cat

Every new instance of a class is unique, regardless of the values used to initialize it. Let's create a new Cat with the same name and weight:

In [5]:
b = Cat('Grumpy', 4)
In [6]:
isinstance(a, Cat)
Out[6]:
True

It is still a unique instance and is not a copy of a in any way:

In [7]:
a == b
Out[7]:
False
In [8]:
b is a
Out[8]:
False

Class/instance attributes

The cat's name and weight are called instance attributes and can be accessed with the dot syntax:

In [9]:
a.name
Out[9]:
'Grumpy'
In [10]:
a.weight
Out[10]:
4

A common synonym for the term "attribute" is "property". The two terms are very close and you might find one or the other term depending on who writes about them. Properties in python are a special kind of attributes, but the difference is subtle and not relevant here.

Instance attributes are specific to the created object. They are often defined at instantiation:

In [11]:
b = Cat('Tiger', 5)
b.name
Out[11]:
'Tiger'

Classes can also define class attributes, which are tied to a class but not to a specific instance:

In [12]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
In [13]:
Cat.language
Out[13]:
'Meow'
In [14]:
a = Cat('Grumpy', 4)
a.language
Out[14]:
'Meow'

Careful! Class and instance attributes are not immutable. They can be changed form outside the class:

In [15]:
a.name = 'Roncheux'
a.language = 'Miaou'
In [16]:
a.name
Out[16]:
'Roncheux'
In [17]:
a.language
Out[17]:
'Miaou'

These changes are specific to the instance, and the class remains unchanged:

In [18]:
Cat.language
Out[18]:
'Meow'

In comparison to other OO languages, python is very "liberal" regarding attributes: some languages like Java would not allow to change attributes this way. In practice, attributes should not be changed by the users of a class. Unless they are documented as being "changeable", and in this case become "properties". More on this later.

Instance Methods

If a class only had attributes, it would merely be a simple data structure. Classes become useful when they are adding "services" to the data they store. These services are called methods, and their syntax has similarities with a function definition:

In [19]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        
    # Method
    def say_name(self):
        print('{}, my name is {}!'.format(self.language, self.name))

The biggest difference with functions is that a method is tied to a class instance: this is made clear by the self argument, present in the method definition but not used when calling the method:

In [20]:
a = Cat('Kitty', 4)
a.say_name()
Meow, my name is Kitty!
In [21]:
b = Cat('Grumpy', 3)
b.say_name()
Meow, my name is Grumpy!

The self variable is implicit in the call above, and refers to the instance of the class which is calling the method. It might sound a little complicated at first, but you'll get used to it: self is used to read and write instance attributes, and is the first argument to virtually any method defined in the class (there is one exception to this rule which we will ignore for now).

At this point, you may have noticed similarities between the objects you used commonly in the climate lecture and the objects we just defined here. Let's make the analogy:

In [22]:
import pandas as pd
a = pd.Series([1, 2, 3], name='data')  # instanciating a class
assert isinstance(a, pd.Series)  # a is an instance of the Series class
print(type(a))  # pandas.core.series.Series is a new datatype
print(a.name)  # name is an instance attribute
print(a.mean())  # mean is an instance method
<class 'pandas.core.series.Series'>
data
2.0

Are you confident about the meaning of all these terms? If not, I might have explained it in a way which is not the right one for you: you can use your google-skills to look for other tutorials. There are plenty!

Extending attributes: the @property decorator

We have said that attributes are often meant to be data describing an instance of a class. It is often the job of instance methods to initialize and update these attributes. Consider the following example:

In [23]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        
    # Method
    def eat_food(self, food_kg):
        self.weight += food_kg
In [24]:
a = Cat('Grumpy', 4)
print('Weight before eating: {} kg'.format(a.weight))
a.eat_food(0.2)
print('Weight after eating: {} kg'.format(a.weight))
Weight before eating: 4 kg
Weight after eating: 4.2 kg

This was a simplified but typical use for instance attributes: they will change in an object's lifetime according to specific events. Now let's suppose that you are working with scientists from the USA, and they'd like to know the cat's weight in pounds. One way to do so would be to compute it at instantiation:

In [25]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        self.weight_lbs = weight * 1 / 0.45359237
        
    # Method
    def eat_food(self, food_kg):
        self.weight += food_kg
In [26]:
a = Cat('Grumpy', 4)
a.weight_lbs
Out[26]:
8.818490487395103

There is an obvious drawback to this method however: what if the cat eats food? Its weight won't be updated!

In [27]:
a.eat_food(0.2)
a.weight_lbs  # this is a problem
Out[27]:
8.818490487395103

A possible way to deal with the issue would be to compute the pound weight on demand, i.e. write a method to compute it:

In [28]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        
    # Method
    def eat_food(self, food_kg):
        self.weight += food_kg
        
    def get_weight_lbs(self):
        return self.weight * 1 / 0.45359237
In [29]:
a = Cat('Grumpy', 4)
a.eat_food(0.2)
a.get_weight_lbs()
Out[29]:
9.259415011764858

This is already much better (and accurate), but it is somehow hiding the fact that the weight of a cat really is an attribute, no matter the unit. It should not be accessed with a get_ method. This is where a new syntax comes in handy:

In [30]:
class Cat:
    # Class attribute
    language = 'Meow'
    
    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        
    # Method
    def eat_food(self, food_kg):
        self.weight += food_kg
    
    @property
    def weight_lbs(self):
        return self.weight * 1 / 0.45359237

weight_lbs looks like a method (it computes something), but only in the class definition. For the class instances, the method is "hidden" in an attribute:

In [31]:
a = Cat('Grumpy', 4)
a.eat_food(0.2)
a.weight_lbs  # weight_lbs is an attribute!
Out[31]:
9.259415011764858

This is a very useful pattern frequently used in python. The @ syntax defines a "decorator", and you might learn about decorators in a more advanced python class.

Class inheritance: introduction

Inheritance is a core concept of OOP. It allows a subclass (also called "child class") to override or extend methods and attributes from a base class (also called "parent class"). In other words, child classes inherit all of the parent's attributes and behaviors but can also specify new behaviors or replace old ones. This is best shown with an example: let's make the Cat and Dog class inherit from the Pet class.

In [32]:
class Pet:

    # Initializer
    def __init__(self, name, weight):
        self.name = name
        self.weight = weight
        
    # Method
    def eat_food(self, food_kg):
        self.weight += food_kg
    
    @property
    def weight_lbs(self):
        return self.weight * 1 / 0.45359237
    
    def say_name_loudly(self):
        return self.say_name().upper()

class Cat(Pet):
    
    # Class attribute
    language = 'Meow'
        
    # Method
    def say_name(self):
        return '{}, my name is {} and I am nice!'.format(self.language, self.name)
        
class Dog(Pet):
    
    # Class attribute
    language = 'Woof'
        
    # Method
    def say_name(self):
        return '{}, my name is {} and I smell funny!'.format(self.language, self.name)

Let's advance through this example step by step.

First, let's have a look at the Pet class. It is a standard class defined the exact same way as the previous Cat class. Therefore, it can be instantiated and will work as expected:

In [33]:
p = Pet('PetName', 10)
p.weight_lbs
Out[33]:
22.046226218487757

As discussed during the lecture, the functionality of the parent class Pet however is very general, and it is unlikely to be used alone (a pet isn't specific enough). We used this class to implement the general functionality supported by all pets: they have a name and a weight, regardless of their species.

The Cat and Dog classes make use of this functionality by inheriting from the Pet parent class. This inheritance is formalized in the class definition class Cat(Pet). The code of the two child classes is remarkably simple: it adds a new functionality to the ones inherited from Pet. For example:

In [34]:
c = Cat('Kitty', 4)
c.say_name()
Out[34]:
'Meow, my name is Kitty and I am nice!'

The Pet instance methods are still available:

In [35]:
c.eat_food(0.2)
c.weight
Out[35]:
4.2
In [36]:
d = Dog('Milou', 8)
d.say_name()
Out[36]:
'Woof, my name is Milou and I smell funny!'

There is a pretty straightforward rule for the behavior of child classes instances: when the called method or attribute is available at the child class level, it will be used (even if also available at the parent class level: this is called overriding, and will be a topic for next week); if not, use the parent class implementation.

This is exactly what happens in the code above: eat_food and weight are defined in the Pet class but are available for both Cat and Dog instances. say_name, however, is a child class instance method and can't be used by Pet instances.

So, what about the say_name_loudly method? Although available for Pet instances, calling it will raise an error:

In [37]:
p = Pet('PetName', 10)
p.say_name_loudly()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-37-c2a6a9ac2512> in <module>()
      1 p = Pet('PetName', 10)
----> 2 p.say_name_loudly()

<ipython-input-32-7a9a9a6d065b> in say_name_loudly(self)
     15 
     16     def say_name_loudly(self):
---> 17         return self.say_name().upper()
     18 
     19 class Cat(Pet):

AttributeError: 'Pet' object has no attribute 'say_name'

However, since say_name_loudly is available to the child class instances, the method will work for them!

In [38]:
c = Cat('Kitty', 4)
c.say_name_loudly()
Out[38]:
'MEOW, MY NAME IS KITTY AND I AM NICE!'

This is a typical use case for class inheritance in OOP: it allows code re-use. We will talk about more advanced use cases next week.

Take home points

  • Python is an object oriented programming language but does not enforce the definition of classes in your own programs. However, a basic understanding of the core concepts of OOP is a strong asset and allows to make better use of Python's capabilities.
  • We defined a lot of new concepts today: classes, objects, instances, instance methods, instance attributes, class attributes, the @property decorator, inheritance... You will have to revise these concepts calmly and step by step, possibly by making use of external resources. The web has plenty of good beginner-level OOP tutorials, I recommend to have a look at at least one of them.

What's next?