Object Oriented Programming

Learning goals

  • Software design approaches and patterns, to identify reusable solutions to commonly occurring problems
  • Apply an appropriate software development approach according to the relevant paradigm (for example object-oriented, event-driven or procedural)

Classes

You are hopefully already familiar with the concept of a class. In OOP, you can consider a class to be a blueprint to create objects. The class has two key types of content:

  • Data – information that’s stored in the class
  • Behaviour – methods that you can call on the class

Constructors

Class constructors are special methods that are used to initialize objects of a class. The constructor method is called __init__() and it is automatically called when a new object of the class is created.

Here’s an example of a constructor in Python:

class Animal:
    def __init__(self, species):
        self.species = species

The first parameter passed to a Python constructor (self) is the object that is being created. In the example above, we’ve defined an Animal class with a constructor that takes one extra argument: species. When a new Animal object is created, the constructor is called and the species property is set.

Object instantiation is the process of creating a new instance of a class. Let’s create a couple of Animal instances:

cassandraTheLion = Animal("Lion")
bobTheZebra = Animal("Zebra")

We can access the properties and methods of an object using the dot notation. For example:

print(cassandraTheLion.species) # output: Lion

Attributes and methods

Properties are the data members of a class that define the state of an object. We can define properties using instance variables. Instance variables are created using the self keyword and can be accessed from within the class methods. In our Animal class, we have the property: species.

Methods are the functions of a class that define the behavior of an object. We define methods using functions that are defined within the class.

Here’s an example of an instance method in Python:

class Animal:
    def __init__(self, species):
        self.species = species

    def roll_over(self):
        return f'{self.species} rolled over'

In this example, we’ve defined the class method roll_over to return a string explaining that the species has just rolled over! This class method can only be accessed from an instance of this class.

Class methods

So far, we’ve just been looking at instance methods, but there’s a class method – when do we want to use each?

  • Use a class method when you want to modify the class itself or perform an action that is related to the class as a whole, rather than to a specific instance of the class.
  • Use an instance method when you want to perform an action on a specific instance of the class, and when you need access to the instance’s attributes.

Class methods are methods that are bound to the class rather than the instance of the class. This means that they can be called on the class itself, rather than on an instance of the class.

To define a class method in Python, we use the @classmethod decorator:

class Animal:
    count = 0

    def __init__(self, species):
        self.species = species
        Animal.count += 1

    @classmethod
    def get_count(cls):
        return cls.count

cassandraTheLion = Animal("Lion")
bobTheZebra = Animal("Zebra")

print(f'Animal population is {Animal.get_count()}')
# Output: "Animal population is 2"

In this example, get_count() is a class method because it is decorated with @classmethod. The cls parameter refers to the class itself and is automatically passed in when we call the method on the class.

Access modifiers

There are no true access control modifiers like public, protected, and private as in other object-oriented languages such as Java. However, one can indicate access so developers know what not to expose.

  • _ before a variable name indicated it’s protected. There’s no name wrangling, so it’s still just as accessible as a public variable. It only serves as an indicator to other developers that the variable should only be accessible to a derived class internally.
  • __ before a variable name means it’s private. In the example __stash_location shouldn’t be accessed outside the class it’s declared. (They can be, by accessing the name-wrangled variable at _Animal__stash_location, but shouldn’t)
class Animal:
    def __init__(self, species, _home_location, stash_location):
        self.species = species
        self._home_location = _home_location
        self.__stash_location = stash_location

    # public method
    def blabbermouth(self):
        print(f'The food is stashed {self.__stash_location}')


lenny = Animal('Leopard', 'beside the river', 'up a tree')
print(lenny.species)           # Output: Leopard
print(lenny.__stash_location)  # AttributeError: 'Animal' object has no attribute '__stash_location'.
lenny.blabbermouth()           # Output: The food is stashed up a tree

Here we see the stash location isn’t able to be directly accessed outside of the class (thanks to name wrangling but developers should see the indicator and know not to try). Below is an example of using protection to indicate variable access:

class ForgetfulAnimal(Animal):
    def __init__(self, species, home_location, stash_location):
        Animal.__init__(self, species, home_location, stash_location)

    def remember_where_home_is(self):
        print(f'The {self.species} remembers it\'s home is {self._home_location}')


jerry = ForgetfulAnimal('Fox', 'in a bush', 'in the same bush')
jerry.remember_where_home_is()  # Output: The Fox remembers its home is in a bush

By protecting _home_location, we indicate to developers we don’t want them directly accessing it, e.g. print(lenny._home_location), even though it would work.

Inheritance

Inheritance allows us to create specialized classes without having to rewrite code by deriving new classes from existing ones. This enables us to reuse code, organize classes in a hierarchy, and define common functionality in a single place, making our code more efficient and organized.

To inherit from a base class in Python, we use the syntax class DerivedClassName(BaseClassName):. Here is an example:

class Animal:
    def __init__(self, species):
        self.species = species
        self.food = 0

class Carnivore(Animal):
    def carnivore_info(self):
        print("This animal eats meat. ")

class Herbivore(Animal):
    def herbivore_info(self):
        print("This animal eats plants. ")

In this example, Animal is the base class, and Carnivore and Herbivore are derived classes. Carnivore and Herbivore inherit the __init__ method from Animal and set new methods: carnivore_info and herbivore_info.

Python supports multiple inheritance, meaning a child class inherits from multiple parent classes, as in the below.

class Omnivore(Carnivore, Herbivore):
    pass

dog = Omnivore()
dog.carnivore_info()   # This animal eats meat.
dog.herbivore_info()  # This animal eats plants.

Super

The super() method in Python is used to call a method in a parent class from a child class

class ChildClass(ParentClass):
    def __init__(self, arg1, arg2, ...):
        super().__init__(arg1, arg2, ...)
        # rest of the child class's code

In this example, ChildClass inherits from ParentClass. When we call super().__init__(arg1, arg2, ...), we are calling the __init__() method of ParentClass with the same arguments that were passed to ChildClass’s __init__() method. This initializes any attributes or properties that were defined in ParentClass.

When we have multiple inheritance, the super() method can be a little more complicated to use. This is because we need to specify the parent class we want to call the method on.

class Omnivore(Carnivore, Herbivore):
    def info(self):
        super(Carnivore, self).carnivore_info()
        super(Herbivore, self).herbivore_info()

dog = Omnivore()
dog.info()  # This animal eats meat. This animal eats plants.

MRO

Method Resolution Order (MRO) is the order in which Python looks for methods and attributes in a hierarchy of classes. It is used to determine which method or attribute will be used when there are multiple classes in a hierarchy that define the same method or attribute.

The MRO can be accessed using the mro() method on a class. For example:

class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

print(D.mro())

In this example, we have four classes A, B, C, and D. B and C both inherit from A, and D inherits from both B and C. When we call D.mro(), Python will use the C3 linearization algorithm to determine the MRO.

The output of this program will be:

[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

When a method is called on an instance of D, Python will look for that method first in D, then B, then C, then A, and finally the object prototype.

Class Decorators

Class decorators are functions that take a class as input and return a modified class as output. They are applied to a class using the @decorator syntax. Here is an example of a class decorator:

def four_legged(cls):
    cls.legs = 4
    return cls

@four_legged
class Animal:
    def __init__(self, species):
        self.species = species

In this example, the four_legged function takes a class (cls) as input, adds a new attribute to it, and then returns the modified class. The @four_legged syntax applies the decorator to the Animal class.

Now, Animal has a new attribute called four_legged with the value 4.

Why use class decorators?

  • Adding new attributes or methods to a class
  • Modifying existing attributes or methods of a class
  • Restricting access to a class or its attributes or methods
  • Implementing mixins

Setters and Getters

Say we want to automatically store a label’s text in upper case regardless of input case, how do we do that? Consider the following version of Label:

class Label:
    def __init__(self, text, font):
        self.set_text(text)
        self.font = font

    def get_text(self):
        return self._text

    def set_text(self, value):
        self._text = value.upper()  # Attached behavior

We’re providing a getter and a setter for the protected variable _text. Now we can get and set the value of this protected variable, without referencing it directly (remember we’re trying to dissuade developers from doing this):

from label import Label

label = Label("Fruits", "JetBrains Mono NL")
print(label.get_text())  # Output: FRUITS

label.set_text("Vegetables")
print(label.get_text())  # Output: VEGETABLES

This is similar to the way other OOP languages would modify the getting and setting of variables, however, there’s a more Pythonic way!

class Employee:
    def __init__(self, name, birth_date):
        self.name = name
        self.birth_date = birth_date

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, value):
        self._name = value.upper()

Which creates a readable-writable variable, _name. We can use it like normal:

from employee import Employee

john = Employee("John", "2001-02-07")
print(john.name)  # Output: JOHN

john.name = "John Doe"
print(john.name)  # Output: JOHN DOE

We should only use properties when you need to process data on getting or setting, to avoid overusing them.

Interfaces and abstraction

In object-oriented programming, interfaces and abstraction are important concepts that help us to create more modular, maintainable, and extensible code.

Interfaces

An interface is a contract that specifies a set of methods that a class must implement. It defines a common set of methods that objects of different classes can use. In Python, interfaces are not explicitly defined like in some other programming languages, but we can use the abc module to create abstract base classes that define interfaces.

Here is an example of an abstract base class that defines an interface:

from abc import ABC, abstractmethod

class MyInterface(ABC):
    @abstractmethod
    def method_one(self):
        pass

    @abstractmethod
    def method_two(self):
        pass

In this example, MyInterface is an abstract base class that defines an interface with two abstract methods, method_one, and method_two. Any class that inherits from MyInterface must implement these two methods.

Abstraction

Abstraction is the process of hiding the implementation details of a class and exposing only the relevant information to the client. It is a way of reducing complexity and increasing modularity. We can use abstract classes to implement abstraction.

Here is an example of an abstract class that implements abstraction:

from abc import ABC, abstractmethod

class MyAbstractClass(ABC):
    def method_one(self):
        print("This is method one.")

    @abstractmethod
    def method_two(self):
        pass

In this example, MyAbstractClass is an abstract class that defines two methods, method_one and method_two. method_one is a concrete method that has an implementation, while method_two is an abstract method that has no implementation. Any class that inherits from MyAbstractClass must implement method_two.

Difference between Interfaces and Abstraction

The main difference between interfaces and abstraction is that an interface specifies only the methods that a class must implement, while abstraction hides the implementation details of a class and exposes only the relevant information to the client.

In other words, an interface defines a contract that a class must follow, while abstraction defines a way of reducing complexity and increasing modularity by hiding implementation details.

Interfaces are useful when we want to define a common set of methods that objects of different classes can use, while abstraction is useful when we want to define a class with some common functionality and leave the implementation details to the subclasses.

Polymorphism

Polymorphism is the ability of objects of different classes to be used interchangeably. This is achieved through method overriding and interfaces. When a method is called on an object, Python determines the appropriate method to call based on the type of the object.

Here is an example:

class Animal:
    def speak(self):
        pass

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat():
    def speak(self):
        return "Meow!"

def animal_speak(animal):
    print(animal.speak())

dog = Dog()
cat = Cat()

animal_speak(dog) # Output: Woof!
animal_speak(cat) # Output: Meow!

In this example, animal_speak is a function that assumes that it has been given an object with a speak method. Therefore animal could be a Dog, Cat, or anything else that derives from Animal or has a speak method – and Python calls the appropriate speak method based on the type of the object.

Object oriented design patterns

Design patterns are common ways of solving problems in software development. Each pattern is a structure that can be followed in your own code, that you implement for your specific situation. Following established design patterns is advantageous because:

  • in general they have evolved as best practice, and so avoid the need for you to solve every problem anew – although “best practice” changes over time so some established patterns fall out of favour; and
  • other developers will probably be familiar with the patterns so it will be easier for them to understand and maintain your code.

Although design patterns aren’t specifically tied to object oriented programming, most of the common design patterns that have arisen fit the OOP paradigm. The following are some common OOP design patterns.

  • Singleton: This pattern ensures that there is only ever one instance of a class. This could be important if your application needs a single shared configuration object or an object manages access to an external resource. This would be implemented by:

    • Having a static property in the class that can hold the singleton
    • Making the class constructor private so that new objects of that class cannot be created
    • Having a static public method that is used to access the singleton; if the shared object doesn’t exist yet then it is created
  • Factory: This is a pattern for creating objects that share an interface, without the caller needing to know about the various classes that implement that interface. For example, a factory pattern for creating objects that have the interface Product could look like:

    • Two classes that implement Product are Pen and Book
    • The interface ProductFactory has the method create_product(), which creates and returns Product objects
    • The class PenFactory implements ProductFactory, and its create_product() method creates and returns a new Pen
    • The class BookFactory implements ProductFactory, and its create_product() method creates and returns a new Book
    • If code elsewhere in the application is given a ProductFactory for creating products, it doesn’t need to know what the products are or be told when new products are created
  • Model-View-Controller: You’ll be familiar with the MVC pattern from the Bootcamp Bookish exercise; it’s a very common way to structure user interfaces.

    • Model objects contains the data that is to be displayed (e.g., an entity that has been fetched from a database)
    • The View is a representation of the Model to the user (such as a table or graphical representation)
    • The Controller processes commands from the user and tells the Model objects to change appropriately
  • Adapter: This pattern would be suitable for the Bootcamp SupportBank exercise, in which you had to process data from files in different formats (CSV, JSON and XML). An adapter is a class that allows incompatible interfaces to interact. In the case of SupportBank, you might decide that:

    • TransactionReader is an interface that has the method load_transactions(), which returns an array of Transaction objects
    • There are three implementations of TransactionReader, each of which knows how to parse its particular file type, convert values where necessary and produce Transaction objects
    • Note that you might use a Factory pattern to create the TransactionReaders, so it’s easy to add support for new file types in future

Strengths of OOP

The following are strengths of the object oriented programmming paradigm.

Abstraction refers to the fact that object oriented code hides information that you don’t need to know, so if you’re using an object all you know about it is its publicly visible characteristics. Your code doesn’t know about how the object’s data and methods are implemented. This is especially clear when dealing with interfaces – code that interacts with an interface knows only the contract that the interface publishes, and that interface might be implemented by lots of different classes that have completely different structures and behaviour.

Encapsulation refers to the fact that the data and behaviour of an object are tied together into a single entity. If you think about programming without encapsulation, if you have a simple data structure with a set of fields in it and then want to run a function on it, you need to make sure that you find the function that correctly handles that specific data structure. With encapsulation, you just call the object’s own method.

Polymorphism refers to the fact that an object can be interacted with as if it were an instance of an ancestor class, while its behaviour will come from its actual class. When a new descendent class is defined with its own implementation of ancestor class methods, code that interacts with the base class will automatically call the new implementations without having to be updated.

Further reading:

  • Reflection: It allows developers to examine and modify the structure and behavior of an object at runtime.
  • Metaclass: A class that defines the behavior of other classes. Metaclasses allow developers to customize the behavior of classes at runtime.