8  Object-Oriented Programming

Python is a versatile programming language that supports object-oriented programming (OOP). In Python, everything is an object, and built-in data types are implemented as classes.

8.1 Classes

8.1.1 Built-in Data Types in Python

In Python, every built-in data type is implemented as a class. This includes:

  • int
  • float
  • str
  • list
  • tuple
  • dict
  • set
  • NoneType

You can confirm this by using the type() function or checking an object’s __class__ attribute:

print(type(42))         
print(type(3.14))        
print(type("hello"))     
print(type([1, 2, 3]))   
print(type(None))       
<class 'int'>
<class 'float'>
<class 'str'>
<class 'list'>
<class 'NoneType'>
# Checking the __class__ attribute
x = 42
print(x.__class__)   
<class 'int'>

8.1.2 Understanding Classes in Python

A class is a blueprint for creating objects. It defines the attributes (characteristics) and methods (behaviors) that its objects will have.

For example, consider a Cat class:

  • Attributes: Characteristics shared by all cats, such as breed, fur_color, and age.
  • Methods: Actions that a cat can perform, such as meow(), run(), or sleep().

For more details, refer to the official Python documentation on classes.

8.1.3 Creating your own classes

Until now, we have worked with built-in Python classes like int, list, and dict. However, in many cases, we need to create our own classes to model real-world entities in a structured way.

Defining your own classes provides several key benefits in programming:

  1. Encapsulation – Organize data and related functionality together.
  2. Reusability – Code can be reused by creating multiple instances of the class.
  3. Abstraction – Hide unnecessary details and expose only the required functionality.
  4. Inheritance – Reuse existing class behavior in new classes, avoiding redundancy.

When we create a new class, we actually create a new type. Now, we are going to create our own type, which we can use in a way that is similar to the built-in types.

Let’s start with the Car class:

class Car:
    def __init__(self, brand, color, speed=0):
        self.brand = brand      # Attribute
        self.color = color      # Attribute
        self.speed = speed      # Attribute

    def accelerate(self, increment):
        """Increase the car's speed."""
        self.speed += increment
        return f"{self.brand} is now moving at {self.speed} mph."

    def brake(self, decrement):
        """Decrease the car's speed."""
        self.speed = max(0, self.speed - decrement)
        return f"{self.brand} slowed down to {self.speed} mph."

We’ll use the example above to explain the following terms:

  • The class statement: We use the class statement to create a class. The Python style guide recommends to use CamelCase for class names.

  • The constructor (or the __init__() method): A class typically has a method called __init__. This method is called a constructor and is automatically called when an object or instance of the class is created. The constructor initializes the attributes of the class. In the above example, the constructor accepts Three values as arguments, and initializes its attributes brand and color with those values and have a default value for speed.

  • The self argument: This is the first parameter of instance methods in a class. It represents the instance of the class itself, allowing access to its attributes and methods. When referring to instance attributes or methods within the class, they must be prefixed with self. The purpose of self is to distinguish instance-specific attributes and methods from local variables or other functions in the program.

This example demonstrates how a class encapsulates attributes and behaviors. The Car class defines three attributes: brand, color, and speed, along with a constructor (__init__()) and two methods: accelerate() and brake(). This structure makes it easy to create multiple car objects and manipulate their states independently.

8.2 Objects

In Python, an object is an instance of a class. A class acts as a blueprint, defining the structure and behavior that its objects will have. Each object has its own attributes (data) and can perform methods (functions associated with the class). Compared to the class (which is just a blueprint), an object is a concrete and tangible entity that exists in memory.

In Python, when you create a variable and assign a value to it, Python internally creates an instance of the corresponding class. Every value in Python is an object,

x = 10    # x ia an insance of int class
y = "Hello"     # y is an instance of str class
z = [1, 2, 3]  # z is an instance of list class

print(type(x)) 
print(type(y))  
print(type(z))  
<class 'int'>
<class 'str'>
<class 'list'>

Once we define a class as a blueprint, we can create instances of that class to generate objects of its type. In fact, we can create as many objects as we want from a single class, each with its own unique data while sharing the same structure and behavior defined in the class.

Let’s create two objects of the Car class we defined earlier

To create an object or instance of the class Car, we’ll use the class name with the values to be passed as argument to the constructor for initializing the object / instance.

# Creating objects (instances of the Car class)
car1 = Car("Toyota", "Red")
car2 = Car("Honda", "Blue")

print(type(car1))
print(type(car2))
<class '__main__.Car'>
<class '__main__.Car'>

Instance: An instance is a specific realization of the object of a particular class. Creating an instance of a class is called Instantiation. Here a particular car is an instance of the class Car. Similarly, in the example above, the object x is an instance of the class integer. The words object and instance are often used interchangeably.

The attributes of an instance can be accessed using the . operator with the object name

print(car1.brand)
print(car2.brand)
print(car1.color)
print(car2.color)
print(car1.speed)
print(car2.speed)
Toyota
Honda
Red
Blue
0
0

What happens if the instance variable doesn’t exist?

car1.engine
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[8], line 1
----> 1 car1.engine

AttributeError: 'Car' object has no attribute 'engine'

Methods are functions inside a class that operate on instance attributes. To call a method use:

print(car1.accelerate(20))
print(car2.brake(10))
Toyota is now moving at 20 mph.
Honda slowed down to 0 mph.

Unlike attributes, methods require parentheses () because they need to be executed, just like the functions we learned earlier

A list of all attributes and methods associated with an object can be obtained with the dir() function. Ignore the ones with underscores - these are used by Python itself. The rest of them can be used to perform operations.

dir(car1)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getstate__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'accelerate',
 'brake',
 'brand',
 'color',
 'speed']

Filtering out only user-defined attributes and methods

print([attr for attr in dir(car1) if not attr.startswith('__')])
['accelerate', 'brake', 'brand', 'color', 'speed']

8.2.1 Example: A class that analyzes a string

Let us create a class that analyzes a string.

class AnalyzeString:
    
    #Constructor
    def __init__(self, s):
        s = s.lower()
        self.words = s.split()
    
    #This method counts the numebr of words
    def number_of_words(self):
        return (len(self.words))
    
    #This method counts the number of words starting with the string s
    def starts_with(self,s):
        return len([x for x in self.words if x[:len(s)]==s])
    
    #This method counts the number of words of length n
    def words_with_length(self,n):
        return len([x for x in self.words if len(x)==n])
    
    #This method returns the frequency of the word w
    def word_frequency(self,w):
        return self.words.count(w)

Let us create an instance of the class AnalyzeString() to analyze a sentence.

#Defining a string
sentence = 'This sentence in an example of a string that we will analyse using a class we have defined'
#Creating an instance of class AnalyzeString()
sentence_analysis = AnalyzeString(sentence)
#The attribute 'word' contains the list of words in the sentence
sentence_analysis.words
['this',
 'sentence',
 'in',
 'an',
 'example',
 'of',
 'a',
 'string',
 'that',
 'we',
 'will',
 'analyse',
 'using',
 'a',
 'class',
 'we',
 'have',
 'defined']
#The method 'word_frequncy()' provides the frequency of a word in the sentence
sentence_analysis.word_frequency('we')
2
#The method 'starts_with()' provides the frequency of number of words starting with a particular string
sentence_analysis.starts_with('th')
2

8.2.2 Practice exercise 1

Write a class called PasswordManager. The class should have a list called old_passwords that holds all of the user’s past passwords. The last item of the list is the user’s current password. There should be a method called get_password that returns the current password and a method called set_password that sets the user’s password. The set_password method should only change the password if the attempted password is different from all the user’s past passwords. It should either print ‘Password changed successfully!’, or ‘Old password cannot be reused, try again.’ Finally, create a method called is_correct that receives a string and returns a boolean True or False depending on whether the string is equal to the current password or not.

To initialize the object of the class, use the list below.

After defining the class:

  1. Check the attribute old_passwords

  2. Check the method get_password()

  3. Try re-setting the password to ‘ibiza1972’, and then check the current password.

  4. Try re-setting the password to ‘oktoberfest2022’, and then check the current password.

  5. Check the is_correct() method

class PasswordManager:
    def __init__(self, initial_passwords):
        self.old_passwords = initial_passwords

    def get_password(self):
        """Returns the current password (last item in the old_passwords list)."""
        return self.old_passwords[-1]

    def set_password(self, new_password):
        """Sets a new password only if it has not been used before."""
        if new_password in self.old_passwords:
            print("Old password cannot be reused, try again.")
        else:
            self.old_passwords.append(new_password)
            print("Password changed successfully!")

    def is_correct(self, password):
        """Checks if the provided password matches the current password."""
        return password == self.get_password()


# Initialize PasswordManager with given passwords
initial_passwords = ["alpha123", "beta456", "gamma789", "delta321", "ibiza1972"]
password_manager = PasswordManager(initial_passwords)

# 1. Check the attribute old_passwords
print("Old passwords:", password_manager.old_passwords)

# 2. Check the method get_password()
print("Current password:", password_manager.get_password())

# 3. Try re-setting the password to 'ibiza1972'
password_manager.set_password("ibiza1972")
print("Current password after attempt:", password_manager.get_password())

# 4. Try re-setting the password to 'oktoberfest2022'
password_manager.set_password("oktoberfest2022")
print("Current password after attempt:", password_manager.get_password())

# 5. Check the is_correct() method
print("Is 'oktoberfest2022' correct?", password_manager.is_correct("oktoberfest2022"))
print("Is 'wrongpassword' correct?", password_manager.is_correct("wrongpassword"))
Old passwords: ['alpha123', 'beta456', 'gamma789', 'delta321', 'ibiza1972']
Current password: ibiza1972
Old password cannot be reused, try again.
Current password after attempt: ibiza1972
Password changed successfully!
Current password after attempt: oktoberfest2022
Is 'oktoberfest2022' correct? True
Is 'wrongpassword' correct? False

8.3 Class Constructors

A constructor is a special method in a class that is automatically called when an object is created.

  • In Python, the constructor method is named __init__().
  • It initializes object attributes when an instance is created.
class Car:
    def __init__(self, brand, color, speed=0):
        """Constructor to initialize Car attributes"""
        self.brand = brand
        self.color = color
        self.speed = speed  # Default value is 0

# Creating instances (objects)
car1 = Car("Toyota", "Red", 50)
car2 = Car("Honda", "Blue")  # speed uses default value

# Accessing attributes
print(car1.brand, car1.color, car1.speed)  # Toyota Red 50
print(car2.brand, car2.color, car2.speed)
Toyota Red 50
Honda Blue 0

8.3.1 Default Constructor (No Parameters)

If a class does not explicitly define a constructor, Python automatically provides a default constructor. This constructor only includes self and takes no additional parameters.

Let’s create an empty class to demonstrate this:

# Define a class Circle that doesn't have any attributes or methods
class Circle:
    pass

# Create an instance of the Circle class
c = Circle()
print(type(c))  
<class '__main__.Circle'>

8.3.2 Using Default Values in Constructors

You can set default values for parameters to make them optional.

class Student:
    def __init__(self, name, grade="Not Assigned"):
        self.name = name
        self.grade = grade  # Default: "Not Assigned"

s1 = Student("John", "A")  # Assigned grade
s2 = Student("Emma")       # Uses default grade

print(s1.name, s1.grade)  # John A
print(s2.name, s2.grade)  # Emma Not Assigned
John A
Emma Not Assigned

8.4 Difference Between Instance Attributes and Class Attributes in Python (OOP)

In Python object-oriented programming, attributes can be defined at two levels:

  • Instance Attributes → Specific to each instance of the class.
  • Class Attributes → Shared across all instances of the class.

8.4.1 Instance Attributes (Defined in __init__)

  • Defined inside the constructor (__init__) using self, and accessed using self.attribute_name
  • Each instance has its own copy of instance attributes.
  • Changes to an instance attribute affect only that instance.
# Example: Instance attributes using our Car class

class Car:
    def __init__(self, brand, color):
        self.brand = brand  # Instance attribute
        self.color = color  # Instance attribute

# Creating instances
car1 = Car("Toyota", "Red")
car2 = Car("Honda", "Blue")

# Each instance has different values
print(car1.brand)  # Toyota
print(car2.brand)  # Honda

# Changing an instance attribute only affects that instance
car1.color = "Black"
print(car1.color)  # Black
print(car2.color)
Toyota
Honda
Black
Blue

8.4.2 Class Attributes ( Defined Outside __init__)

  • Defined at the class level (outside __init__).
  • Shared across all instances of the class.
  • Changing a class attribute affects all instances (unless overridden at the instance level).
class Car:
    wheels = 4  # Class attribute (shared by all instances)
    
    def __init__(self, brand):
        self.brand = brand  # Instance attribute

# Creating instances
car1 = Car("Toyota")
car2 = Car("Honda")

# Accessing class attribute
print(car1.wheels)  # 4
print(car2.wheels)  # 4

# Changing the class attribute affects all instances
Car.wheels = 6
print(car1.wheels)  # 6
print(car2.wheels)  # 6
4
4
6
6

Note: Just like attributes, methods can be categorized into instance methods and class methods. So far, everything we have defined are instance methods. Class methods, however, are beyond the scope of this data science course and will not be covered.

8.5 Inheritance in Python

8.5.1 What is Inheritance?

Inheritance is a fundamental concept in Object-Oriented Programming (OOP) that allows a child class to inherit attributes and methods from a parent class. This promotes code reuse and hierarchical structuring of classes.

Here are Key Benefits of Inheritance:

  • Code Reusability – Avoids redundant code by reusing existing functionality.
  • Extensibility – Allows adding new functionality without modifying the original class.
  • Improves Maintainability – Easier to manage and update related classes.

8.5.2 Defining a Parent (Base) and Child (Derived) Class

A child class inherits from a parent class by specifying the parent class name in parentheses.

Example: Basic Inheritance

# Parent Class (Base Class)
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return "Some sound"

# Child Class (Derived Class)
class Dog(Animal):  # Inheriting from Animal
    def speak(self):
        return "Woof!"

# Creating objects
dog1 = Dog("Buddy")

# Accessing inherited attributes and methods
print(dog1.name)      
print(dog1.speak())   
Buddy
Woof!

Explanation:

  • Dog inherits from Animal, meaning it gets all the properties of Animal.
  • The Dog class overrides the speak() method to provide a specialized behavior.

8.5.3 The super() Function

The super() function allows calling methods from the parent class inside the child class.

Example: Using super() to Extend Parent Behavior

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

    def speak(self):
        return "Some sound"

class Cat(Animal):
    def __init__(self, name, color):
        super().__init__(name)  # Calling Parent Constructor
        self.color = color  # Additional attribute in Child Class

    def speak(self):
        return "Meow!"

# Creating an instance
cat1 = Cat("Whiskers", "Gray")

print(cat1.name)   # Whiskers (Inherited from Animal)
print(cat1.color)  # Gray (Defined in Cat)
print(cat1.speak()) # Meow! (Overridden method)
Whiskers
Gray
Meow!
  • super().__init__(name) ensures the parent class constructor is properly called.
  • This allows the child class to initialize both inherited and new attributes.

8.5.4 Method Overriding in Inheritance

  • If a method exists in both the parent and child class, the child class’s method overrides the parent’s method.
  • This is useful for customizing behavior.

Example: Overriding a Method

class Parent:
    def show(self):
        return "This is the Parent class"

class Child(Parent):
    def show(self):  # Overriding method
        return "This is the Child class"

obj = Child()
print(obj.show())  # This is the Child class
This is the Child class

8.5.5 Check Relationship

  • issubclass(Child, Parent) → Checks if a class is a subclass of another.
  • isinstance(object, Class) → Checks if an object is an instance of a class.
class Animal:
    pass

class Dog(Animal):
    pass

dog1 = Dog()

print(issubclass(Dog, Animal))  # True
print(isinstance(dog1, Dog))    # True
print(isinstance(dog1, Animal)) # True (Since Dog inherits from Animal)
True
True
True

8.5.6 The object class in Python

When you define a class in Python without explicitly specifying a parent class, Python automatically makes it inherit from the built-in object class.

Let’s confirm this using the issubclss() method

print(issubclass(Car, object))
True

You can use the __bases__ attribute to check the parent class(es):

print(Car.__bases__) 
(<class 'object'>,)

8.5.6.1 What is the object class?

  • object is the base class for all classes in Python.
  • It provides default methods like:
    • str()
    • repr()
    • eq()
    • init()
    • And more…

Let’s back to the whole list of car1

dir(car1)
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__getstate__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'brand',
 'wheels']

Many special methods and attributes that start with double underscores (__) are inherited from the object class in Python. These are known as dunder (double underscore) methods or magic methods, such as __init__(), __str__(), and __eq__(). These methods are automatically called by Python for specific operations, and users typically do not need to call them directly.

We generally do not recommend modifying these methods unless you are customizing class behavior (e.g., overloading operators). If you need to define private attributes or methods, use a single underscore _ (convention) or double underscore __ (name mangling) to prevent accidental access.

Key Takeaways:

  • Inheritance promotes code reuse and hierarchy in OOP.
  • A child class can inherit and override parent class methods.
  • Use super() to call parent class methods inside the child class.

For more details, refer to the official Python documentation on Inheritance.

8.5.7 Practice exercise 2

Define a class that inherits the in-built Python class list, and adds a new method to the class called nunique() which returns the number of unique elements in the list.

Define the following list as an object of the class you created. Then:

  1. Find the number of unique elements in the object using the method nunique() of the inherited class.

  2. Check if the pop() method of the parent class works to pop an element out of the object.

list_ex = [1,2,5,3,6,5,5,5,12]
class list_v2(list):
    def nuinque(self):
        unique_elements = []
        for x in self:
            if x not in unique_elements:
                unique_elements.append(x)
        return len(unique_elements)
    
list_ex = list_v2(list_ex)
print("Number of unique elements = ", list_ex.nuinque())
print("Checking the pop() method, the popped out element is", list_ex.pop())
Number of unique elements =  6
Checking the pop() method, the popped out element is 12

8.5.8 Practice exercise 3

Define a class named PasswordManagerUpdated that inherits the class PasswordManager defined in Practice exercise 1. The class PasswordManagerUpdated should have two methods, other than the constructor:

  1. The method set_password() that sets a new password. The new password must only be accepted if it does not have any punctuations in it, and if it is not the same as one of the old passwords. If the new password is not acceptable, then one of the appropriate messages should be printed - (a) Cannot have punctuation in password, try again, or (b) Old password cannot be reused, try again.

  2. The method suggest_password() that randomly sets and returns a password as a string comprising of 15 randomly chosen letters. Letters may be repeated as well.

import random
import string

class PasswordManagerUpdated(PasswordManager):
    def __init__(self, initial_passwords):
        super().__init__(initial_passwords)

    def set_password(self, new_password):
        """Sets a new password if it does not contain punctuation and is not a reused password."""
        if any(char in string.punctuation for char in new_password):
            print("Cannot have punctuation in password, try again.")
        elif new_password in self.old_passwords:
            print("Old password cannot be reused, try again.")
        else:
            self.old_passwords.append(new_password)
            print("Password changed successfully!")

    def suggest_password(self):
        """Generates and returns a random 15-character password without punctuation."""
        suggested_password = ''.join(random.choices(string.ascii_letters, k=15))
        self.old_passwords.append(suggested_password)
        return suggested_password

Note that ascii_letters constant in Python is part of the string module and is a predefined string that contains all the lowercase and uppercase ASCII letters. It It contains all lowercase (‘a’ to ‘z’) and uppercase (‘A’ to ‘Z’) ASCII characters in sequence.

import string

# Accessing ascii_letters
s = string.ascii_letters
print(s) 
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ

Let’s use the class below:

# Initialize PasswordManagerUpdated with given passwords
initial_passwords = ["alpha123", "beta456", "gamma789", "delta321", "ibiza1972"]
password_manager_updated = PasswordManagerUpdated(initial_passwords)

# 1. Try setting a password with punctuation
password_manager_updated.set_password("newPass@word!")

# 2. Try setting an already used password
password_manager_updated.set_password("ibiza1972")

# 3. Set a new valid password
password_manager_updated.set_password("securePass123")
print("Current password:", password_manager_updated.get_password())

# 4. Generate and set a suggested password
suggested = password_manager_updated.suggest_password()
print("Suggested password:", suggested)
print("Current password after suggestion:", password_manager_updated.get_password())
Cannot have punctuation in password, try again.
Old password cannot be reused, try again.
Password changed successfully!
Current password: securePass123
Suggested password: xXgtHYeOAPsVvvY
Current password after suggestion: xXgtHYeOAPsVvvY