All topics

Python Interview Questions & Answers

40 questions with detailed answers — for freshers and experienced candidates.

Want to actually learn Python?

Join a hands-on mini internship or training on iCampusLink and earn a certificate.

Explore programs →

Fresher Level

Q1. What is Python and what are its key features?

Python is a high-level, interpreted, general-purpose programming language known for its readability and versatility. Key features include its simple syntax, dynamic typing, automatic memory management (garbage collection), support for multiple programming paradigms (procedural, object-oriented, functional), extensive standard library, and cross-platform compatibility. It is widely used in web development, data science, AI/ML, automation, and more, thanks to its vast ecosystem of third-party libraries. Its "batteries included" philosophy makes development faster and more efficient.
# Example: Simple print statement
print("Hello, Python!")

Q2. Explain the difference between lists and tuples in Python.

Lists and tuples are both ordered collections of items in Python. The primary difference is mutability: lists are mutable, meaning their elements can be changed, added, or removed after creation. Tuples, on the other hand, are immutable; once a tuple is created, its contents cannot be altered. This makes tuples suitable for data that should remain constant, like coordinates or database records. Lists are typically used for collections where elements might change, such as a queue or a shopping cart. Tuples are generally more memory-efficient and faster for iteration than lists.
my_list = [1, 2, 3]
my_list.append(4) # Valid
my_tuple = (1, 2, 3)
# my_tuple.append(4) # Error: 'tuple' object has no attribute 'append'

Q3. What are Python's built-in data types?

Python has several built-in data types to store various kinds of data. Numeric types include `int` (integers), `float` (floating-point numbers), and `complex` (complex numbers). Sequence types are `str` (strings for text), `list` (mutable ordered collections), and `tuple` (immutable ordered collections). Mapping type is `dict` (mutable key-value pairs). Set types include `set` (mutable unordered collections of unique items) and `frozenset` (immutable version of set). Finally, `bool` represents Boolean values (`True` or `False`), and `NoneType` for the `None` object.
# Examples of built-in types
x = 10         # int
name = "Alice" # str
data = [1, 2]  # list

Q4. What is the purpose of `if __name__ == "__main__":`?

The `if __name__ == "__main__":` block is a common idiom in Python scripts used to determine whether the script is being run directly or imported as a module into another script. When a Python script is executed, the special variable `__name__` is set to `"__main__"`. If the script is imported, `__name__` is set to the module's name. This block ensures that certain code, such as function calls or tests, only runs when the script is executed directly, preventing it from running when the script's functions or classes are imported and reused elsewhere.
def greet():
    print("Hello from the main block!")

if __name__ == "__main__":
    greet() # This will run only when script is executed directly

Q5. Explain Python `for` and `while` loops.

Python provides `for` and `while` loops for iterative execution of code blocks. A `for` loop is primarily used for iterating over a sequence (like a list, tuple, string, or range) or other iterable objects. It executes the loop body once for each item in the sequence. A `while` loop, on the other hand, repeatedly executes a block of code as long as a specified condition remains true. It's suitable when the number of iterations is not known beforehand and depends on a condition. Both loops support `break` to exit the loop and `continue` to skip the rest of the current iteration.
# For loop
for i in range(3):
    print(f"For: {i}")

# While loop
count = 0
while count < 3:
    print(f"While: {count}")
    count += 1

Q6. How do you handle exceptions in Python?

Exceptions in Python are handled using `try`, `except`, `else`, and `finally` blocks. The `try` block contains the code that might raise an exception. If an exception occurs, the code inside the corresponding `except` block is executed. You can specify different `except` blocks for different exception types. The `else` block executes only if no exception was raised in the `try` block. The `finally` block always executes, regardless of whether an exception occurred or not, making it ideal for cleanup operations like closing files. This mechanism ensures robust error handling and prevents program crashes.
try:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero!")
finally:
    print("Execution complete.")

Q7. What is a docstring in Python?

A docstring (documentation string) in Python is a string literal that occurs as the first statement in a module, function, class, or method definition. It provides a concise summary of the object's purpose and usage. Unlike regular comments, docstrings are accessible at runtime via the `__doc__` attribute of the object. They are crucial for writing readable and maintainable code, as they serve as built-in help for users and developers. Tools like Sphinx can extract docstrings to automatically generate API documentation. PEP 257 defines conventions for writing good docstrings.
def add(a, b):
    """
    This function takes two numbers and returns their sum.
    """
    return a + b

print(add.__doc__)

Q8. Explain the difference between `range()` and `xrange()` in Python 2, and how `range()` works in Python 3.

In Python 2, `range()` created a list of numbers in memory, which could consume significant memory for large ranges. `xrange()` was introduced as a generator that yielded numbers one at a time, making it memory-efficient for large sequences. In Python 3, `range()` was redesigned to behave like Python 2's `xrange()`. It now returns an immutable sequence object that generates numbers on the fly (lazily), consuming minimal memory regardless of the range size. This means Python 3's `range()` is always memory-efficient and suitable for iterating over large sequences without creating a full list in memory.
# Python 3 range() example (behaves like Py2 xrange)
for i in range(5):
    print(i) # Numbers generated on demand

Q9. How can you comment code in Python?

In Python, comments are used to explain code and make it more readable, and they are ignored by the interpreter during execution. There are two primary ways to comment: 1. **Single-line comments:** Start with a hash symbol (`#`). Anything after `#` on that line is considered a comment. 2. **Multi-line comments (or block comments):** While Python doesn't have a dedicated multi-line comment syntax like `/* ... */` in C++, multi-line string literals (triple single or double quotes `'''...'''` or `"""..."""`) not assigned to a variable are often used as block comments. When they are the first statement in a module, function, class, or method, they are treated as docstrings; otherwise, they act as comments.
# This is a single-line comment
x = 10 # Inline comment

'''
This is a multi-line string literal,
often used as a block comment.
'''
"""
Another way to write
a block comment.
"""

Q10. What is PEP 8?

PEP 8 is Python's official style guide, providing conventions for writing readable and consistent Python code. Its primary goal is to improve the readability of code and make it consistent across the vast Python community, making it easier for different developers to understand and maintain each other's code. It covers aspects like indentation (4 spaces), line length (max 79 chars), naming conventions (e.g., `snake_case` for functions/variables, `CamelCase` for classes), whitespace usage, and how to structure imports. Adhering to PEP 8 is considered a best practice for professional Python development.
# Example PEP 8 adherence
def calculate_total_amount(price, quantity): # snake_case for function
    total = price * quantity
    return total

Intermediate Level

Q1. Explain mutable vs. immutable objects in Python.

In Python, objects are classified as mutable or immutable based on whether their state can be changed after creation. Mutable objects, like lists, dictionaries, and sets, can be modified in-place; their content can be altered without creating a new object. Immutable objects, such as numbers (int, float), strings, and tuples, cannot be changed after they are created. Any operation that appears to modify an immutable object actually creates a new object. This distinction is crucial for understanding how objects are passed to functions, how they behave as dictionary keys or set elements, and potential side effects.
# Mutable example: list
my_list = [1, 2, 3]
my_list.append(4) # my_list is now [1, 2, 3, 4]

# Immutable example: string
my_string = "hello"
new_string = my_string + " world" # new_string is "hello world", my_string is still "hello"

Q2. What are decorators in Python? Provide an example.

Decorators in Python are a powerful and elegant way to modify or enhance functions or methods without permanently altering their source code. They are essentially functions that take another function as an argument, add some functionality, and return a new function (or the modified original function). Decorators are typically used for cross-cutting concerns like logging, timing, authentication, or caching. They are applied using the `@decorator_name` syntax placed directly above the function definition. This promotes code reusability and separation of concerns, making code cleaner and more modular.
def my_decorator(func):
    def wrapper():
        print("Something is happening before the function is called.")
        func()
        print("Something is happening after the function is called.")
    return wrapper

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

say_hello()

Q3. Explain `*args` and `**kwargs`.

`*args` and `**kwargs` are special syntaxes in Python function definitions to pass a variable number of arguments. `*args` (arbitrary positional arguments) allows a function to accept any number of non-keyword arguments. Inside the function, `args` will be a tuple containing all the positional arguments passed. `**kwargs` (arbitrary keyword arguments) allows a function to accept any number of keyword arguments. Inside the function, `kwargs` will be a dictionary containing all the keyword arguments passed, where keys are argument names and values are their corresponding values. They are useful when you don't know in advance how many arguments will be passed to a function, or to create flexible function interfaces.
def example_func(*args, **kwargs):
    print("Positional args:", args)
    print("Keyword args:", kwargs)

example_func(1, 2, 3, name="Alice", age=30)

Q4. What is the Global Interpreter Lock (GIL) in Python?

The Global Interpreter Lock (GIL) is a mutex that protects access to Python objects, preventing multiple native threads from executing Python bytecodes simultaneously. This means that even on multi-core processors, only one thread can execute Python bytecode at a time. The GIL simplifies memory management and prevents race conditions for CPython, the default Python implementation. However, it can limit the performance of CPU-bound multi-threaded programs. I/O-bound programs, which spend time waiting for external resources, can still benefit from threading because the GIL is released during I/O operations. For true parallelism on CPU-bound tasks, multiprocessing is often used.
import threading
# GIL limits true parallel execution of Python bytecode
# Even with multiple threads, only one can run Python code at a time.

Q5. Differentiate between `__init__` and `__new__` methods in Python classes.

`__new__` and `__init__` are both special methods used in object creation, but they serve different purposes. `__new__` is a static method responsible for creating and returning a *new instance* of the class. It's called before `__init__` and is typically used in cases where you need to control the instance creation itself, such as implementing singletons or immutable classes. It receives the class as its first argument (`cls`). `__init__` is an instance method responsible for *initializing the state* of the newly created object. It receives the newly created instance (`self`) as its first argument. Most of the time, you'll only need `__init__` for setting up object attributes.
class MyClass:
    def __new__(cls, *args, **kwargs):
        print("Inside __new__")
        instance = super().__new__(cls) # Create the actual instance
        return instance

    def __init__(self, value):
        print("Inside __init__")
        self.value = value

obj = MyClass(10)

Q6. What are generators in Python? How are they created?

Generators are a special type of iterator in Python that allow you to iterate over a potentially very large sequence of data without loading it all into memory at once. They are "lazy" in that they produce items one by one, only when requested. This makes them highly memory-efficient for large datasets or infinite sequences. Generators are created using functions that contain the `yield` keyword instead of `return`. When `yield` is encountered, the function pauses, returns a value, and saves its state. The next time `next()` is called on the generator, it resumes execution from where it left off.
def count_up_to(max_val):
    n = 0
    while n <= max_val:
        yield n
        n += 1

counter = count_up_to(3)
print(next(counter)) # 0
print(next(counter)) # 1

Q7. Explain method overriding and method overloading in Python.

**Method Overriding:** Occurs when a subclass provides a specific implementation for a method that is already defined in its superclass. The method in the subclass has the same name, parameters, and return type (though Python doesn't enforce return types) as the method in the superclass. This allows subclasses to customize or extend the behavior inherited from their parent. **Method Overloading:** This typically means having multiple methods with the same name but different parameters (number or type) within the same class. Python does *not* support method overloading in the traditional sense like C++ or Java. If you define multiple methods with the same name, the last one defined will override the previous ones. To achieve similar functionality, you can use default arguments, `*args`, or `**kwargs` to make a single method handle various argument combinations.
class Parent:
    def show(self):
        print("Parent's show")

class Child(Parent):
    def show(self): # Method overriding
        print("Child's show")

c = Child()
c.show()

Q8. What is the purpose of `self` in Python classes?

In Python, `self` is a conventional name for the first parameter of an instance method within a class. It refers to the instance of the class itself, allowing access to its attributes and methods. When you call a method on an object (e.g., `my_object.method()`), Python automatically passes the object `my_object` as the first argument to the `method`, which is received by the `self` parameter. Although you could technically use any other name instead of `self`, using `self` is a widely accepted and highly recommended PEP 8 convention, making code readable and consistent across the Python community.
class Dog:
    def __init__(self, name):
        self.name = name # 'self.name' refers to the instance's 'name' attribute

    def bark(self):
        print(f"{self.name} says Woof!") # 'self.name' accesses the instance's name

my_dog = Dog("Buddy")
my_dog.bark()

Q9. How do you implement inheritance in Python?

Inheritance is a fundamental concept in Object-Oriented Programming (OOP) where a new class (subclass/child class) derives properties and behaviors from an existing class (superclass/parent class). In Python, you implement inheritance by placing the parent class name in parentheses after the child class name during its definition. The child class inherits all public and protected members (attributes and methods) of the parent. It can also override inherited methods or add new ones. The `super()` function is commonly used in the child class's `__init__` method to call the parent class's `__init__` and properly initialize inherited attributes.
class Animal:
    def __init__(self, name):
        self.name = name

    def speak(self):
        raise NotImplementedError("Subclass must implement abstract method")

class Dog(Animal): # Dog inherits from Animal
    def __init__(self, name, breed):
        super().__init__(name) # Call parent's __init__
        self.breed = breed

    def speak(self):
        return f"{self.name} barks!"

my_dog = Dog("Buddy", "Golden Retriever")
print(my_dog.speak())

Q10. What are list comprehensions? Provide an example.

List comprehensions provide a concise and elegant way to create lists in Python. They offer a more readable and often more performant alternative to traditional `for` loops and `append()` operations for generating lists. A list comprehension consists of brackets containing an expression followed by a `for` clause, then zero or more `for` or `if` clauses. They essentially "comprehend" (or build) a list by applying an expression to each item in an iterable, optionally filtering items based on a condition. This syntax is also available for sets (set comprehensions) and dictionaries (dictionary comprehensions).
# Traditional loop
squares_loop = []
for i in range(5):
    squares_loop.append(i * i)
print(squares_loop) # Output: [0, 1, 4, 9, 16]

# List comprehension
squares_comp = [i * i for i in range(5)]
print(squares_comp) # Output: [0, 1, 4, 9, 16]

# With a condition
even_squares = [i * i for i in range(10) if i % 2 == 0]
print(even_squares) # Output: [0, 4, 16, 36, 64]

Q11. Explain the difference between shallow and deep copy.

The `copy` module in Python provides functions for copying objects. A **shallow copy** creates a new compound object but then inserts references to the objects found in the original. This means that if the original object contains mutable elements (like lists within a list), changes to those nested mutable elements will be reflected in both the original and the shallow copy. A **deep copy** creates a new compound object and then recursively inserts copies of the objects found in the original. This means that a deep copy is completely independent of the original object; changes to nested mutable elements in one will not affect the other. Deep copies are more memory-intensive but provide full independence.
import copy

list_original = [[1, 2], [3, 4]]
list_shallow = copy.copy(list_original)
list_deep = copy.deepcopy(list_original)

list_original[0][0] = 99 # Modify nested list
print(list_original) # [[99, 2], [3, 4]]
print(list_shallow)  # [[99, 2], [3, 4]] - affected by original change
print(list_deep)     # [[1, 2], [3, 4]] - unaffected

Q12. What is the purpose of `__slots__` in Python classes?

`__slots__` is a special attribute that can be defined in Python classes to explicitly declare data members (instance variables). When `__slots__` is used, Python replaces the instance dictionary (`__dict__`) with a fixed-size array, which can lead to significant memory savings, especially for classes with many instances. It also makes attribute access slightly faster. However, classes using `__slots__` cannot have arbitrary new attributes added dynamically unless `__dict__` is also included in `__slots__`. It's primarily used for optimization in memory-critical applications or when creating many small objects.
class MyClassWithSlots:
    __slots__ = ('x', 'y') # Declare allowed attributes

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

obj = MyClassWithSlots(1, 2)
# print(obj.__dict__) # AttributeError: 'MyClassWithSlots' object has no attribute '__dict__'
# obj.z = 3 # AttributeError: 'MyClassWithSlots' object has no attribute 'z'

Q13. Explain `pass` statement in Python.

The `pass` statement in Python is a null operation; when executed, nothing happens. It's primarily used as a placeholder where syntactically a statement is required, but you don't want any command or code to execute. This is particularly useful in several scenarios: 1. **Empty blocks:** When defining an empty class, function, or loop that you plan to implement later, `pass` prevents a `SyntaxError`. 2. **Abstract methods:** In parent classes, `pass` can be used in methods that are meant to be overridden by subclasses, signaling that the parent provides no default implementation. 3. **Placeholders in conditional statements:** To temporarily skip a branch of an `if/elif/else` statement.
def coming_soon():
    pass # Function definition requires a statement

class EmptyClass:
    pass # Class definition requires a statement

if False:
    pass # Placeholder for condition

Q14. How do you open and close files in Python?

Files in Python are typically opened using the built-in `open()` function, which returns a file object. The `open()` function takes the file path and a mode string (e.g., 'r' for read, 'w' for write, 'a' for append, 'x' for exclusive creation, 'b' for binary, 't' for text). After performing operations, it's crucial to close the file to release system resources and ensure data integrity. The best practice for handling files is to use the `with` statement (context manager). This ensures that the file is automatically closed, even if errors occur during file operations, making the code safer and cleaner.
# Using with statement (recommended)
with open('example.txt', 'w') as f:
    f.write("Hello, file!")

# Manual open and close (less recommended)
f = open('example.txt', 'r')
content = f.read()
f.close()
print(content)

Q15. What is the difference between `is` and `==` in Python?

In Python, `is` and `==` are two distinct operators for comparing objects. The `==` operator checks for **value equality**. It determines if the values of two objects are equal by calling the `__eq__` method (if defined) of the objects. It compares the *contents* of the objects. The `is` operator checks for **identity equality**. It determines if two variables refer to the *exact same object* in memory. It compares the memory addresses (IDs) of the objects. For immutable objects like small integers and strings, Python often interns them, meaning `is` might return `True` unexpectedly for objects with the same value. However, for mutable objects or larger immutable objects, `is` will typically return `False` even if `==` returns `True`, because they are separate objects in memory.
list1 = [1, 2, 3]
list2 = [1, 2, 3]
list3 = list1

print(list1 == list2) # True (values are equal)
print(list1 is list2)  # False (different objects in memory)
print(list1 is list3)  # True (same object in memory)

Advanced Level

Q1. Explain Python's MRO (Method Resolution Order) and its significance in multiple inheritance.

MRO (Method Resolution Order) defines the order in which Python searches for methods and attributes in a class hierarchy, especially crucial in multiple inheritance scenarios. Python uses the C3 linearization algorithm to determine the MRO. This algorithm ensures that a class precedes its parents, and the order of parents in the class definition is preserved, while also ensuring monotonicity (if a class `X` precedes `Y` in the MRO of `C`, then `X` will also precede `Y` in the MRO of any subclass of `C`). You can inspect the MRO of a class using `ClassName.__mro__` or `ClassName.mro()`. Understanding MRO helps predict which method implementation will be called when multiple base classes define methods with the same name, preventing ambiguous behavior.
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print(D.mro())
# Output: [<class '__main__.D'>, <class '__main__.B'>,
#          <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

Q2. What are metaclasses in Python? Provide a simple use case.

A metaclass in Python is the "class of a class." Just as an object is an instance of a class, a class itself is an instance of a metaclass. The default metaclass in Python is `type`. Metaclasses allow you to control the creation of classes, enabling you to intercept class definition and modify it or even return a completely different class. This is an advanced feature primarily used for implementing framework-level patterns like ORMs, registering classes automatically, or enforcing API conventions. A simple use case might be to automatically add a specific method or attribute to all classes that use a particular metaclass.
class MyMeta(type):
    def __new__(mcs, name, bases, attrs):
        attrs['added_attribute'] = 100
        attrs['say_hello'] = lambda self: f"Hello from {self.__class__.__name__}!"
        return super().__new__(mcs, name, bases, attrs)

class MyClass(metaclass=MyMeta):
    pass

obj = MyClass()
print(obj.added_attribute) # Output: 100
print(obj.say_hello())     # Output: Hello from MyClass!

Q3. Explain descriptors in Python.

Descriptors are objects that implement one or more of the descriptor protocol methods: `__get__`, `__set__`, and `__delete__`. They allow attributes to be managed by another object, providing a powerful mechanism for reusable attribute logic. When an attribute access (get, set, or delete) occurs on an object, if that attribute is an instance of a descriptor, Python's descriptor protocol is invoked. This enables custom behavior like type validation, caching, or access control without needing to write boilerplate code in every method. Properties (`@property`) are a common example of descriptors, simplifying attribute management.
class TenMultiplier:
    def __get__(self, obj, objtype=None):
        if obj is None:
            return self # Accessed via class
        return obj._value * 10

    def __set__(self, obj, value):
        if not isinstance(value, (int, float)):
            raise ValueError("Value must be a number")
        obj._value = value

class MyClass:
    x = TenMultiplier() # x is a descriptor instance

    def __init__(self, value):
        self.x = value # This calls TenMultiplier.__set__

obj = MyClass(5)
print(obj.x) # This calls TenMultiplier.__get__, Output: 50

Q4. What is the purpose of `__call__` method?

The `__call__` method in Python allows an instance of a class to be called as if it were a function. When an object that has a `__call__` method is "called" using parentheses (e.g., `obj()`), the `__call__` method of that object is executed. This makes instances of classes callable objects. It's often used for creating function-like objects that maintain state, such as custom decorators with arguments, stateful factory functions, or objects that represent a callable strategy. It provides a clean way to encapsulate behavior and state within a single callable entity.
class Multiplier:
    def __init__(self, factor):
        self.factor = factor

    def __call__(self, number):
        return number * self.factor

double = Multiplier(2)
triple = Multiplier(3)

print(double(5)) # Output: 10 (calls double.__call__(5))
print(triple(5)) # Output: 15 (calls triple.__call__(5))

Q5. Explain context managers and the `with` statement.

Context managers in Python are objects that define the methods `__enter__` and `__exit__`. They are designed to set up a context for a block of code and then tear it down afterward, ensuring that resources are properly acquired and released, even if errors occur. The `with` statement is the syntax used to utilize context managers. When `with expression as var:` is executed, `expression.__enter__()` is called, and its return value is assigned to `var`. The code inside the `with` block then executes. Regardless of whether the block finishes normally or an exception occurs, `expression.__exit__()` is guaranteed to be called, handling cleanup tasks like closing files or releasing locks.
# Custom context manager
class MyContext:
    def __enter__(self):
        print("Entering context")
        return "Resource"

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Exiting context")
        # Can handle exceptions here if needed

with MyContext() as resource:
    print(f"Inside context: {resource}")

Q6. How does Python's garbage collection work?

Python employs a combination of reference counting and a generational garbage collector to manage memory. **Reference Counting:** This is the primary mechanism. Every object has a count of references pointing to it. When the reference count drops to zero, the object's memory is immediately deallocated. This is efficient but cannot handle reference cycles (e.g., two objects referencing each other, even if unreachable from the rest of the program). **Generational Garbage Collector:** To address reference cycles, Python has a cyclic garbage collector. It operates in "generations," where newly created objects are in the first generation. Objects that survive a garbage collection cycle are promoted to older generations. This collector periodically scans for unreachable reference cycles, primarily focusing on older generations which are less likely to contain transient objects, thus optimizing performance.
import sys
a = []
b = []
a.append(b)
b.append(a) # Creates a reference cycle
# Reference counting alone wouldn't clean this up if 'a' and 'b' go out of scope.
# The generational garbage collector detects and cleans such cycles.

Q7. Explain `asyncio` and `await`/`async` keywords.

`asyncio` is Python's library for writing concurrent code using the `async`/`await` syntax. It enables single-threaded concurrency by allowing the program to switch between tasks while waiting for I/O-bound operations (like network requests or file I/O) to complete, instead of blocking. - `async def` declares a coroutine, which is a special type of function that can be paused and resumed. - `await` is used inside `async` functions to pause the execution of the current coroutine until the awaited task (another coroutine or an awaitable object) completes. During this pause, the `asyncio` event loop can run other tasks. This approach is highly efficient for I/O-bound tasks as it avoids the overhead of context switching between threads or processes, making better use of system resources.
import asyncio

async def fetch_data(delay):
    print(f"Starting fetch for {delay}s")
    await asyncio.sleep(delay) # Simulate I/O bound operation
    print(f"Finished fetch for {delay}s")
    return f"Data after {delay}s"

async def main():
    task1 = asyncio.create_task(fetch_data(2))
    task2 = asyncio.create_task(fetch_data(1))
    # Tasks run concurrently while awaiting
    result1 = await task1
    result2 = await task2
    print(result1, result2)

# asyncio.run(main())

Q8. What is the difference between an iterable and an iterator?

An **iterable** is any Python object that can be iterated over, meaning it can return its members one at a time. Examples include lists, tuples, strings, and dictionaries. An object is iterable if it implements the `__iter__` method (which returns an iterator) or the `__getitem__` method. An **iterator** is an object that represents a stream of data. It is an object that implements the `__iter__` method (which returns `self`) and the `__next__` method. The `__next__` method returns the next item in the sequence, and when there are no more items, it raises a `StopIteration` exception. You get an iterator from an iterable by calling `iter()` on it. Iterators are "one-time-use" objects; once exhausted, they cannot be reset without creating a new iterator.
my_list = [1, 2, 3] # Iterable
my_iterator = iter(my_list) # Iterator

print(next(my_iterator)) # Output: 1
print(next(my_iterator)) # Output: 2

# After exhausting, it raises StopIteration
# print(next(my_iterator))

Q9. How can you enforce type hints at runtime in Python?

Python's type hints (introduced in PEP 484) are primarily for static analysis tools (like MyPy) and IDEs to improve code readability and catch potential type errors *before* runtime. By default, type hints are purely advisory and do not enforce types at runtime. To enforce type hints at runtime, you need to use third-party libraries or custom solutions. Popular options include: 1. **`pydantic`:** A data validation and settings management library that uses Python type hints to define data schemas and automatically validates data at runtime. 2. **`typeguard`:** A library that provides runtime type checking decorators (`@typechecked`) for functions and methods, raising `TypeError` if arguments or return values don't match hints. 3. **Custom Decorators:** You could write a custom decorator that inspects `func.__annotations__` and performs type checks manually. These tools bridge the gap between static type checking and runtime validation, adding robustness to applications.
# Example using typeguard (install: pip install typeguard)
# from typeguard import typechecked
#
# @typechecked
# def greet(name: str) -> str:
#     return f"Hello, {name}"
#
# # greet("Alice")      # Works
# # greet(123)        # Raises TypeError at runtime

Q10. What is the difference between `threading` and `multiprocessing` modules?

Both `threading` and `multiprocessing` modules allow Python programs to achieve concurrency, but they do so in fundamentally different ways, addressing different use cases due to Python's Global Interpreter Lock (GIL). **`threading`:** Creates multiple threads within the same process. Threads share the same memory space. Due to the GIL, only one thread can execute Python bytecode at a time, limiting true parallelism for CPU-bound tasks. However, threads are efficient for I/O-bound tasks, as the GIL is released during I/O operations, allowing other threads to run. **`multiprocessing`:** Creates new processes, each with its own Python interpreter and memory space. This bypasses the GIL, allowing true parallel execution of CPU-bound tasks on multi-core processors. Communication between processes requires explicit mechanisms (e.g., pipes, queues). Multiprocessing involves higher overhead for creation and communication compared to threading. Choose `threading` for I/O-bound tasks and `multiprocessing` for CPU-bound tasks.
import threading
import multiprocessing
# Example of use cases:
# threading.Thread(target=io_operation).start()
# multiprocessing.Process(target=cpu_intensive_task).start()

Q11. How can you optimize Python code for performance?

Optimizing Python code involves several strategies: 1. **Algorithmic Improvements:** The most impactful optimization is often choosing more efficient algorithms and data structures. 2. **Profiling:** Use `cProfile` or `timeit` to identify bottlenecks (hot spots) in your code before optimizing. 3. **List/Dict Comprehensions & Generators:** Prefer comprehensions over explicit loops for list/dict creation, and generators for memory efficiency with large datasets. 4. **Built-in Functions:** Leverage Python's optimized built-in functions and C-implemented modules (e.g., `map`, `filter`, `sum`, `collections`). 5. **Avoid Global Lookups:** Accessing local variables is faster than global ones. 6. **`__slots__`:** For memory-intensive classes with many instances. 7. **`NumPy`/`Pandas`:** For numerical computations and data manipulation, these C-optimized libraries are vastly faster than pure Python. 8. **Concurrency:** Use `asyncio` for I/O-bound tasks and `multiprocessing` for CPU-bound tasks to utilize multiple cores. 9. **JIT Compilers:** Tools like PyPy or Numba can compile Python code to machine code for significant speedups in CPU-bound scenarios.
# Example: List comprehension vs loop
# Faster: [x * 2 for x in range(1000)]
# Slower:
# result = []
# for x in range(1000):
#     result.append(x * 2)

Q12. What is a `classmethod` and `staticmethod`? How do they differ from instance methods?

Python offers three types of methods within a class: 1. **Instance Methods:** The most common type. They receive the instance (`self`) as their first argument and can access/modify instance-specific data. 2. **Class Methods (`@classmethod`):** Declared with the `@classmethod` decorator. They receive the class itself (`cls`) as their first argument instead of the instance. Class methods can access and modify class-level attributes and can be called on both the class and its instances. They are often used as alternative constructors or to define factory methods. 3. **Static Methods (`@staticmethod`):** Declared with the `@staticmethod` decorator. They receive neither `self` nor `cls`. Static methods are essentially regular functions logically grouped within a class, as they don't operate on the instance or the class. They are useful for utility functions that don't depend on class or instance state.
class MyClass:
    class_var = 0

    def instance_method(self):
        print(f"Instance method, instance: {self}")

    @classmethod
    def class_method(cls):
        cls.class_var += 1
        print(f"Class method, class: {cls}, class_var: {cls.class_var}")

    @staticmethod
    def static_method(arg):
        print(f"Static method, arg: {arg}")

obj = MyClass()
obj.instance_method()
MyClass.class_method() # Called on class
obj.class_method()     # Called on instance, but cls is MyClass
MyClass.static_method(10)

Q13. Explain the concept of duck typing in Python.

Duck typing is a programming paradigm used in dynamic languages like Python, where the type or class of an object is less important than the methods it defines. The principle is often summarized as: "If it walks like a duck and quacks like a duck, then it must be a duck." This means that Python focuses on *what an object can do* (its capabilities and interfaces) rather than *what it is* (its explicit type). Instead of checking if an object is an instance of a specific class, you check if it has the necessary methods or attributes. This promotes more flexible and decoupled code, allowing different types of objects to be used interchangeably as long as they provide the required behavior.
class Duck:
    def quack(self):
        print("Quack!")
    def walk(self):
        print("Waddle, waddle")

class Person:
    def quack(self):
        print("I'm pretending to quack!")
    def walk(self):
        print("Stroll")

def make_it_walk_and_quack(entity):
    entity.walk()
    entity.quack()

make_it_walk_and_quack(Duck())
make_it_walk_and_quack(Person()) # Works because Person has walk() and quack()

Q14. What is polymorphism in Python?

Polymorphism, meaning "many forms," is a core concept in Object-Oriented Programming (OOP) that allows objects of different classes to be treated as objects of a common type through a unified interface. In Python, polymorphism is naturally supported through duck typing. If multiple classes share a common method name, they can all be used interchangeably where that method is expected. For example, if both a `Dog` and a `Cat` class have a `speak()` method, you can iterate over a list containing both `Dog` and `Cat` objects and call `speak()` on each without needing to know their specific types. This leads to more generic, flexible, and extensible code.
class Dog:
    def speak(self):
        return "Woof!"

class Cat:
    def speak(self):
        return "Meow!"

class Cow:
    def speak(self):
        return "Moo!"

animals = [Dog(), Cat(), Cow()]

for animal in animals:
    print(animal.speak()) # Each animal speaks in its own way

Q15. How do you create an abstract base class (ABC) in Python?

Abstract Base Classes (ABCs) in Python provide a way to define interfaces and enforce that derived classes implement specific methods. They are useful for establishing common APIs and preventing instantiation of incomplete classes. You create an ABC by inheriting from the `abc.ABC` class and using the `@abstractmethod` decorator from the `abc` module on methods that must be implemented by concrete subclasses. If a class inherits from an ABC and doesn't implement all its abstract methods, Python will prevent its instantiation, raising a `TypeError`. This ensures that any object instantiated from a concrete subclass will conform to the defined interface.
from abc import ABC, abstractmethod

class Vehicle(ABC): # Inherit from ABC
    @abstractmethod
    def start(self):
        pass

    @abstractmethod
    def stop(self):
        pass

class Car(Vehicle):
    def start(self):
        return "Car engine started."

    def stop(self):
        return "Car engine stopped."

# v = Vehicle() # TypeError: Can't instantiate abstract class Vehicle
car = Car()
print(car.start())
Prepared by iCampusLink. 40 Python interview questions.