Python Functions, Recursion & Advanced
Python Functions Interview Questions
What is a function in Python?
A function is a reusable block of code that performs a specific task. Functions help in organizing code, reducing repetition, and making programs more modular and maintainable.
What is the basic syntax of a Python function?
# Function definition
def function_name(parameters):
"""docstring""" # Optional documentation
# function body
# ...
return value # Optional return statement
# Function call
result = function_name(arguments)
# Example:
def greet(name):
"""Return a greeting message"""
return f"Hello, {name}!"
message = greet("Alice")
print(message) # Hello, Alice!
What are the different types of function arguments in Python?
# 1. Positional arguments (required, in order)
def add(a, b):
return a + b
print(add(5, 3)) # 8
# 2. Keyword arguments (by parameter name)
print(add(b=3, a=5)) # 8
# 3. Default arguments (optional with default values)
def greet(name, message="Hello"):
return f"{message}, {name}!"
print(greet("Alice")) # Hello, Alice!
print(greet("Bob", "Hi")) # Hi, Bob!
# 4. Variable-length arguments (*args)
def sum_all(*numbers):
return sum(numbers)
print(sum_all(1, 2, 3, 4)) # 10
# 5. Keyword variable-length arguments (**kwargs)
def print_info(**info):
for key, value in info.items():
print(f"{key}: {value}")
print_info(name="Alice", age=30, city="NYC")
# 6. Combination of all
def complex_func(a, b=10, *args, **kwargs):
print(f"a={a}, b={b}")
print(f"args: {args}")
print(f"kwargs: {kwargs}")
complex_func(1, 2, 3, 4, 5, x=10, y=20)
What is the difference between parameters and arguments?
# Parameters are variables in function definition
# Arguments are actual values passed when calling function
def add(a, b): # a and b are parameters
return a + b
result = add(5, 3) # 5 and 3 are arguments
# Types of parameters:
# 1. Formal parameters: Names in function definition
# 2. Actual parameters: Values passed during call
# Example with different terms:
def calculate(principal, rate, years): # These are parameters
return principal * (1 + rate) ** years
# These values are arguments:
interest = calculate(1000, 0.05, 5) # 1000, 0.05, 5 are arguments
# Summary:
# - Parameters = Placeholders in function definition
# - Arguments = Actual values passed to function
What is function scope in Python?
# Python has 4 levels of scope (LEGB rule):
# L: Local - Inside current function
# E: Enclosing - Inside enclosing functions
# G: Global - At module level
# B: Built-in - Python built-in names
# 1. Local scope
def func():
x = 10 # Local variable
print(x) # Accessible here
func()
# print(x) # Error: x is not defined (out of scope)
# 2. Enclosing scope (nested functions)
def outer():
x = "outer"
def inner():
print(f"Inner accessing: {x}") # Access enclosing scope
inner()
outer()
# 3. Global scope
global_var = "I'm global"
def show_global():
print(global_var) # Access global variable
show_global()
# 4. Modifying global variable
counter = 0
def increment():
global counter # Declare we want to use global variable
counter += 1
increment()
print(counter) # 1
# 5. Built-in scope
print(len("hello")) # len is a built-in function
print(max(1, 2, 3)) # max is built-in
# 6. Nonlocal keyword (for nested functions)
def outer():
count = 0
def inner():
nonlocal count # Refer to count in enclosing scope
count += 1
return count
return inner()
print(outer()) # 1
What are lambda functions in Python?
# Lambda functions are anonymous, single-expression functions
# Syntax: lambda arguments: expression
# 1. Basic lambda
add = lambda x, y: x + y
print(add(5, 3)) # 8
# Equivalent to:
def add(x, y):
return x + y
# 2. Immediately invoked
result = (lambda x, y: x * y)(4, 5)
print(result) # 20
# 3. With map()
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16, 25]
# 4. With filter()
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
# 5. With sorted()
pairs = [(1, 9), (2, 8), (3, 7)]
sorted_pairs = sorted(pairs, key=lambda x: x[1]) # Sort by second element
print(sorted_pairs) # [(3, 7), (2, 8), (1, 9)]
# 6. Multiple conditions
process = lambda x: x * 2 if x > 0 else x * -1
print(process(5)) # 10
print(process(-5)) # 5
# 7. Returning lambda from function
def make_multiplier(n):
return lambda x: x * n
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# Limitations of lambda:
# - Only single expression
# - No statements (if, for, while, etc.)
# - No annotations
# - Limited to one line
What are decorators in Python?
# Decorators modify or enhance functions without changing their code
# They are functions that take another function as argument
# 1. Basic decorator
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()
# 2. Decorator with arguments
def repeat(n):
def decorator(func):
def wrapper(*args, **kwargs):
for _ in range(n):
result = func(*args, **kwargs)
return result
return wrapper
return decorator
@repeat(3)
def greet(name):
print(f"Hello {name}!")
greet("Alice")
# 3. Preserving function metadata
from functools import wraps
def timer(func):
@wraps(func) # Preserves function metadata
def wrapper(*args, **kwargs):
import time
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.2f} seconds")
return result
return wrapper
@timer
def slow_function():
import time
time.sleep(1)
return "Done"
slow_function()
# 4. Class-based decorator
class CountCalls:
def __init__(self, func):
self.func = func
self.num_calls = 0
def __call__(self, *args, **kwargs):
self.num_calls += 1
print(f"Call {self.num_calls} of {self.func.__name__}")
return self.func(*args, **kwargs)
@CountCalls
def say_hello():
print("Hello!")
say_hello()
say_hello()
# 5. Multiple decorators (applied bottom to top)
@timer
@repeat(2)
def example():
print("Example function")
example()
What is recursion in Python?
# Recursion: function calls itself to solve smaller instances
# Must have: Base case (stopping condition) and Recursive case
# 1. Factorial (classic example)
def factorial(n):
if n == 0: # Base case
return 1
else: # Recursive case
return n * factorial(n - 1)
print(factorial(5)) # 120
# 2. Fibonacci sequence
def fibonacci(n):
if n <= 1: # Base case
return n
else: # Recursive case
return fibonacci(n - 1) + fibonacci(n - 2)
print([fibonacci(i) for i in range(10)]) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
# 3. Recursive sum of list
def recursive_sum(lst):
if not lst: # Base case: empty list
return 0
else: # Recursive case
return lst[0] + recursive_sum(lst[1:])
print(recursive_sum([1, 2, 3, 4, 5])) # 15
# 4. Binary search (recursive)
def binary_search(arr, target, low, high):
if low > high: # Base case: not found
return -1
mid = (low + high) // 2
if arr[mid] == target: # Base case: found
return mid
elif arr[mid] > target: # Recursive case: search left half
return binary_search(arr, target, low, mid - 1)
else: # Recursive case: search right half
return binary_search(arr, target, mid + 1, high)
arr = [1, 3, 5, 7, 9, 11, 13]
print(binary_search(arr, 7, 0, len(arr) - 1)) # 3
# 5. Tower of Hanoi
def tower_of_hanoi(n, source, target, auxiliary):
if n == 1: # Base case
print(f"Move disk 1 from {source} to {target}")
return
# Move n-1 disks from source to auxiliary
tower_of_hanoi(n - 1, source, auxiliary, target)
# Move nth disk from source to target
print(f"Move disk {n} from {source} to {target}")
# Move n-1 disks from auxiliary to target
tower_of_hanoi(n - 1, auxiliary, target, source)
tower_of_hanoi(3, 'A', 'C', 'B')
# Recursion vs Iteration:
# - Recursion: Elegant for tree-like structures, but uses more memory
# - Iteration: More memory efficient, sometimes faster
# Tail recursion optimization (Python doesn't optimize it)
What are generators in Python?
# Generators are functions that produce sequence of values lazily
# Use 'yield' instead of 'return'
# 1. Simple generator
def count_up_to(n):
i = 1
while i <= n:
yield i # Pauses here, returns i
i += 1
# Using generator
counter = count_up_to(5)
print(next(counter)) # 1
print(next(counter)) # 2
print(next(counter)) # 3
# Using in loop
for num in count_up_to(3):
print(num) # 1, 2, 3
# 2. Generator expression (like list comprehension but lazy)
squares = (x**2 for x in range(5))
print(list(squares)) # [0, 1, 4, 9, 16]
# 3. Infinite generator
def infinite_counter():
i = 0
while True:
yield i
i += 1
inf = infinite_counter()
print(next(inf)) # 0
print(next(inf)) # 1
# 4. Generator with send()
def accumulator():
total = 0
while True:
value = yield total
if value is None:
break
total += value
acc = accumulator()
next(acc) # Initialize generator
print(acc.send(10)) # 10
print(acc.send(20)) # 30
print(acc.send(5)) # 35
acc.close()
# 5. Generator for file reading
def read_large_file(file_path):
with open(file_path, 'r') as file:
for line in file:
yield line.strip()
# Memory efficient for large files
# for line in read_large_file("large_file.txt"):
# process(line)
# 6. Generator for Fibonacci
def fibonacci_generator(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
print(list(fibonacci_generator(10))) # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
# Advantages of generators:
# - Memory efficient (lazy evaluation)
# - Can represent infinite sequences
# - Cleaner code for certain patterns
What are first-class functions in Python?
# First-class functions: Functions are treated like any other object
# Can be assigned, passed as arguments, returned from functions
# 1. Assign function to variable
def greet(name):
return f"Hello, {name}!"
my_func = greet # No parentheses!
print(my_func("Alice")) # Hello, Alice!
# 2. Pass function as argument
def apply_twice(func, arg):
return func(func(arg))
def add_five(x):
return x + 5
print(apply_twice(add_five, 10)) # 20
# 3. Return function from function
def make_adder(n):
def adder(x):
return x + n
return adder
add_three = make_adder(3)
print(add_three(10)) # 13
# 4. Store functions in data structures
def square(x): return x**2
def cube(x): return x**3
def double(x): return x * 2
operations = [square, cube, double]
for func in operations:
print(func(5)) # 25, 125, 10
# 5. Functions as dictionary values
def add(a, b): return a + b
def subtract(a, b): return a - b
def multiply(a, b): return a * b
def divide(a, b): return a / b if b != 0 else "Error"
calculator = {
'+': add,
'-': subtract,
'*': multiply,
'/': divide
}
print(calculator['+'](10, 5)) # 15
print(calculator['*'](10, 5)) # 50
# 6. Higher-order functions
from functools import reduce
numbers = [1, 2, 3, 4, 5]
# map: apply function to each element
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16, 25]
# filter: keep elements where function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
# reduce: apply function cumulatively
product = reduce(lambda x, y: x * y, numbers)
print(product) # 120
# 7. Function attributes
def example():
"""Example function with attributes"""
pass
example.custom_attr = "I'm a function attribute"
print(example.__name__) # example
print(example.__doc__) # Example function with attributes
print(example.custom_attr) # I'm a function attribute
What are function annotations in Python?
# Function annotations provide metadata about types and parameters
# Syntax: def func(param: annotation) -> return_annotation:
# 1. Type hints (PEP 484)
def greet(name: str) -> str:
"""Return greeting message"""
return f"Hello, {name}!"
# 2. Multiple parameters
def add(a: int, b: int) -> int:
return a + b
# 3. Complex type hints
from typing import List, Tuple, Dict, Optional, Union
def process_items(items: List[str]) -> Dict[str, int]:
"""Process list of items and return dictionary"""
return {item: len(item) for item in items}
# 4. Optional parameters
def find_item(items: List[str], target: str) -> Optional[int]:
"""Return index if found, None otherwise"""
try:
return items.index(target)
except ValueError:
return None
# 5. Union types
def square_root(x: Union[int, float]) -> float:
return x ** 0.5
# 6. Using typing module
from typing import Callable
def apply_func(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
# 7. Variable annotations
pi: float = 3.14159
names: List[str] = ["Alice", "Bob", "Charlie"]
# 8. Accessing annotations
def example(a: int, b: str = "hello") -> bool:
return True
print(example.__annotations__)
# {'a': , 'b': , 'return': }
# 9. Annotations are not enforced at runtime
# They're just metadata for documentation and type checkers
# 10. Using mypy for type checking
# Install: pip install mypy
# Run: mypy your_script.py
# 11. Forward references (for recursive types)
from typing import List
class TreeNode:
def __init__(self, value: int, children: List['TreeNode'] = None):
self.value = value
self.children = children or []
# Benefits of annotations:
# - Better documentation
# - IDE autocompletion
# - Static type checking
# - Runtime introspection
What are closure functions in Python?
# Closure: Function that remembers values from enclosing scope
# Even after the outer function has finished execution
# 1. Basic closure
def outer_func(x):
def inner_func(y):
return x + y # x is "remembered" from outer scope
return inner_func
add_five = outer_func(5) # x = 5 is remembered
print(add_five(3)) # 8
print(add_five(10)) # 15
# 2. Multiple closures
def make_multiplier(n):
def multiplier(x):
return x * n
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
# 3. Closure with mutable state
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c1 = counter()
print(c1()) # 1
print(c1()) # 2
print(c1()) # 3
c2 = counter() # Separate closure with its own state
print(c2()) # 1
# 4. Closure for configuration
def configure_logger(level):
def log(message):
print(f"[{level}] {message}")
return log
info_log = configure_logger("INFO")
error_log = configure_logger("ERROR")
info_log("System started") # [INFO] System started
error_log("Something wrong!") # [ERROR] Something wrong!
# 5. Closure with multiple variables
def make_power(exponent):
def power(base):
return base ** exponent
return power
square = make_power(2)
cube = make_power(3)
print(square(5)) # 25
print(cube(5)) # 125
# 6. Practical example: HTML tag wrapper
def make_html_tag(tag):
def wrap_text(text):
return f"<{tag}>{text}{tag}>"
return wrap_text
bold = make_html_tag("b")
italic = make_html_tag("i")
print(bold("Hello")) # Hello
print(italic("World")) # World
# 7. Checking closure variables
def outer(x):
y = 10
def inner():
z = x + y
return z
return inner
func = outer(5)
print(func.__closure__) # Contains cell objects
print(func.__code__.co_freevars) # ('x', 'y') - captured variables
# When to use closures:
# - When you need to maintain state between calls
# - For creating specialized functions
# - In decorators
# - For callback functions
What are built-in higher-order functions?
# Higher-order functions: Functions that take other functions as arguments
# 1. map(function, iterable) - Apply function to each element
numbers = [1, 2, 3, 4, 5]
# Using lambda
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16, 25]
# Using named function
def square(x):
return x**2
squared = list(map(square, numbers))
print(squared) # [1, 4, 9, 16, 25]
# Multiple iterables
list1 = [1, 2, 3]
list2 = [4, 5, 6]
sums = list(map(lambda x, y: x + y, list1, list2))
print(sums) # [5, 7, 9]
# 2. filter(function, iterable) - Keep elements where function returns True
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8, 10]
# Filter with None (removes falsy values)
mixed = [0, 1, False, True, "", "hello", [], [1, 2]]
truthy = list(filter(None, mixed))
print(truthy) # [1, True, 'hello', [1, 2]]
# 3. reduce(function, iterable, initial) - Cumulative operation
from functools import reduce
numbers = [1, 2, 3, 4, 5]
# Sum
total = reduce(lambda x, y: x + y, numbers)
print(total) # 15
# With initial value
total = reduce(lambda x, y: x + y, numbers, 10)
print(total) # 25 (10 + 1 + 2 + 3 + 4 + 5)
# Product
product = reduce(lambda x, y: x * y, numbers)
print(product) # 120
# Find maximum
maximum = reduce(lambda x, y: x if x > y else y, numbers)
print(maximum) # 5
# 4. sorted(iterable, key=None, reverse=False)
words = ["banana", "apple", "cherry", "date"]
# Sort by length
sorted_by_length = sorted(words, key=len)
print(sorted_by_length) # ['date', 'apple', 'banana', 'cherry']
# Sort by second letter
sorted_by_second = sorted(words, key=lambda x: x[1])
print(sorted_by_second) # ['date', 'banana', 'apple', 'cherry']
# 5. any(iterable) and all(iterable)
numbers = [1, 2, 3, 4, 5]
# any: True if any element is truthy
print(any(x > 3 for x in numbers)) # True (4 and 5 are > 3)
print(any(x > 10 for x in numbers)) # False
# all: True if all elements are truthy
print(all(x > 0 for x in numbers)) # True
print(all(x > 3 for x in numbers)) # False (1, 2, 3 are not > 3)
# 6. zip(*iterables) - Combine iterables
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
cities = ["NYC", "LA", "Chicago"]
combined = list(zip(names, ages, cities))
print(combined) # [('Alice', 25, 'NYC'), ('Bob', 30, 'LA'), ('Charlie', 35, 'Chicago')]
# 7. enumerate(iterable, start=0) - Add counter
fruits = ["apple", "banana", "cherry"]
for index, fruit in enumerate(fruits, start=1):
print(f"{index}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry
What is memoization and how to implement it?
# Memoization: Caching results of expensive function calls
# 1. Manual memoization for factorial
def factorial(n, memo={}):
if n in memo:
return memo[n]
if n == 0:
result = 1
else:
result = n * factorial(n - 1, memo)
memo[n] = result
return result
print(factorial(5)) # 120 (computes once)
print(factorial(5)) # 120 (returns cached)
# 2. Fibonacci with memoization
def fibonacci(n, memo={}):
if n in memo:
return memo[n]
if n <= 1:
result = n
else:
result = fibonacci(n - 1, memo) + fibonacci(n - 2, memo)
memo[n] = result
return result
print(fibonacci(10)) # 55 (much faster than naive recursion)
# 3. Using lru_cache decorator (Python 3.2+)
from functools import lru_cache
@lru_cache(maxsize=None) # Unlimited cache
def fibonacci_cached(n):
if n <= 1:
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
print(fibonacci_cached(50)) # 12586269025 (fast!)
# 4. Custom memoization decorator
def memoize(func):
cache = {}
def wrapper(*args):
if args in cache:
return cache[args]
result = func(*args)
cache[args] = result
return result
return wrapper
@memoize
def expensive_function(x):
print(f"Computing for {x}...")
return x * x # Simulating expensive computation
print(expensive_function(5)) # Computing for 5... 25
print(expensive_function(5)) # 25 (cached, no computation)
print(expensive_function(10)) # Computing for 10... 100
# 5. Memoization with keyword arguments
def memoize_kwargs(func):
cache = {}
def wrapper(*args, **kwargs):
key = (args, tuple(sorted(kwargs.items())))
if key in cache:
return cache[key]
result = func(*args, **kwargs)
cache[key] = result
return result
return wrapper
@memoize_kwargs
def complex_calc(a, b, multiplier=1):
print(f"Calculating {a}, {b}, {multiplier}...")
return (a + b) * multiplier
print(complex_calc(2, 3, multiplier=2)) # Calculating... 10
print(complex_calc(2, 3, multiplier=2)) # 10 (cached)
# 6. Class-based memoization
class Memoize:
def __init__(self, func):
self.func = func
self.cache = {}
def __call__(self, *args):
if args in self.cache:
return self.cache[args]
result = self.func(*args)
self.cache[args] = result
return result
@Memoize
def factorial_class(n):
if n == 0:
return 1
return n * factorial_class(n - 1)
# 7. When to use memoization:
# - Expensive pure functions (same input → same output)
# - Recursive functions with overlapping subproblems
# - Dynamic programming problems
# 8. Cache statistics with lru_cache
@lru_cache(maxsize=3)
def get_data(key):
print(f"Fetching {key}...")
return key.upper()
print(get_data("a")) # Fetching a... A
print(get_data("b")) # Fetching b... B
print(get_data("a")) # A (cached)
print(get_data("c")) # Fetching c... C
print(get_data("d")) # Fetching d... D (a is evicted)
print(get_data.cache_info()) # CacheInfo(hits=1, misses=4, maxsize=3, currsize=3)
What are partial functions in Python?
# Partial functions: Fix some arguments of a function
# Create new function with fewer arguments
from functools import partial
# 1. Basic partial function
def power(base, exponent):
return base ** exponent
# Create square function (fix exponent=2)
square = partial(power, exponent=2)
print(square(5)) # 25 (5²)
# Create cube function (fix exponent=3)
cube = partial(power, exponent=3)
print(cube(5)) # 125 (5³)
# 2. Fixing multiple arguments
def greet(greeting, name, punctuation="!"):
return f"{greeting}, {name}{punctuation}"
say_hello = partial(greet, "Hello")
print(say_hello("Alice")) # Hello, Alice!
say_hi = partial(greet, "Hi", punctuation=".")
print(say_hi("Bob")) # Hi, Bob.
# 3. Using with built-in functions
numbers = [1, 2, 3, 4, 5]
# Create function that always adds 10
add_ten = partial(map, lambda x: x + 10)
result = list(add_ten(numbers))
print(result) # [11, 12, 13, 14, 15]
# 4. Partial with keyword arguments
def connect(host, port, timeout=30, retries=3):
return f"Connecting to {host}:{port} (timeout={timeout}, retries={retries})"
connect_localhost = partial(connect, "localhost", 8080)
print(connect_localhost()) # Connecting to localhost:8080 (timeout=30, retries=3)
connect_fast = partial(connect, timeout=5, retries=1)
print(connect_fast("example.com", 443)) # Connecting to example.com:443 (timeout=5, retries=1)
# 5. Manual partial function
def manual_partial(func, *args, **kwargs):
def new_func(*more_args, **more_kwargs):
all_args = args + more_args
all_kwargs = {**kwargs, **more_kwargs}
return func(*all_args, **all_kwargs)
return new_func
def multiply(a, b, c):
return a * b * c
double = manual_partial(multiply, 2)
print(double(3, 4)) # 24 (2 * 3 * 4)
# 6. Using partial with class methods
class Calculator:
def add(self, a, b):
return a + b
calc = Calculator()
add_five = partial(calc.add, 5)
print(add_five(3)) # 8
# 7. Practical examples
# URL builder
def build_url(scheme, host, path, query=None):
url = f"{scheme}://{host}/{path}"
if query:
url += f"?{query}"
return url
build_https = partial(build_url, "https")
build_github = partial(build_https, "github.com")
print(build_github("user/repo")) # https://github.com/user/repo
print(build_github("org/project", query="branch=main")) # https://github.com/org/project?branch=main
# Log formatter
def log(level, message, timestamp):
return f"[{timestamp}] {level}: {message}"
from datetime import datetime
log_now = partial(log, timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
error_log = partial(log_now, "ERROR")
info_log = partial(log_now, "INFO")
print(error_log("Something went wrong!"))
print(info_log("System started"))
What are function attributes and introspection?
# Function introspection: Examining function properties at runtime
def example_func(a, b=10, *args, **kwargs):
"""Example function for demonstrating introspection."""
return a + b
# 1. Basic attributes
print(example_func.__name__) # example_func
print(example_func.__doc__) # Example function for demonstrating introspection.
print(example_func.__module__) # __main__ (or module name)
# 2. Code object attributes
print(example_func.__code__.co_argcount) # 2 (a, b)
print(example_func.__code__.co_nlocals) # Number of local variables
print(example_func.__code__.co_varnames) # ('a', 'b', 'args', 'kwargs')
print(example_func.__code__.co_filename) # File where function is defined
print(example_func.__code__.co_firstlineno) # Line number where function starts
# 3. Default arguments
print(example_func.__defaults__) # (10,) - default values for positional args
print(example_func.__kwdefaults__) # None - default values for keyword-only args
# 4. Annotations
def typed_func(a: int, b: str = "hello") -> bool:
return True
print(typed_func.__annotations__) # {'a': , 'b': , 'return': }
# 5. Closure attributes
def outer(x):
def inner():
return x
return inner
closure_func = outer(10)
print(closure_func.__closure__) # Cell objects
print(closure_func.__code__.co_freevars) # ('x',)
# 6. Custom attributes
def custom_func():
pass
custom_func.author = "Alice"
custom_func.version = "1.0"
custom_func.tags = ["utility", "helper"]
print(custom_func.author) # Alice
print(custom_func.version) # 1.0
print(custom_func.tags) # ['utility', 'helper']
# 7. Using inspect module for advanced introspection
import inspect
# Get source code
source = inspect.getsource(example_func)
print(source[:50]) # First 50 chars of source
# Get signature
sig = inspect.signature(example_func)
print(sig) # (a, b=10, *args, **kwargs)
# Parameter details
for param_name, param in sig.parameters.items():
print(f"{param_name}:")
print(f" kind: {param.kind}")
print(f" default: {param.default}")
print(f" annotation: {param.annotation}")
# 8. Check if callable
print(callable(example_func)) # True
print(callable("hello")) # False
# 9. Get all members of function
members = inspect.getmembers(example_func)
for name, value in members[:5]: # First 5 members
print(f"{name}: {type(value).__name__}")
# 10. Practical use cases
def validate_arguments(func, *args, **kwargs):
"""Validate arguments against function signature."""
sig = inspect.signature(func)
try:
sig.bind(*args, **kwargs)
return True
except TypeError as e:
print(f"Argument error: {e}")
return False
def add(a: int, b: int) -> int:
return a + b
print(validate_arguments(add, 5, 3)) # True
print(validate_arguments(add, 5)) # False (missing b)
print(validate_arguments(add, 5, "3")) # True (types not checked at runtime)
How to handle function errors and exceptions?
# Error handling in functions
# 1. Basic try-except
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return "Error: Division by zero"
except TypeError:
return "Error: Invalid types"
print(safe_divide(10, 2)) # 5.0
print(safe_divide(10, 0)) # Error: Division by zero
print(safe_divide(10, "2")) # Error: Invalid types
# 2. Multiple exceptions in one except
def process_number(num):
try:
result = int(num) * 2
if result > 100:
raise ValueError("Result too large")
return result
except (ValueError, TypeError) as e:
return f"Error: {e}"
# 3. Else clause (executes if no exception)
def read_file(filename):
try:
file = open(filename, 'r')
except FileNotFoundError:
print(f"File {filename} not found")
else:
content = file.read()
file.close()
return content
# 4. Finally clause (always executes)
def process_data(data):
result = None
try:
result = data.process()
except AttributeError:
print("Invalid data object")
finally:
print("Cleanup completed")
return result
# 5. Raising exceptions
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age is unrealistically high")
return age
try:
validate_age(-5)
except ValueError as e:
print(f"Validation failed: {e}")
# 6. Custom exceptions
class InvalidEmailError(Exception):
"""Exception raised for invalid email addresses."""
def __init__(self, email, message="Invalid email address"):
self.email = email
self.message = message
super().__init__(self.message)
def __str__(self):
return f"{self.message}: {self.email}"
def validate_email(email):
if "@" not in email:
raise InvalidEmailError(email)
return True
try:
validate_email("invalid-email")
except InvalidEmailError as e:
print(e) # Invalid email address: invalid-email
# 7. Exception chaining
def process_file(filename):
try:
with open(filename, 'r') as f:
return f.read()
except FileNotFoundError as e:
raise RuntimeError(f"Failed to process {filename}") from e
# 8. Assertions for debugging
def calculate_discount(price, discount):
assert 0 <= discount <= 1, "Discount must be between 0 and 1"
assert price >= 0, "Price cannot be negative"
return price * (1 - discount)
# 9. Context managers for resource handling
from contextlib import contextmanager
@contextmanager
def managed_resource(name):
resource = open(name, 'w')
try:
yield resource
finally:
resource.close()
print(f"Resource {name} closed")
with managed_resource("test.txt") as f:
f.write("Hello World")
# 10. Logging instead of printing
import logging
logging.basicConfig(level=logging.INFO)
def complex_operation(data):
try:
result = data.process()
logging.info(f"Operation successful: {result}")
return result
except Exception as e:
logging.error(f"Operation failed: {e}", exc_info=True)
raise
What are coroutines and async functions?
# Coroutines and async/await (Python 3.5+)
import asyncio
# 1. Basic async function
async def hello():
print("Hello")
await asyncio.sleep(1) # Non-blocking sleep
print("World")
# Running async function
asyncio.run(hello())
# 2. Multiple async functions
async def say_hello(name, delay):
await asyncio.sleep(delay)
print(f"Hello {name}!")
return f"Greeted {name}"
async def main():
# Run sequentially
result1 = await say_hello("Alice", 1)
result2 = await say_hello("Bob", 2)
# Run concurrently
task1 = asyncio.create_task(say_hello("Charlie", 1))
task2 = asyncio.create_task(say_hello("David", 2))
await task1
await task2
# Using gather
results = await asyncio.gather(
say_hello("Eve", 1),
say_hello("Frank", 2),
say_hello("Grace", 3)
)
print(f"Results: {results}")
asyncio.run(main())
# 3. Async generator
async def async_counter(n):
for i in range(n):
await asyncio.sleep(0.5)
yield i
async def use_async_gen():
async for number in async_counter(5):
print(f"Got: {number}")
asyncio.run(use_async_gen())
# 4. Async context manager
import aiohttp
async def fetch_url(url):
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
return await response.text()
# 5. Coroutine with send() and throw()
async def accumulator():
total = 0
while True:
try:
value = yield total
if value is None:
break
total += value
except ValueError as e:
print(f"Error: {e}")
total = 0
async def use_accumulator():
acc = accumulator()
await acc.send(None) # Initialize
print(await acc.send(10)) # 10
print(await acc.send(20)) # 30
# Throw exception into coroutine
try:
await acc.throw(ValueError("Reset!"))
except StopIteration:
pass
print(await acc.send(5)) # 5 (reset to 0)
acc.close()
asyncio.run(use_accumulator())
# 6. Async comprehensions
async def async_comprehension():
# Async list comprehension
results = [i async for i in async_counter(5)]
print(f"Async list: {results}")
# Async set comprehension
unique = {i % 2 async for i in async_counter(5)}
print(f"Async set: {unique}")
# Async dict comprehension
squares = {i: i**2 async for i in async_counter(5)}
print(f"Async dict: {squares}")
asyncio.run(async_comprehension())
# 7. Asynchronous iteration
class AsyncCounter:
def __init__(self, n):
self.n = n
self.current = 0
def __aiter__(self):
return self
async def __anext__(self):
if self.current >= self.n:
raise StopAsyncIteration
await asyncio.sleep(0.1)
value = self.current
self.current += 1
return value
async def use_async_iterator():
async for num in AsyncCounter(5):
print(num)
asyncio.run(use_async_iterator())
What are common function design patterns?
# Common function design patterns
# 1. Factory pattern (function version)
def create_animal(animal_type):
if animal_type == "dog":
def dog():
return "Woof!"
return dog
elif animal_type == "cat":
def cat():
return "Meow!"
return cat
else:
def unknown():
return "Unknown animal"
return unknown
dog = create_animal("dog")
cat = create_animal("cat")
print(dog()) # Woof!
print(cat()) # Meow!
# 2. Strategy pattern (function version)
def add_strategy(a, b):
return a + b
def multiply_strategy(a, b):
return a * b
def calculator(a, b, strategy):
return strategy(a, b)
print(calculator(5, 3, add_strategy)) # 8
print(calculator(5, 3, multiply_strategy)) # 15
# 3. Observer pattern (callback functions)
class EventEmitter:
def __init__(self):
self.listeners = {}
def on(self, event, callback):
if event not in self.listeners:
self.listeners[event] = []
self.listeners[event].append(callback)
def emit(self, event, *args, **kwargs):
if event in self.listeners:
for callback in self.listeners[event]:
callback(*args, **kwargs)
def log_event(message):
print(f"LOG: {message}")
def notify_user(message):
print(f"USER: {message}")
emitter = EventEmitter()
emitter.on("message", log_event)
emitter.on("message", notify_user)
emitter.emit("message", "Hello World")
# 4. Command pattern
class Command:
def __init__(self, execute, undo):
self.execute = execute
self.undo = undo
def add_command(a, b):
return a + b
def subtract_command(a, b):
return a - b
commands = [
Command(lambda: add_command(5, 3), lambda: subtract_command(8, 3)),
Command(lambda: subtract_command(10, 2), lambda: add_command(8, 2))
]
for cmd in commands:
result = cmd.execute()
print(f"Execute: {result}")
undo_result = cmd.undo()
print(f"Undo: {undo_result}")
# 5. Template method pattern
def template_method(algorithm):
def wrapper():
print("Step 1: Initialize")
result = algorithm()
print("Step 3: Cleanup")
return result
return wrapper
def specific_algorithm():
print("Step 2: Specific algorithm")
return "Result"
algorithm = template_method(specific_algorithm)
print(algorithm())
# 6. Chain of responsibility
def make_handler(handler_func, next_handler=None):
def handler(request):
result = handler_func(request)
if result is None and next_handler:
return next_handler(request)
return result
return handler
def authenticate(request):
if request.get("token") == "valid":
print("Authenticated")
return None # Pass to next handler
return "Authentication failed"
def authorize(request):
if request.get("role") == "admin":
print("Authorized")
return None # Pass to next handler
return "Authorization failed"
def process(request):
print("Processing request")
return "Success"
# Create handler chain
handler = make_handler(process)
handler = make_handler(authorize, handler)
handler = make_handler(authenticate, handler)
# Test chain
valid_request = {"token": "valid", "role": "admin"}
print(handler(valid_request)) # Success
invalid_request = {"token": "invalid"}
print(handler(invalid_request)) # Authentication failed
What are best practices for writing functions?
# Function best practices
# 1. Single Responsibility Principle
# BAD: Function does multiple things
def process_user_data(user_data):
# Validate
if not user_data.get("name"):
return "Error: Name required"
# Process
user_data["name"] = user_data["name"].title()
# Save
database.save(user_data)
# Send notification
email.send(user_data["email"])
return "Success"
# GOOD: Separate functions
def validate_user_data(user_data):
if not user_data.get("name"):
raise ValueError("Name required")
return True
def process_name(user_data):
user_data["name"] = user_data["name"].title()
return user_data
def save_user(user_data):
return database.save(user_data)
def notify_user(user_data):
return email.send(user_data["email"])
# 2. Keep functions small (ideally < 20 lines)
# 3. Use meaningful names
# BAD: def p(d): return d*2
# GOOD: def calculate_discount(price): return price * 0.9
# 4. Limit number of parameters (ideally ≤ 3)
# BAD: Too many parameters
def create_user(name, email, password, age, city, country, phone, role):
pass
# GOOD: Use dictionary or class
def create_user(user_data):
# user_data is a dictionary with all fields
pass
# 5. Use type hints
def calculate_total(items: List[float], discount: float = 0.0) -> float:
total = sum(items)
return total * (1 - discount)
# 6. Document with docstrings
def fibonacci(n: int) -> int:
"""
Calculate the nth Fibonacci number.
Args:
n: The position in the Fibonacci sequence (0-based).
Returns:
The nth Fibonacci number.
Raises:
ValueError: If n is negative.
Examples:
>>> fibonacci(0)
0
>>> fibonacci(5)
5
"""
if n < 0:
raise ValueError("n must be non-negative")
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# 7. Avoid side effects
# BAD: Modifies input parameter
def add_to_list(items, new_item):
items.append(new_item) # Side effect
return items
# GOOD: Returns new list
def add_to_list(items, new_item):
return items + [new_item] # No side effect
# 8. Use default arguments carefully
# BAD: Mutable default argument
def add_item(item, items=[]): # Shared list!
items.append(item)
return items
# GOOD: Use None as default
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
# 9. Handle errors appropriately
def read_config_file(filename):
try:
with open(filename, 'r') as f:
return json.load(f)
except FileNotFoundError:
logger.error(f"Config file {filename} not found")
raise
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in {filename}: {e}")
raise
# 10. Write pure functions when possible
# Pure function: Same input → Same output, no side effects
def pure_multiply(a, b):
return a * b # Pure
# Impure function
impure_counter = 0
def impure_increment():
global impure_counter # Side effect
impure_counter += 1
return impure_counter
# 11. Use function composition
def compose(f, g):
return lambda x: f(g(x))
add_one = lambda x: x + 1
square = lambda x: x ** 2
add_one_then_square = compose(square, add_one)
print(add_one_then_square(5)) # 36 ((5+1)²)
# 12. Consider performance
# Use caching for expensive pure functions
from functools import lru_cache
@lru_cache(maxsize=128)
def expensive_calculation(x):
# Simulate expensive calculation
time.sleep(1)
return x * 2
# 13. Test your functions
def test_functions():
assert add(2, 3) == 5
assert multiply(2, 3) == 6
print("All tests passed!")
Note: These questions cover Python functions comprehensively. Remember: Functions are first-class objects in Python. Use recursion for problems with recursive structure, but be mindful of stack limits. Decorators, generators, and lambda functions are powerful tools for writing clean, efficient code. Always follow best practices: keep functions small, use meaningful names, document with docstrings, and handle errors appropriately. For performance-critical code, consider memoization and generator expressions.