Object Oriented Programming
- 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
arePen
andBook
- The interface
ProductFactory
has the methodcreate_product()
, which creates and returnsProduct
objects - The class
PenFactory
implementsProductFactory
, and itscreate_product()
method creates and returns a newPen
- The class
BookFactory
implementsProductFactory
, and itscreate_product()
method creates and returns a newBook
- 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
- Two classes that implement
-
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 methodload_transactions()
, which returns an array ofTransaction
objects- There are three implementations of
TransactionReader
, each of which knows how to parse its particular file type, convert values where necessary and produceTransaction
objects - Note that you might use a Factory pattern to create the
TransactionReader
s, 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.