Complete Python Programming Guide

1. Features of Python & OOP Concepts Features of Python: Easy to Learn & Read: Simple syntax similar to English Interpreted Language: Executes line by line, no compilation needed Dynamically Typed: No need to declare variable types Object-Oriente...

Ayush Basak
Complete Python Programming Guide

1. Features of Python & OOP Concepts

Features of Python:

  • Easy to Learn & Read: Simple syntax similar to English

  • Interpreted Language: Executes line by line, no compilation needed

  • Dynamically Typed: No need to declare variable types

  • Object-Oriented: Supports OOP concepts

  • Large Standard Library: Built-in modules for various tasks

  • Platform Independent: Works on Windows, Mac, Linux

  • Free & Open Source

OOP Concepts in Python:

Data Types in Python:

1. Numeric Types:

x = 10        # int (integer)
y = 10.5      # float (decimal number)
z = 2 + 3j    # complex number

2. Sequence Types:

my_string = "Hello"           # str (string)
my_list = [1, 2, 3]          # list (mutable, ordered)
my_tuple = (1, 2, 3)         # tuple (immutable, ordered)

3. Set Types:

my_set = {1, 2, 3}           # set (unordered, unique elements)
my_frozenset = frozenset([1, 2, 3])  # immutable set

4. Mapping Type:

my_dict = {"name": "John", "age": 25}  # dictionary (key-value pairs)

5. Boolean Type:

is_active = True             # bool (True or False)

6. None Type:

value = None                 # NoneType (represents absence of value)

2. Functions & Parameters

What is a Function?

A function is a reusable block of code that performs a specific task. It helps avoid code repetition.

def greet():  # Function definition
    print("Hello!")

greet()  # Function call

Formal Parameter vs Actual Parameter:

Formal Parameters: Variables in function definition Actual Parameters: Actual values passed when calling function

def add(a, b):  # a, b are formal parameters
    return a + b

result = add(5, 3)  # 5, 3 are actual parameters (arguments)

Types of Arguments:

1. Positional Arguments:

def person_info(name, age):
    print(f"Name: {name}, Age: {age}")

person_info("Alice", 25)  # Order matters
# Output: Name: Alice, Age: 25

2. Default Arguments:

def greet(name, message="Hello"):  # message has default value
    print(f"{message}, {name}!")

greet("Bob")              # Hello, Bob!
greet("Bob", "Hi")        # Hi, Bob!

3. Keyword Arguments:

def display(name, age, city):
    print(f"{name}, {age}, {city}")

display(age=30, name="John", city="NYC")  # Order doesn't matter

4. Variable Length Arguments:

*Using args (for multiple positional arguments):

def sum_all(*numbers):  # Accepts any number of arguments
    total = 0
    for num in numbers:
        total += num
    return total

print(sum_all(1, 2, 3, 4, 5))  # Output: 15

**Using kwargs (for multiple keyword arguments):

def student_info(**details):  # Accepts any number of key-value pairs
    for key, value in details.items():
        print(f"{key}: {value}")

student_info(name="Alice", age=20, grade="A")
# Output:
# name: Alice
# age: 20
# grade: A

3. Dictionary, Conditional Statements & Loops

Dictionary:

A dictionary stores data in key-value pairs. Keys must be unique and immutable.

Creating a Dictionary:

# Method 1: Using curly braces
student = {"name": "Alice", "age": 20, "grade": "A"}

# Method 2: Using dict() constructor
student = dict(name="Alice", age=20, grade="A")

# Empty dictionary
empty_dict = {}

Dictionary Methods:

student = {"name": "Alice", "age": 20, "grade": "A"}

# Accessing values
print(student["name"])           # Alice
print(student.get("age"))        # 20

# Adding/Updating
student["city"] = "NYC"          # Add new key-value
student["age"] = 21              # Update existing

# Removing items
student.pop("grade")             # Remove specific key
del student["city"]              # Remove using del
student.clear()                  # Remove all items

# Other methods
student = {"name": "Alice", "age": 20}
print(student.keys())            # dict_keys(['name', 'age'])
print(student.values())          # dict_values(['Alice', 20])
print(student.items())           # dict_items([('name', 'Alice'), ('age', 20)])

# Update dictionary
student.update({"grade": "A", "city": "NYC"})

Conditional Statements:

If-Else:

age = 18

if age >= 18:
    print("Adult")
else:
    print("Minor")

Nested If:

marks = 85

if marks >= 50:
    print("Pass")
    if marks >= 75:
        print("Distinction")
    else:
        print("First Class")
else:
    print("Fail")

Elif (else if):

marks = 85

if marks >= 90:
    print("Grade: A+")
elif marks >= 75:
    print("Grade: A")
elif marks >= 60:
    print("Grade: B")
else:
    print("Grade: C")

Loops:

For Loop:

# Iterate through a list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# Using range()
for i in range(5):  # 0 to 4
    print(i)

# For loop with dictionary
student = {"name": "Alice", "age": 20}
for key, value in student.items():
    print(f"{key}: {value}")

For Loop with Else:

for i in range(5):
    print(i)
else:
    print("Loop completed")  # Executes after loop finishes

Break, Continue, Pass:

Break: Exits the loop completely

for i in range(10):
    if i == 5:
        break  # Stop loop when i is 5
    print(i)
# Output: 0, 1, 2, 3, 4

Continue: Skips current iteration, continues with next

for i in range(5):
    if i == 2:
        continue  # Skip when i is 2
    print(i)
# Output: 0, 1, 3, 4

Pass: Does nothing, placeholder

for i in range(5):
    if i == 2:
        pass  # Does nothing
    print(i)
# Output: 0, 1, 2, 3, 4

4. Variable Scope, Modules & OOP Concepts

Local vs Global Variables:

Local Variable: Declared inside a function, accessible only within that function

def my_function():
    x = 10  # Local variable
    print(x)

my_function()  # Output: 10
# print(x)     # Error: x is not defined outside function

Global Variable: Declared outside function, accessible everywhere

x = 10  # Global variable

def my_function():
    print(x)  # Can access global variable

my_function()  # Output: 10
print(x)       # Output: 10

Modifying Global Variable inside Function:

x = 10

def modify():
    global x  # Declare x as global
    x = 20

modify()
print(x)  # Output: 20

Break vs Continue:

BreakContinue
Exits the loop completelySkips current iteration only
Loop terminatesLoop continues with next iteration
Used to stop loop when condition metUsed to skip specific iterations
# Break example
for i in range(10):
    if i == 5:
        break
    print(i)  # Output: 0, 1, 2, 3, 4

# Continue example
for i in range(5):
    if i == 2:
        continue
    print(i)  # Output: 0, 1, 3, 4

Module vs Package:

ModulePackage
A single Python file (.py)A collection of modules
Contains functions, classes, variablesContains multiple modules in a directory
Example: math.pyExample: numpy, pandas

Using a Module:

# math is a built-in module
import math
print(math.sqrt(16))  # Output: 4.0

# Import specific function
from math import sqrt
print(sqrt(25))  # Output: 5.0

Creating Your Own Module:

# File: mymodule.py
def greet(name):
    return f"Hello, {name}!"

# Using the module
import mymodule
print(mymodule.greet("Alice"))

Method Overloading vs Method Overriding:

Method OverloadingMethod Overriding
Multiple methods with same name but different parametersChild class redefines parent class method
Python doesn't support true overloadingPython supports overriding
Uses default arguments as workaroundUses inheritance

Method Overriding Example:

class Animal:
    def sound(self):
        print("Animal makes sound")

class Dog(Animal):
    def sound(self):  # Overriding parent method
        print("Dog barks")

d = Dog()
d.sound()  # Output: Dog barks

Method Overloading Workaround:

class Calculator:
    def add(self, a, b=0, c=0):  # Using default arguments
        return a + b + c

calc = Calculator()
print(calc.add(5))        # Output: 5
print(calc.add(5, 3))     # Output: 8
print(calc.add(5, 3, 2))  # Output: 10

5. Programming Concepts Comparison

Iteration vs Recursion:

IterationRecursion
Repeats using loopsFunction calls itself
Uses for/while loopsUses function calls
Generally fasterCan be slower due to function calls
More memory efficientUses more memory (call stack)

Iteration Example:

def factorial_iterative(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

print(factorial_iterative(5))  # Output: 120

Recursion Example:

def factorial_recursive(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial_recursive(n - 1)

print(factorial_recursive(5))  # Output: 120

Call by Value vs Call by Reference:

Python uses "Call by Object Reference" (similar to call by reference for mutable objects)

# Immutable object (behaves like call by value)
def modify_number(x):
    x = x + 10
    print("Inside function:", x)

num = 5
modify_number(num)
print("Outside function:", num)
# Output:
# Inside function: 15
# Outside function: 5  (unchanged)

# Mutable object (behaves like call by reference)
def modify_list(lst):
    lst.append(4)
    print("Inside function:", lst)

my_list = [1, 2, 3]
modify_list(my_list)
print("Outside function:", my_list)
# Output:
# Inside function: [1, 2, 3, 4]
# Outside function: [1, 2, 3, 4]  (changed)

List vs Tuple:

ListTuple
Mutable (can be changed)Immutable (cannot be changed)
Uses square brackets [ ]Uses parentheses ( )
SlowerFaster
More memoryLess memory
Methods: append, remove, etc.Limited methods
# List
my_list = [1, 2, 3]
my_list[0] = 10  # Allowed
my_list.append(4)  # Allowed
print(my_list)  # Output: [10, 2, 3, 4]

# Tuple
my_tuple = (1, 2, 3)
# my_tuple[0] = 10  # Error: Cannot modify
print(my_tuple)  # Output: (1, 2, 3)

Abstract Class vs Interface:

Abstract ClassInterface
Can have abstract and concrete methodsAll methods are abstract (in pure interface)
Can have instance variablesCannot have instance variables (in pure interface)
Uses ABC modulePython doesn't have true interfaces
Single inheritanceMultiple inheritance possible

Abstract Class Example:

from abc import ABC, abstractmethod

class Vehicle(ABC):  # Abstract class
    def __init__(self, brand):
        self.brand = brand  # Concrete attribute

    @abstractmethod
    def start(self):  # Abstract method
        pass

    def display(self):  # Concrete method
        print(f"Brand: {self.brand}")

class Car(Vehicle):
    def start(self):  # Must implement abstract method
        print("Car started")

c = Car("Toyota")
c.start()    # Car started
c.display()  # Brand: Toyota

Interface-like Structure (using ABC):

from abc import ABC, abstractmethod

class Printable(ABC):  # Interface-like
    @abstractmethod
    def print_document(self):
        pass

class Scannable(ABC):  # Interface-like
    @abstractmethod
    def scan_document(self):
        pass

class Printer(Printable, Scannable):  # Multiple inheritance
    def print_document(self):
        print("Printing...")

    def scan_document(self):
        print("Scanning...")

Mutable vs Immutable Data Types:

MutableImmutable
Can be changed after creationCannot be changed after creation
list, dict, setint, float, str, tuple, frozenset
Modified in placeCreates new object
# Mutable (List)
my_list = [1, 2, 3]
my_list[0] = 10  # Changes original list
print(my_list)  # [10, 2, 3]

# Immutable (Tuple)
my_tuple = (1, 2, 3)
# my_tuple[0] = 10  # Error

# Immutable (String)
text = "Hello"
# text[0] = 'h'  # Error
new_text = text.replace('H', 'h')  # Creates new string
print(text)      # Hello (unchanged)
print(new_text)  # hello (new string)

6. Short Notes on Key Concepts

Data Hiding (Encapsulation):

Restricting access to certain attributes/methods to prevent accidental modification.

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # Private attribute (double underscore)

    def deposit(self, amount):
        self.__balance += amount

    def get_balance(self):
        return self.__balance

account = BankAccount(1000)
# print(account.__balance)  # Error: Cannot access directly
print(account.get_balance())  # 1000 (Access through method)

Polymorphism:

Same method name behaving differently based on context.

Types:

  1. Compile-time (Method Overloading) - Not directly supported in Python

  2. Runtime (Method Overriding) - Supported

# Runtime Polymorphism
class Shape:
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

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

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

# Same method name, different behavior
c = Circle(5)
r = Rectangle(4, 6)
print(c.area())  # 78.5
print(r.area())  # 24

Local, Nonlocal, Global Variables:

x = "global"  # Global variable

def outer():
    x = "enclosing"  # Enclosing variable

    def inner():
        x = "local"  # Local variable
        print("Local:", x)

    inner()
    print("Enclosing:", x)

outer()
print("Global:", x)

# Using nonlocal to modify enclosing variable
def outer():
    x = "enclosing"

    def inner():
        nonlocal x  # Refers to enclosing scope
        x = "modified"

    inner()
    print(x)  # Output: modified

outer()

globals() Function:

Returns a dictionary of global variables.

x = 10
y = 20

print(globals())  # Shows all global variables
print(globals()['x'])  # Access specific global variable: 10

Operators in Python:

1. Arithmetic Operators: +, -, , /, //, %, *

print(10 + 3)   # 13 (Addition)
print(10 - 3)   # 7 (Subtraction)
print(10 * 3)   # 30 (Multiplication)
print(10 / 3)   # 3.333... (Division)
print(10 // 3)  # 3 (Floor division)
print(10 % 3)   # 1 (Modulus)
print(10 ** 3)  # 1000 (Exponent)

2. Comparison Operators: ==, !=, >, <, >=, <=

print(5 == 5)   # True
print(5 != 3)   # True
print(5 > 3)    # True

3. Logical Operators: and, or, not

print(True and False)  # False
print(True or False)   # True
print(not True)        # False

4. Assignment Operators: =, +=, -=, *=, /=

x = 10
x += 5  # x = x + 5
print(x)  # 15

5. Identity Operators: is, is not

a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a is c)      # True (same object)
print(a is b)      # False (different objects)
print(a == b)      # True (same value)

6. Membership Operators: in, not in

print(3 in [1, 2, 3])      # True
print('a' in "apple")      # True

7. Bitwise Operators: &, |, ^, ~, <<, >>

print(5 & 3)   # 1 (AND)
print(5 | 3)   # 7 (OR)
print(5 ^ 3)   # 6 (XOR)

Operator Precedence & Associativity:

Precedence (Highest to Lowest):

  1. ** (Exponent)

  2. *, /, //, % (Multiplication, Division)

  3. +, - (Addition, Subtraction)

  4. <, <=, >, >= (Comparison)

  5. \==, != (Equality)

  6. and, or (Logical)

Associativity:

  • Most operators: Left to Right

  • Exponent (**): Right to Left

# Precedence
result = 2 + 3 * 4  # Multiplication first
print(result)  # 14 (not 20)

# Associativity
result = 2 ** 3 ** 2  # Right to left
print(result)  # 512 (2 ** 9, not 8 ** 2)

Type Conversion:

Implicit Conversion (Automatic):

x = 10      # int
y = 3.5     # float
result = x + y  # int converts to float automatically
print(result)   # 13.5 (float)

Explicit Conversion (Manual):

# String to int
x = int("10")
print(x)  # 10

# Int to string
y = str(100)
print(y)  # "100"

# String to float
z = float("3.14")
print(z)  # 3.14

# List to tuple
my_list = [1, 2, 3]
my_tuple = tuple(my_list)
print(my_tuple)  # (1, 2, 3)

7. Advanced Concepts

Tail Recursion:

Recursion where recursive call is the last operation in the function.

# Non-tail recursion
def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)  # Multiplication after recursive call

# Tail recursion (with helper function)
def factorial_tail(n, accumulator=1):
    if n == 1:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)  # Recursive call is last

print(factorial(5))        # 120
print(factorial_tail(5))   # 120

self Keyword:

Refers to the current instance of the class. Used to access instance variables and methods.

class Person:
    def __init__(self, name, age):
        self.name = name  # self refers to the current object
        self.age = age

    def display(self):
        print(f"Name: {self.name}, Age: {self.age}")

p1 = Person("Alice", 25)
p2 = Person("Bob", 30)
p1.display()  # self = p1
p2.display()  # self = p2

Class Variable vs Instance Variable:

class Employee:
    company = "ABC Corp"  # Class variable (shared by all instances)

    def __init__(self, name, salary):
        self.name = name      # Instance variable (unique to each instance)
        self.salary = salary

e1 = Employee("Alice", 50000)
e2 = Employee("Bob", 60000)

print(e1.company)  # ABC Corp (same for all)
print(e2.company)  # ABC Corp
print(e1.name)     # Alice (different for each)
print(e2.name)     # Bob

# Changing class variable
Employee.company = "XYZ Corp"
print(e1.company)  # XYZ Corp
print(e2.company)  # XYZ Corp

super() Keyword:

Calls methods from parent class.

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

    def speak(self):
        print("Animal speaks")

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call parent constructor
        self.breed = breed

    def speak(self):
        super().speak()  # Call parent method
        print("Dog barks")

d = Dog("Buddy", "Golden Retriever")
d.speak()
# Output:
# Animal speaks
# Dog barks

Static Method:

Method that doesn't require instance or class. Defined using @staticmethod.

class MathOperations:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def multiply(a, b):
        return a * b

# Call without creating instance
print(MathOperations.add(5, 3))       # 8
print(MathOperations.multiply(4, 2))  # 8

Tuple Assignment:

Assigning multiple values at once.

# Basic tuple assignment
x, y, z = 10, 20, 30
print(x, y, z)  # 10 20 30

# Swapping values
a, b = 5, 10
a, b = b, a  # Swap
print(a, b)  # 10 5

# Unpacking
numbers = (1, 2, 3, 4, 5)
first, *middle, last = numbers
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5

Identifier & Token:

Identifier: Names given to variables, functions, classes, etc.

# Valid identifiers
name = "Alice"
age2 = 25
_value = 100

# Invalid identifiers
# 2age = 25  # Cannot start with number
# my-name = "Bob"  # Cannot use hyphen

Rules for Identifiers:

  • Must start with letter (a-z, A-Z) or underscore (_)

  • Can contain letters, digits, underscore

  • Case sensitive (age and Age are different)

  • Cannot be a keyword (if, else, for, etc.)

Token: Smallest unit in a program Types:

  1. Keywords (if, else, for, while, etc.)

  2. Identifiers (variable names, function names)

  3. Literals (10, "hello", 3.14)

  4. Operators (+, -, *, /)

  5. Delimiters ((), [], {}, :, ,)


8. Constructors

What is a Constructor?

Special method that initializes an object when it's created. Always named __init__().

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

p = Person("Alice", 25)  # Constructor called automatically

Features of Constructor:

  1. Automatic Invocation: Called automatically when object is created

  2. Name is Fixed: Always __init__()

  3. Initialize Attributes: Sets initial values for object attributes

  4. No Return Value: Cannot return any value

  5. Can be Overloaded: Using default arguments

Types of Constructors:

1. Default Constructor: Constructor with no parameters (except self).

class Student:
    def __init__(self):  # Default constructor
        self.name = "Unknown"
        self.age = 0
        print("Default constructor called")

s = Student()
print(s.name)  # Unknown
print(s.age)   # 0

2. Parameterized Constructor: Constructor that accepts parameters to initialize object with specific values.

class Student:
    def __init__(self, name, age, grade):  # Parameterized constructor
        self.name = name
        self.age = age
        self.grade = grade
        print("Parameterized constructor called")

    def display(self):
        print(f"Name: {self.name}, Age: {self.age}, Grade: {self.grade}")

# Creating objects with different values
s1 = Student("Alice", 20, "A")
s2 = Student("Bob", 22, "B")

s1.display()  # Name: Alice, Age: 20, Grade: A
s2.display()  # Name: Bob, Age: 22, Grade: B

Parameterized Constructor with Default Values:

class Product:
    def __init__(self, name, price=0, quantity=1):
        self.name = name
        self.price = price
        self.quantity = quantity

    def display(self):
        print(f"{self.name}: ${self.price} x {self.quantity}")

p1 = Product("Laptop", 1000, 2)
p2 = Product("Mouse")  # Uses default price and quantity

p1.display()  # Laptop: $1000 x 2
p2.display()  # Mouse: $0 x 1

append() vs extend():

append()extend()
Adds single elementAdds multiple elements
Adds element as-isIterates and adds each element
Increases length by 1Increases length by number of elements added
# append()
list1 = [1, 2, 3]
list1.append(4)
print(list1)  # [1, 2, 3, 4]

list1.append([5, 6])  # Adds entire list as single element
print(list1)  # [1, 2, 3, 4, [5, 6]]

# extend()
list2 = [1, 2, 3]

Complete Python Programming Guide

1. Features of Python & OOP Concepts

Features of Python:

  • Easy to Learn & Read: Simple syntax similar to English

  • Interpreted Language: Executes line by line, no compilation needed

  • Dynamically Typed: No need to declare variable types

  • Object-Oriented: Supports OOP concepts

  • Large Standard Library: Built-in modules for various tasks

  • Platform Independent: Works on Windows, Mac, Linux

  • Free & Open Source

OOP Concepts in Python:

1. Class & Object:

class Car:
    def __init__(self, brand, model):
        self.brand = brand  # Instance variable
        self.model = model

    def display(self):
        print(f"{self.brand} {self.model}")

# Creating object
my_car = Car("Toyota", "Camry")
my_car.display()  # Output: Toyota Camry

2. Encapsulation (Data Hiding):

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # Private variable (double underscore)

    def get_balance(self):
        return self.__balance

    def deposit(self, amount):
        self.__balance += amount

3. Inheritance:

class Animal:  # Parent class
    def speak(self):
        print("Animal speaks")

class Dog(Animal):  # Child class inherits from Animal
    def bark(self):
        print("Dog barks")

d = Dog()
d.speak()  # Inherited method
d.bark()   # Own method

4. Polymorphism:

class Bird:
    def fly(self):
        print("Bird flies")

class Airplane:
    def fly(self):
        print("Airplane flies")

# Same method name, different behavior
b = Bird()
a = Airplane()
b.fly()  # Bird flies
a.fly()  # Airplane flies

5. Abstraction:

from abc import ABC, abstractmethod

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

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

Data Types in Python:

1. Numeric Types:

x = 10        # int (integer)
y = 10.5      # float (decimal number)
z = 2 + 3j    # complex number

2. Sequence Types:

my_string = "Hello"           # str (string)
my_list = [1, 2, 3]          # list (mutable, ordered)
my_tuple = (1, 2, 3)         # tuple (immutable, ordered)

3. Set Types:

my_set = {1, 2, 3}           # set (unordered, unique elements)
my_frozenset = frozenset([1, 2, 3])  # immutable set

4. Mapping Type:

my_dict = {"name": "John", "age": 25}  # dictionary (key-value pairs)

5. Boolean Type:

is_active = True             # bool (True or False)

6. None Type:

value = None                 # NoneType (represents absence of value)

2. Functions & Parameters

What is a Function?

A function is a reusable block of code that performs a specific task. It helps avoid code repetition.

def greet():  # Function definition
    print("Hello!")

greet()  # Function call

Formal Parameter vs Actual Parameter:

Formal Parameters: Variables in function definition Actual Parameters: Actual values passed when calling function

def add(a, b):  # a, b are formal parameters
    return a + b

result = add(5, 3)  # 5, 3 are actual parameters (arguments)

Types of Arguments:

1. Positional Arguments:

def person_info(name, age):
    print(f"Name: {name}, Age: {age}")

person_info("Alice", 25)  # Order matters
# Output: Name: Alice, Age: 25

2. Default Arguments:

def greet(name, message="Hello"):  # message has default value
    print(f"{message}, {name}!")

greet("Bob")              # Hello, Bob!
greet("Bob", "Hi")        # Hi, Bob!

3. Keyword Arguments:

def display(name, age, city):
    print(f"{name}, {age}, {city}")

display(age=30, name="John", city="NYC")  # Order doesn't matter

4. Variable Length Arguments:

*Using args (for multiple positional arguments):

def sum_all(*numbers):  # Accepts any number of arguments
    total = 0
    for num in numbers:
        total += num
    return total

print(sum_all(1, 2, 3, 4, 5))  # Output: 15

**Using kwargs (for multiple keyword arguments):

def student_info(**details):  # Accepts any number of key-value pairs
    for key, value in details.items():
        print(f"{key}: {value}")

student_info(name="Alice", age=20, grade="A")
# Output:
# name: Alice
# age: 20
# grade: A

3. Dictionary, Conditional Statements & Loops

Dictionary:

A dictionary stores data in key-value pairs. Keys must be unique and immutable.

Creating a Dictionary:

# Method 1: Using curly braces
student = {"name": "Alice", "age": 20, "grade": "A"}

# Method 2: Using dict() constructor
student = dict(name="Alice", age=20, grade="A")

# Empty dictionary
empty_dict = {}

Dictionary Methods:

student = {"name": "Alice", "age": 20, "grade": "A"}

# Accessing values
print(student["name"])           # Alice
print(student.get("age"))        # 20

# Adding/Updating
student["city"] = "NYC"          # Add new key-value
student["age"] = 21              # Update existing

# Removing items
student.pop("grade")             # Remove specific key
del student["city"]              # Remove using del
student.clear()                  # Remove all items

# Other methods
student = {"name": "Alice", "age": 20}
print(student.keys())            # dict_keys(['name', 'age'])
print(student.values())          # dict_values(['Alice', 20])
print(student.items())           # dict_items([('name', 'Alice'), ('age', 20)])

# Update dictionary
student.update({"grade": "A", "city": "NYC"})

Conditional Statements:

If-Else:

age = 18

if age >= 18:
    print("Adult")
else:
    print("Minor")

Nested If:

marks = 85

if marks >= 50:
    print("Pass")
    if marks >= 75:
        print("Distinction")
    else:
        print("First Class")
else:
    print("Fail")

Elif (else if):

marks = 85

if marks >= 90:
    print("Grade: A+")
elif marks >= 75:
    print("Grade: A")
elif marks >= 60:
    print("Grade: B")
else:
    print("Grade: C")

Loops:

For Loop:

# Iterate through a list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(fruit)

# Using range()
for i in range(5):  # 0 to 4
    print(i)

# For loop with dictionary
student = {"name": "Alice", "age": 20}
for key, value in student.items():
    print(f"{key}: {value}")

For Loop with Else:

for i in range(5):
    print(i)
else:
    print("Loop completed")  # Executes after loop finishes

Break, Continue, Pass:

Break: Exits the loop completely

for i in range(10):
    if i == 5:
        break  # Stop loop when i is 5
    print(i)
# Output: 0, 1, 2, 3, 4

Continue: Skips current iteration, continues with next

for i in range(5):
    if i == 2:
        continue  # Skip when i is 2
    print(i)
# Output: 0, 1, 3, 4

Pass: Does nothing, placeholder

for i in range(5):
    if i == 2:
        pass  # Does nothing
    print(i)
# Output: 0, 1, 2, 3, 4

4. Variable Scope, Modules & OOP Concepts

Local vs Global Variables:

Local Variable: Declared inside a function, accessible only within that function

def my_function():
    x = 10  # Local variable
    print(x)

my_function()  # Output: 10
# print(x)     # Error: x is not defined outside function

Global Variable: Declared outside function, accessible everywhere

x = 10  # Global variable

def my_function():
    print(x)  # Can access global variable

my_function()  # Output: 10
print(x)       # Output: 10

Modifying Global Variable inside Function:

x = 10

def modify():
    global x  # Declare x as global
    x = 20

modify()
print(x)  # Output: 20

Break vs Continue:

BreakContinue
Exits the loop completelySkips current iteration only
Loop terminatesLoop continues with next iteration
Used to stop loop when condition metUsed to skip specific iterations
# Break example
for i in range(10):
    if i == 5:
        break
    print(i)  # Output: 0, 1, 2, 3, 4

# Continue example
for i in range(5):
    if i == 2:
        continue
    print(i)  # Output: 0, 1, 3, 4

Module vs Package:

ModulePackage
A single Python file (.py)A collection of modules
Contains functions, classes, variablesContains multiple modules in a directory
Example: math.pyExample: numpy, pandas

Using a Module:

# math is a built-in module
import math
print(math.sqrt(16))  # Output: 4.0

# Import specific function
from math import sqrt
print(sqrt(25))  # Output: 5.0

Creating Your Own Module:

# File: mymodule.py
def greet(name):
    return f"Hello, {name}!"

# Using the module
import mymodule
print(mymodule.greet("Alice"))

Method Overloading vs Method Overriding:

Method OverloadingMethod Overriding
Multiple methods with same name but different parametersChild class redefines parent class method
Python doesn't support true overloadingPython supports overriding
Uses default arguments as workaroundUses inheritance

Method Overriding Example:

class Animal:
    def sound(self):
        print("Animal makes sound")

class Dog(Animal):
    def sound(self):  # Overriding parent method
        print("Dog barks")

d = Dog()
d.sound()  # Output: Dog barks

Method Overloading Workaround:

class Calculator:
    def add(self, a, b=0, c=0):  # Using default arguments
        return a + b + c

calc = Calculator()
print(calc.add(5))        # Output: 5
print(calc.add(5, 3))     # Output: 8
print(calc.add(5, 3, 2))  # Output: 10

5. Programming Concepts Comparison

Iteration vs Recursion:

IterationRecursion
Repeats using loopsFunction calls itself
Uses for/while loopsUses function calls
Generally fasterCan be slower due to function calls
More memory efficientUses more memory (call stack)

Iteration Example:

def factorial_iterative(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

print(factorial_iterative(5))  # Output: 120

Recursion Example:

def factorial_recursive(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial_recursive(n - 1)

print(factorial_recursive(5))  # Output: 120

Call by Value vs Call by Reference:

Python uses "Call by Object Reference" (similar to call by reference for mutable objects)

# Immutable object (behaves like call by value)
def modify_number(x):
    x = x + 10
    print("Inside function:", x)

num = 5
modify_number(num)
print("Outside function:", num)
# Output:
# Inside function: 15
# Outside function: 5  (unchanged)

# Mutable object (behaves like call by reference)
def modify_list(lst):
    lst.append(4)
    print("Inside function:", lst)

my_list = [1, 2, 3]
modify_list(my_list)
print("Outside function:", my_list)
# Output:
# Inside function: [1, 2, 3, 4]
# Outside function: [1, 2, 3, 4]  (changed)

List vs Tuple:

ListTuple
Mutable (can be changed)Immutable (cannot be changed)
Uses square brackets [ ]Uses parentheses ( )
SlowerFaster
More memoryLess memory
Methods: append, remove, etc.Limited methods
# List
my_list = [1, 2, 3]
my_list[0] = 10  # Allowed
my_list.append(4)  # Allowed
print(my_list)  # Output: [10, 2, 3, 4]

# Tuple
my_tuple = (1, 2, 3)
# my_tuple[0] = 10  # Error: Cannot modify
print(my_tuple)  # Output: (1, 2, 3)

Abstract Class vs Interface:

Abstract ClassInterface
Can have abstract and concrete methodsAll methods are abstract (in pure interface)
Can have instance variablesCannot have instance variables (in pure interface)
Uses ABC modulePython doesn't have true interfaces
Single inheritanceMultiple inheritance possible

Abstract Class Example:

from abc import ABC, abstractmethod

class Vehicle(ABC):  # Abstract class
    def __init__(self, brand):
        self.brand = brand  # Concrete attribute

    @abstractmethod
    def start(self):  # Abstract method
        pass

    def display(self):  # Concrete method
        print(f"Brand: {self.brand}")

class Car(Vehicle):
    def start(self):  # Must implement abstract method
        print("Car started")

c = Car("Toyota")
c.start()    # Car started
c.display()  # Brand: Toyota

Interface-like Structure (using ABC):

from abc import ABC, abstractmethod

class Printable(ABC):  # Interface-like
    @abstractmethod
    def print_document(self):
        pass

class Scannable(ABC):  # Interface-like
    @abstractmethod
    def scan_document(self):
        pass

class Printer(Printable, Scannable):  # Multiple inheritance
    def print_document(self):
        print("Printing...")

    def scan_document(self):
        print("Scanning...")

Mutable vs Immutable Data Types:

MutableImmutable
Can be changed after creationCannot be changed after creation
list, dict, setint, float, str, tuple, frozenset
Modified in placeCreates new object
# Mutable (List)
my_list = [1, 2, 3]
my_list[0] = 10  # Changes original list
print(my_list)  # [10, 2, 3]

# Immutable (Tuple)
my_tuple = (1, 2, 3)
# my_tuple[0] = 10  # Error

# Immutable (String)
text = "Hello"
# text[0] = 'h'  # Error
new_text = text.replace('H', 'h')  # Creates new string
print(text)      # Hello (unchanged)
print(new_text)  # hello (new string)

6. Short Notes on Key Concepts

Data Hiding (Encapsulation):

Restricting access to certain attributes/methods to prevent accidental modification.

class BankAccount:
    def __init__(self, balance):
        self.__balance = balance  # Private attribute (double underscore)

    def deposit(self, amount):
        self.__balance += amount

    def get_balance(self):
        return self.__balance

account = BankAccount(1000)
# print(account.__balance)  # Error: Cannot access directly
print(account.get_balance())  # 1000 (Access through method)

Polymorphism:

Same method name behaving differently based on context.

Types:

  1. Compile-time (Method Overloading) - Not directly supported in Python

  2. Runtime (Method Overriding) - Supported

# Runtime Polymorphism
class Shape:
    def area(self):
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14 * self.radius ** 2

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

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

# Same method name, different behavior
c = Circle(5)
r = Rectangle(4, 6)
print(c.area())  # 78.5
print(r.area())  # 24

Local, Nonlocal, Global Variables:

x = "global"  # Global variable

def outer():
    x = "enclosing"  # Enclosing variable

    def inner():
        x = "local"  # Local variable
        print("Local:", x)

    inner()
    print("Enclosing:", x)

outer()
print("Global:", x)

# Using nonlocal to modify enclosing variable
def outer():
    x = "enclosing"

    def inner():
        nonlocal x  # Refers to enclosing scope
        x = "modified"

    inner()
    print(x)  # Output: modified

outer()

globals() Function:

Returns a dictionary of global variables.

x = 10
y = 20

print(globals())  # Shows all global variables
print(globals()['x'])  # Access specific global variable: 10

Operators in Python:

1. Arithmetic Operators: +, -, , /, //, %, *

print(10 + 3)   # 13 (Addition)
print(10 - 3)   # 7 (Subtraction)
print(10 * 3)   # 30 (Multiplication)
print(10 / 3)   # 3.333... (Division)
print(10 // 3)  # 3 (Floor division)
print(10 % 3)   # 1 (Modulus)
print(10 ** 3)  # 1000 (Exponent)

2. Comparison Operators: ==, !=, >, <, >=, <=

print(5 == 5)   # True
print(5 != 3)   # True
print(5 > 3)    # True

3. Logical Operators: and, or, not

print(True and False)  # False
print(True or False)   # True
print(not True)        # False

4. Assignment Operators: =, +=, -=, *=, /=

x = 10
x += 5  # x = x + 5
print(x)  # 15

5. Identity Operators: is, is not

a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a is c)      # True (same object)
print(a is b)      # False (different objects)
print(a == b)      # True (same value)

6. Membership Operators: in, not in

print(3 in [1, 2, 3])      # True
print('a' in "apple")      # True

7. Bitwise Operators: &, |, ^, ~, <<, >>

print(5 & 3)   # 1 (AND)
print(5 | 3)   # 7 (OR)
print(5 ^ 3)   # 6 (XOR)

Operator Precedence & Associativity:

Precedence (Highest to Lowest):

  1. ** (Exponent)

  2. *, /, //, % (Multiplication, Division)

  3. +, - (Addition, Subtraction)

  4. <, <=, >, >= (Comparison)

  5. \==, != (Equality)

  6. and, or (Logical)

Associativity:

  • Most operators: Left to Right

  • Exponent (**): Right to Left

# Precedence
result = 2 + 3 * 4  # Multiplication first
print(result)  # 14 (not 20)

# Associativity
result = 2 ** 3 ** 2  # Right to left
print(result)  # 512 (2 ** 9, not 8 ** 2)

Type Conversion:

Implicit Conversion (Automatic):

x = 10      # int
y = 3.5     # float
result = x + y  # int converts to float automatically
print(result)   # 13.5 (float)

Explicit Conversion (Manual):

# String to int
x = int("10")
print(x)  # 10

# Int to string
y = str(100)
print(y)  # "100"

# String to float
z = float("3.14")
print(z)  # 3.14

# List to tuple
my_list = [1, 2, 3]
my_tuple = tuple(my_list)
print(my_tuple)  # (1, 2, 3)

7. Advanced Concepts

Tail Recursion:

Recursion where recursive call is the last operation in the function.

# Non-tail recursion
def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)  # Multiplication after recursive call

# Tail recursion (with helper function)
def factorial_tail(n, accumulator=1):
    if n == 1:
        return accumulator
    return factorial_tail(n - 1, n * accumulator)  # Recursive call is last

print(factorial(5))        # 120
print(factorial_tail(5))   # 120

self Keyword:

Refers to the current instance of the class. Used to access instance variables and methods.

class Person:
    def __init__(self, name, age):
        self.name = name  # self refers to the current object
        self.age = age

    def display(self):
        print(f"Name: {self.name}, Age: {self.age}")

p1 = Person("Alice", 25)
p2 = Person("Bob", 30)
p1.display()  # self = p1
p2.display()  # self = p2

Class Variable vs Instance Variable:

class Employee:
    company = "ABC Corp"  # Class variable (shared by all instances)

    def __init__(self, name, salary):
        self.name = name      # Instance variable (unique to each instance)
        self.salary = salary

e1 = Employee("Alice", 50000)
e2 = Employee("Bob", 60000)

print(e1.company)  # ABC Corp (same for all)
print(e2.company)  # ABC Corp
print(e1.name)     # Alice (different for each)
print(e2.name)     # Bob

# Changing class variable
Employee.company = "XYZ Corp"
print(e1.company)  # XYZ Corp
print(e2.company)  # XYZ Corp

super() Keyword:

Calls methods from parent class.

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

    def speak(self):
        print("Animal speaks")

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call parent constructor
        self.breed = breed

    def speak(self):
        super().speak()  # Call parent method
        print("Dog barks")

d = Dog("Buddy", "Golden Retriever")
d.speak()
# Output:
# Animal speaks
# Dog barks

Static Method:

Method that doesn't require instance or class. Defined using @staticmethod.

class MathOperations:
    @staticmethod
    def add(a, b):
        return a + b

    @staticmethod
    def multiply(a, b):
        return a * b

# Call without creating instance
print(MathOperations.add(5, 3))       # 8
print(MathOperations.multiply(4, 2))  # 8

Tuple Assignment:

Assigning multiple values at once.

# Basic tuple assignment
x, y, z = 10, 20, 30
print(x, y, z)  # 10 20 30

# Swapping values
a, b = 5, 10
a, b = b, a  # Swap
print(a, b)  # 10 5

# Unpacking
numbers = (1, 2, 3, 4, 5)
first, *middle, last = numbers
print(first)   # 1
print(middle)  # [2, 3, 4]
print(last)    # 5

Identifier & Token:

Identifier: Names given to variables, functions, classes, etc.

# Valid identifiers
name = "Alice"
age2 = 25
_value = 100

# Invalid identifiers
# 2age = 25  # Cannot start with number
# my-name = "Bob"  # Cannot use hyphen

Rules for Identifiers:

  • Must start with letter (a-z, A-Z) or underscore (_)

  • Can contain letters, digits, underscore

  • Case sensitive (age and Age are different)

  • Cannot be a keyword (if, else, for, etc.)

Token: Smallest unit in a program Types:

  1. Keywords (if, else, for, while, etc.)

  2. Identifiers (variable names, function names)

  3. Literals (10, "hello", 3.14)

  4. Operators (+, -, *, /)

  5. Delimiters ((), [], {}, :, ,)


8. Constructors

What is a Constructor?

Special method that initializes an object when it's created. Always named __init__().

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

p = Person("Alice", 25)  # Constructor called automatically

Features of Constructor:

  1. Automatic Invocation: Called automatically when object is created

  2. Name is Fixed: Always __init__()

  3. Initialize Attributes: Sets initial values for object attributes

  4. No Return Value: Cannot return any value

  5. Can be Overloaded: Using default arguments

Types of Constructors:

1. Default Constructor: Constructor with no parameters (except self).

class Student:
    def __init__(self):  # Default constructor
        self.name = "Unknown"
        self.age = 0
        print("Default constructor called")

s = Student()
print(s.name)  # Unknown
print(s.age)   # 0

2. Parameterized Constructor: Constructor that accepts parameters to initialize object with specific values.

class Student:
    def __init__(self, name, age, grade):  # Parameterized constructor
        self.name = name
        self.age = age
        self.grade = grade
        print("Parameterized constructor called")

    def display(self):
        print(f"Name: {self.name}, Age: {self.age}, Grade: {self.grade}")

# Creating objects with different values
s1 = Student("Alice", 20, "A")
s2 = Student("Bob", 22, "B")

s1.display()  # Name: Alice, Age: 20, Grade: A
s2.display()  # Name: Bob, Age: 22, Grade: B

Parameterized Constructor with Default Values:

class Product:
    def __init__(self, name, price=0, quantity=1):
        self.name = name
        self.price = price
        self.quantity = quantity

    def display(self):
        print(f"{self.name}: ${self.price} x {self.quantity}")

p1 = Product("Laptop", 1000, 2)
p2 = Product("Mouse")  # Uses default price and quantity

p1.display()  # Laptop: $1000 x 2
p2.display()  # Mouse: $0 x 1

append() vs extend():

append()extend()
Adds single elementAdds multiple elements
Adds element as-isIterates and adds each element
Increases length by 1Increases length by number of elements added
# append()
list1 = [1, 2, 3]
list1.append(4)
print(list1)  # [1, 2, 3, 4]

list1.append([5, 6])  # Adds entire list as single element
print(list1)  # [1, 2, 3, 4, [5, 6]]

# extend()
list2 = [1, 2, 3]
list2.extend([4, 5])  # Adds each element individually
print(list2)  # [1, 2, 3, 4, 5]

list2.extend("abc")  # Extends with each character
print(list2)  # [1, 2, 3, 4, 5, 'a', 'b', 'c']

9. Runtime and Compile-time Polymorphism in OOP

Compile-time Polymorphism (Method Overloading):

Method name is same but parameters differ. Decision made at compile time.

Note: Python doesn't support true method overloading, but we can simulate it using default arguments or variable-length arguments.

class Calculator:
    # Using default arguments
    def add(self, a, b=0, c=0):
        return a + b + c

calc = Calculator()
print(calc.add(5))          # 5 (compile-time: uses defaults)
print(calc.add(5, 3))       # 8
print(calc.add(5, 3, 2))    # 10

# Using *args (variable-length arguments)
class MathOps:
    def multiply(self, *args):
        result = 1
        for num in args:
            result *= num
        return result

m = MathOps()
print(m.multiply(2))           # 2
print(m.multiply(2, 3))        # 6
print(m.multiply(2, 3, 4))     # 24

Runtime Polymorphism (Method Overriding):

Child class provides specific implementation of parent class method. Decision made at runtime based on object type.

class Animal:
    def sound(self):
        print("Animal makes a sound")

    def move(self):
        print("Animal moves")

class Dog(Animal):
    def sound(self):  # Override parent method
        print("Dog barks: Woof Woof!")

class Cat(Animal):
    def sound(self):  # Override parent method
        print("Cat meows: Meow Meow!")

class Bird(Animal):
    def sound(self):  # Override parent method
        print("Bird chirps: Chirp Chirp!")

    def move(self):  # Override parent method
        print("Bird flies")

# Runtime polymorphism in action
def make_sound(animal):
    animal.sound()  # Which sound? Decided at runtime!

# Create objects
dog = Dog()
cat = Cat()
bird = Bird()

# Same method call, different behavior (decided at runtime)
make_sound(dog)   # Dog barks: Woof Woof!
make_sound(cat)   # Cat meows: Meow Meow!
make_sound(bird)  # Bird chirps: Chirp Chirp!

# Another example
animals = [Dog(), Cat(), Bird()]
for animal in animals:
    animal.sound()  # Runtime decision based on object type
    animal.move()

Practical Example with Shapes:

class Shape:
    def area(self):
        return 0

    def perimeter(self):
        return 0

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):  # Override
        return 3.14 * self.radius ** 2

    def perimeter(self):  # Override
        return 2 * 3.14 * self.radius

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

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

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

class Triangle(Shape):
    def __init__(self, base, height, side1, side2, side3):
        self.base = base
        self.height = height
        self.side1 = side1
        self.side2 = side2
        self.side3 = side3

    def area(self):  # Override
        return 0.5 * self.base * self.height

    def perimeter(self):  # Override
        return self.side1 + self.side2 + self.side3

# Runtime polymorphism
def display_shape_info(shape):
    print(f"Area: {shape.area()}")
    print(f"Perimeter: {shape.perimeter()}")
    print()

# Create different shapes
circle = Circle(5)
rectangle = Rectangle(4, 6)
triangle = Triangle(4, 3, 3, 4, 5)

# Same function, different behavior based on object type
display_shape_info(circle)
display_shape_info(rectangle)
display_shape_info(triangle)

Key Differences:

Compile-time PolymorphismRuntime Polymorphism
Method overloadingMethod overriding
Same classParent-child relationship
Resolved during compilationResolved during execution
FasterSlightly slower
Not truly supported in PythonFully supported in Python

10. Loops, Tuples, Lists with Examples

For Loop:

Used to iterate over a sequence (list, tuple, string, etc.)

# Example 1: Iterating through a list
fruits = ["apple", "banana", "cherry"]
for fruit in fruits:
    print(f"I like {fruit}")

# Output:
# I like apple
# I like banana
# I like cherry

# Example 2: Using range()
print("Numbers from 0 to 4:")
for i in range(5):
    print(i, end=" ")
print()  # 0 1 2 3 4

# Example 3: Range with start and end
print("Numbers from 2 to 7:")
for i in range(2, 8):
    print(i, end=" ")
print()  # 2 3 4 5 6 7

# Example 4: Range with step
print("Even numbers from 0 to 10:")
for i in range(0, 11, 2):
    print(i, end=" ")
print()  # 0 2 4 6 8 10

# Example 5: Iterating through string
word = "Python"
for letter in word:
    print(letter, end="-")
print()  # P-y-t-h-o-n-

While Loop:

Repeats as long as condition is true.

# Example 1: Basic while loop
count = 1
print("Counting from 1 to 5:")
while count <= 5:
    print(count, end=" ")
    count += 1
print()  # 1 2 3 4 5

# Example 2: Sum of numbers
total = 0
number = 1
while number <= 10:
    total += number
    number += 1
print(f"Sum of 1 to 10: {total}")  # 55

# Example 3: User input (infinite loop with break)
# while True:
#     password = input("Enter password: ")
#     if password == "secret":
#         print("Access granted!")
#         break
#     else:
#         print("Wrong password, try again!")

# Example 4: While loop with else
counter = 0
while counter < 3:
    print(f"Counter: {counter}")
    counter += 1
else:
    print("Loop completed successfully!")

Nested Loop:

Loop inside another loop.

# Example 1: Multiplication table
print("Multiplication Table:")
for i in range(1, 4):  # Outer loop
    for j in range(1, 4):  # Inner loop
        print(f"{i} x {j} = {i*j}", end="  ")
    print()  # New line after each row

# Output:
# 1 x 1 = 1  1 x 2 = 2  1 x 3 = 3  
# 2 x 1 = 2  2 x 2 = 4  2 x 3 = 6  
# 3 x 1 = 3  3 x 2 = 6  3 x 3 = 9

# Example 2: Pattern printing
print("\nStar Pattern:")
for i in range(1, 6):  # Outer loop (rows)
    for j in range(i):  # Inner loop (columns)
        print("*", end=" ")
    print()  # New line

# Output:
# * 
# * * 
# * * * 
# * * * * 
# * * * * *

# Example 3: Nested list iteration
students = [
    ["Alice", 20, "A"],
    ["Bob", 22, "B"],
    ["Charlie", 21, "A"]
]

for student in students:  # Outer loop
    print("Student Information:")
    for info in student:  # Inner loop
        print(f"  {info}")
    print()

Why Tuple is Immutable?

A tuple is designed to be immutable (unchangeable) for several important reasons:

Tuples are immutable because they are designed to be a fixed collection of items that cannot be changed after creation, which helps ensure data integrity and allows for certain optimizations in programming. This immutability distinguishes them from lists, which can be modified. in depth

1. Memory Efficiency:

# Tuple uses less memory than list
import sys
my_list = [1, 2, 3, 4, 5]
my_tuple = (1, 2, 3, 4, 5)

print(f"List size: {sys.getsizeof(my_list)} bytes")    # Larger
print(f"Tuple size: {sys.getsizeof(my_tuple)} bytes")  # Smaller

2. Performance:

# Tuples are faster to access and iterate
# Python can optimize tuple operations because it knows they won't change

3. Dictionary Keys:

# Tuples can be dictionary keys (lists cannot)
location = (40.7128, -74.0060)  # Tuple of coordinates
city_map = {location: "New York"}  # Valid

# my_list = [40.7128, -74.0060]
# city_map = {my_list: "New York"}  # Error! Lists are not hashable

4. Data Integrity:

# Tuples protect data from accidental modification
coordinates = (10, 20, 30)
# coordinates[0] = 15  # Error! Cannot modify

# This is useful when you want to ensure data doesn't change
def get_user_info():
    return ("Alice", 25, "Engineer")  # Returns fixed data

user = get_user_info()
# user[0] = "Bob"  # Error! Data is protected

5. Hashable:

# Tuples are hashable, so they can be used in sets
coordinates_set = {(1, 2), (3, 4), (5, 6)}  # Valid

# Lists cannot be used in sets
# coord_set = {[1, 2], [3, 4]}  # Error!

Why Python Made This Design Choice:

  • Safety: Prevents accidental changes to important data

  • Efficiency: Allows Python to optimize storage and access

  • Predictability: Once created, tuple data never changes

  • Use as Keys: Enables use in dictionaries and sets

What is a List?

A list is a collection of items that are ordered, changeable (mutable), and allow duplicate values. Think of it like a shopping list where you can add, remove, or change items.

Creating Lists:

# Empty list
empty_list = []

# List with numbers
numbers = [1, 2, 3, 4, 5]

# List with strings
fruits = ["apple", "banana", "cherry"]

# List with mixed data types
mixed = [1, "hello", 3.14, True, [1, 2, 3]]

# Using list() constructor
my_list = list((1, 2, 3))  # Convert tuple to list

Methods of List:

my_list = [1, 2, 3, 4, 5]

# 1. append() - Add single element at end
my_list.append(6)
print(my_list)  # [1, 2, 3, 4, 5, 6]

# 2. extend() - Add multiple elements at end
my_list.extend([7, 8, 9])
print(my_list)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# 3. insert() - Add element at specific position
my_list.insert(0, 0)  # Insert 0 at index 0
print(my_list)  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# 4. remove() - Remove first occurrence of value
my_list.remove(0)
print(my_list)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

# 5. pop() - Remove and return element at index (default: last)
last_item = my_list.pop()
print(last_item)  # 9
print(my_list)    # [1, 2, 3, 4, 5, 6, 7, 8]

item_at_2 = my_list.pop(2)  # Remove item at index 2
print(item_at_2)  # 3
print(my_list)    # [1, 2, 4, 5, 6, 7, 8]

# 6. clear() - Remove all elements
temp_list = [1, 2, 3]
temp_list.clear()
print(temp_list)  # []

# 7. index() - Return index of first occurrence
numbers = [10, 20, 30, 40, 30]
print(numbers.index(30))  # 2 (first occurrence)

# 8. count() - Count occurrences of value
print(numbers.count(30))  # 2

# 9. sort() - Sort list in ascending order
unsorted = [5, 2, 8, 1, 9]
unsorted.sort()
print(unsorted)  # [1, 2, 5, 8, 9]

unsorted.sort(reverse=True)  # Descending order
print(unsorted)  # [9, 8, 5, 2, 1]

# 10. reverse() - Reverse the list
my_list = [1, 2, 3, 4, 5]
my_list.reverse()
print(my_list)  # [5, 4, 3, 2, 1]

# 11. copy() - Create shallow copy of list
original = [1, 2, 3]
copied = original.copy()
copied.append(4)
print(original)  # [1, 2, 3]
print(copied)    # [1, 2, 3, 4]

List Traversal:

Traversal means accessing each element of the list one by one.

# Method 1: Using for loop
fruits = ["apple", "banana", "cherry", "date"]

print("Method 1: Simple for loop")
for fruit in fruits:
    print(fruit)

# Output:
# apple
# banana
# cherry
# date

# Method 2: Using index
print("\nMethod 2: Using index with range")
for i in range(len(fruits)):
    print(f"Index {i}: {fruits[i]}")

# Output:
# Index 0: apple
# Index 1: banana
# Index 2: cherry
# Index 3: date

# Method 3: Using enumerate() - Get index and value
print("\nMethod 3: Using enumerate()")
for index, fruit in enumerate(fruits):
    print(f"{index}: {fruit}")

# Output:
# 0: apple
# 1: banana
# 2: cherry
# 3: date

# Method 4: Using while loop
print("\nMethod 4: Using while loop")
i = 0
while i < len(fruits):
    print(fruits[i])
    i += 1

# Method 5: Reverse traversal
print("\nMethod 5: Reverse traversal")
for fruit in reversed(fruits):
    print(fruit)

# Output:
# date
# cherry
# banana
# apple

List Slicing:

List slicing in Python allows you to access specific parts of a list using a simple syntax. You can specify a start index, an end index, and an optional step to retrieve elements, such as myList[1:4] to get items from index 1 to 3.

Slicing allows you to extract a portion of a list. Syntax: list[start:end:step]

numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# Basic slicing
print(numbers[2:6])      # [2, 3, 4, 5] (from index 2 to 5)
print(numbers[:4])       # [0, 1, 2, 3] (from start to index 3)
print(numbers[5:])       # [5, 6, 7, 8, 9] (from index 5 to end)
print(numbers[:])        # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] (entire list)

# Slicing with step
print(numbers[::2])      # [0, 2, 4, 6, 8] (every 2nd element)
print(numbers[1::2])     # [1, 3, 5, 7, 9] (every 2nd element starting from index 1)
print(numbers[::3])      # [0, 3, 6, 9] (every 3rd element)

# Negative indexing
print(numbers[-3:])      # [7, 8, 9] (last 3 elements)
print(numbers[:-3])      # [0, 1, 2, 3, 4, 5, 6] (all except last 3)
print(numbers[-5:-2])    # [5, 6, 7] (from -5 to -2)

# Reverse list using slicing
print(numbers[::-1])     # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
print(numbers[::-2])     # [9, 7, 5, 3, 1] (reverse, every 2nd)

# Practical examples
fruits = ["apple", "banana", "cherry", "date", "elderberry", "fig"]

# Get first 3 fruits
print(fruits[:3])        # ['apple', 'banana', 'cherry']

# Get last 2 fruits
print(fruits[-2:])       # ['elderberry', 'fig']

# Get middle fruits (skip first and last)
print(fruits[1:-1])      # ['banana', 'cherry', 'date', 'elderberry']

# Get every alternate fruit
print(fruits[::2])       # ['apple', 'cherry', 'elderberry']

# Copy a list using slicing
original = [1, 2, 3, 4, 5]
copy = original[:]       # Creates a new list
copy.append(6)
print(original)          # [1, 2, 3, 4, 5] (unchanged)
print(copy)              # [1, 2, 3, 4, 5, 6]

# Replace part of list
numbers = [0, 1, 2, 3, 4, 5]
numbers[1:4] = [10, 20, 30]
print(numbers)           # [0, 10, 20, 30, 4, 5]

# Delete part of list
numbers = [0, 1, 2, 3, 4, 5]
del numbers[1:3]
print(numbers)           # [0, 3, 4, 5]

Understanding Slicing with Visual Example:

# List: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Index: 0  1  2  3  4  5  6  7  8  9
# -Index: -10 -9 -8 -7 -6 -5 -4 -3 -2 -1

numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

# numbers[2:7]
# Start at index 2 (value 2), end before index 7 (value 7)
# Result: [2, 3, 4, 5, 6]

# numbers[-4:-1]
# Start at index -4 (value 6), end before index -1 (value 9)
# Result: [6, 7, 8]

# numbers[1:8:2]
# Start at index 1, end before 8, step of 2
# Result: [1, 3, 5, 7]

11. Files and File Functions

What is a File? A file is a named location on disk used to store related information permanently. Files are used to store data persistently beyond program execution.

File Modes:

  • 'r' - Read (default), error if file doesn't exist

  • 'w' - Write, creates new file or truncates existing

  • 'a' - Append, creates file if doesn't exist

  • 'r+' - Read and Write

  • 'w+' - Write and Read (truncates)

  • 'a+' - Append and Read

  • 'rb', 'wb' - Binary modes

  • 'x' - Exclusive creation, fails if file exists

File Functions:

# a) read() - Reads entire file or n bytes
f = open('file.txt', 'r')
content = f.read()  # Read entire file
content = f.read(10)  # Read first 10 characters
f.close()

# b) write() - Writes string to file
f = open('file.txt', 'w')
f.write("Hello World\n")  # Returns number of characters written
f.close()

# c) seek(offset, whence) - Moves file pointer
# whence: 0=beginning, 1=current, 2=end
f = open('file.txt', 'r')
f.seek(5)  # Move to 5th byte from beginning
f.seek(0, 0)  # Move to start
f.close()

# d) readline() - Reads single line
f = open('file.txt', 'r')
line = f.readline()  # Reads first line
line2 = f.readline()  # Reads second line
f.close()

# e) writeline() - NOT a standard method (write() is used)
# For single line: use write()
f = open('file.txt', 'w')
f.write("Line 1\n")
f.close()

# f) tell() - Returns current file pointer position
f = open('file.txt', 'r')
print(f.tell())  # 0 (at start)
f.read(5)
print(f.tell())  # 5 (after reading 5 chars)
f.close()

# g) readlines() - Returns list of all lines
f = open('file.txt', 'r')
lines = f.readlines()  # ['line1\n', 'line2\n', ...]
f.close()

# h) writelines() - Writes list of strings
f = open('file.txt', 'w')
lines = ['Line 1\n', 'Line 2\n', 'Line 3\n']
f.writelines(lines)
f.close()

12. Inheritance, Classes & Objects

Types of Inheritance:

In Python, there are five main types of inheritance: single inheritance, multiple inheritance, multilevel inheritance, hierarchical inheritance, and hybrid inheritance. Each type defines how classes can inherit properties and methods from other classes, promoting code reuse and organization.

  • Single Inheritance: A class inherits from a single base class. This is the most common form.

  • Multiple Inheritance: A class inherits from multiple parent classes.

  • Multilevel Inheritance: A chain where a class inherits from a class which itself inherits from another class (e.g., A -> B -> C).

  • Hierarchical Inheritance: Multiple child classes inherit from a single parent class.

  • Hybrid Inheritance: A combination of two or more of the above types.

# 1. Single Inheritance
class Parent:
    def parent_method(self):
        print("Parent method")

class Child(Parent):
    def child_method(self):
        print("Child method")

c = Child()
c.parent_method()  # Inherited
c.child_method()

# 2. Multiple Inheritance
class Father:
    def skills(self):
        print("Gardening")

class Mother:
    def skills2(self):
        print("Cooking")

class Child(Father, Mother):
    pass

c = Child()
c.skills()
c.skills2()

# 3. Multilevel Inheritance
class GrandParent:
    def gp_method(self):
        print("GrandParent")

class Parent(GrandParent):
    def p_method(self):
        print("Parent")

class Child(Parent):
    def c_method(self):
        print("Child")

c = Child()
c.gp_method()

# 4. Hierarchical Inheritance
class Parent:
    def show(self):
        print("Parent")

class Child1(Parent):
    pass

class Child2(Parent):
    pass

# 5. Hybrid Inheritance (combination of above)
class A:
    pass
class B(A):
    pass
class C(A):
    pass
class D(B, C):
    pass

Class and Object Creation:

class Student:
    # Class variable (shared by all instances)
    school = "ABC School"

    def __init__(self, name, roll):
        # Instance variables (unique to each instance)
        self.name = name
        self.roll = roll

    def display(self):
        print(f"Name: {self.name}, Roll: {self.roll}")
        print(f"School: {Student.school}")

# Creating objects
s1 = Student("Alice", 101)
s2 = Student("Bob", 102)

s1.display()
s2.display()

# Class variable is same for all
print(s1.school)  # ABC School
print(s2.school)  # ABC School

# Changing class variable
Student.school = "XYZ School"
print(s1.school)  # XYZ School
print(s2.school)  # XYZ School

# Instance variable is unique
print(s1.name)  # Alice
print(s2.name)  # Bob

Class Variable vs Instance Variable:

  • Class Variable: Shared across all instances, defined outside __init__, accessed via class name

  • Instance Variable: Unique to each object, defined inside __init__ with self


13. String Methods, F-Strings, Recursion, Shallow Copy

String Methods:

Category

MethodDescription
Case conversionupper()Converts all characters in the string to uppercase.
lower()Converts all characters in the string to lowercase.
capitalize()Converts the first character of the string to uppercase and the rest to lowercase.
title()Converts the first character of each word to uppercase.
swapcase()Swaps the case of every character (lowercase becomes uppercase, and vice versa).
Searchingfind()Returns the lowest index where a substring is found, or -1 if not found.
rfind()Returns the highest index where a substring is found, searching from the right.
index()Like find(), but raises a ValueError if the substring is not found.
count()Returns the number of times a specified substring occurs in the string.
Checking (Boolean)startswith()Returns True if the string begins with the specified prefix.
endswith()Returns True if the string ends with the specified suffix.
isalpha()Returns True if all characters in the string are alphabetic and there is at least one character.
isdigit()Returns True if all characters in the string are digits and there is at least one character.
isalnum()Returns True if all characters in the string are alphanumeric (letters or numbers) and there is at least one character.
isspace()Returns True if all characters in the string are whitespace characters and there is at least one character.
Modifyingstrip()Returns a copy of the string with leading and trailing whitespace (or specified characters) removed.
replace()Returns a copy of the string with all occurrences of a specified substring replaced by another substring.
split()Splits the string at whitespace (or a specified separator) and returns a list of substrings.
join()Concatenates elements of an iterable (e.g., a list) into a single string using the string itself as the separator.
Alignmentcenter()Centers the string within a specified width, padding with a fill character (space by default).
ljust()Left-justifies the string within a specified width, padding with a fill character.
rjust()Right-justifies the string within a specified width, padding with a fill character.
s = "Hello World"

# Case conversion
print(s.upper())        # HELLO WORLD
print(s.lower())        # hello world
print(s.capitalize())   # Hello world
print(s.title())        # Hello World
print(s.swapcase())     # hELLO wORLD

# Search
print(s.find('o'))      # 4 (first occurrence)
print(s.rfind('o'))     # 7 (last occurrence)
print(s.index('W'))     # 6
print(s.count('l'))     # 3

# Check
print(s.startswith('He'))  # True
print(s.endswith('ld'))    # True
print(s.isalpha())         # False (has space)
print(s.isdigit())         # False
print("123".isdigit())     # True
print(s.isalnum())         # False
print(s.isspace())         # False

# Modify
print(s.strip())           # Removes whitespace
print(s.replace('World', 'Python'))  # Hello Python
print(s.split())           # ['Hello', 'World']
print('-'.join(['a','b','c']))  # a-b-c

# Alignment
print(s.center(20, '*'))   # ****Hello World*****
print(s.ljust(15))         # Left justify
print(s.rjust(15))         # Right justify

F-Strings (Formatted String Literals):

F-strings, or formatted string literals,provide a concise and readable way to embed Python expressions inside string literals for formatting. They are denoted by prepending an f (or F) before the opening quote of the string.

Key Features and Usage:

  • Syntax: Use f"string text {expression}".

  • Evaluation: The expressions within the curly braces {} are evaluated at runtime and the result is inserted into the string.

  • Readability: Generally considered cleaner and faster than older methods like format() or the % operator

name = "Alice"
age = 25
marks = 95.567

# Basic f-string
print(f"Name: {name}, Age: {age}")

# Expressions inside f-strings
print(f"Next year age: {age + 1}")

# Formatting
print(f"Marks: {marks:.2f}")  # 95.57 (2 decimal places)
print(f"Name: {name:>10}")    # Right align in 10 spaces
print(f"Name: {name:<10}")    # Left align
print(f"Name: {name:^10}")    # Center align

# Multiple lines
message = f"""
Student Details:
Name: {name}
Age: {age}
"""
print(message)

Recursion - Advantages and Disadvantages:

Advantages:

  1. Clean and elegant code

  2. Complex problems broken into simpler sub-problems

  3. Easy to write for tree/graph traversals

  4. Reduces code complexity

Disadvantages:

  1. More memory usage (call stack)

  2. Slower than iterative solutions

  3. Risk of stack overflow

  4. Difficult to debug

  5. Not all problems suit recursion

Shallow Copy of Dictionary:

Python, a shallow copy of a dictionary creates a new dictionary object, but the new dictionary references the same inner objects as the original. If the dictionary contains mutable objects (like lists or other dictionaries), changes made to those inner objects in the copy will affect the original, and vice versa

import copy

original = {
    'name': 'Alice',
    'marks': [90, 85, 95],
    'address': {'city': 'Mumbai'}
}

# Shallow copy methods
shallow1 = original.copy()
shallow2 = dict(original)
shallow3 = copy.copy(original)

# Modifying nested object affects both
shallow1['marks'].append(100)
print(original['marks'])  # [90, 85, 95, 100] - Changed!

# Modifying top-level doesn't affect original
shallow1['name'] = 'Bob'
print(original['name'])  # Alice - Not changed

# Deep copy for complete independence
deep = copy.deepcopy(original)
deep['marks'].append(88)
print(original['marks'])  # [90, 85, 95, 100] - Not changed

Shaloow copy vs Deep copy

Shallow CopyDeep Copy
1. Independence (Top Level)Creates a new object at the top level, independent from the original.Creates a new object at the top level, independent from the original.
2. Independence (Nested Objects)Shares references to nested mutable objects with the original.Recursively creates new, independent copies of all nested objects.
3. Effect of ModificationModifying a nested mutable object in the copy also modifies it in the original.Modifying a nested object in the copy has no effect on the original.
4. Method of CreationUses methods like .copy(), dict(), or list slicing [:].Requires the copy.deepcopy() function from the copy module.
5. Memory/PerformanceGenerally faster and uses less memory as it avoids creating new copies of all internal elements.Slower and uses more memory as it duplicates the entire object structure recursively.

14. Packing, Unpacking, Literals, Pickling

Packing and Unpacking:

Packing

  • Definition: The process of collecting multiple individual values into a single compound object.

  • Default Type: Values are typically packed into a tuple implicitly.

  • Mechanism: Python automatically creates a single iterable (e.g., my_var = 1, 2, 3 results in a tuple).

  • Function Use: Used in function definitions with *args to handle a variable number of positional arguments, and with **kwargs for keyword arguments.

Unpacking

  • Definition: The process of extracting elements from an iterable (tuple, list, string, etc.) and assigning them to distinct variables.

  • Mechanism: Requires the number of variables on the left side of the assignment operator to match the number of elements in the iterable on the right side.

  • Error Handling: A ValueError is raised if the number of variables does not match the number of elements, unless the asterisk operator is used.

  • Extended Unpacking: The asterisk (*) operator can collect remaining items into a list (e.g., a, *b, c = my_list). This is also known as "iterable unpacking" or "starring".

# Packing - Multiple values into one variable
def sum_all(*args):  # Packing into tuple
    return sum(args)

print(sum_all(1, 2, 3, 4))  # 10

def display(**kwargs):  # Packing into dictionary
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display(name="Alice", age=25)

# Unpacking - Extracting values from collection
# Tuple unpacking
coordinates = (10, 20, 30)
x, y, z = coordinates
print(x, y, z)  # 10 20 30

# List unpacking
numbers = [1, 2, 3, 4, 5]
a, *middle, b = numbers
print(a)      # 1
print(middle) # [2, 3, 4]
print(b)      # 5

# Dictionary unpacking
def greet(name, age):
    print(f"Hello {name}, you are {age}")

info = {'name': 'Alice', 'age': 25}
greet(**info)  # Unpacking dictionary

Literals - Different Types:

1. String Literals

Used for sequences of characters. They can be enclosed in single quotes ('...'), double quotes ("..."), or triple quotes for multi-line strings ("""...""" or '''...''').

  • Example: "Hello", 'World', '''Multi-line string'''

2. Numeric Literals

Used for numerical values.

  • Integer Literals: Whole numbers.

    • Decimal: 10, 100

    • Binary: 0b101 (prefix 0b)

    • Octal: 0o12 (prefix 0o)

    • Hexadecimal: 0xFace (prefix 0x)

  • Floating-Point Literals: Numbers with a decimal point or an exponent (e or E).

    • Example: 3.14, -0.01, 1.2e5 (1.2 * 10^5)
  • Complex Literals: Numbers with a real and an imaginary part (uses j or J).

    • Example: 3 + 4j, -1j

3. Boolean Literals

Represent truth values.

  • Keywords: True, False

4. Collection Literals

Used to create built-in data structures.

  • List Literals: Ordered collections of items enclosed in square brackets [].

    • Example: ['apple', 'banana', 10]
  • Tuple Literals: Ordered, immutable collections of items enclosed in parentheses ().

    • Example: (1, 2, 3)
  • Dictionary Literals: Unordered collections of key-value pairs enclosed in curly braces {}.

    • Example: {'name': 'Bob', 'age': 25}
  • Set Literals: Unordered collections of unique items enclosed in curly braces {} (Note: an empty {} creates a dictionary).

    • Example: {10, 20, 30}

5. Special Literal

  • None Literal: A keyword representing the absence of a value or a null value.

For comprehensive details on how Python parses these, you can refer to the official Python documentation on literals.

# Numeric Literals
decimal = 100
binary = 0b1010      # 10
octal = 0o12         # 10
hexadecimal = 0xA    # 10
float_num = 3.14
complex_num = 3 + 4j
scientific = 1.5e2   # 150.0

# String Literals
single = 'Hello'
double = "World"
triple = '''Multi
line
string'''
raw = r"C:\new\folder"  # Raw string

# Boolean Literals
is_true = True
is_false = False

# Special Literal
nothing = None

# Collection Literals
list_lit = [1, 2, 3]
tuple_lit = (1, 2, 3)
dict_lit = {'key': 'value'}
set_lit = {1, 2, 3}

Pickling and Unpickling:

Pickling serializes Python objects into a byte stream (using

pickle.dump() in write binary mode 'wb'). This saves object states to a file. Unpickling reverses this process (using pickle.load() in read binary mode 'rb'), reconstructing the object from the bytes. Use the copy module for deep copies.

  • Pickling: Object →right arrow→ Bytes (pickle.dump, 'wb').

  • Unpickling: Bytes →right arrow→ Object (pickle.load, 'rb').

  • Caution: Unpickle only trusted data due to security risks.

import pickle

# Pickling - Serializing object to binary
data = {
    'name': 'Alice',
    'marks': [90, 85, 95],
    'age': 25
}

# Write to file
with open('data.pkl', 'wb') as f:
    pickle.dump(data, f)

# Unpickling - Deserializing from binary
with open('data.pkl', 'rb') as f:
    loaded_data = pickle.load(f)
    print(loaded_data)

# Pickle to string
pickled_string = pickle.dumps(data)
unpickled = pickle.loads(pickled_string)

15. PVM, Memory Management, C vs Python, Lambda, Tuple

Python Virtual Machine (PVM):

The Python Virtual Machine (PVM) is the runtime environment that executes Python bytecode. When a Python program is run, the source code is first compiled into intermediate bytecode (

.pyc files). The PVM then interprets and executes this bytecode instruction by instruction on the host machine. It manages memory and ensures platform independence.

  • PVM is the runtime engine of Python

  • Interprets byte code (.pyc files)

  • Platform-independent execution layer

  • Part of Python interpreter

  • Executes compiled byte code line by line

Memory Management in Python:

Python manages memory primarily through three mechanisms:

  • Private Heap: All Python objects reside in a private heap memory space managed internally by the PVM.

  • Automatic Management: The programmer does not manually allocate or deallocate memory (unlike languages like C/C++).

  • Reference Counting: The core mechanism is reference counting. Each object keeps a count of pointers referencing it. When this count reaches zero, the memory is immediately reclaimed.

  • Garbage Collection (Generational GC): A secondary garbage collector runs periodically to handle reference cycles (objects that reference each other but are no longer reachable by the main program), preventing memory leaks from cycles.

  • Memory Pools: Python uses memory pools for small, common objects to speed up allocation and deallocation.

# 1. Automatic memory management via garbage collector
# 2. Reference counting mechanism
import sys

a = [1, 2, 3]
print(sys.getrefcount(a))  # Shows reference count

# 3. Garbage collection for circular references
import gc
gc.collect()  # Force garbage collection

# 4. Memory pools for optimization
# 5. Private heap space for Python objects

C vs Python:

FeatureCPython
TypeCompiledInterpreted
SpeedFastSlower
MemoryManualAutomatic
SyntaxComplexSimple
Code LengthMoreLess
PortabilityLessMore
UsageSystem programmingWeb, AI, Data Science

Lambda Function: Lambda functions in Python are small, anonymous functions defined using the

lambda keyword instead of def. They can take any number of arguments but are restricted to a single expression, whose result is implicitly returned. They are typically used for short, throwaway operations where a full function definition would be overkill, often within functions like filter(), map(), or sorted()

# Syntax: lambda arguments: expression

# Simple lambda
square = lambda x: x ** 2
print(square(5))  # 25

# Multiple arguments
add = lambda x, y: x + y
print(add(3, 4))  # 7

# With built-in functions
numbers = [1, 2, 3, 4, 5]
squares = list(map(lambda x: x**2, numbers))
print(squares)  # [1, 4, 9, 16, 25]

evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens)  # [2, 4]

# Lambda with sorted
students = [('Alice', 85), ('Bob', 92), ('Charlie', 78)]
sorted_students = sorted(students, key=lambda x: x[1])
print(sorted_students)

Create and Access Tuple: Tuples are created using parentheses

() and hold ordered, immutable collections of items. You access elements using zero-based indexing, just like lists. Trying to modify an element after creation will raise an error.

# Creating tuples
empty = ()
single = (1,)  # Comma needed for single element
numbers = (1, 2, 3, 4, 5)
mixed = (1, "Hello", 3.14, True)
nested = ((1, 2), (3, 4))

# Accessing tuple
print(numbers[0])    # 1 (first element)
print(numbers[-1])   # 5 (last element)
print(numbers[1:4])  # (2, 3, 4) (slicing)

# Tuple methods
print(numbers.count(3))  # 1
print(numbers.index(4))  # 3

# Tuple unpacking
a, b, c = (1, 2, 3)

# Immutable
# numbers[0] = 10  # Error!

# But can contain mutable objects
t = ([1, 2], [3, 4])
t[0].append(3)  # Works!
print(t)  # ([1, 2, 3], [3, 4])

16. Multiple Returns, Bytecode, Unicode, For vs While

Return Multiple Values: In Python, functions implicitly pack multiple returned values into a single tuple, which can then be easily unpacked by the calling code. This is a common and straightforward mechanism.

Returning Multiple Values (Packing)

When you use a single return statement followed by several comma-separated expressions, Python packs those expressions into a tuple automatically.

def calculate(a, b):
    sum_val = a + b
    diff = a - b
    prod = a * b
    quot = a / b
    return sum_val, diff, prod, quot  # Returns tuple

# Unpacking returned values
s, d, p, q = calculate(10, 2)
print(f"Sum: {s}, Diff: {d}, Prod: {p}, Quot: {q}")

# Or as single tuple
result = calculate(10, 2)
print(result)  # (12, 8, 20, 5.0)

# Return list
def get_stats():
    return [10, 20, 30]

# Return dictionary
def get_info():
    return {'name': 'Alice', 'age': 25}

Byte Code and Execution Steps:

Bytecode Explained

Bytecode is a low-level, platform-independent set of instructions that the PVM understands. It is not machine code (which the CPU runs directly) but rather an optimized, intermediate representation of your source code. Bytecode files typically have the .pyc extension and are often stored in __pycache__ directories.

Python Code Execution Steps

The entire execution process can be broken down into these primary steps:

  1. Source Code (.py file):
    The process begins with your human-readable Python code (e.g., script.py).

  2. Compilation (to Bytecode):
    When you run a program, Python’s compiler parses the source code, checks for syntax errors, and translates it into bytecode instructions. This step happens automatically and is often very quick.

  3. Storage (Optional):
    For optimization, if the file hasn't changed since the last run, Python saves the generated bytecode to a .pyc file in the __pycache__ directory to skip the compilation step next time.

  4. Execution (by the PVM):
    The Python Virtual Machine (PVM) is the runtime environment that reads and interprets the bytecode instructions one by one. The PVM acts as a mediator between the generic bytecode and the specific operating system/hardware your program is running on.

  5. Memory Management and Output:
    During execution, the PVM manages the program's memory (using reference counting and the garbage collector) and interacts with the operating system to perform tasks like input/output operations, ultimately producing the program's final results.

This compilation to bytecode and execution via the PVM is why Python is considered an interpreted language and is highly portable across different operating systems.

# Byte code is platform-independent intermediate code
# Steps of execution:

# 1. Source Code (.py file)
# 2. Compiler converts to Byte Code (.pyc in __pycache__)
# 3. PVM (Python Virtual Machine) executes byte code
# 4. Output

# View byte code
import dis

def add(a, b):
    return a + b

dis.dis(add)  # Disassemble function to see byte code

# Compilation happens automatically
# .pyc files stored in __pycache__ folder

Unicode Value of String:

In Python, every character in a string corresponds to a specific Unicode value (code point). You can easily retrieve and convert between characters and their integer Unicode values using the built-in functions:

  • ord(): Takes a single character string as input and returns its integer Unicode value.

  • chr(): Takes an integer Unicode value as input and returns the corresponding single-character string.

# Get Unicode (ord function)
print(ord('A'))   # 65
print(ord('a'))   # 97
print(ord('0'))   # 48
print(ord('★'))   # 9733

# Get character from Unicode (chr function)
print(chr(65))    # A
print(chr(97))    # a
print(chr(9733))  # ★

# Unicode for entire string
text = "Hello"
for char in text:
    print(f"{char}: {ord(char)}")

# Unicode representation
print('\u0041')   # A (Unicode escape)
print('\u2605')   # ★

For vs While Loop:

For Loop

A for loop is used to iterate over a sequence (like a list, tuple, dictionary, string, or range) or any other iterable object.

  • Iteration over Iterables: It's designed to automatically handle looping through a predefined set of items one by one.

  • Known Iterations: Best used when you know in advance exactly how many times you want to loop or when you need to process every item in a collection.

  • Syntax Simplicity: The syntax is clean and concise for iterating through existing data structures.

  • Automatic Termination: The loop naturally terminates once all items in the sequence have been processed.

Example Use Case: Printing every item in a shopping list or performing an action a specific number of times using range().

While Loop

A while loop is used to execute a block of code repeatedly as long as a specified condition remains true.

  • Condition-Based: It focuses purely on a Boolean condition (True/False).

  • Unknown Iterations: Best used when the number of iterations is unknown and depends on a condition being met during runtime (e.g., waiting for user input, reading from a file until the end is reached).

  • Manual Control: Requires manual management of loop variables (initializing a counter before the loop and incrementing it inside the loop) to prevent infinite loops.

  • Potential Infinite Loops: If the condition never becomes False, the loop runs indefinitely.

Example Use Case: Validating user input until a correct value is entered, or monitoring a changing sensor value.

# FOR Loop
# - Used when iterations are known
# - Iterates over sequence
# - More concise for collections

for i in range(5):
    print(i)

fruits = ['apple', 'banana', 'cherry']
for fruit in fruits:
    print(fruit)

# WHILE Loop
# - Used when iterations unknown
# - Continues until condition false
# - Better for indefinite loops

count = 0
while count < 5:
    print(count)
    count += 1

# While for user input
while True:
    ans = input("Continue? (y/n): ")
    if ans == 'n':
        break

17. 2D List, Keywords, Variables & Scope

2D List in Python:A 2D list (or nested list) in Python is simply

a list where each element is another list. This structure is commonly used to represent data grids, matrices, or tables, where you need both rows and columns.

# Creating 2D list
matrix = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

# Accessing elements
print(matrix[0][0])  # 1
print(matrix[1][2])  # 6
print(matrix[2][1])  # 8

# Using nested loops
for row in matrix:
    for element in row:
        print(element, end=' ')
    print()

# With indices
for i in range(len(matrix)):
    for j in range(len(matrix[i])):
        print(f"matrix[{i}][{j}] = {matrix[i][j]}")

# List comprehension for 2D list
matrix2 = [[i*j for j in range(3)] for i in range(3)]
print(matrix2)

# Initialize 2D list with zeros
rows, cols = 3, 4
zeros = [[0 for _ in range(cols)] for _ in range(rows)]

Keywords in Python:In Python, keywords are reserved words that have specific meanings to the interpreter and cannot be used as variable names, function names, or any other identifiers.

Here are all the keywords in Python (as of Python 3.10+):

Keywords
Description
FalseBoolean value (false)
NoneRepresents the absence of a value or a null value
TrueBoolean value (true)
andLogical operator
asUsed to create an alias (e.g., in import or with statements)
assertUsed for debugging; checks if a condition is true
asyncDeclares an asynchronous function or context manager
awaitPauses execution of an async function until a result is returned
breakExits the current loop immediately
classDefines a class (a blueprint for objects)
continueSkips the rest of the current loop iteration and moves to the next one
defDefines a function or method
delDeletes objects (variables, list items, etc.)
elifShort for "else if," used in conditional statements
elseExecutes a block of code if conditions above it are not met
exceptCatches exceptions in a try...except block
finallyExecutes a block of code regardless of whether an exception occurred
forCreates a loop to iterate over an iterable object
fromUsed to import specific parts of a module
globalDeclares a variable as global in scope
ifStarts a conditional statement
importImports modules or packages
inChecks if a value is present in a sequence (membership operator)
isTests if two variables refer to the same object (identity operator)
lambdaCreates a small, anonymous function
nonlocalDeclares a variable as non-local (in a nested function scope)
notLogical operator (negation)
orLogical operator
passA null operation; nothing happens (a placeholder)
raiseRaises an exception manually
returnExits a function and returns a value
tryStarts a block of code where exceptions might occur
whileCreates a loop that runs as long as a condition is true
withUsed for simplified handling of external resources (like file I/O)
# Python has 35 keywords (reserved words)
import keyword

print(keyword.kwlist)
# ['False', 'None', 'True', 'and', 'as', 'assert', 
#  'async', 'await', 'break', 'class', 'continue', 
#  'def', 'del', 'elif', 'else', 'except', 'finally', 
#  'for', 'from', 'global', 'if', 'import', 'in', 
#  'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 
#  'raise', 'return', 'try', 'while', 'with', 'yield']

# Cannot be used as identifiers
# class = 10  # SyntaxError

Variables and Scope:

Variable scope in Python determines the visibility and accessibility of a variable within the code. Python follows the

LEGB rule, defining four levels of scope:

  • Local (L): Variables defined inside a function. They exist only while the function executes and are inaccessible outside it.

  • Enclosing (E): Variables in the scope of an outer function that contains an inner (nested) function. The inner function can access these variables.

  • Global (G): Variables defined at the top level of a script or module. They are accessible everywhere in the program. The global keyword is needed to modify a global variable from within a function.

  • Built-in (B): Reserved names for predefined Python functions and keywords (e.g., print, len, True, None).

Python checks scopes in the order: Local →right arrow→ Enclosing →right arrow→ Global →right arrow→ Built-in.

# Global Scope
global_var = "I'm global"

def outer():
    # Enclosing/Nonlocal Scope
    enclosing_var = "I'm enclosing"

    def inner():
        # Local Scope
        local_var = "I'm local"
        print(global_var)      # Can access
        print(enclosing_var)   # Can access
        print(local_var)       # Can access

    inner()
    # print(local_var)  # Error - not accessible

outer()

# LEGB Rule: Local -> Enclosing -> Global -> Built-in

# Global keyword
count = 0

def increment():
    global count
    count += 1

increment()
print(count)  # 1

# Nonlocal keyword
def outer():
    x = 10
    def inner():
        nonlocal x
        x = 20
    inner()
    print(x)  # 20

outer()

18. Uses of Python, Compiled vs Interpreted, Membership Operator, SOP vs OOP, For Loop Adv/Disadv

Uses of Python:

  1. Web Development (Django, Flask)

  2. Data Science & Analysis (Pandas, NumPy)

  3. Machine Learning & AI (TensorFlow, PyTorch)

  4. Automation & Scripting

  5. Game Development (Pygame)

  6. Desktop Applications (Tkinter, PyQt)

  7. Scientific Computing

  8. Network Programming

  9. IoT Applications

  10. Cybersecurity

Compiled or Interpreted: Python is both compiled and interpreted:

  • Source code → Compiled to byte code (.pyc)

  • Byte code → Interpreted by PVM

  • Called "interpreted" because compilation is automatic and invisible

Membership Operators:

Python's membership operators are used to test whether a specific value or subsequence is present within a container (like a list, tuple, string, set, or dictionary).

  • in Operator: Evaluates to True if the value on the left side is found within the iterable on the right side.

  • not in Operator: Evaluates to True if the value on the left side is not found within the iterable on the right side.

For dictionaries, these operators check for the presence of a key, not a value. These operators provide a highly readable way to perform inclusion checks.

# 'in' and 'not in' operators

# With lists
numbers = [1, 2, 3, 4, 5]
print(3 in numbers)        # True
print(10 in numbers)       # False
print(10 not in numbers)   # True

# With strings
text = "Hello World"
print('H' in text)         # True
print('xyz' in text)       # False
print('Hello' in text)     # True

# With dictionaries (checks keys)
person = {'name': 'Alice', 'age': 25}
print('name' in person)    # True
print('Alice' in person)   # False (not a key)

# With tuples
tup = (1, 2, 3)
print(2 in tup)            # True

# With sets
s = {1, 2, 3}
print(2 in s)              # True (very fast)

SOP (Structure-Oriented Programming) vs OOP:

FeatureSOPOOP
ApproachFunctions/ProceduresObjects/Classes
DataSeparate from functionsEncapsulated in objects
ReusabilityLimitedHigh (inheritance)
SecurityLowHigh (encapsulation)
MaintainabilityDifficultEasy
ExampleCPython, Java, C++
FocusWhat to doWhat to work with
# SOP Style
def calculate_area(length, width):
    return length * width

area = calculate_area(10, 5)

# OOP Style
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

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

rect = Rectangle(10, 5)
area = rect.calculate_area()

For Loop - Advantages and Disadvantages:

Advantages:

  1. Simple and readable syntax

  2. Automatic iteration over sequences

  3. No need to manage counter

  4. Works with any iterable

  5. Prevents off-by-one errors

  6. Built-in iteration protocol

Disadvantages:

  1. Less flexible than while loop

  2. Cannot easily modify iteration variable

  3. Harder to implement complex conditions

  4. Cannot go backwards easily (need reversed())

  5. Fixed iteration pattern


19. Programs---

1. Factorial

def factorial(n): if n == 0 or n == 1: return 1 return n * factorial(n - 1)

print("Factorial of 5:", factorial(5)) # 120

2. Check Prime Number

def is_prime(n): if n <= 1: return False if n == 2: return True if n % 2 == 0: return False 
for i in range(3, int(n**0.5) + 1, 2): if n % i == 0: return False return True

print("Is 17 prime?", is_prime(17)) # True print("Is 20 prime?", is_prime(20)) # False

3. Fibonacci using Recursion

def fibonacci(n): if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2)

print("First 10 Fibonacci numbers:") for i in range(10): print(fibonacci(i), end=' ') print()

4. GCD of 2 Numbers

def gcd(a, b): if b == 0: return a return gcd(b, a % b)

print("GCD of 48 and 18:", gcd(48, 18)) # 6

5. Power of X^N

def power(x, n): if n == 0: return 1 if n < 0: return 1 / power(x, -n) return x * power(x, n - 1)

print("2^5 =", power(2, 5)) # 32

6. 1 + (1+2) + (1+2+3) + ...


def series_sum(n): total = 0 for i in range(1, n + 1): # Sum from 1 to i for j in range(1, i + 1): total += j return total

7. Sum of Digits

def sum_of_digits(n): if n == 0: return 0 return n % 10 + sum_of_digits(n // 10)

print("Sum of digits of 12345:", sum_of_digits(12345)) # 15

8. Reverse Number

def reverse_number(n): reversed_num = 0 while n > 0: reversed_num = reversed_num * 10 + n % 10 n //= 10 return reversed_num

Recursive version

def reverse_recursive(n, rev=0): if n == 0: return rev return reverse_recursive(n // 10, rev * 10 + n % 10)

print("Reverse of 12345:", reverse_number(12345)) # 54321

9. String Palindrome

def is_palindrome(s): s = s.lower().replace(" ", "") return s == s[::-1]

Recursive version

def is_palindrome_recursive(s): s = s.lower().replace(" ", "") if len(s) <= 1: return True if s[0] != s[-1]: return False return is_palindrome_recursive(s[1:-1])

print("Is 'racecar' palindrome?", is_palindrome("racecar")) # True print("Is 'hello' palindrome?", is_palindrome("hello")) # False

10. Multiplication Table

def multiplication_table(n): print(f"Multiplication Table of {n}:") for i in range(1, 11): print(f"{n} x {i} = {n * i}")

multiplication_table(5)

11. Matrix Multiplication

def matrix_multiply(A, B): rows_A, cols_A = len(A), len(A[0]) rows_B, cols_B = len(B), len(B[0])

if cols_A != rows_B: return None

# Initialize result matrix result = [[0 for _ in range(cols_B)] for _ in range(rows_A)]

for i in range(rows_A): for j in range(cols_B): for k in range(cols_A): result[i][j] += A[i][k] * B[k][j]

return result

A = [[1, 2, 3], [4, 5, 6]]

B = [[7, 8], [9, 10], [11, 12]]

result = matrix_multiply(A, B) print("\nMatrix Multiplication Result:") for row in result: print(row)

12. File Copy

def copy_file(source, destination): try: with open(source, 'r') as src: content = src.read()

with open(destination, 'w') as dest: dest.write(content)

print(f"File copied from {source} to {destination}") except FileNotFoundError: print(f"Source file {source} not found") except Exception as e: print(f"Error: {e}")

Example usage (commented out):

copy_file('source.txt', 'destination.txt')

13. sin(x) = x - x^3/3! + x^5/5! - ...

import math

def factorial_iter(n): result = 1 for i in range(2, n + 1): result *= i return result

def sin_series(x, terms=10): x_rad = math.radians(x) # Convert to radians result = 0 for n in range(terms): sign = (-1) ** n term = sign * (x_rad ** (2 * n + 1)) / factorial_iter(2 * n + 1) result += term return result

angle = 30 print(f"\nsin({angle}) using series:", sin_series(angle)) print(f"sin({angle}) using math.sin:", math.sin(math.radians(angle)))

14. 1^2 + 2^3 + 3^4 + ...

def power_series(n): total = 0 for i in range(1, n + 1): total += i ** (i + 1) return total

print(f"\n1^2 + 2^3 + 3^4 for n=4:", power_series(4)) # 1 + 8 + 81 = 90

20. Additional Programs---

1. Leap Year Check

def is_leap_year(year): if (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0): return True return False

year = 2024 if is_leap_year(year): print(f"{year} is a leap year") else: print(f"{year} is not a leap year")

2. Dictionary Creation - Student Info

student = { 'name': 'Alice Johnson', 'roll': 101, 'marks': { 'Math': 95, 'Physics': 88, 'Chemistry': 92 } }

print("\nStudent Information:") print(f"Name: {student['name']}") print(f"Roll: {student['roll']}") print(f"Marks: {student['marks']}")

Multiple students

students = [ {'name': 'Alice', 'roll': 101, 'marks': 95}, {'name': 'Bob', 'roll': 102, 'marks': 88}, {'name': 'Charlie', 'roll': 103, 'marks': 92} ]

print("\nAll Students:") for s in students: print(f"{s['name']} (Roll: {s['roll']}) - Marks: {s['marks']}")

3. Largest Among 3 Numbers

def largest_of_three(a, b, c): if a >= b and a >= c: return a elif b >= a and b >= c: return b else: return c

Alternative using max()

def largest_simple(a, b, c): return max(a, b, c)

num1, num2, num3 = 45, 78, 23 print(f"\nLargest among {num1}, {num2}, {num3}: {largest_of_three(num1, num2, num3)}")

4. Armstrong Number Check

def is_armstrong(n):
    # Convert to string to extract digits
    num_str = str(n)
    num_digits = len(num_str)

    # Calculate sum of each digit raised to the power of number of digits
    sum_of_powers = sum(int(digit) ** num_digits for digit in num_str)

    return sum_of_powers == n

Test Armstrong numbers

test_numbers = [153, 370, 371, 407, 9474, 123] print("\nArmstrong Number Check:") for num in test_numbers: if is_armstrong(num): print(f"{num} is an Armstrong number") else: print(f"{num} is not an Armstrong number")

5. Count Vowels in String

def count_vowels(text): vowels = 'aeiouAEIOU' count = 0 vowel_list = []

for char in text: if char in vowels: count += 1 vowel_list.append(char)

return count, vowel_list

Alternative using list comprehension

def count_vowels_compact(text): vowels = 'aeiouAEIOU' vowel_list = [char for char in text if char in vowels] return len(vowel_list), vowel_list

text = "Hello World, Welcome to Python Programming" count, vowels_found = count_vowels(text) print(f"\nText: {text}") print(f"Number of vowels: {count}") print(f"Vowels found: {vowels_found}")

6. Store Student Info in File and Display

def store_students_to_file(filename, students): try: with open(filename, 'w') as f: f.write("Student Information\n") f.write("=" * 50 + "\n\n")

for student in students: f.write(f"Name: {student['name']}\n") f.write(f"Roll Number: {student['roll']}\n") f.write(f"Marks: {student['marks']}\n") f.write(f"Grade: {student.get('grade', 'N/A')}\n") f.write("-" * 50 + "\n")

print(f"\nStudent information stored in {filename}") except Exception as e: print(f"Error writing to file: {e}")

def display_students_from_file(filename): try: with open(filename, 'r') as f: content = f.read() print("\nDisplaying Student Information from File:") print(content) except FileNotFoundError: print(f"File {filename} not found") except Exception as e: print(f"Error reading file: {e}")

Example data

students_data = [ {'name': 'Alice Johnson', 'roll': 101, 'marks': 95, 'grade': 'A'}, {'name': 'Bob Smith', 'roll': 102, 'marks': 88, 'grade': 'B'}, {'name': 'Charlie Brown', 'roll': 103, 'marks': 92, 'grade': 'A'} ]

Store and display

store_students_to_file('students.txt', students_data) display_students_from_file('students.txt')

7. Bank Account - Class and Object

class BankAccount: # Class variable bank_name = "Python Bank" interest_rate = 3.5

def init(self, account_holder, account_number, balance=0): # Instance variables self.account_holder = account_holder self.account_number = account_number self.balance = balance self.transactions = []

def deposit(self, amount): if amount > 0: self.balance += amount self.transactions.append(f"Deposited: ${amount}") print(f"${amount} deposited successfully") print(f"New balance: ${self.balance}") else: print("Invalid deposit amount")

def withdrawal(self, amount): if amount > 0: if amount <= self.balance: self.balance -= amount self.transactions.append(f"Withdrew: ${amount}") print(f"${amount} withdrawn successfully") print(f"Remaining balance: ${self.balance}") else: print("Insufficient balance!") print(f"Current balance: ${self.balance}") else: print("Invalid withdrawal amount")

def display(self): print("\n" + "=" * 50) print(f"Bank: {BankAccount.bank_name}") print(f"Account Holder: {self.account_holder}") print(f"Account Number: {self.account_number}") print(f"Current Balance: ${self.balance}") print(f"Interest Rate: {BankAccount.interest_rate}%")

if self.transactions: print("\nTransaction History:") for transaction in self.transactions: print(f" - {transaction}") print("=" * 50)

def calculate_interest(self, years): interest = self.balance  (BankAccount.interest_rate / 100)  years return interest

def check_balance(self): return self.balance

Creating bank account objects

print("\n" + "="  50) print("BANK ACCOUNT MANAGEMENT SYSTEM") print("="  50)

Account 1

account1 = BankAccount("Alice Johnson", "ACC001", 1000) account1.display()

print("\n--- Performing Transactions on Account 1 ---") account1.deposit(500) account1.withdrawal(200) account1.withdrawal(2000) # Should fail account1.display()

print(f"\nInterest for 2 years: ${account1.calculate_interest(2):.2f}")

Account 2

print("\n\n--- Creating Second Account ---") account2 = BankAccount("Bob Smith", "ACC002", 5000) account2.display()

print("\n--- Performing Transactions on Account 2 ---") account2.deposit(1000) account2.withdrawal(500) account2.display()

Demonstrate class variable

print(f"\n--- Bank Information (Class Variables) ---") print(f"Bank Name: {BankAccount.bank_name}") print(f"Interest Rate: {BankAccount.interest_rate}%")

Check multiple account balances

print("\n--- Summary of All Accounts ---") print(f"Account 1 ({account1.account_holder}): ${account1.check_balance()}") print(f"Account 2 ({account2.account_holder}): ${account2.check_balance()}")
Complete Python Programming Guide