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:
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.
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 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
:
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:
__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:
a = Cat('Grumpy', 4)
a
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
:
# Ask if a is an instance of Cat or not
isinstance(a, Cat)
In fact, we just created a new datatype called Cat
:
type(a)
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:
b = Cat('Grumpy', 4)
isinstance(a, Cat)
It is still a unique instance and is not a copy of a
in any way:
a == b
b is a
The cat's name and weight are called instance attributes and can be accessed with the dot syntax:
a.name
a.weight
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:
b = Cat('Tiger', 5)
b.name
Classes can also define class attributes, which are tied to a class but not to a specific instance:
class Cat:
# Class attribute
language = 'Meow'
# Initializer
def __init__(self, name, weight):
self.name = name
self.weight = weight
Cat.language
a = Cat('Grumpy', 4)
a.language
Careful! Class and instance attributes are not immutable. They can be changed form outside the class:
a.name = 'Roncheux'
a.language = 'Miaou'
a.name
a.language
These changes are specific to the instance, and the class remains unchanged:
Cat.language
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.
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:
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:
a = Cat('Kitty', 4)
a.say_name()
b = Cat('Grumpy', 3)
b.say_name()
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:
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
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!
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:
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
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))
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:
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
a = Cat('Grumpy', 4)
a.weight_lbs
There is an obvious drawback to this method however: what if the cat eats food? Its weight won't be updated!
a.eat_food(0.2)
a.weight_lbs # this is a problem
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:
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
a = Cat('Grumpy', 4)
a.eat_food(0.2)
a.get_weight_lbs()
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:
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:
a = Cat('Grumpy', 4)
a.eat_food(0.2)
a.weight_lbs # weight_lbs is an attribute!
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.
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.
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:
p = Pet('PetName', 10)
p.weight_lbs
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:
c = Cat('Kitty', 4)
c.say_name()
The Pet
instance methods are still available:
c.eat_food(0.2)
c.weight
d = Dog('Milou', 8)
d.say_name()
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:
p = Pet('PetName', 10)
p.say_name_loudly()
However, since say_name_loudly
is available to the child class instances, the method will work for them!
c = Cat('Kitty', 4)
c.say_name_loudly()
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.
Back to the table of contents, or jump to this week's assignment.