Thanks to visit codestin.com
Credit goes to www.scribd.com

0% found this document useful (0 votes)
13 views47 pages

Unique

The document provides an overview of key Python concepts including iterators, generators, namespaces, scopes, comprehensions, and decorators. It explains how iterators allow for efficient iteration over sequences, the differences between iterators and generators, and the role of namespaces and scopes in managing variable accessibility. Additionally, it covers comprehensions for creating sequences and decorators for enhancing function behavior dynamically.

Uploaded by

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

Unique

The document provides an overview of key Python concepts including iterators, generators, namespaces, scopes, comprehensions, and decorators. It explains how iterators allow for efficient iteration over sequences, the differences between iterators and generators, and the role of namespaces and scopes in managing variable accessibility. Additionally, it covers comprehensions for creating sequences and decorators for enhancing function behavior dynamically.

Uploaded by

Ajit s Adin
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as DOCX, PDF, TXT or read online on Scribd
You are on page 1/ 47

ITERATORS –2

Namespce and scope-8


COMPREHENSION -13
DECORATORS -14
CLOSURES -18
Question: What are Iterators in Python?

Answer:
An iterator in Python is an object that allows you to iterate over a sequence of elements,
such as lists, tuples, strings, or even custom objects, one element at a time without needing
to know the underlying structure.

It implements two main methods:

 __iter__() → Returns the iterator object itself.


 __next__() → Returns the next element in the sequence and raises StopIteration
when there are no more elements.

Question: How are iterators different from iterables?

Answer:

 An iterable is any Python object capable of returning its elements one at a time. It has
an __iter__() method that returns an iterator. Examples: lists, tuples, dictionaries,
strings.
 An iterator is an object with a state so that it remembers where it is during iteration.
It implements both __iter__() and __next__() methods.

You can get an iterator from an iterable using the built-in iter() function:

python
CopyEdit
my_list = [1, 2, 3]
it = iter(my_list) # it is an iterator
print(next(it)) # 1
print(next(it)) # 2

Question: Can you create your own iterator?

Answer:
Yes. You can create a custom iterator by defining a class that implements __iter__() and
__next__().

Example:

python
CopyEdit
class MyRange:
def __init__(self, start, end):
self.current = start
self.end = end

def __iter__(self):
return self

def __next__(self):
if self.current >= self.end:
raise StopIteration
val = self.current
self.current += 1
return val

# Usage:
for num in MyRange(1, 5):
print(num)

Output:

CopyEdit
1
2
3
4

Question: Why use iterators?

Answer:

 Memory efficiency: They don’t load the entire sequence into memory at once (useful
for large datasets).
 Lazy evaluation: Elements are produced only when needed.
 Flexibility: Works well with loops and comprehensions.

Question: How does for loop work internally with iterators?

Answer:
A for loop in Python:

1. Calls iter() on the iterable to get an iterator.


2. Repeatedly calls next() on the iterator.
3. Stops when StopIteration is raised.

So, this:

python
CopyEdit
for x in [1, 2, 3]:
print(x)

Internally works like:

python
CopyEdit
it = iter([1, 2, 3])
while True:
try:
x = next(it)
print(x)
except StopIteration:
break

Question: What is the difference between iterator and generator?

Answer:

 Both are iterators, but generators are created using functions with the yield keyword
or generator expressions, whereas custom iterators require writing a class.
 Generators are more compact and easier to write.
 Generators are also lazy and memory efficient.

GENARATORS

✅ Q1: What is a generator in Python?

Answer:
A generator in Python is a special type of iterator that allows you to iterate over a sequence
of values without storing the entire sequence in memory. It’s implemented using a
function with the yield keyword instead of return.

When a generator function is called, it doesn’t execute immediately. Instead, it returns a


generator object, which can be iterated using next() or in a loop. Generators use lazy
evaluation, meaning they produce values on demand.

• yield pauses the function, sends a value back, and resumes from that point later.

________________________________________

Example:

python
CopyEdit
def my_generator():
yield 1
yield 2
yield 3

gen = my_generator()
print(next(gen)) # 1
print(next(gen)) # 2
print(next(gen)) # 3

✅ Q2: How is a generator different from a normal function?

Answer:

 A normal function returns once and then terminates.


 A generator function can yield multiple values and resumes execution from where it
left off after each yield.

Example difference:

python
CopyEdit
def normal_function():
return 1
return 2 # This never executes

def generator_function():
yield 1
yield 2

✅ Q3: How is a generator different from an iterator?

Answer:

 Iterators are objects that implement __iter__() and __next__() methods.


 Generators are a simpler way to create iterators without writing those methods
manually.
 Every generator is an iterator, but not every iterator is a generator.

✅ Q4: What is the advantage of using generators?

Answer:

 Memory Efficiency: They don’t store all values in memory; values are produced on
demand.
 Performance: Faster for large datasets because they don’t require full
materialization.
 Convenience: Easier to implement than writing a custom iterator class.

✅ Q5: Can you show a real-world example where generators are useful?

Answer:
Example: Reading a large file line by line

python
CopyEdit
def read_large_file(filename):
with open(filename) as f:
for line in f:
yield line.strip()

for line in read_large_file('huge_file.txt'):


print(line)

This way, the entire file isn’t loaded into memory at once.

✅ Q6: What is the difference between return and yield?

Answer:

 return ends the function and sends a single value back.


 yield pauses the function, sends a value back, and resumes from that point later.

✅ Q7: What is a generator expression?


Answer:
A generator expression is like a list comprehension but uses parentheses () instead of
brackets []. It creates a generator instead of a list.

Example:

python
CopyEdit
gen = (x**2 for x in range(5))
print(next(gen)) # 0
print(next(gen)) # 1

✅ Q8: How do you manually control a generator?

Answer:

 Use next() to get the next value.


 Use send() to send a value to the generator.
 Use throw() to raise an exception inside the generator.
 Use close() to stop the generator.

Example with send():

python
CopyEdit
def coro():
val = yield "start"
yield f"received {val}"

g = coro()
print(next(g)) # "start"
print(g.send("hello")) # "received hello"

✅ Q9: What happens when a generator is exhausted?

Answer:
When all yield statements are done, the generator raises StopIteration.

✅ Q10: Can generators be reused?

Answer:
No. Once exhausted, a generator cannot be restarted. You need to create a new generator
instance.
NAME SPACE AND SCCOPE IMP
https://www.youtube.com/watch?v=uAi6ZzsWN0k

✅ Interview-Style Deep Explanation:


Namespace and Scope in Python

1. What is a Namespace in Python?


Answer:
A namespace in Python is a container that holds name-object mappings, i.e., it associates
identifiers (like variable names, function names, class names) with the corresponding objects
in memory.

Whenever we create a variable, Python stores it in a specific namespace so that there is no


naming conflict.

Think of it like a dictionary, where:

 Key = Name of the variable


 Value = Object the name refers to

Why do we need namespaces?

 To avoid name collisions in large programs.


 To keep variables organized and isolated.
 To determine the scope of a variable.

2. Types of Namespaces in Python


Python has 4 types of namespaces, created at different times and with different lifetimes.

✅ a) Built-in Namespace

 Contains names of built-in functions and exceptions (print(), len(), int(), etc.).
 Created when the Python interpreter starts.
 Stays alive until the interpreter terminates.

Example:
python
CopyEdit
print(len("Python")) # 'print' and 'len' are in the built-in namespace

✅ b) Global Namespace

 Contains names defined at the module level (functions, variables, classes).


 Created when the module is loaded.
 Exists until the program ends.

Example:

python
CopyEdit
x = 100 # Global namespace
def func():
print(x)
func() # Output: 100

✅ c) Enclosing Namespace

 Exists in nested functions (functions inside functions).


 Contains names in the outer function (not global).
 Helps implement closures.

Example:

python
CopyEdit
def outer():
y = 20 # Enclosing namespace
def inner():
print(y) # Accesses enclosing variable
inner()
outer()

✅ d) Local Namespace

 Contains names defined inside a function (parameters and local variables).


 Created when the function is called.
 Destroyed when the function ends.

Example:

python
CopyEdit
def func():
z = 30 # Local namespace
print(z)
func()
✅ 3. How does Python resolve names across these
namespaces? (LEGB Rule)
Python follows the LEGB rule:

 L – Local: Current function


 E – Enclosing: Any enclosing function
 G – Global: Module-level
 B – Built-in: Python’s predefined names

Example:

python
CopyEdit
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x)
inner()
outer()
# Output: local

✅ 4. What is Scope in Python and How is it Related to


Namespace?
Answer:

 A namespace is a container of names.


 Scope refers to the region of code where those names are accessible.

Types of scope (based on LEGB):

 Local Scope → Inside a function.


 Enclosing Scope → Outer functions (for nested functions).
 Global Scope → Whole module.
 Built-in Scope → Python system-wide.

✅ 5. Difference Between Namespace and Scope


Namespace Scope
Stores names and objects Defines where a name can be accessed
Implemented as a dictionary Logical area in code
Namespace Scope
Example: globals() returns global namespace Example: LEGB rule

✅ 6. Role of global and nonlocal Keywords


 global: Tells Python to use the global variable.
 nonlocal: Tells Python to use the variable from the enclosing scope.

Example:

python
CopyEdit
x = "global"

def outer():
x = "enclosing"
def inner():
nonlocal x
x = "modified by inner"
inner()
print(x)

outer() # Output: modified by inner

✅ 7. Common Pitfalls and Tricky Questions


Q1: What happens if you modify a global variable inside a function without
using global?
python
CopyEdit
x = 10
def func():
x += 1 # Error: UnboundLocalError
func()

Reason: Python assumes x is local, but it's used before assignment.

Q2: How to access global variables inside a function?


python
CopyEdit
x = 10
def func():
global x
x += 1
func()
print(x) # 11
Q3: How do closures work with nonlocal?
python
CopyEdit
def outer():
count = 0
def inner():
nonlocal count
count += 1
return count
return inner

counter = outer()
print(counter()) # 1
print(counter()) # 2

✅ 8. Diagram: Namespace Hierarchy


sql
CopyEdit
+-------------------+
| Built-in Namespace|
+-------------------+

+-------------------+
| Global Namespace |
+-------------------+

+-------------------+
| Enclosing Namespace|
+-------------------+

+-------------------+
| Local Namespace |
+-------------------+

✅ 9. Summary
 Namespace = Mapping of names to objects.
 Scope = Region of code where a name is accessible.
 Python uses LEGB rule.
 global and nonlocal help modify outer variables.
COMPREHENSIONS

✅ Topic 11: Comprehensions in Python

Q1: What are comprehensions in Python?


A: Comprehensions in Python provide a concise and readable way to create new sequences
(like lists, sets, or dictionaries) from existing iterables, using a single line of code instead of
traditional loops.

Q2: Can you explain different types of comprehensions in Python?

A: There are three main types:

1. List Comprehension
Syntax:

python
CopyEdit
[expression for item in iterable if condition]

Example:

python
CopyEdit
squares = [x**2 for x in range(1, 6)]
# Output: [1, 4, 9, 16, 25]

2. Set Comprehension
Similar to list comprehension but creates a set:

python
CopyEdit
unique_squares = {x**2 for x in [1, 2, 2, 3]}
# Output: {1, 4, 9}

3. Dictionary Comprehension
Syntax:

python
CopyEdit
{key_expr: value_expr for item in iterable if condition}
Example:

python
CopyEdit
squares_dict = {x: x**2 for x in range(1, 6)}
# Output: {1: 1, 2: 4, 3: 9, 4: 16, 5: 25}

Q3: What is a nested comprehension?

A: A nested comprehension involves multiple loops inside a single comprehension.


Example:

python
CopyEdit
matrix = [[1, 2], [3, 4]]
flattened = [num for row in matrix for num in row]
# Output: [1, 2, 3, 4]

Q4: What is a conditional comprehension?

A: It includes an if condition inside the comprehension.


Example:

python
CopyEdit
even_squares = [x**2 for x in range(10) if x % 2 == 0]
# Output: [0, 4, 16, 36, 64]
DECORATORS
https://www.youtube.com/watch?v=uG4FD0SZ-cQ
def decorator(func):
def wrapper():
print("initial")
func()
print("completed")
return wrapper

def hello():
print("Executing")

hello1=decorator(hello) # return the wrapper


hello1() # calls the wrapper()
Without explicitly ccreating the object
We can use @decorator_name

First hello() will be called then it will create obj and call the
hello=decorator(hello) soo on…

Q1: What is a decorator in Python?

A:
A decorator in Python is a function that takes another function (or method) as input and
returns a new function with additional functionality, without modifying the original
function's code.
Decorators allow you to add behavior dynamically at runtime.

Q2: How do decorators work internally?

A:
They use functions as first-class citizens and closures.

 Functions in Python can be passed as arguments.


 Functions can return other functions.
 Closures allow inner functions to remember variables from the outer function.

Q3: Can you show a simple example of a decorator?

A:

python
CopyEdit
def my_decorator(func):
def wrapper():
print("Before function execution")
func()
print("After function execution")
return wrapper

@my_decorator
def say_hello():
print("Hello!")

say_hello()

Output:

pgsql
CopyEdit
Before function execution
Hello!
After function execution

Q4: What does the @decorator syntax mean?

A:
@decorator is just syntactic sugar for:

python
CopyEdit
say_hello = my_decorator(say_hello)

Q5: Can decorators take arguments?

A: Yes, by using an additional wrapper function:

python
CopyEdit
def repeat(n):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(n):
func(*args, **kwargs)
return wrapper
return decorator
@repeat(3)
def greet():
print("Hi!")

greet()

Output:

CopyEdit
Hi!
Hi!
Hi!

Q6: Can you explain decorators with arguments (like @staticmethod,


@classmethod)?

A:

 @staticmethod and @classmethod are built-in decorators:


o @staticmethod defines a method that doesn’t access self or cls.
o @classmethod defines a method that uses cls instead of self.

Example:

python
CopyEdit
class Example:
@staticmethod
def static_method():
print("Static Method")

@classmethod
def class_method(cls):
print("Class Method")

CLOSURES
https://www.programiz.com/python-programming/closure

Q1: What is a closure in Python?

A:
A closure is a function object that remembers the values from its enclosing lexical scope
even after the outer function has finished executing.

In simple terms:

 A closure is created when:


1. A nested function is defined inside another function.
2. The nested function refers to variables from the outer function.
3. The outer function returns the nested function.

def calculate():

num = 1

def inner_func():

nonlocal num

num += 2

return num

return inner_func

odd = calculate()

print(odd.__closure__) # Shows closure cells

print(odd.__closure__[0].cell_contents) # Shows current value of 'num'

print(odd()) # Calls the inner function, increments num by 2

Q3: How do closures work internally?

A:
Closures work because of Lexical Scoping:

 Functions carry a reference to their enclosing environment.


 Python stores this in the __closure__ attribute.

Example:

python
CopyEdit
print(closure_func.__closure__) # Shows cell objects storing values

Q4: Why are closures used?


A:
Closures are used for:

 Data hiding (like private variables in OOP).


 Callbacks and Event Handling.
 Decorator implementation.
 Maintaining state without using global variables.

Q5: Can you give a practical example of closure usage?

A:
Example: Creating a multiplier function

python
CopyEdit
def multiplier(n):
def multiply(x):
return x * n
return multiply

double = multiplier(2)
triple = multiplier(3)

print(double(5)) # Output: 10
print(triple(5)) # Output: 15

Q6: What is the difference between a closure and a normal function?

A:

 A normal function does not retain state between calls.


 A closure retains variables from its enclosing function, even after that function has
finished executing.

Q7: How do closures relate to decorators?

A:

 Decorators are built using closures because they need to wrap another function and
maintain state.

Q8: How can you check if a function is a closure in Python?


A:
By checking the __closure__ attribute:

python
CopyEdit
print(closure_func.__closure__) # Non-None means it's a closure

Q9: Can closures modify the outer function’s variable?

A:

 Yes, but you need the nonlocal keyword (for enclosing scope).
 Example:

python
CopyEdit
def outer():
count = 0
def inner():
nonlocal count
count += 1
return count
return inner

counter = outer()
print(counter()) # 1
print(counter()) # 2

Q10: What are some common pitfalls with closures?

A:

 Late binding issue: Inner functions capture variables by reference, not by value.
Example:

python
CopyEdit
funcs = []
for i in range(3):
funcs.append(lambda: i)

for f in funcs:
print(f()) # Output: 2, 2, 2 (NOT 0, 1, 2)

Fix:

python
CopyEdit
funcs = []
for i in range(3):
funcs.append(lambda i=i: i)
✅ Python Multithreading Example using threading.Thread
Code:
python
CopyEdit
from threading import Thread
from time import sleep

# Define first thread task


class Task1(Thread):
def run(self):
for i in range(5):
print("TASK1")
sleep(0.5) # Simulate work with delay

# Define second thread task


class Task2(Thread):
def run(self):
for i in range(5):
print("TASK2")
sleep(0.5)

# Create thread objects


thread1 = Task1()
thread2 = Task2()

# Start threads (runs in parallel)


thread1.start()
thread2.start()

# Wait for both threads to complete


thread1.join()
thread2.join()

# Main thread resumes after child threads finish


print("Main THREAD executing")

Explanation:

1. Import Modules
o threading.Thread → Used to create threads.
o time.sleep() → Adds delay to simulate work
and allow thread interleaving.
2. Create Thread Classes
o Task1 and Task2 inherit from Thread class.
o Override run() method to define the work each thread will do.
3. Create Thread Instances

python
CopyEdit
thread1 = Task1()
thread2 = Task2()

4. Start Threads

python
CopyEdit
thread1.start()
thread2.start()

o creates a new thread and calls run() internally.


.start()
o Both threads now run concurrently.
5. Wait for Threads to Finish

python
CopyEdit
thread1.join()
thread2.join()

o blocks the main thread until the respective thread finishes.


.join()
6. Main Thread Executes Last
o After both threads complete, the main thread prints:

css
CopyEdit
Main THREAD executing

Output (Interleaved):
css
CopyEdit
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
Main THREAD executing

✅ Key Points:

 Always use .start() to start threads; never call .run() directly.


 Use .join() if you need the main thread to wait for others.
 sleep() is optional but useful for demonstrating concurrency.
✅ Python Multiprocessing Example using
multiprocessing.Process

Code:
python
CopyEdit
from multiprocessing import Process
from time import sleep

# Define first process task


def task1():
for i in range(5):
print("TASK1")
sleep(0.5)

# Define second process task


def task2():
for i in range(5):
print("TASK2")
sleep(0.5)

# Create process objects


process1 = Process(target=task1)
process2 = Process(target=task2)

# Start processes (run in parallel on separate CPU cores)


process1.start()
process2.start()

# Wait for both processes to complete


process1.join()
process2.join()

print("Main PROCESS executing")

✅ Explanation

1. Import Modules
o multiprocessing.Process → Creates a new process (not just a thread).
o time.sleep() → Simulates work and shows concurrency.
2. Define Functions for Tasks
o Unlike threading, we usually define functions (not classes) for Process.
3. Create Process Instances

python
CopyEdit
process1 = Process(target=task1)
process2 = Process(target=task2)

4. Start Processes
python
CopyEdit
process1.start()
process2.start()

o Each process runs in its own memory space, separate from the main process.
o Can run on multiple CPU cores (true parallelism).
5. Wait for Processes to Complete

python
CopyEdit
process1.join()
process2.join()

6. Main Process Executes Last

css
CopyEdit
Main PROCESS executing

✅ Output (Interleaved):
css
CopyEdit
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
TASK1
TASK2
Main PROCESS executing
Q1: What is Multithreading in Python?

Answer:
Multithreading is a technique where multiple threads (smaller units of a process) run
concurrently within the same process. Threads share the same memory space, which makes
communication between them easy and efficient.

 In Python, multithreading is implemented using the threading module.


 Each thread runs in the same process space, so switching between threads is
lightweight compared to creating new processes.
 However, due to the Global Interpreter Lock (GIL) in CPython, only one thread
can execute Python bytecode at a time.
o This means multithreading in Python is not ideal for CPU-bound tasks,
because the GIL becomes a bottleneck.
o But it works well for I/O-bound tasks (like network requests, file operations)
since threads can release the GIL while waiting for I/O.

Example Use Case: Downloading multiple web pages concurrently.

Q2: What is Multiprocessing in Python?

Answer:
Multiprocessing is the technique of running multiple processes simultaneously, with each
process having its own Python interpreter and memory space.

 Implemented using the multiprocessing module in Python.


 Each process runs independently, so the GIL does not apply, making
multiprocessing ideal for CPU-bound tasks (like heavy computations).
 Downside: Processes don’t share memory, so communication requires IPC (Inter-
Process Communication) mechanisms like Pipe or Queue.
 Creating processes is heavier than creating threads and requires more memory.

Example Use Case: Performing heavy numerical computations on multiple CPU cores.

Q3: Difference Between Multithreading and Multiprocessing?

Feature Multithreading Multiprocessing


Multiple processes running
Definition Multiple threads in one process
independently
Memory Shared memory Separate memory space
Affected by GIL (one thread runs at a
GIL Impact Not affected by GIL
time)
Best For I/O-bound tasks CPU-bound tasks
Overhead Lightweight Heavyweight (more memory &
Feature Multithreading Multiprocessing
overhead)
Communication Easy (shared memory) Requires IPC (queues, pipes)

Q4: Can you give examples where each should be used?

 Multithreading: Web scraping, handling multiple I/O operations (file read/write,


network calls).
 Multiprocessing: Machine learning model training, image/video processing, data
crunching on large datasets.

Q5: How do you implement both in Python?

Multithreading Example

python
CopyEdit
import threading
import time

def print_numbers():
for i in range(5):
print(f"Number: {i}")
time.sleep(1)

thread1 = threading.Thread(target=print_numbers)
thread2 = threading.Thread(target=print_numbers)

thread1.start()
thread2.start()

thread1.join()
thread2.join()
print("Finished Multithreading")

Multiprocessing Example

python
CopyEdit
import multiprocessing
import time

def print_numbers():
for i in range(5):
print(f"Number: {i}")
time.sleep(1)

if __name__ == "__main__":
process1 = multiprocessing.Process(target=print_numbers)
process2 = multiprocessing.Process(target=print_numbers)

process1.start()
process2.start()

process1.join()
process2.join()
print("Finished Multiprocessing")

✅ Key Takeaways:

 Use multithreading for I/O-bound tasks.


 Use multiprocessing for CPU-bound tasks.
 GIL limits true multithreading in Python, but not multiprocessing.

✅ 1. threading.Thread Methods
In the threading example, we used these methods:

(a) start()

 Purpose: Starts the thread’s activity.


 What it does:
o Allocates resources for the thread.
o Creates a new thread in the operating system.
o Internally calls the run() method in that new thread.
 Important: Do NOT call run() directly, or the code will run in the main thread.

(b) run()

 Purpose: Contains the code that the thread will execute.


 How it works:
o We override this method in a subclass of Thread.
o When .start() is called, Python internally calls this run() method.
 Example:

python
CopyEdit
class MyThread(Thread):
def run(self):
print("Running in a new thread")

(c) join()

 Purpose: Blocks the calling (main) thread until the thread on which join() is called
terminates.
 Why used:
o Ensures that the main program waits for all threads to finish before continuing.
 Example:

python
CopyEdit
thread1.join() # Wait for thread1 to finish

(d) sleep()

 From: time module.


 Purpose: Pauses the current thread for the given number of seconds.
 Why used:
o Simulate work or slow down output to show interleaving.
 Example:

python
CopyEdit
sleep(0.5) # Pauses for half a second

✅ 2. multiprocessing.Process Methods
In the multiprocessing example, we used:

(a) Process()

 Purpose: Creates a new process object.


 Parameters:
o target: Function to be executed in the new process.
o args: Tuple of arguments to pass to the target function.
 Example:

python
CopyEdit
process1 = Process(target=task1)

(b) start()

 Purpose: Starts the process.


 What it does:
o Spawns a new operating system process.
o Executes the target function in that process.
 Difference from Threads:
o A new process is completely independent and has its own memory space.
(c) join()

 Purpose: Blocks the calling process until the process whose join is called terminates.
 Why used:
o Prevents the main process from exiting before child processes complete.
 Example:

python
CopyEdit
process1.join()

(d) sleep()

 Same as in threading, pauses execution of the current process.

✅ Key Internal Details


 Threading: Uses the same memory space; good for I/O-bound tasks.
 Multiprocessing: Creates separate memory space; good for CPU-bound tasks.
 Both use start() and join() to manage execution flow.

✅ Notes on Multiprocessing with Pool


1. Purpose

 Used to parallelize tasks using multiple processes.


 Speeds up CPU-bound or time-consuming operations by running in multiple cores.

2. Code Overview
python
CopyEdit
from multiprocessing import Pool
import time

def square(n):
time.sleep(0.5) # Simulate heavy computation
return n * n

if __name__ == "__main__":
numbers = [1, 2, 3, 4, 5]
with Pool(processes=3) as pool: # Create 3 worker processes
results = pool.map(square, numbers)
print("Results:", results)

3. Key Components

 Pool(processes=n): Creates a pool of n worker processes.


 map(func, iterable):
o Applies func to every element in iterable in parallel.
o Returns results in the same order as input.
 with Pool(...) as pool::
o Context manager ensures proper cleanup of processes.

4. Execution Flow

 Input list: [1, 2, 3, 4, 5]


 3 processes handle 5 tasks:
o Round 1: P1 → 1, P2 → 2, P3 → 3
o Round 2: P1 → 4, P2 → 5
 Total time ≈ (5 ÷ 3) × 0.5s ≈ 1 second (parallel).
 Sequential would take 2.5 seconds.

5. Output
makefile
CopyEdit
Results: [1, 4, 9, 16, 25]

6. Why if __name__ == "__main__":?

 Prevents infinite process spawning on Windows.


 Required for multiprocessing entry point.

7. Advantages

 True parallelism using multiple CPU cores (unlike threads, which are limited by GIL
in CPython).
 Ideal for CPU-bound tasks.

8. Alternatives
 ThreadPool (for I/O-bound tasks).
 apply() or apply_async() for single-task submission.
 concurrent.futures.ProcessPoolExecutor.

2. How Do They Work?

Asyncio

 Imagine you’re a single chef (one thread) cooking 10 dishes. Instead of waiting for
each dish to finish cooking before starting the next, you chop vegetables for one dish,
then while it’s simmering, you mix ingredients for another. You keep switching
between tasks to keep everything moving.
 How it works:
o Uses an event loop, which is like a scheduler that decides which task runs
next.
o Tasks are written as coroutines (special functions using async def and await).
o When a task is waiting (e.g., for a network response), it “pauses” and lets other
tasks run.
o Everything happens in one thread, so it’s lightweight and efficient.
 Example:

python

CollapseWrapRun

Copy

import asyncio

async def cook_dish(name):

print(f"Starting {name}")

await asyncio.sleep(1) # Simulate waiting (e.g., for an oven)

print(f"Finished {name}")
async def main():

await asyncio.gather(cook_dish("Pizza"), cook_dish("Pasta")) #


Run tasks together

asyncio.run(main())

Output:

text

CollapseWrap

Copy

Starting Pizza

Starting Pasta

Finished Pizza

Finished Pasta

Here, the chef (event loop) starts Pizza, waits (but works on Pasta during the wait),
then finishes both.

Threading

 Imagine you hire two chefs (two threads) to cook Pizza and Pasta. Each chef works on
their dish independently, but they share the same kitchen (memory). The kitchen has a
rule (the GIL) that only one chef can use the stove at a time for certain tasks.
 How it works:
o Each thread runs its own task, and the operating system decides when to
switch between them.
o Threads are preemptive, meaning the system can interrupt one chef to let
another work.
o Good for tasks where waiting happens (e.g., downloading files), but the GIL
limits performance for tasks needing heavy computation.
 Example:

python

CollapseWrapRun

Copy
import threading

import time

def cook_dish(name):

print(f"Starting {name}")

time.sleep(1) # Simulate waiting

print(f"Finished {name}")

threads = [threading.Thread(target=cook_dish, args=("Pizza",)),

threading.Thread(target=cook_dish, args=("Pasta",))]

for t in threads:

t.start()

for t in threads:

t.join()

Output:

text

CollapseWrap

Copy

Starting Pizza

Starting Pasta

Finished Pizza

Finished Pasta
Here, two chefs (threads) work on their dishes, but the GIL might slow things down if
they need to share resources.

3. Key Differences

Aspect Asyncio Threading


How Many
One thread (lightweight). Multiple threads (heavier).
Threads?
How Tasks Switch You control switching with await. OS decides when to switch threads.
Best For Waiting tasks (e.g., web requests). Waiting tasks with blocking code.
Scalability Handles thousands of tasks easily. Struggles with hundreds of threads.
No risk of data conflicts (single Risk of data conflicts (shared
Safety
thread). memory).
Easier for beginners, but tricky to
Ease of Use Needs async/await knowledge.
manage.

Imp

✅ 1. Yield
 Yield = temporarily give up control so something else can run.
 In asyncio, you don’t use yield (except in generator-based coroutines); you use
await.
 await is the modern way to say:
“Pause me here until the awaited result is ready, and let the event loop run other
tasks in the meantime.”
 Without yield/await, you block the entire loop.

✅ 2. Coroutine
 A coroutine is a special function that can be suspended and resumed.
 Declared with async def, returns a coroutine object when called.
 Unlike normal functions, coroutines can stop mid-way at await points and come back
later.

Example:

python
CopyEdit
async def say_hello():
print("Hello")
await asyncio.sleep(1) # yield to event loop
print("World")

✅ 3. Event Loop
 The event loop is the scheduler that runs your coroutines.
 Single-threaded in asyncio (usually).
 It keeps a list of:
o Ready tasks (can run immediately)
o Waiting tasks (blocked on I/O, timers)
 Runs in a loop:

arduino
CopyEdit
while tasks remain:
pick ready tasks -> run until next await
check I/O events
wake tasks that are ready

✅ 4. Preemption
 Preemptive multitasking = The OS can interrupt any thread at any time, without its
consent.
 Threads use this: The OS scheduler forces context switches even if your code never
yields.
 Asyncio is NOT preemptive:
o The loop never interrupts a coroutine in the middle of Python code.
o Coroutines must cooperate by calling await to give control back.

⚖️Key Difference

Feature asyncio threads


Switching Only at await (cooperative) Anytime (preemptive)
Who decides? Your code OS scheduler

📉 Problem if you block the loop

If a coroutine runs CPU-heavy work without await, the event loop cannot schedule
anything else.
→ Other tasks freeze.
Want me to draw a diagram showing:

 How await cooperates with event loop scheduling?


 How preemptive threading interrupts anytime?

Or do you want a low-level explanation of coroutine internals (send(), yield from, etc.)
next?

✅ Definition

Cooperative multitasking means:

 Tasks voluntarily give up control so others can run.


 The system does not forcibly interrupt a running task.
 You (the programmer) decide where tasks can pause, usually with await.

✅ How it works in asyncio

 Every time a coroutine hits an await on something that’s not immediately ready (I/O,
timer), it yields control back to the event loop.
 The event loop then schedules other tasks that are ready.
 When the awaited operation completes, the event loop resumes the original
coroutine.

Example:

python
CopyEdit
async def task1():
print("Start T1")
await asyncio.sleep(2) # yields control to loop
print("End T1")

async def task2():


print("Start T2")
await asyncio.sleep(1) # yields earlier than task1
print("End T2")

async def main():


await asyncio.gather(task1(), task2())

asyncio.run(main())

Output:

sql
CopyEdit
Start T1
Start T2
End T2
End T1
Notice:

 Both started almost “together.”


 While task1 waited for 2s, the event loop ran task2.

✅ Contrast with preemptive multitasking

 In threads, the OS preempts (forces a context switch) at any time.


 Your code doesn’t decide where to yield—it just happens.

For interview

Q1. What is asyncio in Python?

Answer:
asyncio is a Python library used to write concurrent code using the async/await syntax. It
provides an event loop that runs asynchronous tasks and callbacks, performs network I/O,
and manages subprocesses. Unlike threads, asyncio uses single-threaded cooperative
multitasking, which means tasks voluntarily yield control when they perform I/O or await
something, making it very efficient for I/O-bound operations like web scraping, database
calls, or network requests.

Key points:

 Introduced in Python 3.4, modern usage uses async and await (from Python 3.5+).
 Uses coroutines for concurrency, not threads.
 Great for I/O-bound tasks but not suitable for CPU-bound tasks because the event
loop runs in a single thread.

Example:

python
CopyEdit
import asyncio

async def fetch_data():


await asyncio.sleep(1)
return "Data fetched"

async def main():


result = await fetch_data()
print(result)

asyncio.run(main())
Q2. What is multithreading in Python, and how is it different from asyncio?

Answer:
Multithreading in Python refers to using multiple threads within a single process to execute
code concurrently. It is managed by the threading module. However, due to Python's
Global Interpreter Lock (GIL), only one thread executes Python bytecode at a time. This
means multithreading is not truly parallel for CPU-bound tasks, but it works well for I/O-
bound tasks like reading files or making network requests.

Key points:

 Threads share the same memory space.


 Useful for I/O-bound operations but limited for CPU-bound due to GIL.
 Can still provide concurrency because while one thread waits for I/O, others can run.

Example:

python
CopyEdit
import threading
import time

def task():
time.sleep(1)
print("Task complete")

threads = []
for _ in range(5):
t = threading.Thread(target=task)
t.start()
threads.append(t)

for t in threads:
t.join()

Difference between asyncio and multithreading

Aspect asyncio Multithreading


Concurrency type Cooperative (coroutines) Pre-emptive (OS threads)
Threads used Single-threaded Multiple threads
Best for I/O-bound tasks I/O-bound tasks (not CPU-bound due to GIL)
Overhead Low Higher (thread context switching)

✅ Pro tip for interview: If asked "When to use asyncio vs threads?", say:

 Use asyncio when your tasks involve lots of network or I/O and you want minimal
overhead.
 Use multithreading when you have blocking I/O libraries that are not async-
compatible.
1. Memory Management in Python: Overview
 Python uses automatic memory management, meaning the developer does not need
to manually allocate or free memory.
 It is built on top of the private heap space where all Python objects and data
structures are stored.
 Python’s memory management system is mainly handled by:
1. Python Memory Manager
2. Garbage Collector
3. Reference Counting
4. Dynamic Typing & Object Reuse

2. Components of Python Memory Management


2.1 Python Private Heap

 All objects (e.g., integers, lists, dictionaries) are stored in a private heap reserved for
the Python interpreter.
 This heap is not accessible directly by the programmer.
 The Python memory manager handles the allocation of this heap for various objects.

2.2 Python Memory Manager

 Responsible for allocating and deallocating memory for objects.


 It works in layers:
o Object-specific allocators: Allocate memory for specific types (lists, dicts,
strings).
o Python memory allocator (PyMalloc): Handles memory requests from
Python objects.
o Underlying system allocator (malloc/free): Provided by the OS.

3. Reference Counting
 Python uses reference counting as the primary memory management technique.
 Every object in Python has an internal counter that tracks the number of references
pointing to it.
 When you assign an object to a variable, the reference count increases:
python
CopyEdit
a = [1, 2, 3]
b = a # reference count of the list increases

 When references are deleted or go out of scope, the count decreases.


 When the reference count reaches zero, the object’s memory is deallocated
immediately.

Check reference count using sys.getrefcount():

python
CopyEdit
import sys
a = []
print(sys.getrefcount(a)) # Usually returns 2 (one for 'a', one for the
function argument)

4. Garbage Collection (GC)


 Reference counting cannot handle circular references (e.g., two objects referencing
each other).
 Example:

python
CopyEdit
a = []
b = []
a.append(b)
b.append(a)

 Both a and b have references > 0, even though they are unreachable.
 Python’s garbage collector (in the gc module) detects these cycles and frees
memory.

GC Mechanism

 Python uses a generational garbage collection algorithm:


o Generation 0: New objects
o Generation 1: Survived one collection
o Generation 2: Survived multiple collections
 Most garbage is short-lived, so young generations are collected more frequently.

5. Memory Pools and PyMalloc


 To reduce fragmentation and improve performance, Python uses a memory pool
system.
 PyMalloc is a specialized allocator for small objects (≤ 512 bytes).
 For large objects, Python requests memory directly from the OS.

6. Dynamic Typing and Object Reuse


 Python objects are dynamically typed; their size depends on type and content.
 Python reuses some immutable objects for efficiency:
o Integers from -5 to 256 are cached and reused.
o Empty strings, tuples, and some interned strings are reused.

7. Tips for Memory Optimization


 Use del to delete unnecessary references:

python
CopyEdit
del a

 Use gc.collect() to manually run garbage collection (rarely needed).


 Use generators instead of large lists to save memory.
 Profile memory usage with:
o sys.getsizeof()
o tracemalloc
o memory_profiler

REGULAR EXPRESSIONS –code basics video


See notebook
✅ What is a Regular Expression?
 A regular expression (regex) is a sequence of characters that defines a search
pattern. In Python, regex is handled using the re module, which provides functions
for searching, matching, replacing, and splitting text based on patterns.
 \d matches any digit (0–9)
 \w matches any word character (letters, digits, underscore)
 . matches any character except newline
 * matches 0 or more repetitions of the previous character/pattern

✅ Importing re Module
python
CopyEdit
import re

✅ Common Regex Functions in Python


Function Description
re.match() Checks for a match only at the beginning of the string
re.search() Searches the string for a match anywhere
re.findall() Returns all matches as a list
re.finditer() Returns an iterator with match objects
re.sub() Replaces matches with a new string
re.split() Splits a string by the matches

🔍 Regex Metacharacters & Special


Sequences
Pattern Meaning
. Any character except newline
^ Start of string
$ End of string
* 0 or more occurrences
+ 1 or more occurrences
? 0 or 1 occurrence
{m,n} m to n occurrences
[] Character class
\d Digit
\D Non-digit
Pattern Meaning
\w Word character
\W Non-word character
\s Whitespace
\S Non-whitespace

✅ Examples with Detailed Explanation

Example 1: Basic Match


python
CopyEdit
import re

pattern = r"Hello"
text = "Hello World"

match = re.match(pattern, text)


print(match) # Output: <re.Match object; span=(0, 5), match='Hello'>

✔ Explanation:

 re.match() checks only at the start of the string.


 The pattern Hello matches the first word in Hello World.
 span=(0, 5) means match is at position 0–5.

Example 2: Search Anywhere


python
CopyEdit
pattern = r"World"
text = "Hello World"

match = re.search(pattern, text)


print(match.span()) # Output: (6, 11)

✔ Explanation:

 re.search() looks for the pattern anywhere in the string.


 Found "World" at positions 6–11.

Example 3: Find All Numbers


python
CopyEdit
pattern = r"\d+"
text = "There are 12 apples and 34 oranges."

numbers = re.findall(pattern, text)


print(numbers) # Output: ['12', '34']

✔ Explanation:

 \d+ means one or more digits.


 Finds all numbers in the string.

Example 4: Splitting a String


python
CopyEdit
pattern = r"\s+"
text = "Split this text by spaces"

result = re.split(pattern, text)


print(result) # Output: ['Split', 'this', 'text', 'by', 'spaces']

✔ Explanation:

 \s+ matches one or more spaces.


 re.split() splits the string wherever this pattern occurs.

Example 5: Replace Words


python
CopyEdit
pattern = r"apple"
text = "I like apple pie and apple juice."

new_text = re.sub(pattern, "orange", text)


print(new_text) # Output: I like orange pie and orange juice.

✔ Explanation:

 re.sub() replaces all matches of the pattern with "orange".

Example 6: Using Groups


python
CopyEdit
pattern = r"(\d{2})-(\d{2})-(\d{4})"
text = "Today's date is 18-07-2025"
match = re.search(pattern, text)
print(match.groups()) # Output: ('18', '07', '2025')

✔ Explanation:

 (\d{2}) captures two digits, (\d{4}) captures four digits.


 groups() returns the captured parts.

Example 7: Optional Matching


python
CopyEdit
pattern = r"colou?r"
texts = ["color", "colour"]

for t in texts:
print(re.match(pattern, t))

✔ Explanation:

 u?means u is optional.
 Matches both "color" and "colour".

✅ When to Use match() vs search()


 match() → only at start of string
 search() → anywhere in the string

✅ Practical Example: Extract Emails


python
CopyEdit
pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
text = "Contact us at [email protected] or [email protected]"

emails = re.findall(pattern, text)


print(emails) # Output: ['[email protected]', '[email protected]']

✔ Explanation:

 Matches email pattern using character classes and quantifiers.

4. Validate IP Address
LeetCode #468
Difficulty: Medium
Problem:
Write a function to check if a given string is a valid IPv4 or IPv6 address.

✔ Solution using regex:

 IPv4: ^(25[0-5]|2[0-4]\d|[0-1]?\d?\d)(\.(...)){3}$
 IPv6: ^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$

5. Strong Password Checker

LeetCode #420
Difficulty: Hard
Problem:
Check if a string meets strong password criteria:

 Length between 6 and 20


 At least one lowercase, one uppercase, one digit
 No three repeating characters

✔ Regex can be used for partial checks.

You might also like