Chapter 03 Object Oriented Programming and Exceptions in Python
Chapter 03 Object Oriented Programming and Exceptions in Python
Chapter 03:
Object Oriented Programming
1
1st Master - AI & Data Science
Dr. ZERROUGUI S
What is Object-Oriented Programming (OOP)?
Object-Oriented Programming (OOP) is a
programming paradigm that structures a program
by bundling related data and functions together into
objects. OOP is based on four core principles:
Encapsulation, Abstraction, Inheritance, and
Polymorphism. These principles provide a
framework for organizing and designing software in
a modular and reusable way. 2
What is Object-Oriented Programming (OOP)?
Encapsulation: The bundling of data (attributes) and methods (functions) that
operate on that data within a single unit, usually called a class.
Abstraction: Hiding the internal details and showing only essential features of
the object.
Inheritance: The mechanism by which one class can inherit the attributes and
methods of another class.
Polymorphism: The ability to use the same method or function in different
contexts with different types of data.
In OOP, you work with objects, which are instances of classes. A class defines
the blueprint for creating objects, while objects represent concrete instances of
3
that blueprint, each with its own unique data.
Classes and Objects
•A class is like a blueprint for creating objects. It defines a
set of attributes and methods that the objects created from
the class will have.
•An object is an instance of a class. Each object has its
own attributes (data) and methods (behavior).
In this example, the Car class has two attributes (brand, model)
and one method (drive). The objects car1 and car2 are instances of
the Car class with different attribute values. 4
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def drive(self):
print(f"{self.brand} {self.model} is driving.")
# Creating objects
car1 = Car("Toyota", "Corolla")
car2 = Car("Honda", "Civic")
def bark(self):
print(f"{self.name} is barking!")
def start(self):
print(f"The {self.brand} {self.model} is
11
starting.")
Creating and Using Objects
An object is an instance of a class. Once a class is defined, you can create
objects from it, and each object can have its own values for the class's
attributes.
Creating Objects
You create an object by calling the class as if it were a function.
# Creating an object of the class Car
my_car = Car("Toyota", "Corolla")
13
Modifying Object State
You can modify an object’s attributes directly:
my_car.model = "Camry" # Changing the model
my_car.start() # Output: The Toyota Camry is starting.
14
The self Parameter
In Python, the self parameter refers to the current instance of the class. It is
used to access instance attributes and methods from within the class.
Every method in a class must take self as its first parameter (though this is
implicit when you call the method).
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def start(self):
15
print(f"{self.brand} {self.model} is starting.")
The self Parameter
In the example, the self.brand and self.model refer to the specific attributes
of the object that calls the method. When we create a new object like
my_car, self refers to my_car.
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def start(self):
16
print(f"{self.brand} {self.model} is starting.")
Example Without self (Incorrect)
Without self, Python wouldn’t know which object's attributes and methods
to use.
If we omit self, Python will raise an error because it doesn’t understand
which object's attributes are being referred to.
def start():
print(f"{brand} {model} is starting.") # This will
raise an error
In this case, brand and model are undefined unless self is used. 17
Special Methods (Magic Methods)
Python classes include some special methods, also known as magic
methods or dunder methods (short for "double underscore"). These
methods are called automatically by Python in certain situations, such as
when you create an object or use operators.
def __str__(self):
return f"{self.name} is {self.age} years old."
def __repr__(self):
return f"Person(name='{self.name}', age={self.age})"
You can use special methods to define how objects of your class
should behave when used with operators such as +, -, *, ==, etc.
For example, the __add__() method can be used to overload the +
operator.
21
Operator Overloading with Magic Methods
class Vector:
def __init__(self, x, y):
self.x = x In this example, we’ve
self.y = y overloaded the + operator
so that it can add two
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
Vector objects.
def __repr__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
23
In Python, encapsulation is primarily implemented through the use
of access modifiers that define how the attributes and methods of a
class can be accessed. Python provides three types of access
modifiers:
Public (accessible from anywhere)
Private (accessible only within the class)
Protected (accessible within the class and its subclasses)
24
Access Modifiers in Python
Public Attributes and Methods
By default, all attributes and methods in Python are public, meaning they
can be accessed and modified directly from outside the class.
class Car:
def __init__(self, brand, model):
Here, both brand and
self.brand = brand # Public attribute
model are public
self.model = model # Public attribute
attributes and can be
accessed and modified
my_car = Car("Toyota", "Corolla")
from outside the class.
print(my_car.brand) # Output: Toyota
my_car.model = "Camry" # Modify the model directly
print(my_car.model) # Output: Camry 25
Access Modifiers in Python
26
Private Attributes and Methods
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.__balance = balance # Private attribute
class Animal:
def __init__(self, name):
self._name = name # Protected attribute
class Dog(Animal):
def display_name(self):
print(f"The dog's name is {self._name}")
dog = Dog("Buddy")
dog.display_name() # Output: The dog's name is Buddy
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = salary
def get_salary(self):
return self.__salary
With this method, you ensure
def set_salary(self, new_salary):
if new_salary > 0: that salary changes are validated
self.__salary = new_salary before they are applied.
30
Getters and Setters
Using @property and @setter Decorators
In Python, you can use the @property decorator to define getters and
setters in a more Pythonic way.
31
Getters and Setters
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = salary
@property
def salary(self):
return self.__salary # Getter method
@salary.setter
def salary(self, new_salary):
if new_salary > 0:
self.__salary = new_salary # Setter method with validation
Single Inheritance
In single inheritance, a subclass inherits from one parent class.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print(f"{self.name} makes a sound.") dog = Dog("Buddy")
dog.speak() # Output: Buddy makes a
class Dog(Animal): # Dog inherits from Animal sound.
def bark(self): dog.bark() # Output: Buddy barks.
print(f"{self.name} barks.") 33
Multilevel Inheritance
In multilevel inheritance, a class inherits from a parent class, and then another class inherits from that
child class. This creates a chain of inheritance.
class Animal:
def __init__(self, name):
self.name = name
class Animal:
def __init__(self, name):
self.name = name dog = Dog("Buddy")
dog.speak() # Output: Buddy makes a
def speak(self): sound.
print(f"{self.name} makes a sound.") dog.bark() # Output: Buddy barks.
class Canine:
def bark(self):
print("Barking...")
class Pet:
def play(self):
print("Playing...") dog = Dog()
dog.bark() # Output: Barking...
class Dog(Canine, Pet): # Inherits from both dog.play() # Output: Playing...
Canine and Pet dog.wag_tail() # Output: Wagging tail.
def wag_tail(self):
36
print("Wagging tail.")
Method Overriding
Method overriding allows a subclass to provide its own implementation of a
method that is already defined in its parent class. When a method is
overridden in the child class, the new method will be executed instead of the
parent class method for that class’s objects.
class Animal:
def speak(self):
print("The animal makes a sound.") dog = Dog()
dog.speak() # Output: The dog barks.
class Dog(Animal):
def speak(self): # Overriding the parent
class method
print("The dog barks.")
37
Using super() to Call Parent Class Methods
If you want to extend the behavior of the parent class method instead of completely
overriding it, you can use the super() function to call the parent class method inside the
child class method.
class Animal:
def speak(self):
print("The animal makes a sound.")
dog = Dog()
class Dog(Animal): dog.speak()
def speak(self): # Output:
super().speak() # Call the parent class method # The animal makes a sound.
print("The dog barks.") # The dog barks.
In this example, the super().speak() calls the speak() method from the Animal class,
allowing the child class Dog to build upon the parent class’s behavior.
38
The isinstance() and issubclass() Functions
Python provides two built-in functions, isinstance() and issubclass(), that are useful when
working with inheritance.
isinstance()
The isinstance() function checks if an object is an instance of a specific class or a subclass
of that class.
dog = Dog("Buddy")
print(isinstance(dog, Dog)) # Output: True
print(isinstance(dog, Animal)) # Output: True
print(isinstance(dog, Cat)) # Output: False
In this example, dog is an instance of the Dog class, and since Dog inherits from
Animal, dog is also an instance of the Animal class.
39
The isinstance() and issubclass() Functions
issubclass()
The issubclass() function checks if a class is a subclass of another class.
40
Polymorphism
Polymorphism is one of the core principles of Object-Oriented Programming (OOP).
The term "polymorphism" is derived from the Greek words "poly" (many) and "morph"
(form), meaning "many forms." In OOP, polymorphism allows objects of different classes
to be treated as objects of a common superclass. It enables a single interface to represent
different underlying forms (data types).
Polymorphism enhances the flexibility and maintainability of code by allowing the same
method to behave differently based on the object that invokes it. This is particularly
useful when dealing with a hierarchy of classes that share a common interface or
superclass.
41
Polymorphism
Types of Polymorphism in Python
Polymorphism in Python can be broadly categorized into two types:
1. Compile-Time Polymorphism (Static Polymorphism)
2. Run-Time Polymorphism (Dynamic Polymorphism)
However, it's important to note that Python does not support compile-time
polymorphism in the traditional sense (like method overloading found in languages
such as Java or C++). Instead, Python achieves polymorphism primarily through
run-time mechanisms.
42
Polymorphism
Compile-Time Polymorphism (Static Polymorphism)
Method Overloading: In languages like Java and C++, compile-time polymorphism is achieved through
method overloading, where multiple methods can have the same name but different parameters.
Python's Stance: Python does not support method overloading in the traditional sense. If multiple methods
with the same name are defined in a class, the last definition overwrites the previous ones.
Alternative in Python: To achieve similar functionality, Python uses default arguments or variable-length
arguments.
class MathOperations:
def add(self, a, b, c=0):
return a + b + c
math = MathOperations()
print(math.add(2, 3)) # Output: 5
print(math.add(2, 3, 4)) # Output: 9 43
Polymorphism
Compile-Time Polymorphism (Static Polymorphism)
Method Overloading: Python uses default arguments or variable-length arguments
default arguments
class MathOperations:
def add(self, a, b, c=0):
return a + b + c
math = MathOperations()
print(math.add(2, 3)) # Output: 5
print(math.add(2, 3, 4)) # Output: 9
44
Compile-Time Polymorphism (Static Polymorphism)
Method Overloading: Python uses default arguments or variable-length arguments.
Using Variable-Length Arguments
class Calculator:
def multiply(self, *args):
result = 1
for num in args:
result *= num
return result
calc = Calculator()
print(calc.multiply(2, 3)) # Output: 6
print(calc.multiply(2, 3, 4)) # Output: 24
print(calc.multiply(2, 3, 4, 5)) # Output: 120 45
Compile-Time Polymorphism (Static Polymorphism)
Method Overloading: Python uses default arguments or variable-length arguments.
Using Type Checking
class Example:
def process(self, a, b=None):
if b is not None:
print(f"Processing two arguments: {a} and {b}")
else:
print(f"Processing one argument: {a}")
example = Example()
example.process(5) # Output: Processing one argument: 5
example.process(5, 10) # Output: Processing two arguments: 5 and 10
46
Polymorphism
Run-Time Polymorphism (Dynamic Polymorphism)
47
class Shape:
def area(self):
pass # Abstract method
class Rectangle(Shape):
def __init__(self, width, height):
Run-Time Polymorphism (Dynamic self.width = width
self.height = height
Polymorphism)
def area(self):
Method Overriding example return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.1416 * self.radius ** 2
Output
shapes = [Rectangle(3, 4), Circle(5)]
Area: 12
Area: 78.54 for shape in shapes:
print(f"Area: {shape.area()}")
48
Polymorphism The saying goes, "If it walks like a duck and it quacks
Duck Typing like a duck, then it must be a duck."
class Cat:
def speak(self):
return "Meow"
class Dog:
def speak(self):
return "Bark"
def animal_speak(animal):
print(animal.speak())
class Cat(Animal):
def sound(self):
return "Meow"
dog = Dog()
cat = Cat()
51
Abstraction vs. Encapsulation
While both abstraction and encapsulation aim to simplify code and hide
unnecessary details, they are fundamentally different concepts:
Abstraction focuses on hiding the complexity by exposing only the essential
features of an object. It is concerned with the "what" an object does.
Encapsulation is about bundling the data and methods that operate on the
data into a single unit (class) and restricting access to some of the object's
components. It is concerned with the "how" the data is accessed and
modified.
52
MRO (Method Resolution Order) and super() Function
class A:
def say_hello(self):
print("Hello from A")
d = D()
d.say_hello() # Output: Hello from B
print(D.__mro__) # Shows the method resolution order
Hello from B 53
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
E-Library Management System
54
E-Library Management System
•Library:
The Library class should manage a collection of books and support adding,
borrowing, and returning books.
•Books:
Each book should have a title, author, and availability status.
Books can be borrowed if they are available and returned when borrowed.
•Members:
Implement a base class Member to represent general library members.
Create subclasses for different types of members:
StandardMember: Can borrow books but has a borrowing limit.
PremiumMember: Can borrow books without a borrowing limit and receives
recommendations.
•Polymorphism and Method Overriding:
Use polymorphism by defining a borrow() method in each member class, where
each type of member can borrow books according to their specific rules.
55
Errors and Exceptions
56
Error vs Exception
Errors
Errors are issues in code that prevent the program from running correctly. They are usually
syntax or logical mistakes made by the programmer.
Types:
Syntax Errors: Occur when there’s a mistake in the structure or rules of Python code.
These are detected by the Python interpreter before execution.
Logical Errors: Occur when the program runs without crashing, but produces incorrect
results due to faulty logic. These are often harder to detect because the code appears to
function.
def calculate_area(radius):
return 2 * 3.14159 * radius # Incorrect formula for area of a circle
Example of a Logical Error:
print(calculate_area(5)) # Runs but gives wrong result 57
Error vs Exception
Exceptions
Exceptions are issues that occur during the execution of code. They disrupt the normal flow of a program but
can be caught and handled by the program, allowing it to continue running.
Categories of Exceptions:
Built-in Exceptions: Python has numerous built-in exceptions (e.g., ZeroDivisionError, ValueError,
TypeError).
User-defined Exceptions: Programmers can define their own exceptions for custom scenarios by
inheriting from the Exception class.
Exception Handling: Exceptions can be managed using try, except, else, and finally blocks, allowing the
program to handle or recover from errors gracefully.
try:
print(10 / 0)
Example of an Exception: except ZeroDivisionError as e:
print("Error:", e)
58
Error vs Exception
Key Differences
59
Basic Exception Handling with try and except
•Using try and except: Wrap code that may fail in a try block and use except to handle
specific exceptions.
•Multiple Exceptions: Handle different exceptions individually to provide customized
responses.
try:
num = int(input("Enter a number: "))
result = 10 / num
print("Result:", result)
except ValueError:
print("Please enter a valid integer.")
except ZeroDivisionError:
print("Cannot divide by zero.")
60
Using else and finally
• else Block: Executes only if no exceptions are raised in the try block.
• finally Block: Always executes, ideal for cleanup actions (e.g., closing files).
try:
file = open("example.txt", "r")
content = file.read()
except FileNotFoundError:
print("File not found.")
else:
print(content)
finally:
print("Closing file.")
file.close()
61
Raising Exceptions
raise Keyword: Used to trigger exceptions manually, often with custom error
messages.
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero!")
return a / b
try:
print(divide(10, 0))
except ValueError as e:
print("Error:", e)
Exercise:
Create a function that raises a TypeError if input types aren’t as expected (e.g., strings instead of numbers).
62
Custom Exception Classes
Defining Custom Exceptions: Inherit from Exception to create meaningful,
application-specific error messages.
class NegativeNumberError(Exception):
def __init__(self, value):
self.value = value
super().__init__(f"Negative value not allowed: {self.value}")
def square_root(num):
if num < 0:
raise NegativeNumberError(num)
return num ** 0.5
try:
print(square_root(-4))
except NegativeNumberError as e:
print(e)
63
Exercise: Create a custom PermissionError exception that triggers if a user tries to access a restricted area.
Best Practices in Error Handling
Keep try Blocks Small: Only place risky code in try blocks, not the entire
function.
Use Specific Exceptions: Handle specific errors for better debugging and clarity.
Log Errors: Use logging for error messages instead of print statements for
production code.
64
Best Practices in Error Handling
logging
import logging
logging.basicConfig(level=logging.ERROR)
try:
result = 10 / 0
except ZeroDivisionError:
logging.error("Attempted to divide by zero.")
65
Testing Error Scenarios
Assertions: Use assert to ensure assumptions in code, which can simplify
debugging.Testing Frameworks: Write test cases that simulate exceptions
to verify error handling.
try:
print(add_positive_numbers(-1, 5))
except AssertionError as e:
print("AssertionError:", e)
66
The most common exceptions in Python
SyntaxError: Occurs when there’s a syntax error in the code.
print("Hello" # Missing closing parenthesis
ValueError: Raised when a function receives an argument of the right type but inappropriate value.
number = int("abc") # Cannot convert a string that is not a number
NameError: Raised when trying to use a variable or function that has not been defined.
IndexError: Raised when an index is out of range for a sequence (like a list).
my_list = [1, 2, 3]
67
print(my_list[5]) # Index 5 is out of range
The most common exceptions in Python
KeyError: Raised when trying to access a dictionary key that doesn’t exist.
my_dict = {"name": "Alice"}
print(my_dict["age"]) # 'age' key does not exist
AttributeError: Raised when trying to access an attribute or method that doesn’t exist on an object.
my_list = [1, 2, 3]
my_list.append_new(4) # 'append_new' is not a method of list
ZeroDivisionError: Raised when attempting to divide by zero.
result = 10 / 0 # Cannot divide by zero
70