Object Oriented Programming#

For as long as we have been working with Python, we have been using objects. In fact, everything in Python is an object, which makes using objects naturally inescapable. Specifically, when we introduced the different types of variables that we can define, we saw that each of them was associated to specific methods, which varied depending of the data type. Here, we will make explicit what we have been using intuitively in previous chapters. We will also introduce classes, which are central to object-oriented programming in Python.

Attributes and methods#

We will start with some definitions. There are two fundamental properties of objects, attributes and methods. Attributes are named elements associated to an object. The way we invoke them is similar to using variables from a module that we have imported (e.g. math.pi). Methods are functions intrinsic to a specific type of object. For example, list objects have an associated method called append:

s = ['txema', 'david', 'kirill']
s.append('javier')
print (s)
['txema', 'david', 'kirill', 'javier']

Classes#

In addition to the intrinsic types built into Python, we can define our own types of objects that may be useful for our needs. Classes are user-defined types and their use provides incredibly rich functionality in Python. Because classes carry their own specific attributes and methods, we can use them to conveniently pack a set of data and corresponding functions operating on the data. Most of the time, you can live without classes and simply write code using simpler data structures. But if you start using classes, you will find that they provide an extremely elegant framework to solve problems.

A simple class example#

We will start defining a simple class that we will call Cup. To define the class we will use

class Cup(object):
    """Represents a cup someone can drink from"""

Note that we are using CapWords for class definitions, following PEP8. Cups usually are of some colour and have a volume that can be filled with some liquid. Cups also normally have an owner. These will be the cup attributes and we will assign an initial value to them when the class is initialized. In order to initialize a class, we use the __init__ method (note the two underscore characters at the beginning and end of init).

class Cup(object):
    """Represents a cup someone can drink from"""
    def __init__(self):
        self.volume = 1.
        self.owner = 'Jeremy'
        self.content = None

In order to create an instance of this class, you can do something as simple as

mycup = Cup()

When we instantiate an object, the __init__ method is invoked and whatever takes place inside will be part of our object. We can pass parameters to the __init__ method, which normally receive the same names as the attributes of the class. For example,

class Cup(object):
    """Represents a cup someone can drink from"""
    def __init__(self, volume=1., owner='Jeremy', content=None):
        self.volume = volume
        self.owner = owner
        self.content = content

and in this way we may access the values that are packaged in the class

my_bigger_cup = Cup(volume=10)
print (mycup.volume)
1.0

This second instance of the class is identical to the first, just with a 10 times bigger volume. But you do not necessarily have to pass values to the attributes, as they have default values. As we have seen, the attributes mutable and can be assigned different values for different instances of the class.

There are other special methods like __init__, like for example the __str__ method, which returns a string representation of an object. For example, we could write something like

    def __str__(self):
        if not self.content:
            return 'An empty %s unit cup owned by %s'%(self.volume, self.owner)
        else:
            return 'A %s unit cup owned by %s filled with '%(self.volume, self.owner, self.content)

Additionally, we can include a number of methods in our class. Cups, for example, can be filled with coffee or tea

class Cup(object):
    """Represents a cup someone can drink from"""
    def __init__(self, volume=1., owner='Jeremy', content=None):
        self.volume = volume
        self.owner = owner
        self.content = content
        
    def __str__(self):
        if not self.content:
            return 'An empty %s unit cup owned by %s'%(self.volume, self.owner)
        else:
            return 'A %s unit cup owned by %s filled with %s'%(self.volume, self.owner, self.content)
        
    def fill(self, liquid):
        self.content = liquid
    
    def drink(self):
        if not self.content:
            print ('You cannot drink from an empty cup')
        else:
            self.content = None

This more complete class offers some additional functionality. We can create a new cup, initally empty

my_new_cup = Cup()
print (my_new_cup)
An empty 1.0 unit cup owned by Jeremy

then fill it with coffee

my_new_cup.fill('coffee')
print (my_new_cup)
A 1.0 unit cup owned by Jeremy filled with coffee

and drink from it

my_new_cup.drink()
print (my_new_cup)
An empty 1.0 unit cup owned by Jeremy

Our class of the will complain if we try to empty it again

my_new_cup.drink()
You cannot drink from an empty cup

The Call special method#

Another special method, __call__, allows you to write a class and call it like a function. It’s a powerful feature that can make instances behave like callable objects. Here’s a simple example

class Counter(object):
    def __init__(self):
        self.value = 0
    
    def increment(self):
        self.value += 1
    
    def __call__(self):
        self.increment()
        return self.value

As you see, we have written a __call__ method that first invokes the increment method and then returns a value. Let’s see it in action. First we create an instance of the class

my_counter = Counter()

and then we “call” it like a function a number of times

print(my_counter())
print(my_counter())
print(my_counter())
1
2
3

If your class represents a mathematical function, then it is good practice to add a __call__ method to your class. You can check whether an object has a __call__ method using the build-in function callable

print(callable(my_counter))
True

There are other special methods like __init__, __str__ or __call__ but you will learn about them as you need.

Copying instances of classes#

You can generate as many instances of a class as you need, and you can also copy instances of a class, but in the latter case you must be a bit careful. We will illustrate this with an example class called ItemList

class ItemList(object):
    def __init__(self, items=[]):
        self.items = items
    
    def add_item(self, item):
        self.items.append(item)
        
    def remove_item(self, item):
        if item in self.items:
            self.items.remove(item)
    
    def get_items(self):
        return self.items

We will first create an instance of this class and fill it with fruits

original_list = ItemList()
[original_list.add_item(x) for x in ["Apple", "Banana", "Orange"]]
[None, None, None]

Now we may want to make a copy of this list, and add some more stuff

import copy
shallow_copy = copy.copy(original_list)
shallow_copy.add_item("Grapes")

However, this operation also unintentionally changes the original list

print("Original List:", original_list.get_items())
print("Shallow Copy:", shallow_copy.get_items())
Original List: ['Apple', 'Banana', 'Orange', 'Grapes']
Shallow Copy: ['Apple', 'Banana', 'Orange', 'Grapes']

If we want to make an independent copy of the list we must use deepcopy instead

deep_copy = copy.deepcopy(original_list)
deep_copy.remove_item("Grapes")
print("Original List:", original_list.get_items())
print("Deep Copy:", deep_copy.get_items())
Original List: ['Apple', 'Banana', 'Orange', 'Grapes']
Deep Copy: ['Apple', 'Banana', 'Orange']

As you see, after having used deepcopy the original copy remains unchanged.

Static methods and attributes#

Static attributes and methods belong to a class rather than an instance of the class. They are typically used for values or functions that are related to the class but do not require access to your specific instance of the class.

Here we define a class MathUtils with a static attribute pi, representing the value of the mathematical constant \(pi\) and a static method that calculates the factorial of a number recursively

class MathUtils:
    pi = 3.14159 
    
    @staticmethod
    def factorial(n):
        if n == 0 or n == 1:
            return 1
        else:
            return n * MathUtils.factorial(n - 1)

We can hence access the value of \(pi\)

print("Value of pi:", MathUtils.pi)
Value of pi: 3.14159

or calculate the factorial of an integer as

num = 5
factorial_result = MathUtils.factorial(num)
print("The factorial of %g is %g"%(num, factorial_result))
The factorial of 5 is 120

Inheritance#

Sometimes you will find that there exists a natural hierarchy between your classes: dogs are for example, a specific type of animal. Let’s start writing a general class, called Animal

class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species
    
    def make_sound(self):
        pass  # This method will be overridden in subclasses

Continuing with our dog-as-a-type-of-animal example, we can write a new class for dogs thah inherits the methods of the existing class

class Dog(Animal):
    def __init__(self, name):
        super().__init__(name, species="Dog")

    def make_sound(self):
        return "Woof!"

You may instead be a cat person, and still inherit from Animal

class Cat(Animal):
    def __init__(self, name):
        super().__init__(name, species="Cat")

    def make_sound(self):
        return "Meow!"

Then we can generate instances of our Dog and Cat classes

dog = Dog("Buddy")
cat = Cat("Whiskers")

animals = [dog, cat]

for animal in animals:
    print(f"{animal.name} the {animal.species} says: {animal.make_sound()}")
Buddy the Dog says: Woof!
Whiskers the Cat says: Meow!