Lecture 5 - Object-Oriented Programming

View notebook on Github Open In Collab

5.1 Overview

Object-oriented programming (OOP) is a programming approach to structuring programs based on the concept of objects, which can contain data and behavior in the form of attributes and methods, respectively. For instance, an object could represent a person with attributes like name, age, and address, and methods such as walking, talking, and running. Or, it could represent an email with attributes like a recipient list, subject, and body, and methods like adding attachments and sending.

In OOP, computer programs are designed by defining objects that interact with one another. In Python, the main tool for achieving OOP are Python classes. Classes are created using the class statement. Then, from classes we can construct object instances, which are specific objects created from a particular class.

Besides OOP, other programming paradigms on which other languages are based include procedural, functional, and logic programming paradigms.

5.2 Defining a Class

The class Statement

Let’s define a class Dog by using the class statement and the name of the class. It is a convention in Python to begin class names with an uppercase letter, and module and function names with a lowercase letter. This is not a requirement, but if you follow this naming convention it will be appreciated by others who are to use your codes.

[1]:
# Create a new class called Dog
class Dog:
    """Class object for a dog."""
    pass
[2]:
# Create an instance object of the class Dog
sam = Dog()

print(type(sam))
<class '__main__.Dog'>

In the above code, inside the class definition we currently have just the pass command, which is only a placeholder for the code that we intend to write afterwards and means do nothing for now.

Classes can be thought of as blueprints for creating objects. When we defined the class Dog using the line class Dog:, we didn’t actually create an object.

To create an object of the class Dog, we called the class by its name and a pair of parentheses (and optionally we can pass arguments in the parentheses as we did with functions in Python). That is, we instantiated the Dog class, and sam is now the reference to our new instance of the Dog class. Or, the action of creating instances (objects) from an existing class is known as instantiation.

The name sam is referred to as a class instance, or instance object, or just an instance. We will use these terms interchangeably.

We can create many instances of the class by calling the class with Dog(). For example, below we created two Dog instances sam and frank. Note that although they are both instances of the class Dog, they represent two distinct objects.

[3]:
sam = Dog()
frank = Dog()

sam == frank
[3]:
False

Note below that the two instances sam and frank have different memory addresses, shown after at in the cell outputs. The addresses for these two instances in your computer’s memory will be different than those shown here.

[4]:
sam
[4]:
<__main__.Dog at 0x2c132164160>
[5]:
frank
[5]:
<__main__.Dog at 0x2c132164550>

In summary:

Classes serve as instance factories. They provide attributes and methods that are inherited by all the instances created from them.

Instances represent the concrete objects of a class. Their attributes consist of information that varies per specific object. Their methods describe behavior that is different for specific objects.

Class objects can have attributes and methods.

An attribute is an individual characteristic of an instance of the class. Or, attributes are variables that hold class data.

A method is an operation that is performed with the instance of the class. Or, methods are functions that provide behavior to class objects.

5.2.1 Attributes

As we explained, attributes allow us to attach data to class objects. Python classes can have two types of attributes: instance attributes and class attributes.

Instance Attributes

In Python, instance attributes are defined by using the __init__() constructor method. The term init is abbreviated from initialize, since it is used to initialize the attributes of class instances.

In the parentheses of the __init__() method first self is listed, and afterwards the attributes are listed.

The syntax for creating attributes is:

def __init__(self, attribute1, attribute2, ...):
     self.attribute1 = attribute1
     self.attribute2 = attribute2

For example:

[6]:
class Dog:
    def __init__(self, breed, name):
        self.breed = breed
        self.name = name
[7]:
# Create an instance from the 'Dog' class by passing breed and name, assign it to the name 'sam'
sam = Dog(breed='Labrador', name='Sam')
# Create another instance from the 'Dog' class by passing breed and name, assign it to the name 'frank'
frank = Dog(breed='Huskie', name='Frank')
# Create another instance from the 'Dog' class by passing breed and name, assign it to the name 'my_dog'
my_dog = Dog(breed='Terrier', name='Scooby')

The __init__() method is present in almost every class, and it is used to initialize newly created class instances by passing attributes. In the above example, the attributes breed and name are the arguments to the special method __init__(). Each attribute in a class definition begins with a reference to the class instance, which by convention is named self, such as in self.breed = breed.

When we created the instances of the class Dog, __init__() initialized these objects by passing the assigned values for breed and name to the instances sam, frank, and my_dog. In the __init__() method, the word self is the newly created instance. Therefore, for the instance sam, the line self.breed = breed is equivalent to stating sam.breed = 'Labrador'. Similarly, self.name = name is equivalent to stating sam.name = 'Sam'. Similarly, for the instance frank, self.breed = breed is equivalent to stating frank.breed = 'Huskie' and self refers to the instance frank.

Notice again that sam, frank, and my_dog are three separate instances of the Dog class, and they have their own attributes, i.e., different breed and name.

Accessing Instance Attributes

The syntax for accessing an attribute of a class instance uses the dot operator.

instance.attribute

We can therefore access the attributes breed and name as in the next examples.

[8]:
# Access the 'breed' attribute of the class instance 'sam'
sam.breed
[8]:
'Labrador'
[9]:
# Access the 'breed' attribute of the class instance 'frank'
frank.breed
[9]:
'Huskie'
[10]:
# Access the 'name' attribute of the class instance 'my_dog'
my_dog.name
[10]:
'Scooby'

Note that we cannot access the instance attributes through the class, as in class.attribute, since they are specific to concrete instances of the class. If we try to do that, we will get an Attribute Error.

[11]:
Dog.name
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\4053790346.py in <module>
----> 1 Dog.name

AttributeError: type object 'Dog' has no attribute 'name'

In general, it is possible to create new classes without the __init__() construction method. This is shown below, where we used a def statement to introduce a method called enterinfo to the class Dog.

[12]:
class Dog:
    def enterinfo(self, breed):
        self.breed = breed
[13]:
sam = Dog()
sam.enterinfo(breed='Labrador')
[14]:
sam.breed
[14]:
'Labrador'

However, in this case, we need to first create a new class instance sam as shown above, and afterward assign the breed attribute using the enterinfo() method.

On the other hand, by using the __init__() method, we can initialize the instance attributes at the same time when the new instance is created. Therefore, using __init__() is preferred and always recommended. Without __init__(), an empty instance is created, and we need to initialize it afterwards.

In addition, we can dynamically attach new instance attributes to existing class objects that we have already created. In the next cell, the new attribute age is attached to the instance sam. However, it is preferred to define instance attributes inside the class definition, since it makes the code more organized and makes it easier for others to understand or debug our code.

[15]:
sam.age = 3
print(sam.age)
3
Modifying Instance Attributes

We can modify the attributes of an instance by using the dot . notation and an assignment statement, as in:

instance.attribute = new_value
[16]:
frank.name
[16]:
'Frank'
[17]:
# Modify attribute
frank.name = 'Franki'
frank.name
[17]:
'Franki'

To delete any instance attribute, use the del keyword.

[18]:
del frank.name
[19]:
# Error, the name attribute does not exist for 'frank'
print(frank.name, frank.breed)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\2831644747.py in <module>
      1 # Error, the name attribute does not exist for 'frank'
----> 2 print(frank.name, frank.breed)

AttributeError: 'Dog' object has no attribute 'name'

The above code does not delete the attribute name for the other class instances.

[20]:
# The name attribute still exist for 'my_dog'
my_dog.name
[20]:
'Scooby'

Class Attributes

Class attributes in Python are also referred to as class object attributes. The class attributes are the same for all instances of the class.

For example, we could create the attribute species for the Dog class, as shown in the next cell. Regardless of their breed, name, or other attributes, all dog instances will have the attribute species = 'mammal'. The instances of the class Dog in the next cell also have the instance attributes breed and name which can be unique for each class instance.

We apply this logic in the following manner.

[21]:
class Dog:

    # Class attribute
    species = 'mammal'

    # Instance attributes
    def __init__(self, breed, name):
        self.breed = breed
        self.name = name
[22]:
# Create an instance from the 'Dog' class by passing breed and name
sam = Dog('Labrador','Sam')

Accessing class attributes is the same as accessing instance attributes.

[23]:
# Access class attributes
sam.species
[23]:
'mammal'
[24]:
# Access instance attributes
sam.name
[24]:
'Sam'

Note that the class attribute species is defined directly in the body of the class definition, outside of any methods in the class. Also by convention, the class attributes are placed before the __init__() method.

Also, we can access class attributes through the class via class.attribute, as in the next example.

[25]:
Dog.species
[25]:
'mammal'
Modifying Class Attributes

We cannot modify class attributes via assignment to class instances. In the next example, we used an assignment statement frank.species = 'bird' to modify the attribute species of the class instance frank to bird.

[26]:
frank = Dog(breed='Huskie', name='Frank')
[27]:
frank.species
[27]:
'mammal'
[28]:
# Reassing the attribute 'species' to 'bird'
frank.species = 'bird'

This didn’t change the class attribute for the newly created class instance my_dog, as shown below. Instead, the reassignment frank.species = 'bird' created a new instance attribute species for the class instance frank that has the same name as the class attribute species.

[29]:
my_dog = Dog(breed='Terrier', name='Scooby')
[30]:
# The class attribute of the new instance is still 'mammal'
my_dog.species
[30]:
'mammal'

We can change the class atribute via assignment when using the class name, as shown in the next example.

[31]:
Dog.species = 'animal'
[33]:
sam = Dog('Labrador','Sam')
[34]:
sam.species
[34]:
'animal'

In summary:

  • Class attributes are defined in the body of the class definition directly. Class attributes are common to the class. Their data is the same for all instances of the class.

  • Instance attributes are defined inside the __init__() method within the class definition. Instance attributes belong to a concrete instance of the class. Their data is specific to that concrete instance of the class.

The __dict__ Attribute

Both classes and instances in Python have a special attribute called __dict__. This attribute is a dictionary, with the keys being the attribute names and the values are the attached attribute values. For a class instance __dict__ holds the instance attributes, and for a class __dict__ holds class attributes and methods.

[35]:
my_dog.__dict__
[35]:
{'breed': 'Terrier', 'name': 'Scooby'}
[36]:
frank.__dict__
[36]:
{'breed': 'Huskie', 'name': 'Frank', 'species': 'bird'}
[37]:
Dog.__dict__
[37]:
mappingproxy({'__module__': '__main__',
              'species': 'animal',
              '__init__': <function __main__.Dog.__init__(self, breed, name)>,
              '__dict__': <attribute '__dict__' of 'Dog' objects>,
              '__weakref__': <attribute '__weakref__' of 'Dog' objects>,
              '__doc__': None})

Python also allows to change the value of existing instance attributes through __dict__, or even to add new attributes through __dict__.

5.2.2 Methods

Methods are functions defined inside the body of a class. By defining it inside the class, we establish a relationship between the method and the class. Because methods are functions, they can take arguments and return values.

In a Python class, we can define three different types of methods:

  • Instance methods, which take the current instance self as their first argument.

  • Class methods, which take the current class cls as their first argument.

  • Static methods, which take neither the class nor the instance.

This section describes instance methods, as the most common type of methods in classes. Class methods and static methods are described in the Appendix section.

Instance Methods

Instance methods are functions defined inside the body of a class, designed to perform operations on the class objects.

Methods have access to all attributes for an instance of the class. They can access and modify the attributes through the argument self.

We can basically think of methods as regular functions, with one major difference that the first argument of the method is always the instance object referenced through self.

Technically, even the word self is a convention, and any other term can be used instead of self. However, if you use another word, that would be very unusual for other coders using your code.

Let’s see an example of creating a class for a Circle shown below. The objects of this class have three methods: getArea which calculates the area, getCircumference which calculates the circumference, and SetRadius which allows to change the attribute radius.

[38]:
class Circle:
    pi = 3.14

    # Circle gets instantiated with a radius (default is 1)
    def __init__(self, radius=1):
        self.radius = radius

    # Method for getting Area
    def getArea(self):
        return self.radius * self.radius * self.pi

    # Method for getting Circumference
    def getCircumference(self):
        return self.radius * self.pi * 2

    # Method for resetting Radius
    def setRadius(self, new_radius):
        self.radius = new_radius

Notice that within the methods getArea and getCircumference we used the notation self.radius to reference the instance attribute radius which we defined with the __init__ method inside the body of the class.

Similarly, we defined the class attribute pi = 3.14 inside the body of the class, and since we can access it as an attribute of new instances, we used the notation self.pi to reference it within the methods getArea and getCircumference. As we explained in the previous section, we can also access class attributes through the class name, that is, we could have used the notation Circle.pi within the methods getArea and getCircumference to get access to the class attribute pi.

The two methods getArea and getCircumference don’t take any other arguments except self. The method setRadius takes another argument new_radius, and it allows to change the value of the current attribute radius.

The methods are accessed by using the dot notation.

instance.method()
[39]:
# Let's call it
c = Circle()

print('Radius is: ', c.radius)
print('Area is: ', c.getArea())
print('Circumference is: ', c.getCircumference())
Radius is:  1
Area is:  3.14
Circumference is:  6.28

Now let’s change the radius with the method setRadius and see how that affects the Circle object:

[40]:
c.setRadius(3)

print('Radius is: ', c.radius)
print('Area is: ', c.getArea())
print('Circumference is: ', c.getCircumference())
Radius is:  3
Area is:  28.26
Circumference is:  18.84

Notice again in the above cell that when we call getArea and getCircumference methods, we don’t need to provide a value for the self argument. Python takes care of that step, and it automatically passes the class instance to self.

However, if we wish, we can manually provide the desired class instance when calling these methods. To do this though, we need to call the method on the class, as shown next.

[41]:
Circle.getCircumference(c)
[41]:
18.84

If we try to call the methods on the instance c, that will raise an exception.

[42]:
c.getCircumference(c)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\521085191.py in <module>
----> 1 c.getCircumference(c)

TypeError: getCircumference() takes 1 positional argument but 2 were given

Shown next is one more example of a class Customer with attributes name and balance, and methods withdraw and deposit.

[43]:
class Customer():
    """A customer of ABC Bank with a checking account. Customers have the
    following properties:

    Attributes:
        name: A string representing the customer's name.
        balance: A float tracking the current balance of the customer's account.
    """

    def __init__(self, name, balance=0.0):
        """Return a Customer object whose name is *name* and starting
        balance is *balance*."""
        self.name = name
        self.balance = balance

    def withdraw(self, amount):
        """Return the balance remaining after withdrawing *amount*
        dollars."""
        if amount > self.balance:
            raise RuntimeError('Amount greater than available balance.')
        self.balance -= amount
        return self.balance

    def deposit(self, amount):
        """Return the balance remaining after depositing *amount*
        dollars."""
        self.balance += amount
        return self.balance
[44]:
# Create a new instance
bob = Customer('Bob Smith', 1000)
[45]:
bob.withdraw(100)
[45]:
900
[46]:
bob.deposit(400)
[46]:
1300
[47]:
# Based on the exception in the 'withdraw' method
bob.withdraw(1600)
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\323204834.py in <module>
      1 # Based on the exception in the 'withdraw' method
----> 2 bob.withdraw(1600)

~\AppData\Local\Temp\ipykernel_32936\2930014863.py in withdraw(self, amount)
     18         dollars."""
     19         if amount > self.balance:
---> 20             raise RuntimeError('Amount greater than available balance.')
     21         self.balance -= amount
     22         return self.balance

RuntimeError: Amount greater than available balance.

Polymorphism in Classes

We learned about polymorphism in functions, and we saw that when functions take in different arguments, the actions depend on the type of objects. Similarly, in Python polymorphism exists with classes, where different classes can share the same method name, and these methods can perform different actions based on the object they act upon.

Let’s see an example.

[48]:
class Dog:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return self.name+' says Woof!'

class Cat:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return self.name+' says Meow!'

niko = Dog('Niko')
felix = Cat('Felix')

print(niko.speak())
print(felix.speak())
Niko says Woof!
Felix says Meow!

Here we have a Dog class and a Cat class, and each has a speak() method. When called, each object’s speak() method returns a result that is unique to the class of the instance. This demonstrated polymorphism, because we passed in different object types to the speak() method, and we obtained object-specific results from the same method.

Naming Conventions in Classes

The recommended naming convention for Python classes is to use capitalized names, and for longer names each word is capitalized and connected without underscores. For example, examples of classes in the machine learning library Keras include Conv2DTranspose, CheckpointCallback, BatchNormalization, etc.

Another naming convention is to include a leading underscore in the names of attributes and methods (e.g., _radius, _calculate_area() in the class Circle) to communicate them as non-public attributes and methods. All regular names (such as radius and calculate_area()) are public attributes and methods.

Public members are intended to be part of the official interface or API of the classes, while non-public members are not intended to be part of the API. This naming convention indicates that the non-public members should not be used outside their defining class. However, the naming convention does not prevent direct access. Non-public members exist only to support the internal implementation of a given class and may be removed at any time, so we should not rely on them.

5.3 Inheritance

For our programs to be truly object-oriented, it is required that they use inheritance hierarchy. Inheritance is the process of creating a new class by reusing the attributes and methods from an existing class. This way, we can edit only what we need to modify in the new class, and this will override the behavior of the old class.

The newly formed inheriting class is known as a subclass or child class or derived class, and the class it inherits from is known as a superclass or parent class or base class.

Important benefits of inheritance are code reuse and reduction of complexity of a program, because the child classes override or extend the functionality of parent classes.

Let’s see an example by incorporating inheritance. In this example, we have four classes: Animal, Dog, Cat, and Fish. The Animal is the parent class (superclass), and Dog, Cat, and Fish are the child classes (subclasses).

Note that when defining the child classes Dog, Cat, and Fish, the parent class Animal is listed in parentheses in the class header, i.e., class Dog(Animal).

[49]:
class Animal:
    def __init__(self):
        print("Animal created")

    def whoAmI(self):
        print("Animal")

    def eat(self):
        print("Eating")


class Dog(Animal): # The class Dog inherits the functionalities of the class Animal
    def __init__(self):
        Animal.__init__(self)   # This can also be replaced with: super().__init__()
        print("Dog created")

    def whoAmI(self):
        print("Dog")

    def bark(self):
        print("Woof!")


class Cat(Animal): # The class Cat inherits the functionalities of the class Animal
    def __init__(self):
        # The line Animal.__init__(self) is missing in the Cat class
        print("Cat created")

    def whoAmI(self):
        print("Cat")


class Fish(Animal): # The class Fish inherits the functionalities of the class Animal
    # attributes are not specified

    def whoAmI(self):
        print("Fish")

Let’s create instances of the child classes.

[50]:
d = Dog()
Animal created
Dog created
[51]:
# Note the difference in the attributes in comparison to Dog
c = Cat()
Cat created
[52]:
# Note the difference in the attributes in comparison to Cat
f = Fish()
Animal created

Parent classes typically provide generic and common functionality that we can reuse throughout multiple child classes. In this sense, the Animal class provides properties that are common for most other animals.

Child classes inherit attributes and methods from the parent class. For instance, notice that if call the eat() method with the class instances of Dog and Fish, the word Eating is printed. Although the method eat() is not defined in the classes Dog and Fish, the instances inherit the method from the parent class Animal.

[53]:
d.eat()
Eating
[54]:
f.eat()
Eating

The child classes not only inherit attributes and methods from the parent class, but they can also modify attributes and methods existing in the parent class. This is shown by the method whoAmI(). When this method is called, Python searches for the name first in the child class, and if it is not found, afterwards it searches in the parent class. In this case, whoAmI() method is found in the child classes Dog, Cat, and Fish.

[55]:
d.whoAmI()
Dog
[56]:
c.whoAmI()
Cat

Finally, the child class Dog extends the functionality of the parent class by defining a new bark() method that does not exist in the Animal class.

[57]:
d.bark()
Woof!

Similar to inheritance in nature, only child classes inherit from the parent class, and the parent class does not inherit attributes and methods from the child classes.

One more example follows, where Person is a parent class, and Manager is a child class of Person and inherits attributes and methods.

[58]:
class Person:
    def __init__(self, name, job=None, pay=0):
        self.name = name
        self.job = job
        self.pay = pay

    def giveRaise(self, percent):
        self.pay = int(self.pay * (1 + percent))

class Manager(Person):
    def giveRaise(self, percent, bonus=.10):
        self.pay = int(self.pay * (1 + percent + bonus))
[59]:
# Create a new instance of Person
bob = Person('Bob Smith', pay=50000)
bob.giveRaise(percent=0.1)  # 50000 * (1+ 0.1) = 50000 * 1.1 = 55000
bob.pay
[59]:
55000
[60]:
# Create a new instance of Manager
tom = Manager('Tom Jones', 'mgr', 50000)
print(tom.name, tom.job, tom.pay)
Tom Jones mgr 50000
[61]:
# On a salary of 50,000, giveRaise for Person applied 10% raise, and giveRaise for Manager applied 10% bonus
tom.giveRaise(percent=0.1, bonus=0.1)   # 50000 * (1+ 0.1 + 0.1) = 50000 * 1.2 = 60000
tom.pay
[61]:
60000

Another way to define the method giveRaise for the Manager child class is by using the syntax below superclass.method(self, arguments), as in Person.giveRaise(self, percent + bonus) shown below. Note that this is different from the syntax above self.method(arguments) as in self.pay = int(self.pay * (1 + percent + bonus)). However, this coding approach using self.method(arguments) does not rely on the superclass Person, and if Person is changed, the code will not work as expected. Therefore, it is preferred to use the syntax superclass.method(self, arguments).

[62]:
class Manager(Person):
    def giveRaise(self, percent, bonus=.10):
        Person.giveRaise(self, percent + bonus)
[63]:
tom = Manager('Tom Jones', 'mgr', 50000)
# On a salary of 50,000, giveRaise for Person applied 10% raise, and giveRaise for Manager applied 10% bonus
tom.giveRaise(percent=0.1, bonus=0.1)   # Person.giveRaise(.10+0.10) = Person.giveRaise(0.20)   # 50000 * 1.2 = 60000
tom.pay
[63]:
60000

The super() function

The super() function in Python returns a temporary object of the parent class that then allows to call methods from the parent class in child classes. This allows to define new methods in the child class with minimal code changes.

For instance, in the example below, a parent class Rectangle is defined, and a child class Cube is created that inherits from Rectangle. To calculate the volume of a Cube, the child class Cube inherited the method area() from the class Rectangle via super().area(). Since the method volume() for a cube relies on calculating the area of a single face, rather than reimplementing the area calculation, we use the function super() to extend the area calculation. The function super() returns an object of the superclass, and allows to call the method volume() directly through super().area().

[64]:
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

    def perimeter(self):
        return 2 * self.length + 2 * self.width

class Cube(Rectangle):
    def __init__(self, length, width, height):
        self.length = length
        self.width = width
        self.height = height

    def volume(self):
        face_area = super().area()
        return face_area * self.height
[65]:
cube1 = Cube(4, 4, 2)
cube1.volume()
[65]:
32

One more example is provided next, with a parent class Person and child class Student. Note that in the definition of the Student class, we called the __init__() function from the superclass to initialize the attributes student_name, student_age, and student_residence. The call to the parent class super().__init__(student_name, student_age, student_residence) is equivalent to calling the function as Person.__init__(self, student_name, student_age, student_residence).

[66]:
class Person:

    def __init__(self, name, age, residence):
        self.name = name
        self.age = age
        self.residence = residence

    def show_name(self):
        print(self.name)

    def show_age(self):
        print(self.age)

class Student(Person):

    def __init__(self, student_name, student_age, student_residence, student_id):
        super().__init__(student_name, student_age, student_residence)
        self.studentId = student_id

    def show_id(self):
        print(self.studentId)
[67]:
# Create an object of the child class
student1 = Student("Max", 22, "Moscow", "100022")
student1.show_name()
Max
[68]:
student1.show_id()
100022

Class Hierarchies

Using inheritance, we can build class hierarchies, also known as inheritance trees. A class hierarchy is a set of closely related classes that are connected through inheritance and arranged in a tree-like structure. The class or classes at the top of the hierarchy are the parent classes, while the classes below are derived classes or child classes.

Therefore, classes at the top of the hierarchy are generic classes with common functionality, while classes down the hierarchy are more specialized and they inherit attributes and methods from their parent classes and also have their own attributes and methods.

Let’s revisit again the example with the animals, where the following tree hierarchy will be created.

bf04a2873b464a8ab14e3b57b97dbca1 Figure source: Reference [3].

In this hierarchy, a parent class Animal is at the top. Below this class, we have subclasses like Mammal, Bird, and Fish, which inherit the attributes and methods from the class Animal. At the bottom level, we can have classes like Dog, Cat, Eagle, Penguin, Salmon, and Shark. E.g., Dog and Cat are both mammals and animals, and they inherit from both of these superclasses and have their own attributes and methods.

[69]:
class Animal:
    def __init__(self, name, sex, habitat):
        self.name = name
        self.sex = sex
        self.habitat = habitat

class Mammal(Animal):
    unique_feature = "Mammary glands"

class Bird(Animal):
    unique_feature = "Feathers"

class Fish(Animal):
    unique_feature = "Gills"

class Dog(Mammal):
    def walk(self):
        print("The dog is walking")

class Cat(Mammal):
    def walk(self):
        print("The cat is walking")

class Eagle(Bird):
    def fly(self):
        print("The eagle is flying")

class Penguin(Bird):
    def swim(self):
        print("The penguin is swimming")

class Salmon(Fish):
    def swim(self):
        print("The salmon is swimming")

class Shark(Fish):
    def swim(self):
        print("The shark is swimming")
[70]:
d = Dog('Fido', 'M', 'Europe')
[71]:
d.unique_feature
[71]:
'Mammary glands'
[72]:
d.walk()
The dog is walking

5.4 Special Methods

Python has many other built-in methods which can be used with user-defined classes. These methods are also known as special methods or magic methods. Similar to the __init__() method, all special methods have leading and trailing double underscores (also called dunders).

For instance, the list of special methods for mathematical operators in Python involve the following.

a + b       a.__add__(b)
a - b       a.__sub__(b)
a * b       a.__mul__(b)
a / b       a.__truediv__(b)
a // b      a.__floordiv__(b)
a % b       a.__mod__(b)
a << b      a.__lshift__(b)
a >> b      a.__rshift__(b)
a & b       a.__and__(b)
a | b       a.__or__(b)
a ^ b       a.__xor__(b)
a ** b      a.__pow__(b)
-a          a.__neg__()
~a          a.__invert__()
abs(a)      a.__abs__()

Special methods for item access in sequences involve the following.

len(x)      x.__len__()
x[a]        x.__getitem__(a)
x[a] = v    x.__setitem__(a,v)
del x[a]    x.__delitem__(a)

Other type of special methods are used for access to object attributes. These include:

x.a         x.__getattr__(a)
x.a = v     x.__setattr__(a,v)
del x.a     x.__delattr__(a)

And there are other types of special methods that are not listed above.

The use of special methods with user-defined classes is also called operator overloading in OOP, because these methods allow the new instances of our user-defined classes to exhibit the behaviors of the applied special methods. For example, the operator + is implemented using the special method __add__() and it can perform addition of numbers, concatenation of strings, etc. Operator overloading in Python is an example of polymorphism in Python. Note that the term polymorphism is more general, and it describes actions performed upon different objects in a different way based on the object, as we saw in the example from the previous section.

By implementing special methods into our user-defined classes, our classes can behave like built-in Python types.

Sequence Length with __len__()

We can implement the special method __len__() in our custom classes, which will allow us to use len() with the instances of the class.

In the example below, we used the method __len__() in the class Employee, which returns the length of the attribute self.pay.

[73]:
class Employee:

    def __init__(self, name, pay):
        self.name = name
        self.pay = pay

    def __len__(self):
        return len(self.pay)
[74]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[75]:
# Length of the bob.pay object
len(bob.pay)
[75]:
4
[76]:
sue = Employee(name='Sue Jones', pay=[50000, 60000])
print(sue.name, sue.pay)
Sue Jones [50000, 60000]
[77]:
len(sue.pay)
[77]:
2

See the Appendix for additional information about special methods for classes in Python.

5.5 When to Use Classes

Classes allow to leverage the power of Python while writing and organizing code. The benefits of using classes include:

  • Reuse code and avoid repetition: we can define hierarchies of related classes, where the parent classes at the top of a hierarchy provide common functionality that we can reuse later in the child classes down the hierarchy. This allows to reuse code and reduces code duplication.

  • Group related data and behaviors in a single entity: classes allow to group together related attributes and methods in a single entity. This helps you better organize code using modular entities that can be reused across multiple projects.

  • Abstract away the implementation details of concepts and objects: classes allow to abstract away the implementation details of core concepts and objects. This helps provide the users with intuitive interfaces to process complex data and behaviors.

In conclusion, Python classes can help write more organized, structured, maintainable, reusable, flexible, and user-friendly code. appears, then go for it.

On the other hand, we should not use classes for everything in Python, since in some situations, they can overcomplicate our solutions. Sometimes, writing a couple of functions are enough for solving a problem.

For example, we don’t need to use classes when we need to:

  • Store only data: if there are no any methods inside the body of a class, we can use a dictionary or a named tuple instead.

  • Provide a single method: if a class has only one method, it would be better to use a function instead.

  • When a fucntionality is available through built-in types or third-party classes: in that case, we should avoid creating custom classes.

Also, there are other situations where we may not need to use classes, such as: in short and simple programs with simple logic and data structures, in performance-critical programs where classes may slow down the performance, when working in a team with a coding style that doesn’t rely on classes, etc.

Therefore, although classes provide many benefits, they don’t need to be used in every situations. Often, it is preferred to begin with a simple but working code, and if there is a need to use classes, then go for it.

Appendix: Additional OOP Info

The material in the Appendix is not required for quizzes and assignments.

Static Methods and Class Methods

In Section 5.2 above we studied instance methods, and we explained that they are applied to class instances through the use of the keyword self.

In Python there are also static methods and class methods, that are defined inside a class and are not connected to a particular instance of that class. These methods are created with the built-in decorators @staticmethod and @classmethod.

The following code shows the difference in the syntax between instance method, @classmethod and @staticmethod.

[78]:
class MyClass:
    def instance_method(self, arg1, arg2, argN):
        return 'instance method called', self

    @classmethod
    def classmethod(cls, arg1, arg2, argN):
        return 'class method called', cls

    @staticmethod
    def staticmethod(arg1, arg2, argN):
        return 'static method called'

Static Methods with @staticmethod

In Python and other programming languages, a static method is a method that does not require the creation of an instance of a class. For Python, it means that the first argument of a static method is not self, but a regular positional or keyword argument. Also, a static method can have no arguments at all, as in the following example.

In general, static methods are used to create helper functions that have a logical connection with the class but do not have access to the attributes or methods of the class, or to the class instances.

[79]:
class Cellphone:
    def __init__(self, brand, number):
        self.brand = brand
        self.number = number

    def get_number(self):
        return self.number

    @staticmethod
    def get_emergency_number():
        return "911"
[80]:
Cellphone.get_emergency_number()
[80]:
'911'

In this example, get_number() is a regular instance method of the class and requires the creation of an instance. The method get_emergency_number() is a static method because it is decorated with the @staticmethod decorator. Also note that get_emergency_number() does not have self as the first argument, which means that it does not require the creation of an instance of the Cellphone class.

Again, get_emergency_number() can just work as a standalone function, and it does not need to be defined as a static method. However, it makes sense and is intuitive to put it in the Cellphone class because a cellphone should be able to provide the emergency number.

Here is one more example of using a static method. The method is_full_name() just checks whether the entered name for a student consists of more than one string.

[81]:
class Student():

    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @staticmethod
    def is_full_name(name_str):
        names = name_str.split(' ')
        return len(names) > 1
[82]:
scott = Student('Scott',  'Robinson')
[83]:
# call the static method
Student.is_full_name('Scott Robinson')
[83]:
True
[84]:
# call the static method
Student.is_full_name('Scott')
[84]:
False

And one more example of using @staticmethod follows. In order to convert the slash-dates to dash-dates, we used the function toDashDate within the Dates class. It is a static method because it doesn’t need to access any properties of the class Dates through self. It is also possible to create a function toDashDate() outside the class, but since it works for dates, it is logical to keep it inside the Dates class.

[85]:
class Dates:
    def __init__(self, date):
        self.date = date

    def getDate(self):
        return self.date

    @staticmethod
    def toDashDate(slash_date):
        return slash_date.replace("/", "-")
[86]:
date1 = Dates("15/12/2016")
date1.getDate()
[86]:
'15/12/2016'
[87]:
date2 = Dates.toDashDate("15/12/2016")
date2
[87]:
'15-12-2016'

In addition, static methods are used when we don’t want subclasses of a superclass to change or override a specific implementation of a method. Because @staticmethod is ignorant of the class it is attached to, we can use it in subclasses just as it was defined in the superclass.

In the following code, DatesWithSlashes is derived from the superclass Dates. We wouldn’t want the subclass DatesWithSlashes to override the static method toDashDate() because it only has a single use, i.e., change slash-dates to dash-dates. Therefore, we will use the static method to our advantage by overriding getDate() method in the subclass so that it works well with the DatesWithSlashes class.

[88]:
class Dates:
    def __init__(self, date):
        self.date = date

    def getDate(self):
        return self.date

    @staticmethod
    def toDashDate(date):
        return date.replace("/", "-")

class DatesWithSlashes(Dates):
    def getDate(self):
        return Dates.toDashDate(self.date)
[89]:
date1 = Dates("15/12/2016")
date1.getDate()
[89]:
'15/12/2016'
[90]:
date2 = DatesWithSlashes("15/12/2016")
date2.getDate()
[90]:
'15-12-2016'

Class Methods with @classmethod

In Python, a class method is created with the @classmethod decorator and requires the class itself as the first argument, which is written as cls. A class method returns an instance of the class with supplied arguments or adds other additional functionality.

[91]:
class Cellphone:
    def __init__(self, brand, number):
        self.brand = brand
        self.number = number

    def get_number(self):
        return self.number

    @staticmethod
    def get_emergency_number():
        return "911"

    @classmethod
    def iphone(cls, number):
        print("An iPhone is created.")
        return cls("Apple", number)
[92]:
# create an iPhone instance using the class method
iphone = Cellphone.iphone("1112223333")
An iPhone is created.
[93]:
# call the instance method
iphone.get_number()
[93]:
'1112223333'
[94]:
# call the static method
iphone.get_emergency_number()
[94]:
'911'
[95]:
samsung1 = Cellphone('Samsung', '123456789')
[96]:
samsung1.get_number()
[96]:
'123456789'
[97]:
# the 'iphone' method cannot modify the instance 'samsung1'
samsung1.iphone('222222222')
An iPhone is created.
[97]:
<__main__.Cellphone at 0x2c1326f49d0>
[98]:
# the brand atribute of the instance was not modified by the 'iphone' method
# class method cannot modify specific instances
samsung1.brand
[98]:
'Samsung'
[99]:
# the number atribute of the instance was not modified by the 'iphone' method
samsung1.number
[99]:
'123456789'

In this example, iphone() is a class method since it is decorated with the @classmethod decorator and has cls as the first argument. It returns an instance of the Cellphone class with the brand preset to 'Apple'.

Class methods are often used as alternative constructors beside the __init__() constructor method, or as factory methods in order to create instances based on different use cases.

This is shown in the following example. Here, the __init__() constructor method takes two parameters name and age. The class method fromBirthYear() takes class, name, and birthYear, and calculates the current age by subtracting it from the current year. That is, it allows to create instances based on the year of birth, instead of based on the age. The reason for it is because we don’t want the list of arguments in the __init__() method to be lengthy and confusing. Instead, we can use class methods to return a new instance based on different arguments.

Note again that the fromBirthYear() method takes Person class as the first parameter cls, and not an instance of the class Person via self. Also, this method returns cls(name, date.today().year - birthYear), which is equivalent to Person(name, date.today().year - birthYear).

[100]:
from datetime import date

# random Person
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def fromBirthYear(cls, name, birthYear):
        return cls(name, date.today().year - birthYear)

    def display(self):
        print(self.name + "'s age is: " + str(self.age))
[101]:
person1 = Person('Adam', 19)
person1.display()
Adam's age is: 19
[102]:
person2 = Person.fromBirthYear('John',  1985)
person2.display()
John's age is: 38
[103]:
# class method cannot modify specific instances
person1.fromBirthYear('John', 1985)
[103]:
<__main__.Person at 0x2c1326e97c0>
[104]:
person1.name
[104]:
'Adam'

The main difference between a static method and a class method is:

  • Static methods can neither modify the class nor class instances, and they just handle the attributes. They are used to create helper or utility functions. Static methods have a logical connection with the class but do not have access to class or instance states.

  • Class methods can modify the class since its parameter is always the class itself, but they cannot modify class instances. They can be used as factory methods to create new instances based on alternative information about a class.

Abstract Classes

An abstract class is one that never expects to be instantiated. In the next example, we will never instantiate an Animal object, but only Dog and Cat objects will be derived from the class Animal.

Abstract classes allow to create a set of methods that must be created within any subclasses built from the abstract class. An abstract method is a method that has a declaration but does not have an implementation. An example is the speak method in the Animal class. Abstract classes are helpful when designing large functional units and we want to provide a common interface for different implementations of a method.

An abstract class is one that is not expected to be instantiated, and contains one or more abstract methods.

Python supports abstract classes through the abc module, which provides the infrastructure for defining abstract classes. Defining an abstract method is achieved by using the @abstractmethod decorator.

[105]:
from abc import ABC, abstractmethod

# Abstract class
class Animal(ABC):

    def __init__(self, name):
        self.name = name

    @abstractmethod
    def speak(self):              # Abstract method, it is not implemented
        pass

# Subclass of Animal
class Dog(Animal):

    def speak(self):
        return self.name+' says Woof!'

# Subclass of Animal
class Cat(Animal):

    def speak(self):
        return self.name+' says Meow!'

# Create instances
fido = Dog('Fido')
isis = Cat('Isis')

# The method 'speak' has different implementations for the subclasses Dog and Cat
print(fido.speak())
print(isis.speak())
Fido says Woof!
Isis says Meow!

Note that the abstract class Animal cannot be instantiated, because it has only an abstract version of the speak method.

[106]:
a = Animal('fido')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\3885960488.py in <module>
----> 1 a = Animal('fido')

TypeError: Can't instantiate abstract class Animal with abstract method speak

By defining an abstract class, one can define common methods for a set of subclasses. This capability is especially useful in situations where a third-party is going to provide implementations, or it can also help when working in a large team or with a large code-base where maintaining all classes is difficult or not possible.

One more example is shown, where the abstract class Employee has an abstract method get_salary. The subclasses FulltimeEmployee and HourlyEmployee are derived, and they define different get_salary methods for each class. The class Payroll has methods to add an employee and print the name and salary information.

[107]:
from abc import ABC, abstractmethod

# Abstract class Employee
class Employee(ABC):
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @abstractmethod
    def get_salary(self):
        pass

# Subclass
class FulltimeEmployee(Employee):
    def __init__(self, first_name, last_name, salary):
        super().__init__(first_name, last_name)
        self.salary = salary

    def get_salary(self):
        return self.salary

# Subclass
class HourlyEmployee(Employee):
    def __init__(self, first_name, last_name, worked_hours, rate):
        super().__init__(first_name, last_name)
        self.worked_hours = worked_hours
        self.rate = rate

    def get_salary(self):
        return self.worked_hours * self.rate

# A separate class Payroll
class Payroll:
    def __init__(self):
        self.employee_list = []

    def add(self, employee):
        self.employee_list.append(employee)

    def display(self):
        for e in self.employee_list:
            print(f'{e.first_name} {e.last_name} \t ${e.get_salary()}')
[108]:
payroll = Payroll()

payroll.add(FulltimeEmployee('John', 'Doe', 6000))
payroll.add(FulltimeEmployee('Jane', 'Doe', 6500))
payroll.add(HourlyEmployee('Jenifer', 'Smith', 200, 50))
payroll.add(HourlyEmployee('David', 'Wilson', 150, 100))
payroll.add(HourlyEmployee('Kevin', 'Miller', 100, 150))
[109]:
payroll.display()
John Doe         $6000
Jane Doe         $6500
Jenifer Smith    $10000
David Wilson     $15000
Kevin Miller     $15000

Additional Special Methods

Indexing and Slicing with __getitem__() and __setitem__()

Indexing in Python is implemented with the built-in method __getitem__().

[110]:
list1 = [1, 2, 3]
list1[0]
[110]:
1
[111]:
list1.__getitem__(0)
[111]:
1

We can implement the special method __getitem__() in our classes to provide built-in indexing behaviors of Python sequences to our class instances.

In the following code, the index argument is used to specify the elements in self.pay.

[112]:
class Employee:

    def __init__(self, name, pay):
        self.name = name
        self.pay = pay

    def __getitem__(self, index):
        return self.pay[index]
[113]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[114]:
bob[1]
[114]:
55000
[115]:
bob[-1]
[115]:
60000

Interestingly, in addition to indexing, __getitem__() is also used for slicing expressions, as shown below. The Python slice object is used for this purpose to define the starting and stopping index (and optional index step) for extracting the elements from a sequence.

[116]:
list1 = [1, 2, 3, 4, 5]
[117]:
list1[slice(2, 4)]
[117]:
[3, 4]
[118]:
list1[slice(1, -1)]
[118]:
[2, 3, 4]
[119]:
list1[slice(3, None)]
[119]:
[4, 5]

This means that we can use the __getitem__() method within our defined class to perform slicing, if we wanted to, and not only indexing.

[120]:
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[121]:
bob[0:2]
[121]:
[50000, 55000]
[122]:
bob[1:]
[122]:
[55000, 53000, 60000]

On the other hand, if we want to change the value of bob.pay outside of the class, we won’t be able to do that.

[123]:
bob[0] = 25000
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_32936\64624401.py in <module>
----> 1 bob[0] = 25000

TypeError: 'Employee' object does not support item assignment

The special method __setitem__() allows to assign values to sequence objects. In the example below, new value can be assigned to the elements of the instance bob with a user-entered index.

[124]:
class Employee:

    def __init__(self, name, pay):
        self.name = name
        self.pay = pay

    def __setitem__(self, index, value):
        self.pay[index] = value
[125]:
bob = Employee(name='Bob Smith', pay=[50000, 55000, 53000, 60000])
print(bob.name, bob.pay)
Bob Smith [50000, 55000, 53000, 60000]
[126]:
bob[0] = 45000
[127]:
print(bob.name, bob.pay)
Bob Smith [45000, 55000, 53000, 60000]

Printing using __str__() and __repr__()

We know that str() is used to convert an object to a str object. Internally, it is implemented by the __str__() method. Moreover, Python uses __str__() when we call print() to display an object.

Let’s consider again the instance of the Employee class. If we call the instance bob which we created before or if we try to print it, we can see a general Python output that tells us that it is an object created by the Employee class and Python also provides its memory address.

[128]:
bob
[128]:
<__main__.Employee at 0x2c1326f8040>
[129]:
print(bob)
<__main__.Employee object at 0x000002C1326F8040>

We can implement the __str__() method, so that, when the class instance self is printed, we can customize the displayed output. The code below instructs the output to list the employee name and pay. Recall the string formatting methods, which we covered earlier: %s with % (name) formatting, {} with .format(name) formatting, and f strings f with {name} formatting.

[130]:
class Employee:
    def __init__(self, name, pay):
        self.name = name
        self.pay = pay

    def __str__(self):
        return 'Employee name %s and pay %s' % (self.name, self.pay)
        #  return 'Employee name {0} and pay {1}'.format(self.name, self.pay)
        # return f'Employee name {self.name} and pay {self.pay}'
[131]:
bob = Employee(name='Bob Smith', pay=50000)
print(bob.name, bob.pay)
Bob Smith 50000
[132]:
print(bob)
Employee name Bob Smith and pay 50000
[133]:
sue = Employee(name='Sue Jones', pay=60000)
print(sue)
Employee name Sue Jones and pay 60000

If we enter only the instance name without print, we will still obtain the general Python output.

[134]:
sue
[134]:
<__main__.Employee at 0x2c1326f8a00>

Besides the __str__() method, another similar method is __repr__() which stands for representation and it is also used for overloading the print method in Python. It provides another way to customize the printed outputs, and returns what is known as formal string representation. Similarly, the __str__() method is known as informal string representation.

[135]:
class Employee:
    def __init__(self, name, pay):
        self.name = name
        self.pay = pay

    def __repr__(self):
        return '<The name and pay for the employee are {} and {} dollars>'.format(self.name, self.pay)
[136]:
sue = Employee(name='Sue Jones', pay=60000)
print(sue)
<The name and pay for the employee are Sue Jones and 60000 dollars>

Note also in the cell below that the displayed output for sue is the same as for print(sue). I.e., Python uses __repr__() to display the object in the interactive prompt.

[137]:
sue
[137]:
<The name and pay for the employee are Sue Jones and 60000 dollars>

The method __repr__() is more general than _str__() and it applies to nested appearances and a few other additional cases. The __str__() and __repr__() methods are very useful, because when other people are using our codes, they can get a good idea of what an object is by just printing it.

References

  1. Mark Lutz, “Learning Python,” 5th edition, O-Reilly, 2013. ISBN: 978-1-449-35573-9.

  2. Pierian Data Inc., “Complete Python 3 Bootcamp,” codes available at: link.

  3. Leodanis Pozo Ramos, Python Classes: The Power of Object-Oriented Programming, available at: link

  4. Python - Made with ML, Goku Mohandas, codes available at: link.

  5. Jeff Knupp, Improve Your Python: Python Classes and Object Oriented Programming, available at link.

  6. Python Tutorial, Python Abstract Classes, available at link.

  7. Kyle Stratis, Supercharge Your Classes with Python super(), available at link.

BACK TO TOP