Object-oriented Programming

Object-oriented programming (OOP) is a programming paradigm, which allows to structure a program such that data and behavior are combined in objects.

An example: an object could represent a person. This person has characteristics (data) like name, age or address. Possible behavior could be running, speaking or walking.

We already used the object Turtle. It has characteristics like position and angle and behavior like turning to the left or right and move forward. With Turtle we already have seen, that behavior of an object can change its state, that is its data. Additionally objects can also interact with other objects.

So far we have focused on procedural programming. There tasks are collected as procedures, i.e. functions, and data is organized as data structures (lists, tuple, etc.). For OOP, the object is the main element of the programming structure and a reasonable transcription of tasks in objects and their interactions is the essential component of the programming performance.

With Python one can, depending on the task, use procedural programming, object-oriented programming or a mixture of both. Hence, it is a multi-paradigm programming language.

Classes

Lets concentrate on the data first. Every object is an instance of a class. Classes serve to define new data structures from already existing ones (e.g. primitive data types, containers, other classes). They define the structure of an object, so to say a class is the blueprint of an object.

The simplest class does not contain any structure. It is defined as follows:

class Dog():
    """A simple class."""
    pass

pass is an instruction that does not do anything. However it is needed, since Python requires an indented code block following the class signature. Classes also have a docstring!

As a convention, class names are the only names in Python, which are written capitalized. For more complex names, the CamelCase notation is used.

Instances

If classes are blueprints, instances represent the products, which are build from them.

class Dog():
    """A simple dog class."""
    pass

jim = Dog()
george = Dog()

print(jim is george)
False

Every instance of a class has the same structure, but the actual values of its properties can differ. With the instantiation, when an object is created, there will be enough memory reserved to save all the data for every object. Objects are mutable, so a allocation creates a reference and not a copy.

jim2 = jim
print(jim2 is jim)
True

If one wants to check, if a an object is created from a certain class, the function isinstance(obj, class) can be used.

print(isinstance(jim, Dog))
print(isinstance(george, Dog))
print(isinstance(jim, type(george)))
True
True
True

Attributes of instances

Every class creates objects and every object can obtain properties, which are called attributes. To initialize attributes (to define its value at the creation of the object) the __init__ method is used. All methods have at least one argument in their signature: the object itself, usually called self. Conventionally, it is omitted when calling the method and it references the instance automatically.

class Dog():
    """Simple Dog with attributes."""
    
    def __init__(self, name, age, address=''):
        """Set name, age and probably the address of the Dog."""
        print("__init__ for {} called".format(name))
        self.name = name
        self.age = age
        self.address = address

jimbo = Dog('Jimbo', 4)
print(
    'This is {}, {} years old. He is living in {}'
    .format(jimbo.name, jimbo.age, jimbo.address)
)

jimbo.address = 'Düsternbrooker Weg 20'
print('This is {a.name}, {a.age} years old. He is living in {a.address}'.format(a=jimbo))
__init__ for Jimbo called
This is Jimbo, 4 years old. He is living in 
This is Jimbo, 4 years old. He is living in Düsternbrooker Weg 20

As one can see, while creating the object jimbo, the method __init__ is called. The attributes of an object can be accessed by using the . notation.

Class attributes

Instance attributes are different for every object of the class. There is also the possibility to define attributes for a whole class, which is then the same for all instances.

class Dog():
    """Person with class attribute."""
    
    # class attribute
    species = "Mammal"
    
    def __init__(self, name, age, address=''):
        self.name = name
        self.age = age
        self.address = address
        
jimbo = Dog('Jimbo', 15)
george = Dog('George', 2)

print(jimbo.species, george.species)
Mammal Mammal

To change a class attribute, the new value can be assigned on the class level. Is the value assigned on instance level, a new instance attribute with the same name is created, which then supersedes the class attribute.

Dog.species = 'Fish'
judy = Dog('Judy', 30)
print(judy.species, jimbo.species, george.species)

george.species = 'Amphibian'
print(judy.species, jimbo.species, george.species, george.__class__.species)
Fish Fish Fish
Fish Fish Amphibian Fish

Instance methods

Instance methods are defined within the class and represent possible behavior of an object. We have already seen an example of a method, the __init__ method which is always called when an instance is created.

Method signatures are very much like a function signatures. However, it always contains a reference to the instance as the first argument, usually called self. When calling the method, this argument is omitted and is passed by Python automatically. Apart from that, the same rules hold for method signatures as for function signatures.

Via the reference self to the instance, it is possible to access all instance attributes, class attributes or methods within the method body.

class Dog():
    """Simple Dog class with Method."""
    
    species = "Mammal"
    
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    # Instanzmethode
    def bark(self):
        print('{} is barking'.format(self.name))
    
    def get_older(self):
        self.age += 1
        
jimbo = Dog('Jimbo', 15)
jimbo.bark()

jimbo.get_older()
print(jimbo.age)
Jimbo is barking
16

Inheritance

Inheritance is the process where a class takes attributes and behavior from another class. The new class is called subclass and the original class is called parent class.

The intention behind this is that the subclass overwrites or extends parts of the parent class. That way, a new class can be created which mostly keeps the functionality of the parent class but differs in certain parts or offers new functionalities. E.g. we can differentiate dogs into breeds by introducing a new class attribute breed.

class Dog():
    """Simple Dog class with Method."""

    species = "Mammal"
    
    def __init__(self, name, age):
        self.name = name
        self.age = age

    # Instanzmethode
    def bark(self):
        print('{} is barking.'.format(self.name))

        
class Labrador(Dog):
    """Simple class for Labrador."""
    breed = "Labrador"
    
    def bark(self):
        print('{} the {} is barking.'.format(self.name, self.breed))
        
class Dobermann(Dog):
    """Simple class for Dobermann."""
    breed = "Dobermann"
    
    def bark(self):
        print('{} the {} is barking.'.format(self.name, self.breed))

        
judy = Labrador('Judy', 4)
jim = Dobermann('Jim', 3)

judy.bark()
jim.bark()
Judy the Labrador is barking.
Jim the Dobermann is barking.

Here the method bark is overwritten for both subclasses Labrador and Dobermann identically. This should be improved:

class DogWithBreed(Dog):
    def bark(self):
        print('{} the {} is barking.'.format(self.name, self.breed))

class Labrador(DogWithBreed):
    """Simple class for Labrador."""
    breed = "Labrador"
        
class Dobermann(DogWithBreed):
    """Simple class for Dobermann."""
    breed = "Dobermann"

judy = Labrador('Judy', 24)
jim = Dobermann('Jim', 23)

judy.bark()
jim.bark()
Judy the Labrador is barking.
Jim the Dobermann is barking.

Inheritance serves to specialize classes and to extend their behavior. This prevents repetition of code and creates flexible programming structures. Sometimes one needs use functionality of the parent class when extending the subclasses. In particular if the __init__ method shall be overwritten. Then the function super is used.

class DogWithBreed(Dog):
    
    def __init__(self, name, age, special_breed=''):
        # call __init__ from parent class but bound
        # to this instance
        super().__init__(name, age)
        if special_breed:
            self.breed = special_breed
        
    def bark(self):
        print(
            '{} the {} is barking.'.format(
                self.name, self.breed
            )
        )

class Labrador(DogWithBreed):
    """Simple class for Labrador."""
    breed = "Labrador"
        
class Dobermann(DogWithBreed):
    """Simple class for Dobermann."""
    breed = "Dobermann"

judy = Labrador('Judy', 24, special_breed='Labrador mix')
jim = Dobermann('Jim', 23)

judy.bark()
jim.bark()
Judy the Labrador mix is barking.
Jim the Dobermann is barking.