Skip to content
On this page

Collection of Python Interview Questions

Q: Static vs Class methods?

Static methods and class methods are both methods that are associated with a class rather than an instance of the class.

  1. Static Method:

    • A static method is a method that belongs to the class rather than an instance of the class.
    • It is defined using the @staticmethod decorator.
    • It does not have access to the instance or its attributes.
    • It is called on the class, not on an instance of the class.
    python
    class MyClass:
        @staticmethod
        def my_static_method():
            # Code for static method
  2. Class Method:

    • A class method is a method that takes the class itself as its first argument.
    • It is defined using the @classmethod decorator.
    • It has access to the class and its attributes, but not to the instance.
    • It is called on the class, not on an instance of the class.
    python
    class MyClass:
        @classmethod
        def my_class_method(cls):
            # Code for class method

In summary, static methods are independent of class instances and class methods have access to the class itself.

Q: What is MRO?

MRO stands for Method Resolution Order in Python. It defines the order in which classes are searched when looking for a method in the inheritance hierarchy. The MRO plays a crucial role in multiple inheritance scenarios.

In Python, the C3 linearization algorithm is used to determine the MRO. The MRO is calculated based on the following principles:

  1. Depth-First Search:

    • The MRO starts with the derived class and then follows the chain of base classes in a depth-first manner.
  2. Left-to-Right:

    • In case of multiple inheritance (a class inheriting from more than one class), the MRO follows a left-to-right order as specified in the class definition.

For example:

python
class A:
    pass

class B(A):
    pass

class C(A):
    pass

class D(B, C):
    pass

# MRO for class D: D -> B -> C -> A
print(D.mro())

In the example above, the MRO for class D is [D, B, C, A]. This means that when looking for a method in class D, it will first check in D, then in B, then in C, and finally in A.

Understanding the MRO is essential for resolving method and attribute lookup in complex class hierarchies.

Q: Python module vs package?

In Python, both modules and packages are organizational units for code, but they serve different purposes.

  1. Module:

    • A module is a single Python file that contains code, functions, and variables.
    • It is a way to organize related code into a file to make it reusable and maintainable.
    • You can create a module by saving a Python script with a .py extension.

    Example of a module (my_module.py):

    python
    # my_module.py
    def my_function():
        print("Hello from my function in my_module")

    You can then use this module in another script:

    python
    # main_script.py
    import my_module
    
    my_module.my_function()
  2. Package:

    • A package is a way of organizing related modules into a directory hierarchy.
    • It contains a special file called __init__.py to indicate that the directory should be treated as a package.
    • Packages help in organizing larger codebases and avoid naming conflicts.

    Example of a package:

    my_package/
    ├── __init__.py
    ├── module1.py
    └── module2.py

    Contents of module1.py:

    python
    # module1.py
    def function1():
        print("Function 1 from module1")

    Contents of module2.py:

    python
    # module2.py
    def function2():
        print("Function 2 from module2")

    You can then use these modules within the package:

    python
    # main_script.py
    from my_package import module1, module2
    
    module1.function1()
    module2.function2()

In summary, a module is a single file containing Python code, while a package is a collection of related modules organized in a directory hierarchy. The __init__.py file distinguishes a directory as a package.

Q: What is the purpose of a single underscore variable in Python?

In Python, a single underscore (_) has a specific purpose and meaning, but it can be used in different contexts. Here are some common use cases:

  1. Unused Variable:

    • A single underscore is often used as a variable name when the variable is intentionally not going to be used. This convention is a way to indicate to other programmers (and to tools like linters) that the variable is intentionally ignored.
    python
    # Unused variable
    _ = my_function_that_returns_a_value()
  2. Last Result in the Interpreter:

    • In an interactive Python session (like the Python REPL or IPython), the single underscore _ is automatically assigned to the result of the last expression.
    python
    >>> 2 + 3
    5
    >>> _  # Represents the result of the last expression (5)
    5
  3. Internationalization (gettext):

    • In the context of internationalization and localization (using the gettext module), the single underscore is often used as a shorthand for marking strings for translation.
    python
    from gettext import gettext as _
    
    message = _("This is a translatable string")

    However, in practice, double underscores are more commonly used for this purpose.

It's important to note that using a single underscore as a variable name is a convention and not a strict rule enforced by the Python interpreter. Programmers use it to convey specific meanings in their code.

Q: __init__ vs __new__ methods in Python?

In Python, both __init__ and __new__ are special methods, but they serve different purposes in the object creation process.

  1. __new__ Method:

    • The __new__ method is responsible for creating a new instance of a class.
    • It is a static method (a method bound to the class and not the instance) and is called before the __init__ method.
    • The primary purpose of __new__ is to create and return a new instance of the class. It takes the class as its first argument, followed by any additional arguments that were passed to the class constructor.
    • If __new__ is not defined in a class, it defaults to the object.__new__ method, which creates a new instance of the class.

    Example:

    python
    class MyClass:
        def __new__(cls, *args, **kwargs):
            # Custom logic for creating a new instance
            instance = super().__new__(cls)
            # Additional initialization can be done here if needed
            return instance
  2. __init__ Method:

    • The __init__ method is responsible for initializing the attributes of an instance after it has been created by __new__.
    • It is an instance method and takes the newly created instance (self) along with any additional arguments that were passed to the class constructor.
    • The primary purpose of __init__ is to set up the initial state of the object, assigning values to attributes or performing other initialization tasks.

    Example:

    python
    class MyClass:
        def __init__(self, arg1, arg2):
            # Initialization logic
            self.arg1 = arg1
            self.arg2 = arg2

In summary, __new__ is responsible for creating a new instance of the class, and __init__ is responsible for initializing the attributes of that instance. In most cases, you'll only need to define the __init__ method unless you have specific requirements for customizing the object creation process.

Q: Why are full values shared between two objects?

In Python, when two objects share the same values, it is usually because they are referencing the same object in memory, not because the values themselves are shared. This behavior is a result of how Python handles certain types of objects, especially immutable objects.

Let's distinguish between mutable and immutable objects:

  1. Mutable Objects:

    • Objects whose state can be changed after creation are mutable.
    • Examples include lists, dictionaries, and custom objects.
    python
    list1 = [1, 2, 3]
    list2 = list1  # Both list1 and list2 reference the same list object

    In this case, if you modify list1, list2 will reflect the changes because they point to the same list object.

  2. Immutable Objects:

    • Objects whose state cannot be changed after creation are immutable.
    • Examples include strings, tuples, and numeric types.
    python
    string1 = "hello"
    string2 = string1  # Both string1 and string2 reference the same string object

    Although strings are immutable, the reference (string2) is shared, and both variables point to the same string object.

This behavior is more evident with immutable objects because modifying their values actually creates new objects. It's important to understand that this sharing of values is specific to certain types of objects and does not apply universally across all types in Python.

If you want to create a new object with the same values but independent of the original object, you can use techniques like slicing (for sequences) or the copy module.

python
# Creating a new list with the same values
new_list = list(original_list)

In summary, the sharing of values between two objects in Python typically occurs when they reference the same mutable or immutable object in memory. Understanding the mutability or immutability of objects helps in grasping this behavior.

Q: Explain Python's garbage collection mechanism

Python's garbage collection manages memory by using reference counting and a cyclic garbage collector for handling circular references. The gc module provides manual control over garbage collection, with gc.collect() triggering the process. CPython, the main Python implementation, employs a generational garbage collector. While automatic garbage collection is efficient, programmers should be aware of potential memory issues.

Q: What is the global interpreter lock and why is it an issue with an example?

The Global Interpreter Lock (GIL) is a mutex (or lock) that protects access to Python objects, preventing multiple native threads from executing Python bytecodes at once. In other words, it allows only one thread to execute Python bytecode in the interpreter at any given time, even on multi-core systems.

Why the GIL is an Issue:

  1. Concurrency Limitation:

    • The GIL limits the execution of multiple threads simultaneously, impacting the ability to take full advantage of multi-core processors.
    • It becomes a bottleneck for CPU-bound and multithreaded applications as it restricts parallel execution.
  2. Performance Implications:

    • Although Python supports threading, due to the GIL, threads are not as effective for parallelizing CPU-bound tasks.
    • The GIL doesn't hinder performance for I/O-bound tasks (tasks waiting for external resources) as much because the lock is released during I/O operations.

Example:

Consider a simple CPU-bound task that calculates the square of each element in a list using multiple threads:

python
import threading

def square_numbers(numbers):
    global result
    for number in numbers:
        result.append(number * number)

result = []
numbers = [1, 2, 3, 4, 5]

# Create two threads to square the numbers concurrently
thread1 = threading.Thread(target=square_numbers, args=(numbers,))
thread2 = threading.Thread(target=square_numbers, args=(numbers,))

# Start the threads
thread1.start()
thread2.start()

# Wait for both threads to finish
thread1.join()
thread2.join()

print(result)

Due to the GIL, the threads will not execute concurrently when performing the CPU-bound task. As a result, the performance improvement typically associated with multithreading in other languages may not be realized in this Python example.

It's important to note that the GIL is specific to the CPython interpreter, and other Python implementations like Jython or IronPython do not have a GIL. Additionally, for I/O-bound tasks, asynchronous programming using asyncio and async/await can be a more effective approach than traditional multithreading.

Q: What are iterators?

In Python, an iterator is an object that implements the iterator protocol, which consists of the methods __iter__() and __next__() (or __iter__() and __getitem__() for older-style iterators). Iterators are used to represent a stream of data and facilitate iteration over elements in a sequence, container, or collection. They allow you to loop over a set of values, one at a time, without having to know the underlying details of the data structure.

Here are the key components of iterators:

  1. __iter__() Method:

    • The __iter__() method returns the iterator object itself.
    • It is called when you use the iter() function on an object.
  2. __next__() Method:

    • The __next__() method returns the next element from the iterator.
    • It is called when you use the next() function on an iterator.
  3. StopIteration Exception:

    • When there are no more elements to return, the __next__() method should raise the StopIteration exception to signal the end of the iteration.
  4. Iterable:

    • An iterable is an object that can be iterated over, and it typically implements the __iter__() method.
    • Iterables may or may not be iterators themselves.

Example of a simple iterator:

python
class MyIterator:
    def __init__(self, data):
        self.data = data
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.index < len(self.data):
            result = self.data[self.index]
            self.index += 1
            return result
        else:
            raise StopIteration

# Using the iterator
my_list = [1, 2, 3, 4, 5]
my_iterator = MyIterator(my_list)

for item in my_iterator:
    print(item)

In this example, MyIterator is an iterator for a list. The __iter__ method returns the iterator object (self), and the __next__ method returns the next element from the list until there are no more elements to return.

In practice, many Python objects, such as lists, tuples, dictionaries, and strings, are iterable and can be used directly in for loops. Iterators provide a way to customize the iteration behavior for your own objects.

Q: What do you understand about generators in Python?

Generators in Python are a way to create iterators using a special kind of function. They allow you to iterate over a potentially large sequence of data without creating the entire sequence in memory, which is particularly useful for working with large datasets or infinite sequences.

Key characteristics of generators:

  1. Function with yield:

    • Generators are created using functions that contain the yield keyword.
    • When a generator function is called, it returns an iterator but does not start executing immediately. The function is paused at the yield statement.
  2. Lazy Evaluation:

    • Values are generated one at a time and are only computed when requested.
    • This is in contrast to normal functions that compute and return the entire result at once.
  3. Stateful Execution:

    • The generator function retains its local state between successive calls.
    • When the generator is resumed after a call to yield, it continues execution from where it was paused.
  4. Example:

    python
    def simple_generator():
        yield 1
        yield 2
        yield 3
    
    # Using the generator
    my_generator = simple_generator()
    print(next(my_generator))  # Output: 1
    print(next(my_generator))  # Output: 2
    print(next(my_generator))  # Output: 3
  5. Infinite Sequences:

    • Generators can represent infinite sequences, such as counting numbers or a stream of data, without consuming infinite memory.
    python
    def infinite_counter():
        count = 0
        while True:
            yield count
            count += 1
    
    # Using the infinite counter
    counter = infinite_counter()
    print(next(counter))  # Output: 0
    print(next(counter))  # Output: 1
    # ...

Generators are particularly useful when dealing with large datasets, streaming data, or when the entire sequence is not needed at once. They provide a memory-efficient and elegant way to work with sequences in Python. Additionally, the yield keyword allows generators to maintain their state between calls, making them suitable for scenarios where maintaining state is important.

Q: What is a monkey patching in Python?

Monkey patching in Python refers to the dynamic modification of a module or class during runtime. It involves altering or extending the behavior of a module or class, typically for the purpose of fixing bugs, adding new features, or modifying existing functionality. Monkey patching is powerful but should be used judiciously, as it can lead to code that is harder to understand and maintain.

Key points about monkey patching:

  1. Dynamic Modification:

    • Monkey patching involves making changes to code at runtime, often by directly modifying the attributes or methods of classes or objects.
  2. Common Use Cases:

    • Fixing Bugs: Monkey patching can be used to fix bugs in third-party libraries or modules without modifying their source code.
    • Adding Functionality: It allows developers to add new functionality to existing classes or modules without subclassing.
    • Testing: Monkey patching is sometimes used in testing to replace or mock certain behaviors temporarily.
  3. Example:

    python
    # Original class
    class MyClass:
        def original_method(self):
            return "Original method"
    
    # Monkey patching: Adding a new method
    def new_method(self):
        return "Patched method"
    
    MyClass.patched_method = new_method
    
    # Using the modified class
    obj = MyClass()
    print(obj.original_method())  # Output: Original method
    print(obj.patched_method())   # Output: Patched method
  4. Considerations:

    • Monkey patching can lead to code that is harder to understand and maintain, as it introduces changes outside the regular development process.
    • It can cause compatibility issues with future versions of the patched code or with other modules that interact with it.
    • It's important to document and communicate the use of monkey patching in a codebase to ensure that other developers are aware of the modifications.

While monkey patching can be a powerful tool, it's generally recommended to use it with caution and explore other alternatives such as subclassing, decorators, or more structured ways of extending or modifying functionality, especially when working on larger projects or collaborating with other developers.

Q: What’s the difference between deep and shallow copy in Python?

In Python, the concepts of shallow copy and deep copy refer to creating copies of objects, particularly complex objects like lists or dictionaries. The distinction lies in how nested objects within the original are handled.

  1. Shallow Copy:

    • A shallow copy creates a new object but does not create copies of nested objects. Instead, it copies references to the nested objects.
    • Changes made to the nested objects will be reflected in both the original and the shallow copy.
    • In Python, you can use the copy module's copy() function or the object's own copy() method to create a shallow copy.
    python
    import copy
    
    original_list = [1, [2, 3], 4]
    
    # Using copy() method for shallow copy
    shallow_copy_list = original_list.copy()
    
    # or using copy() function
    shallow_copy_list = copy.copy(original_list)
  2. Deep Copy:

    • A deep copy creates a new object and recursively creates copies of all nested objects, ensuring that changes in nested objects do not affect the original or other copies.
    • In Python, you can use the copy module's deepcopy() function to create a deep copy.
    python
    import copy
    
    original_list = [1, [2, 3], 4]
    
    # Using deepcopy() function for deep copy
    deep_copy_list = copy.deepcopy(original_list)

Example:

python
import copy

original_list = [1, [2, 3], 4]

# Shallow copy
shallow_copy_list = copy.copy(original_list)

# Deep copy
deep_copy_list = copy.deepcopy(original_list)

# Modify the nested list
original_list[1][0] = 99

# Changes are reflected in shallow copy but not in deep copy
print(original_list)      # Output: [1, [99, 3], 4]
print(shallow_copy_list)  # Output: [1, [99, 3], 4]
print(deep_copy_list)     # Output: [1, [2, 3], 4]

In the example, modifying the nested list in the original list affects the shallow copy, but the deep copy remains unchanged.

In summary, the key difference is how nested objects are treated. Shallow copy creates new objects but copies references to nested objects, while deep copy creates new objects and recursively copies all nested objects, ensuring complete independence between the original and the copy.

Q: How will you define polymorphism in Python?

Polymorphism in Python refers to the ability of different objects to be treated as instances of a common type. It allows objects of different classes to be used interchangeably based on their common interface, methods, or attributes. There are two main types of polymorphism in Python:

  1. Compile-Time Polymorphism (Static Binding):

    • Also known as method overloading.
    • It involves defining multiple methods in a class with the same name but different parameter types or a different number of parameters.
    • The correct method is selected during compilation based on the method signature.
    python
    class MathOperations:
        def add(self, x, y):
            return x + y
    
        def add(self, x, y, z):
            return x + y + z
    
    math_obj = MathOperations()
    result1 = math_obj.add(2, 3)      # Calls the first add method
    result2 = math_obj.add(2, 3, 4)   # Calls the second add method
  2. Run-Time Polymorphism (Dynamic Binding):

    • Also known as method overriding.
    • It involves defining a method in the subclass that already exists in the superclass.
    • The correct method is selected during runtime based on the actual type of the object.
    python
    class Animal:
        def sound(self):
            pass
    
    class Dog(Animal):
        def sound(self):
            return "Woof!"
    
    class Cat(Animal):
        def sound(self):
            return "Meow!"
    
    # Polymorphic behavior
    def make_sound(animal):
        return animal.sound()
    
    dog = Dog()
    cat = Cat()
    
    print(make_sound(dog))  # Output: Woof!
    print(make_sound(cat))  # Output: Meow!

In the example, the make_sound function takes any object of type Animal and calls its sound method. The correct sound method is determined at runtime based on the actual type of the object passed.

Polymorphism enhances code flexibility and readability by allowing objects of different types to be treated uniformly through a common interface. It is a fundamental concept in object-oriented programming that supports code reuse and extensibility.

Q: What is a closure in Python?

In Python, a closure is a function object that has access to variables in its lexical scope, even when the function is called outside that scope. This means that a closure can "close over" variables from its outer function, retaining access to those variables even after the outer function has finished execution.

Key characteristics of closures:

  1. Nested Function:

    • A closure involves a nested function (a function defined within another function).
  2. Access to Outer Function's Variables:

    • The inner function has access to the variables of its outer (enclosing) function, even after the outer function has completed execution.
  3. Immutable Closure:

    • Closures capture variables by reference, not by value. This means if the enclosed variables are mutable, changes to them will be reflected in the closure. However, reassignment of the variable within the outer function does not affect the closure.

Example of a Closure:

python
def outer_function(x):
    def inner_function(y):
        return x + y
    return inner_function

closure_instance = outer_function(10)
result = closure_instance(5)
print(result)  # Output: 15

In this example, inner_function is a closure because it has access to the x variable from its outer function, outer_function, even though outer_function has already finished execution. When closure_instance is called with 5, it adds 5 to the captured value of x (10), resulting in 15.

Closures are often used to create functions with behavior dependent on some initial setup or configuration. They provide a way to achieve data encapsulation and help manage the scope of variables in a clean and modular way.

Q: Explain multithreading in Python

Multithreading in Python involves the concurrent execution of multiple threads within a single process. Threads are lightweight sub-processes that share the same memory space, allowing for parallel execution of tasks. Python provides a built-in threading module for working with threads.

However, it's important to note that due to the Global Interpreter Lock (GIL) in the standard CPython implementation, true parallel execution of threads is limited. The GIL allows only one thread to execute Python bytecode at a time, impacting the parallelism benefits of multiple threads, especially in CPU-bound tasks. For I/O-bound tasks, multithreading can still provide advantages.

Here's an overview of multithreading in Python using the threading module:

  1. Creating Threads:

    • Threads are created by instantiating the Thread class from the threading module.
    • You can define a target function that the thread will execute.
    python
    import threading
    
    def my_function():
        # Code to be executed by the thread
    
    my_thread = threading.Thread(target=my_function)
  2. Starting Threads:

    • Threads are started by calling the start() method on the thread object.
    • The start() method initiates the execution of the target function in a separate thread.
    python
    my_thread.start()
  3. Joining Threads:

    • The join() method is used to wait for the thread to complete its execution before proceeding further in the main thread.
    python
    my_thread.join()
  4. Thread Safety:

    • Thread safety is crucial when working with shared resources. It involves using locks or other synchronization mechanisms to prevent race conditions and ensure data consistency.
  5. Thread Pools:

    • Python provides the concurrent.futures module for working with thread pools, allowing you to submit tasks to a pool of threads.
    python
    from concurrent.futures import ThreadPoolExecutor
    
    with ThreadPoolExecutor() as executor:
        results = executor.map(my_function, iterable_of_arguments)

Multithreading in Python is particularly beneficial for I/O-bound tasks where threads can execute independently, waiting for external resources, such as network or file I/O. For CPU-bound tasks, alternative approaches like multiprocessing or asynchronous programming may be more suitable due to the limitations imposed by the GIL.

Q: asyncio vs multithreading?

  1. Concurrency Model:

    • Asyncio is a single-threaded, single-process design. It uses coroutines and an event loop to manage tasks.
    • Multithreading involves multiple threads of execution within a single process. Each thread runs independently and can execute different parts of your program concurrently.
  2. Use Cases:

    • Asyncio is ideal for I/O-bound tasks, especially when you're dealing with many connections and each connection doesn't need to do much work.
    • Multithreading is beneficial for CPU-bound tasks and can also be used for I/O-bound tasks if the number of connections is limited.
  3. Performance:

    • Asyncio can handle many open connections concurrently, making it suitable for building high-performance network servers.
    • Multithreading can become less efficient with a large number of threads due to the overhead of context switching.
  4. Ease of Use:

    • Asyncio can be more complex to understand and implement due to its asynchronous nature.
    • Multithreading can be easier to understand and implement, but it can be prone to synchronization issues.

Here's a simple example to illustrate the difference:

python
# Multithreading Example
import threading
import time

def print_nums():
    for i in range(5):
        time.sleep(1)
        print(i)

def print_hello():
    for _ in range(5):
        time.sleep(1)
        print("Hello")

t1 = threading.Thread(target=print_nums)
t2 = threading.Thread(target=print_hello)

t1.start()
t2.start()
python
# Asyncio Example
import asyncio

async def print_nums():
    for i in range(5):
        await asyncio.sleep(1)
        print(i)

async def print_hello():
    for _ in range(5):
        await asyncio.sleep(1)
        print("Hello")

loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.gather(print_nums(), print_hello()))
loop.close()

In both examples, print_nums and print_hello run concurrently. However, the multithreading example uses threads, while the asyncio example uses coroutines¹².

Q: Explain with statement in python

The with statement in Python is used to simplify resource management by providing a convenient way to acquire and release resources, such as files, sockets, or locks. It ensures that certain operations are properly set up and cleaned up, even if an exception occurs during the execution of the block of code.

The general syntax of the with statement is as follows:

python
with expression [as variable]:
    # Code block

Here's how the with statement works:

  1. Acquiring Resources:

    • The expression following the with keyword is expected to return a context manager object. A context manager is an object that defines the methods __enter__() and __exit__().
    python
    with open("example.txt", "r") as file:
        # Code to read from the file

    In this example, the open() function returns a file object, which acts as a context manager. The file is automatically opened when entering the with block.

  2. Code Execution:

    • The indented code block following the with statement is executed. This block represents the body of the with statement and is where you work with the acquired resources.
    python
    with open("example.txt", "r") as file:
        content = file.read()
        # Code to process the file content
  3. Automatic Cleanup:

    • After the code block is executed, the __exit__() method of the context manager is called. This method is responsible for releasing or cleaning up any resources acquired in the __enter__() method.
    python
    with open("example.txt", "r") as file:
        content = file.read()
        # Code to process the file content
    
    # The file is automatically closed at this point, regardless of whether an exception occurred.
  4. Handling Exceptions:

    • The with statement also handles exceptions that may occur within the code block. If an exception occurs, the __exit__() method is still called, allowing for proper cleanup.
    python
    try:
        with open("example.txt", "r") as file:
            content = file.read()
            # Code that may raise an exception
    except SomeException as e:
        # Exception handling

The with statement is particularly useful when working with resources that require explicit setup and cleanup procedures. It enhances code readability and reduces the likelihood of resource leaks by ensuring that cleanup operations are consistently performed, even in the presence of exceptions.

Q: Explain decorators in python

In Python, decorators are a powerful and flexible way to modify or extend the behavior of functions or methods. Decorators allow you to wrap a function with additional functionality without changing its source code directly. They are often used for tasks such as logging, memoization, access control, and more.

The syntax for using a decorator involves placing the decorator symbol (@decorator_name) above the function definition. The decorator can be a function or a class.

Here's a basic overview of how decorators work:

  1. Decorator Function:

    • A decorator is a function that takes another function as its argument and returns a new function that usually extends or modifies the behavior of the original function.
    python
    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
  2. Applying the Decorator:

    • Use the @decorator_name syntax to apply a decorator to a function.
    python
    @my_decorator
    def say_hello():
        print("Hello!")
    
    say_hello()

    This is equivalent to say_hello = my_decorator(say_hello).

  3. Chaining Decorators:

    • You can apply multiple decorators to a single function, and they will be applied in the order they appear.
    python
    @decorator1
    @decorator2
    @decorator3
    def my_function():
        # Function code
  4. Passing Arguments to Decorators:

    • Decorators can accept arguments, allowing for more flexibility.
    python
    def parametrized_decorator(param):
        def decorator(func):
            def wrapper():
                print(f"Decorator parameter: {param}")
                func()
            return wrapper
        return decorator
    
    @parametrized_decorator("some_value")
    def my_function():
        print("Hello from my_function!")
    
    my_function()

    In this example, parametrized_decorator is a decorator factory that returns a decorator based on the provided parameter.

Decorators are widely used in Python for various purposes, including code organization, code reuse, and aspect-oriented programming. Common use cases include logging, timing, access control, and memoization. Understanding decorators is essential for writing clean and modular code.

Q: map, filter, and reduce functions

In functional programming, Python's map, filter, and reduce functions are powerful tools that allow for concise and expressive manipulation of data. Here's a brief overview of each:

  1. map Function:

    • Purpose: map applies a given function to all items in an iterable (e.g., a list) and returns a new iterable with the results.

    • Example:

      python
      numbers = [1, 2, 3, 4, 5]
      squared_numbers = map(lambda x: x**2, numbers)

      This will result in squared_numbers containing [1, 4, 9, 16, 25].

  2. filter Function:

    • Purpose: filter constructs a list from those elements of the iterable for which a function returns true.

    • Example:

      python
      numbers = [1, 2, 3, 4, 5]
      even_numbers = filter(lambda x: x % 2 == 0, numbers)

      This will result in even_numbers containing [2, 4].

  3. reduce Function:

    • Purpose: reduce is not a built-in function in Python 3, but it can be imported from the functools module. It successively applies a binary function to the items of an iterable, reducing it to a single accumulated result.

    • Example:

      python
      from functools import reduce
      numbers = [1, 2, 3, 4, 5]
      sum_all = reduce(lambda x, y: x + y, numbers)

      This will result in sum_all containing 15 (the sum of all elements).

When discussing these functions in an interview, it's essential to demonstrate not only the syntax but also an understanding of how they fit into functional programming paradigms. Emphasize the immutability of data, the avoidance of side effects, and the benefits of writing more declarative and concise code.

Q: Monolithic vs Microservice architecture

Monolithic and microservice architectures are two different approaches to structuring applications. Here's a detailed comparison:

Monolithic Architecture:

  • A monolithic application is built as a single, unified unit.
  • All functionalities of a project exist in a single codebase.
  • It's often easier to develop and deploy due to its simplicity.
  • It's ideal for smaller, less complex applications or for businesses with limited resources.
  • However, it becomes too large and difficult to manage over time.
  • Any change requires updating the entire stack, making updates restrictive and time-consuming.
  • A single bug in any module can bring down the entire application.

Microservice Architecture:

  • A microservices architecture is a collection of smaller, independently deployable services.
  • Each service handles a small portion of the functionality and data.
  • It's more appropriate for larger and more intricate applications that demand greater scalability and flexibility.
  • It allows for the use of different technologies and languages across services.
  • It's failure-resistant and fault-tolerant.
  • However, it's more complex to develop and requires managing inter-service communication.
  • It can also introduce challenges related to data consistency and managing distributed systems.

In summary, the choice between monolithic and microservices architectures depends on the specific requirements of your project.

Q: Mean, Median, and Mode in Python

Please refer to this.

Q: What is the purpose of asterisk (*) & forward slash (/) in function arguments?

asterisk (*) and forward slash (/) controls how you pass values to the function.

  • Arguments before / are positional-only.
  • Arguments between / and * can be positional or keyword.
  • Arguments after * are keyword-only.
py
def my_func(position_only, /, positional_or_keyword, *, keyword_only):
    print(position_only, positional_or_keyword, keyword_only)

my_func(1, 2, keyword_only=3) # ✅ Valid
my_func(1, positional_or_keyword=2, keyword_only=3) # ✅ Valid

my_func(position_only=1, positional_or_keyword=2, keyword_only=3) # ❌ Invalid
my_func(1, 2, 3) # ❌ Invalid

You can even use * or / alone in function arguments.

py
def my_func(*, keyword_only):
    print(keyword_only)

def my_func(position_only, /):
    print(position_only)

Last updated: