0% found this document useful (0 votes)
1 views19 pages

Python Programming - Comprehensive Guide For Object

This comprehensive guide covers Python programming focusing on Object-Oriented Programming (OOP), Multi-threading, and Exception Handling. It explains key concepts such as classes, methods, inheritance, polymorphism, and data hiding, along with practical examples. Additionally, it discusses multi-threading basics, thread lifecycle, and synchronization techniques using locks and semaphores.

Uploaded by

fvk782qcc
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
1 views19 pages

Python Programming - Comprehensive Guide For Object

This comprehensive guide covers Python programming focusing on Object-Oriented Programming (OOP), Multi-threading, and Exception Handling. It explains key concepts such as classes, methods, inheritance, polymorphism, and data hiding, along with practical examples. Additionally, it discusses multi-threading basics, thread lifecycle, and synchronization techniques using locks and semaphores.

Uploaded by

fvk782qcc
Copyright
© All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

Python Programming: Comprehensive Guide for

Object-Oriented Programming, Threads, and


Exception Handling
Python is a powerful object-oriented programming language that excels at implementing
complex programming paradigms with simple, readable syntax. This comprehensive guide
covers three major areas of Python programming: Object-Oriented Programming (OOP), Multi-
threading, and Exception Handling - all critical topics for intermediate to advanced Python
developers.

Object-Oriented Programming (OOP)


Object-Oriented Programming is a programming paradigm that uses objects and classes to
structure code. Python implements OOP principles in an elegant and flexible way.

Classes and Objects


Classes serve as blueprints for creating objects, defining both attributes (data) and methods
(functions).

class Dog:
# Class attribute (shared by all instances)
species = "Canis familiaris"

# Constructor method
def __init__(self, name, age):
# Instance attributes (unique to each instance)
[Link] = name
[Link] = age

# Instance method
def bark(self):
return f"{[Link]} says Woof!"

# Creating objects (instances)


buddy = Dog("Buddy", 9)
miles = Dog("Miles", 4)

# Accessing attributes and methods


print([Link]) # Output: Buddy
print([Link]) # Output: Canis familiaris
print([Link]()) # Output: Buddy says Woof!

In this example, Dog is a class with both class and instance attributes, while buddy and miles are
objects (instances) of the Dog class [1] .
Attributes and Methods
Python classes can have several types of attributes and methods:

Types of Attributes:
Instance attributes: Unique to each object, defined in __init__
Class attributes: Shared across all instances of a class

Types of Methods:
Instance methods: Take self as first parameter, operate on instance data
Class methods: Take cls as first parameter, operate on class data
Static methods: Don't require access to instance or class, utility functions

class Student:
# Class attribute
school = "Python University"

def __init__(self, name, grade):


# Instance attributes
[Link] = name
[Link] = grade

# Instance method
def get_info(self):
return f"{[Link]} is in grade {[Link]}"

# Class method
@classmethod
def change_school(cls, new_school):
[Link] = new_school
return f"School changed to {[Link]}"

# Static method
@staticmethod
def is_school_day(day):
return [Link]() < 5 # Returns True for Mon-Fri

Access Specifiers
Python uses naming conventions rather than explicit keywords for access control:

class Employee:
def __init__(self, name, salary, pin):
[Link] = name # Public attribute
self._salary = salary # Protected attribute (convention)
self.__pin = pin # Private attribute (name mangling)

def display_public_info(self):
print(f"Name: {[Link]}")
def _protected_method(self):
print(f"Salary: {self._salary}")

def __private_method(self):
print(f"PIN: {self.__pin}")

emp = Employee("Alice", 75000, 1234)


print([Link]) # Accessible: public attribute
print(emp._salary) # Accessible but convention says don't use directly
# print(emp.__pin) # AttributeError - private attribute not directly accessible
print(emp._Employee__pin) # Accessible through name mangling

Public members: Regular names like name


Protected members: Names prefixed with single underscore _salary
Private members: Names prefixed with double underscore __pin
Data hiding in Python is primarily achieved using the double underscore prefix, which triggers
name mangling to make attributes less accessible from outside the class [2] .

Constructors
Constructors initialize object attributes when the object is created. In Python, the __init__
method serves as the constructor:

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

def area(self):
return [Link] * [Link]

def perimeter(self):
return 2 * ([Link] + [Link])

# Using the constructor


rect = Rectangle(5, 3)
print(f"Area: {[Link]()}") # Output: Area: 15
print(f"Perimeter: {[Link]()}") # Output: Perimeter: 16

Python also has other special methods like __str__ that control object behavior:

class Book:
def __init__(self, title, author):
[Link] = title
[Link] = author

def __str__(self):
return f"{[Link]} by {[Link]}"
book = Book("Python Crash Course", "Eric Matthes")
print(book) # Output: Python Crash Course by Eric Matthes

Static Methods
Static methods are utility functions that belong to the class but don't access or modify class or
instance state:

class MathUtil:
@staticmethod
def add(x, y):
return x + y

@staticmethod
def multiply(x, y):
return x * y

@staticmethod
def is_prime(n):
if n <= 1:
return False
if n <= 3:
return True
if n % 2 == 0 or n % 3 == 0:
return False
i = 5
while i * i <= n:
if n % i == 0 or n % (i + 2) == 0:
return False
i += 6
return True

# Using static methods (no instance needed)


print([Link](5, 3)) # Output: 8
print(MathUtil.is_prime(17)) # Output: True

Data Hiding
Data hiding prevents direct access to certain attributes, protecting data integrity. Python
implements this through name mangling with double underscores:

class BankAccount:
def __init__(self, account_number, balance):
self.account_number = account_number # Public
self.__balance = balance # Private

def deposit(self, amount):


if amount > 0:
self.__balance += amount
return f"Deposited ${amount}. New balance: ${self.__balance}"
return "Invalid amount"
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
return f"Withdrew ${amount}. New balance: ${self.__balance}"
return "Insufficient funds or invalid amount"

def get_balance(self):
return f"Current balance: ${self.__balance}"

account = BankAccount("123456789", 1000)


print(account.get_balance()) # Output: Current balance: $1000
print([Link](500)) # Output: Deposited $500. New balance: $1500
# print(account.__balance) # AttributeError - private attribute

The __balance attribute is hidden from direct access, forcing users to interact with it through the
class's public methods, which can implement validation and business logic [2] .

Inheritance
Inheritance allows a class to inherit attributes and methods from another class, facilitating code
reuse:

# Base class
class Animal:
def __init__(self, name, species):
[Link] = name
[Link] = species

def make_sound(self):
return "Some generic animal sound"

def info(self):
return f"{[Link]} is a {[Link]}"

# Derived class
class Dog(Animal):
def __init__(self, name, breed, age):
# Call parent class constructor
super().__init__(name, "Dog")
[Link] = breed
[Link] = age

# Override parent method


def make_sound(self):
return "Woof!"

# Add new method


def fetch(self):
return f"{[Link]} is fetching the ball!"

# Using inheritance
my_dog = Dog("Rex", "German Shepherd", 3)
print(my_dog.info()) # Output: Rex is a Dog
print(my_dog.make_sound()) # Output: Woof!
print(my_dog.fetch()) # Output: Rex is fetching the ball!

Python also supports multiple inheritance, where a class can inherit from multiple parent classes:

class Swimmer:
def swim(self):
return "Swimming"

class Flyer:
def fly(self):
return "Flying"

class Duck(Swimmer, Flyer):


def __init__(self, name):
[Link] = name

def info(self):
return f"{[Link]} can swim and fly"

duck = Duck("Donald")
print([Link]()) # Output: Swimming
print([Link]()) # Output: Flying

Polymorphism
Polymorphism allows methods to behave differently based on the object that calls them:

class Animal:
def __init__(self, name):
[Link] = name

def speak(self):
raise NotImplementedError("Subclasses must implement this")

class Dog(Animal):
def speak(self):
return f"{[Link]} says Woof!"

class Cat(Animal):
def speak(self):
return f"{[Link]} says Meow!"

# Polymorphic function
def animal_sound(animal):
return [Link]()

# Same function, different behavior based on the object


dog = Dog("Buddy")
cat = Cat("Whiskers")
print(animal_sound(dog)) # Output: Buddy says Woof!
print(animal_sound(cat)) # Output: Whiskers says Meow!
# Polymorphism with collections
animals = [Dog("Rex"), Cat("Felix"), Dog("Max")]
for animal in animals:
print([Link]())

Polymorphism makes code more flexible and extensible by allowing operations to work on
objects of different classes [3] .

Operator Overloading
Operator overloading lets classes define custom behavior for Python's standard operators:

class Vector:
def __init__(self, x, y):
self.x = x
self.y = y

def __str__(self):
return f"Vector({self.x}, {self.y})"

# Overload + operator
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)

# Overload - operator
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)

# Overload * operator for scalar multiplication


def __mul__(self, scalar):
if isinstance(scalar, (int, float)):
return Vector(self.x * scalar, self.y * scalar)
raise TypeError("Multiplication only supported with scalar")

# Overload == operator
def __eq__(self, other):
return self.x == other.x and self.y == other.y

v1 = Vector(2, 3)
v2 = Vector(5, 7)
print(v1 + v2) # Output: Vector(7, 10)
print(v1 * 3) # Output: Vector(6, 9)
print(v1 == Vector(2, 3)) # Output: True

Common overloadable operator methods include:


__add__ for +

__sub__ for -

__mul__ for *

__truediv__ for /

__eq__ for ==
__lt__ for <

__len__ for len()

__getitem__ for indexing

Abstract Classes
Abstract classes provide templates for derived classes and cannot be instantiated directly:

from abc import ABC, abstractmethod

class Shape(ABC):
@abstractmethod
def area(self):
pass

@abstractmethod
def perimeter(self):
pass

def description(self):
return "This is a geometric shape"

class Circle(Shape):
def __init__(self, radius):
[Link] = radius

def area(self):
return 3.14159 * [Link] ** 2

def perimeter(self):
return 2 * 3.14159 * [Link]

class Rectangle(Shape):
def __init__(self, length, width):
[Link] = length
[Link] = width

def area(self):
return [Link] * [Link]

def perimeter(self):
return 2 * ([Link] + [Link])

# shape = Shape() # TypeError: Can't instantiate abstract class

circle = Circle(5)
print(f"Circle area: {[Link]()}") # Output: Circle area: 78.53975

Abstract classes enforce that certain methods must be implemented by any derived classes,
ensuring a consistent interface across all subclasses [4] .
Multi-Threading
Multi-threading allows concurrent execution of code, which can improve performance for I/O-
bound tasks or take advantage of multiple CPU cores.

Multi-Threading Basics
Python's threading module provides tools for working with threads:

import threading
import time

def task(name, delay):


print(f"{name} started")
[Link](delay) # Simulate work
print(f"{name} completed")

# Create threads
t1 = [Link](target=task, args=("Task-1", 2))
t2 = [Link](target=task, args=("Task-2", 3))

# Start threads
[Link]()
[Link]()

# Wait for threads to complete


[Link]()
[Link]()

print("All tasks completed")

This example creates two threads that execute the task function concurrently.

Life-Cycle of a Thread
A thread in Python goes through several states during its lifetime:

import threading
import time

def task(name, sleep_time):


print(f"{name} is running")
[Link](sleep_time)
print(f"{name} is finished")

# 1. Thread creation (New state)


thread = [Link](target=task, args=("Thread-1", 3))
print(f"Thread created: {thread.is_alive()}") # False

# 2. Thread started (Runnable state)


[Link]()
print(f"Thread started: {thread.is_alive()}") # True
# 3. Thread running/waiting
[Link](1)
print(f"Thread running: {thread.is_alive()}") # True

# 4. Wait for thread to finish (join puts main thread in waiting state)
[Link]()
print(f"Thread completed: {thread.is_alive()}") # False

The thread lifecycle stages:


1. New: Thread created but not started
2. Runnable: After calling start(), thread is ready to run
3. Running: Thread is executing
4. Blocked/Waiting: Thread waiting for a resource or event
5. Terminated: Thread has completed execution

Synchronization using Locks


Locks prevent multiple threads from accessing shared resources simultaneously, avoiding race
conditions:

import threading

counter = 0
lock = [Link]()

def increment(amount):
global counter
for _ in range(amount):
# Method 1: Explicit lock acquisition
[Link]()
try:
global counter
counter += 1
finally:
[Link]()

def increment_with_context(amount):
global counter
for _ in range(amount):
# Method 2: Using context manager (recommended)
with lock:
counter += 1

# Create and start threads


threads = []
for _ in range(5):
thread = [Link](target=increment_with_context, args=(10000,))
[Link](thread)
[Link]()

# Wait for all threads to complete


for thread in threads:
[Link]()

print(f"Final counter value: {counter}") # Expected: 50000

Without locks, the final counter value might be less than expected due to race conditions. Locks
ensure that only one thread can modify the counter at a time [5] .

Synchronization using Semaphores


Semaphores allow a specified number of threads to access a resource simultaneously:

import threading
import time

# Semaphore limiting to 2 concurrent accesses


semaphore = [Link](2)

def access_resource(thread_id):
print(f"Thread {thread_id} is waiting to access the resource")

with semaphore:
print(f"Thread {thread_id} is now using the resource")
[Link](2) # Simulate resource usage
print(f"Thread {thread_id} is releasing the resource")

# Create and start 5 threads


threads = []
for i in range(5):
thread = [Link](target=access_resource, args=(i,))
[Link](thread)
[Link]()

# Wait for all threads to complete


for thread in threads:
[Link]()

print("All threads have completed")

This example limits resource access to 2 threads at a time using a semaphore. This approach is
useful for managing a pool of limited resources such as database connections [5] .

Exception Handling
Exception handling allows you to gracefully manage errors that occur during program execution.

Exception Class Hierarchy


Python has a comprehensive hierarchy of built-in exceptions, all inheriting from BaseException.
Some common exceptions include:
Exception: Base class for most built-in exceptions
ArithmeticError: Base for math-related errors
ZeroDivisionError: Division or modulo by zero
LookupError: Base for indexing/key errors
IndexError: Sequence index out of range
KeyError: Dictionary key not found
TypeError: Operation on an inappropriate type
ValueError: Operation on valid type but inappropriate value
FileNotFoundError: Requested file not found
PermissionError: No permission to access resource

Try-Except Clause
The basic structure for exception handling uses try-except blocks:

# Basic exception handling


try:
# Code that might raise an exception
num = int(input("Enter a number: "))
result = 10 / num
print(f"Result: {result}")
except ValueError:
# Handle specific exception type
print("Invalid input! Please enter a valid number.")
except ZeroDivisionError:
# Handle another exception type
print("Cannot divide by zero!")

# Handling multiple exceptions with the same handler


try:
value = int(input("Enter a number: "))
print(f"10 / {value} = {10 / value}")
except (ValueError, ZeroDivisionError) as e:
print(f"Error occurred: {e}")

# Catching exceptions by hierarchy


try:
# Some operation that might raise exceptions
num = int("abc")
except Exception as e:
print(f"An error occurred: {type(e).__name__} - {e}")

Each except clause can handle specific types of exceptions, allowing for targeted error
handling [6] .
Try-Except-Else-Finally Clause
Python's exception handling can be extended with else and finally clauses:

try:
num = int(input("Enter a positive number: "))
if num <= 0:
raise ValueError("Number must be positive")
except ValueError as e:
print(f"Invalid input: {e}")
else:
# Executes if no exception occurs in the try block
print(f"You entered {num}")
finally:
# Always executes, regardless of whether an exception occurred
print("Execution complete")

The else clause executes only if no exception was raised in the try block, while the finally
clause always executes, making it ideal for cleanup operations [6] .

User-Defined Exceptions
You can create custom exception classes by inheriting from the Exception class:

# Custom exception class


class InsufficientFundsError(Exception):
"""Raised when a withdrawal would result in a negative balance"""
def __init__(self, balance, amount):
[Link] = balance
[Link] = amount
[Link] = f"Cannot withdraw ${amount} from account with balance ${balance}"
super().__init__([Link])

class BankAccount:
def __init__(self, balance=0):
[Link] = balance

def deposit(self, amount):


if amount <= 0:
raise ValueError("Deposit amount must be positive")
[Link] += amount
return [Link]

def withdraw(self, amount):


if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > [Link]:
raise InsufficientFundsError([Link], amount)
[Link] -= amount
return [Link]

# Using the custom exception


account = BankAccount(100)
try:
[Link](150)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"You need ${[Link] - [Link]} more to make this withdrawal.")

Custom exceptions help make code more readable and maintainable by providing meaningful
error types specific to your application.

Assertions
Assertions test a condition and raise an AssertionError if the condition is false:

def calculate_average(numbers):
# Ensure we have a non-empty list
assert len(numbers) > 0, "Cannot calculate average of empty list"
return sum(numbers) / len(numbers)

# Using assertions
try:
avg1 = calculate_average([1, 2, 3, 4, 5])
print(f"Average: {avg1}") # This works

avg2 = calculate_average([]) # This will raise an AssertionError


except AssertionError as e:
print(f"Assertion failed: {e}")

Assertions are primarily a debugging aid and can be disabled when running Python with the -O
(optimize) flag, so they shouldn't be used for normal error handling that must always be
performed.

Exam Questions with Solutions

Q1: What are the advantages of Tuple over List?


Tuples have several advantages over lists:
1. Immutability: Once created, tuples cannot be modified, making them safer for data that
shouldn't change
2. Performance: Tuples are generally faster than lists for iteration and lookup operations
3. Hashability: Unlike lists, tuples can be used as dictionary keys or in sets
4. Memory efficiency: Tuples typically use less memory than equivalent lists
5. Structural integrity: Tuples can guarantee that a collection maintains a specific structure
and size

# Demonstrating tuple as dictionary key (not possible with lists)


coordinates = {(0, 0): "origin", (1, 0): "unit x", (0, 1): "unit y"}
print(coordinates[(0, 0)]) # Output: origin
# This would cause an error:
# coordinates[[0, 0]] = "origin" # TypeError: unhashable type: 'list'

Q2: WAP that combines lists to a dictionary

def lists_to_dict(keys, values):


"""
Combine two lists into a dictionary where items from the first list
are the keys and items from the second list are the values.
"""
if len(keys) != len(values):
return "Lists must be of equal length"

result = {}
for i in range(len(keys)):
result[keys[i]] = values[i]

return result

# Example usage
names = ["Alice", "Bob", "Charlie"]
scores = [85, 92, 78]

# Method 1: Using the function


students_dict = lists_to_dict(names, scores)
print(students_dict) # {'Alice': 85, 'Bob': 92, 'Charlie': 78}

# Method 2: Using zip function (more concise)


students_dict2 = dict(zip(names, scores))
print(students_dict2) # {'Alice': 85, 'Bob': 92, 'Charlie': 78}

Q3: WAP that has dictionary of names of students and a list of their marks in 4
subjects, create another dictionary from this dictionary that has name of the
students and their total marks. Find out the topper and his/her score.

def find_topper(student_marks):
# Create a dictionary with student names and their total marks
total_marks = {}
for student, marks in student_marks.items():
total_marks[student] = sum(marks)

# Find the topper


topper = max(total_marks, key=total_marks.get)
topper_score = total_marks[topper]

return total_marks, topper, topper_score

# Example data
student_marks = {
"Alice": [85, 90, 92, 88],
"Bob": [76, 82, 79, 84],
"Charlie": [92, 95, 88, 95],
"David": [89, 82, 79, 81]
}

# Calculate totals and find topper


total_marks, topper, topper_score = find_topper(student_marks)

print("Students' total marks:")


for student, total in total_marks.items():
print(f"{student}: {total}")

print(f"\nTopper: {topper} with score: {topper_score}")

Q4: Differentiate between implicit and explicit type conversions


Implicit type conversions (coercions) are automatically performed by Python, while explicit type
conversions require manually calling conversion functions:

# Implicit type conversion


num_int = 10
num_float = 5.5
sum_result = num_int + num_float # int implicitly converted to float
print(f"num_int: {type(num_int)}, num_float: {type(num_float)}, sum_result: {type(sum_res
# Output: num_int: <class 'int'>, num_float: <class 'float'>, sum_result: &lt

# Explicit type conversion


num_str = "42"
num_int = int(num_str) # Explicitly convert string to int
print(f"num_str: {type(num_str)}, num_int: {type(num_int)}")
# Output: num_str: <class 'str'>, num_int: <class 'int'>

float_val = 3.14
int_val = int(float_val) # Explicitly convert float to int (truncates decimal)
print(f"float_val: {float_val}, int_val: {int_val}")
# Output: float_val: 3.14, int_val: 3

# More explicit conversions


str_to_float = float("3.14")
int_to_str = str(42)
bin_to_int = int("1010", 2) # Binary to integer
hex_to_int = int("1A", 16) # Hexadecimal to integer

print(f"str_to_float: {str_to_float}, int_to_str: {int_to_str}")


print(f"bin_to_int: {bin_to_int}, hex_to_int: {hex_to_int}")
# Output: str_to_float: 3.14, int_to_str: 42
# Output: bin_to_int: 10, hex_to_int: 26
Q5: Differentiate between iterator and generators with syntax and example

# Iterator example
class CountUpTo:
def __init__(self, max):
[Link] = max
[Link] = 0

def __iter__(self):
return self

def __next__(self):
if [Link] < [Link]:
[Link] += 1
return [Link]
else:
raise StopIteration

# Using the iterator


counter = CountUpTo(3)
iterator = iter(counter)
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3
# print(next(iterator)) # StopIteration exception

# Generator example (much simpler for the same functionality)


def count_up_to(max):
current = 0
while current < max:
current += 1
yield current

# Using the generator


gen = count_up_to(3)
print(next(gen)) # Output: 1
print(next(gen)) # Output: 2
print(next(gen)) # Output: 3
# print(next(gen)) # StopIteration exception

# Generator expressions (similar to list comprehensions)


squares_gen = (x**2 for x in range(5))
print(list(squares_gen)) # Output: [0, 1, 4, 9, 16]

Key differences:
1. Implementation: Iterators require implementing __iter__ and __next__ methods, while
generators use yield for simpler syntax
2. State management: Generators automatically save state between yields, while iterators
must manually track state
3. Memory efficiency: Generators compute values on-demand, consuming less memory for
large sequences
4. One-time use: Generators are exhausted after one pass, while iterator classes can create
multiple iterator instances

Q6: Write a program to accept string/sentences from the user till the user enters
"END". Save the data in a text file and then display only those sentences which
begin with an uppercase alphabet.

def collect_and_filter_sentences():
# Part 1: Collect sentences from user
sentences = []
print("Enter sentences. Type 'END' to finish:")
while True:
sentence = input("> ")
if sentence == "END":
break
[Link](sentence)

# Part 2: Save to file


with open("[Link]", "w") as file:
for sentence in sentences:
[Link](sentence + "\n")

# Part 3: Read from file and filter sentences


uppercase_sentences = []
with open("[Link]", "r") as file:
for line in file:
line = [Link]()
if line and line[^0].isupper():
uppercase_sentences.append(line)

# Part 4: Display filtered sentences


print("\nSentences starting with uppercase:")
for sentence in uppercase_sentences:
print(sentence)

# Run the program


collect_and_filter_sentences()

Conclusion
This comprehensive guide covers the essential aspects of Object-Oriented Programming, Multi-
threading, and Exception Handling in Python, providing both theoretical explanations and
practical code examples. These concepts form the foundation of intermediate to advanced
Python programming and are essential knowledge for Python developers.
Object-Oriented Programming offers powerful ways to organize and structure code, making it
more maintainable and reusable. Multi-threading enables concurrent execution for improved
performance. Exception handling provides mechanisms to gracefully manage errors that occur
during program execution.
Understanding these concepts thoroughly will significantly improve your Python programming
skills and enable you to develop more robust, efficient, and sophisticated applications. The exam
questions and solutions provided offer practical implementations that demonstrate these
concepts in real-world scenarios.

1. [Link]
2. [Link]
3. [Link]
4. [Link]
5. [Link]
eis4c
6. [Link]

You might also like