What Are Functions?
A function is a named, reusable block of code that performs a specific task. Instead of writing the same logic over and over, you write it once inside a function and then call that function whenever you need it.
Think of a function like a recipe. The recipe has a name ("Chocolate Cake"), a list of ingredients (parameters), a set of steps (the function body), and an end result (the return value). Every time you follow the recipe, you get a cake — without having to reinvent the process.
# Without functions — repetitive
print("=" * 40)
print("Welcome, Priya!")
print("=" * 40)
print("=" * 40)
print("Welcome, Rahul!")
print("=" * 40)
print("=" * 40)
print("Welcome, Sneha!")
print("=" * 40)
# With a function — clean and reusable
def welcome(name):
print("=" * 40)
print(f"Welcome, {name}!")
print("=" * 40)
welcome("Priya")
welcome("Rahul")
welcome("Sneha")
Why Use Functions?
Functions are one of the most fundamental building blocks of programming. Here is why every Python developer relies on them:
| Benefit | Explanation |
|---|---|
| Reusability | Write once, call many times. Avoid duplicating code across your program. |
| Modularity | Break complex problems into smaller, manageable pieces. Each function handles one task. |
| Readability | A well-named function like calculate_tax() is easier to understand than 20 lines of math. |
| Testability | Small, focused functions are easy to test individually. |
| Maintainability | When logic changes, you update it in one place instead of hunting through your entire codebase. |
| Abstraction | Callers do not need to know how a function works — just what it does. |
# Functions let you think at a higher level
def clean_data(raw_data):
# ... 50 lines of cleaning logic ...
return cleaned
def analyze(data):
# ... 30 lines of analysis ...
return results
def generate_report(results):
# ... 40 lines of formatting ...
return report
# The main flow reads like plain English
data = clean_data(raw_input)
results = analyze(data)
report = generate_report(results)
Defining and Calling Functions
The def Keyword
You create a function with the def keyword, followed by a name, parentheses (with optional parameters), and a colon. The indented block underneath is the function body.
def function_name(parameters):
"""Optional docstring explaining what the function does."""
# function body — indented code
return value # optional
A Simple Function
def say_hello():
print("Hello, World!")
# Calling the function
say_hello()
Output:
Hello, World!
The function say_hello takes no parameters and returns nothing explicitly. It simply performs an action (printing) when called.
A Function with Parameters
def greet(name):
print(f"Hello, {name}!")
greet("Priya")
greet("Rahul")
Output:
Hello, Priya!
Hello, Rahul!
A Function That Returns a Value
def add(a, b):
return a + b
result = add(5, 3)
print(result) # 8
# You can use the return value directly
print(add(10, 20)) # 30
Functions Can Call Other Functions
def square(x):
return x * x
def sum_of_squares(a, b):
return square(a) + square(b)
print(sum_of_squares(3, 4)) # 9 + 16 = 25
Naming Conventions
Python functions follow these naming rules:
- Use lowercase letters with underscores between words:
calculate_tax,get_user_name - Names must start with a letter or underscore, not a digit
- Avoid single-letter names except for very short lambdas or loop variables
- Choose descriptive verb-based names:
send_email(),validate_input(),parse_json()
# Good names
def calculate_average(numbers):
return sum(numbers) / len(numbers)
def is_valid_email(email):
return "@" in email and "." in email
def format_currency(amount, currency="INR"):
return f"{currency} {amount:,.2f}"
# Poor names — avoid these
def func1(x): # What does this do?
return x * 2
def process(data): # Too vague
pass
Order Matters: Define Before You Call
A function must be defined before it is called. Python reads code top to bottom.
# This works
def greet():
print("Hi!")
greet()
# This causes a NameError
# greet_v2() # NameError: name 'greet_v2' is not defined
# def greet_v2():
# print("Hi again!")
However, the order of definition does not matter as long as both functions are defined before the call site executes:
def main():
greet("Priya") # This is fine — helper() is defined before main() runs
def greet(name):
print(f"Hello, {name}!")
main() # Both functions exist by this point
Parameters and Arguments
These two terms are often used interchangeably, but they have a subtle difference:
- Parameter — the variable name listed in the function definition
- Argument — the actual value passed when you call the function
def greet(name): # 'name' is a PARAMETER
print(f"Hello, {name}!")
greet("Priya") # "Priya" is an ARGUMENT
Positional Arguments
Positional arguments are matched to parameters based on their order:
def describe_pet(animal, name):
print(f"I have a {animal} named {name}.")
describe_pet("dog", "Bruno") # I have a dog named Bruno.
describe_pet("cat", "Whiskers") # I have a cat named Whiskers.
If you mix up the order, the meaning changes:
describe_pet("Bruno", "dog") # I have a Bruno named dog. (wrong!)
Keyword Arguments
Keyword arguments are matched by name, so order does not matter:
describe_pet(name="Bruno", animal="dog") # I have a dog named Bruno.
describe_pet(animal="cat", name="Whiskers") # I have a cat named Whiskers.
Mixing Positional and Keyword Arguments
You can combine both styles, but positional arguments must come first:
def create_user(name, age, city):
print(f"{name}, age {age}, from {city}")
# Valid combinations
create_user("Priya", 22, "Mumbai") # All positional
create_user("Priya", 22, city="Mumbai") # Mixed
create_user("Priya", age=22, city="Mumbai") # Mixed
create_user(name="Priya", age=22, city="Mumbai") # All keyword
# INVALID — positional after keyword
# create_user(name="Priya", 22, "Mumbai") # SyntaxError
Comparison: Positional vs Keyword Arguments
| Feature | Positional | Keyword |
|---|---|---|
| Matched by | Order/position | Parameter name |
| Order matters? | Yes | No |
| Readability | Lower for many params | Higher — self-documenting |
| When to use | Few, obvious arguments | Many arguments or unclear meaning |
Default Parameter Values
You can give parameters default values. If the caller omits that argument, the default is used automatically.
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Priya")) # Hello, Priya!
print(greet("Priya", "Welcome")) # Welcome, Priya!
print(greet("Priya", "Namaste")) # Namaste, Priya!
Rules for Default Parameters
Parameters with defaults must come after parameters without defaults:
# Correct
def power(base, exponent=2):
return base ** exponent
print(power(5)) # 25 (5^2)
print(power(5, 3)) # 125 (5^3)
# INCORRECT — non-default after default
# def power(base=2, exponent): # SyntaxError
# return base ** exponent
Multiple Default Parameters
def create_profile(name, age=25, city="Mumbai", country="India"):
return {
"name": name,
"age": age,
"city": city,
"country": country,
}
# Using all defaults
print(create_profile("Priya"))
# {'name': 'Priya', 'age': 25, 'city': 'Mumbai', 'country': 'India'}
# Overriding some defaults
print(create_profile("Rahul", city="Delhi"))
# {'name': 'Rahul', 'age': 25, 'city': 'Delhi', 'country': 'India'}
# Overriding all defaults
print(create_profile("Sneha", 30, "Bangalore", "India"))
# {'name': 'Sneha', 'age': 30, 'city': 'Bangalore', 'country': 'India'}
The Mutable Default Argument Gotcha
This is one of the most common Python traps. Default values are evaluated once when the function is defined, not each time the function is called. If the default is a mutable object (like a list or dict), it is shared across all calls.
# BUG — the list is shared!
def add_item(item, shopping_list=[]):
shopping_list.append(item)
return shopping_list
print(add_item("milk")) # ['milk']
print(add_item("bread")) # ['milk', 'bread'] ← Surprise!
print(add_item("eggs")) # ['milk', 'bread', 'eggs'] ← Still accumulating!
The list [] is created once and reused. Each call mutates the same list object.
The Fix: Use None as a Sentinel
# CORRECT — use None and create a new list inside
def add_item(item, shopping_list=None):
if shopping_list is None:
shopping_list = []
shopping_list.append(item)
return shopping_list
print(add_item("milk")) # ['milk']
print(add_item("bread")) # ['bread'] ← Fresh list each time
print(add_item("eggs")) # ['eggs'] ← Correct!
# You can still pass an existing list
my_list = ["butter"]
print(add_item("jam", my_list)) # ['butter', 'jam']
This pattern (using None as a sentinel) is the standard Python idiom for mutable default arguments. You will see it everywhere in professional Python code.
# Another example — dict default
def add_score(name, score, scoreboard=None):
if scoreboard is None:
scoreboard = {}
scoreboard[name] = score
return scoreboard
print(add_score("Priya", 95)) # {'Priya': 95}
print(add_score("Rahul", 88)) # {'Rahul': 88} ← Independent dict
Variable-Length Arguments
Sometimes you do not know in advance how many arguments a function will receive. Python provides *args and **kwargs for this.
*args — Variable Positional Arguments
The *args syntax collects any number of positional arguments into a tuple:
def total(*numbers):
print(f"Type: {type(numbers)}") # <class 'tuple'>
print(f"Values: {numbers}")
return sum(numbers)
print(total(1, 2, 3)) # 6
print(total(10, 20, 30, 40)) # 100
print(total(5)) # 5
print(total()) # 0
The name args is a convention — you can use any name after the *, but args is universally understood.
def print_all(*items):
for item in items:
print(item)
print_all("apple", "banana", "cherry")
Output:
apple
banana
cherry
**kwargs — Variable Keyword Arguments
The **kwargs syntax collects any number of keyword arguments into a dictionary:
def build_profile(**info):
print(f"Type: {type(info)}") # <class 'dict'>
for key, value in info.items():
print(f" {key}: {value}")
build_profile(name="Priya", age=22, city="Mumbai")
Output:
Type: <class 'dict'>
name: Priya
age: 22
city: Mumbai
Again, kwargs is a convention. Any name after ** works, but stick with kwargs for clarity.
def create_html_tag(tag, content, **attributes):
attrs = " ".join(f'{k}="{v}"' for k, v in attributes.items())
if attrs:
return f"<{tag} {attrs}>{content}</{tag}>"
return f"<{tag}>{content}</{tag}>"
print(create_html_tag("a", "Click here", href="https://example.com", target="_blank"))
# <a href="https://example.com" target="_blank">Click here</a>
print(create_html_tag("p", "Hello World"))
# <p>Hello World</p>
Combining All Parameter Types
Python enforces a strict order for parameter types:
def func(positional, default=value, *args, keyword_only, **kwargs):
def example(a, b, *args, key="default", **kwargs):
print(f"a = {a}")
print(f"b = {b}")
print(f"args = {args}")
print(f"key = {key}")
print(f"kwargs = {kwargs}")
example(1, 2, 3, 4, 5, key="custom", x=10, y=20)
Output:
a = 1
b = 2
args = (3, 4, 5)
key = custom
kwargs = {'x': 10, 'y': 20}
The Complete Parameter Order
| Position | Type | Example | Description |
|---|---|---|---|
| 1st | Positional | a, b | Regular required arguments |
| 2nd | Default | c=10 | Arguments with default values |
| 3rd | *args | *args | Catches extra positional args |
| 4th | Keyword-only | key=val | Must be passed by name (after *args) |
| 5th | **kwargs | **kwargs | Catches extra keyword args |
Unpacking Arguments with * and **
You can also use * and ** when calling a function to unpack sequences and dictionaries:
def add(a, b, c):
return a + b + c
# Unpack a list or tuple with *
numbers = [10, 20, 30]
print(add(*numbers)) # 60
# Unpack a dictionary with **
params = {"a": 10, "b": 20, "c": 30}
print(add(**params)) # 60
This is especially useful when building function calls dynamically:
def greet(name, greeting, punctuation):
return f"{greeting}, {name}{punctuation}"
args = ["Priya"]
kwargs = {"greeting": "Hello", "punctuation": "!"}
print(greet(*args, **kwargs)) # Hello, Priya!
*args vs **kwargs Comparison
| Feature | *args | **kwargs |
|---|---|---|
| Collects | Extra positional arguments | Extra keyword arguments |
| Stored as | Tuple | Dictionary |
| Access by | Index (args[0]) | Key (kwargs["name"]) |
| Common use | Flexible positional input | Configuration options |
| Unpacking operator | * | ** |
Return Values
Functions can send data back to the caller using the return statement.
Single Return Value
def square(x):
return x * x
result = square(7)
print(result) # 49
Multiple Return Values
Python functions can return multiple values as a tuple. You can unpack them directly:
def min_max(numbers):
return min(numbers), max(numbers)
# Tuple unpacking
low, high = min_max([4, 1, 8, 2, 9])
print(f"Min: {low}, Max: {high}") # Min: 1, Max: 9
# You can also capture as a single tuple
result = min_max([4, 1, 8, 2, 9])
print(result) # (1, 9)
print(type(result)) # <class 'tuple'>
def analyze_text(text):
words = text.split()
num_words = len(words)
num_chars = len(text)
avg_word_length = num_chars / num_words if num_words > 0 else 0
return num_words, num_chars, round(avg_word_length, 2)
words, chars, avg = analyze_text("Python is amazing and powerful")
print(f"Words: {words}, Characters: {chars}, Avg length: {avg}")
# Words: 5, Characters: 30, Avg length: 6.0
Returning None Implicitly
If a function has no return statement, or if it reaches the end without returning, it returns None automatically:
def say_hello(name):
print(f"Hello, {name}!")
# No return statement
result = say_hello("Priya")
print(result) # None
print(type(result)) # <class 'NoneType'>
A bare return (with no value) also returns None:
def check_age(age):
if age < 0:
return # Returns None
print(f"Age: {age}")
result = check_age(-5)
print(result) # None
Early Returns
You can use return to exit a function early, which often makes code cleaner than deeply nested if statements:
# Without early return — deeply nested
def get_grade_nested(score):
if score >= 0 and score <= 100:
if score >= 90:
return "A"
else:
if score >= 80:
return "B"
else:
if score >= 70:
return "C"
else:
return "F"
else:
return "Invalid"
# With early returns — flat and clean (guard clauses)
def get_grade(score):
if score < 0 or score > 100:
return "Invalid"
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
return "F"
print(get_grade(95)) # A
print(get_grade(82)) # B
print(get_grade(65)) # F
print(get_grade(150)) # Invalid
Returning Different Types
Python allows a function to return different types, but this can make code harder to reason about. Use it sparingly:
def safe_divide(a, b):
if b == 0:
return None # Signal failure
return a / b
result = safe_divide(10, 3)
if result is not None:
print(f"Result: {result:.2f}") # Result: 3.33
else:
print("Cannot divide by zero")
Scope and Lifetime
Scope determines where a variable can be accessed. Lifetime determines how long a variable exists in memory.
Local Scope
Variables created inside a function are local — they exist only while the function is executing and cannot be accessed from outside.
def my_function():
x = 10 # Local variable
print(f"Inside: x = {x}")
my_function() # Inside: x = 10
# print(x) # NameError: name 'x' is not defined
Each function call creates its own local scope:
def counter():
count = 0
count += 1
return count
print(counter()) # 1
print(counter()) # 1 (fresh 'count' each time)
print(counter()) # 1
Global Scope
Variables created outside any function are global — they are accessible from anywhere in the module.
name = "Priya" # Global variable
def greet():
print(f"Hello, {name}!") # Can READ global variables
greet() # Hello, Priya!
print(name) # Priya
Variable Shadowing
A local variable with the same name as a global variable shadows (hides) the global one inside the function:
x = 10 # Global x
def foo():
x = 20 # Local x — shadows the global
print(f"Inside foo: x = {x}")
foo() # Inside foo: x = 20
print(f"Global: x = {x}") # Global: x = 10 (unchanged)
The global Keyword
To modify a global variable from inside a function, you must declare it as global:
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
increment()
print(counter) # 3
Without global, assigning to counter inside the function creates a new local variable instead of modifying the global one:
counter = 0
def increment_broken():
# counter += 1 # UnboundLocalError! Python sees the assignment and
pass # treats 'counter' as local, but it has no value yet.
# increment_broken() # Would raise UnboundLocalError
Best practice: Avoid
global. Pass values as arguments and return results instead. Global state makes code harder to test and debug.
# Instead of global...
def increment(counter):
return counter + 1
count = 0
count = increment(count) # 1
count = increment(count) # 2
print(count) # 2
The nonlocal Keyword
nonlocal is used in nested functions to modify a variable from an enclosing (but not global) scope:
def outer():
count = 0
def inner():
nonlocal count
count += 1
print(f"Count: {count}")
inner() # Count: 1
inner() # Count: 2
inner() # Count: 3
outer()
Without nonlocal, the inner function would create its own local count:
def outer():
count = 0
def inner():
count = 99 # This creates a new LOCAL variable
print(f"Inner count: {count}")
inner() # Inner count: 99
print(f"Outer count: {count}") # Outer count: 0 (unchanged)
outer()
The LEGB Rule
When you reference a variable, Python searches for it in this order:
| Level | Scope | Description |
|---|---|---|
| L | Local | Variables defined in the current function |
| E | Enclosing | Variables in outer (enclosing) functions — for nested functions |
| G | Global | Variables defined at the module level |
| B | Built-in | Python's built-in names like print, len, True |
Python stops at the first scope where it finds the name.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # Finds 'local' first (L)
inner()
outer() # local
# Demonstrating each level
x = "global" # G
def outer():
x = "enclosing" # E
def inner():
# No local x # L — not found, move to E
print(x)
inner()
outer() # enclosing
# Built-in scope
# print is found in the Built-in scope
print(len("hello")) # 5 — both 'print' and 'len' are built-ins
# Shadowing a built-in (don't do this!)
# len = 99
# print(len("hello")) # TypeError: 'int' object is not callable
Scope Comparison
| Feature | Local | Global | nonlocal |
|---|---|---|---|
| Created in | Function body | Module level | Enclosing function |
| Accessible from | That function only | Anywhere in module | Nested function |
| Keyword needed to modify | None | global | nonlocal |
| Lifetime | Function call | Program duration | Enclosing function call |
| Best practice | Preferred | Use sparingly | Use for closures |
Lambda Functions
A lambda is a small anonymous (unnamed) function defined with the lambda keyword. It can take any number of arguments but can only contain a single expression.
Syntax
lambda arguments: expression
The expression is evaluated and returned automatically — no return keyword needed.
# Regular function
def square(x):
return x ** 2
# Lambda equivalent
square_lambda = lambda x: x ** 2
print(square(5)) # 25
print(square_lambda(5)) # 25
# Lambda with multiple arguments
add = lambda a, b: a + b
print(add(3, 7)) # 10
# Lambda with a conditional expression
classify = lambda x: "even" if x % 2 == 0 else "odd"
print(classify(4)) # even
print(classify(7)) # odd
Lambdas with sorted()
Lambdas shine when used as the key argument for sorting:
# Sort by second element of each tuple
pairs = [(1, "banana"), (3, "apple"), (2, "cherry")]
sorted_pairs = sorted(pairs, key=lambda x: x[1])
print(sorted_pairs) # [(3, 'apple'), (1, 'banana'), (2, 'cherry')]
# Sort students by grade (descending)
students = [
{"name": "Priya", "grade": 92},
{"name": "Rahul", "grade": 85},
{"name": "Sneha", "grade": 97},
]
by_grade = sorted(students, key=lambda s: s["grade"], reverse=True)
for s in by_grade:
print(f"{s['name']}: {s['grade']}")
Output:
Sneha: 97
Priya: 92
Rahul: 85
Lambdas with map()
map() applies a function to every item in an iterable:
numbers = [1, 2, 3, 4, 5]
# Double each number
doubled = list(map(lambda x: x * 2, numbers))
print(doubled) # [2, 4, 6, 8, 10]
# Convert to strings
str_numbers = list(map(lambda x: str(x), numbers))
print(str_numbers) # ['1', '2', '3', '4', '5']
# Note: a list comprehension is often clearer
doubled_lc = [x * 2 for x in numbers]
print(doubled_lc) # [2, 4, 6, 8, 10]
Lambdas with filter()
filter() keeps only items where the function returns True:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
# Keep even numbers
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8, 10]
# Keep strings longer than 3 characters
words = ["hi", "hello", "hey", "greetings", "yo"]
long_words = list(filter(lambda w: len(w) > 3, words))
print(long_words) # ['hello', 'greetings']
When to Use Lambda vs def
| Use Lambda When... | Use def When... |
|---|---|
| Function is very short (one expression) | Logic requires multiple statements |
| Used once as an argument to another function | Function needs a descriptive name |
| Readability is not sacrificed | Function is used more than once |
Inside sorted(), map(), filter() | Function needs docstrings or type hints |
# Good use of lambda
sorted_data = sorted(items, key=lambda x: x.date)
# Bad use of lambda — too complex, use def instead
# process = lambda x: x.strip().lower().replace(" ", "_") if x else ""
# Better:
def process(x):
"""Clean and normalize a string for use as an identifier."""
if not x:
return ""
return x.strip().lower().replace(" ", "_")
Higher-Order Functions
A higher-order function is a function that does at least one of the following:
- Takes one or more functions as arguments
- Returns a function as its result
In Python, functions are first-class objects — they can be assigned to variables, stored in data structures, and passed around just like integers or strings.
Functions as Arguments
def apply(func, value):
"""Apply a function to a value and return the result."""
return func(value)
def double(x):
return x * 2
def negate(x):
return -x
print(apply(double, 5)) # 10
print(apply(negate, 5)) # -5
print(apply(abs, -42)) # 42 (abs is a built-in function)
Functions as Return Values
def make_multiplier(factor):
"""Return a function that multiplies by the given factor."""
def multiplier(x):
return x * factor
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(10)) # 20
print(triple(10)) # 30
print(double(7)) # 14
Storing Functions in Data Structures
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
# Store functions in a dictionary
operations = {
"+": add,
"-": subtract,
"*": multiply,
}
# Use the dictionary to dispatch operations
op = "+"
result = operations[op](10, 3)
print(f"10 {op} 3 = {result}") # 10 + 3 = 13
op = "*"
result = operations[op](10, 3)
print(f"10 {op} 3 = {result}") # 10 * 3 = 30
Built-in Higher-Order Functions
Python ships with several higher-order functions:
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
# map — apply a function to each item
squares = list(map(lambda x: x ** 2, numbers))
print(squares) # [9, 1, 16, 1, 25, 81, 4, 36]
# filter — keep items where function returns True
big = list(filter(lambda x: x > 4, numbers))
print(big) # [5, 9, 6]
# sorted — sort using a key function
desc = sorted(numbers, key=lambda x: -x)
print(desc) # [9, 6, 5, 4, 3, 2, 1, 1]
# min/max with key
words = ["banana", "apple", "cherry", "date"]
longest = max(words, key=len)
print(longest) # banana (or cherry — both length 6)
Built-in Functions Reference
Python includes many built-in functions that you do not need to import. Here is a reference table of the most commonly used ones:
Numeric and Math Functions
| Function | Description | Example | Result |
|---|---|---|---|
abs(x) | Absolute value | abs(-7) | 7 |
round(x, n) | Round to n decimal places | round(3.14159, 2) | 3.14 |
min(...) | Smallest value | min(3, 1, 4) | 1 |
max(...) | Largest value | max(3, 1, 4) | 4 |
sum(iterable) | Sum of all items | sum([1, 2, 3]) | 6 |
pow(base, exp) | Power (base^exp) | pow(2, 3) | 8 |
divmod(a, b) | Quotient and remainder | divmod(17, 5) | (3, 2) |
Sequence and Iteration Functions
| Function | Description | Example |
|---|---|---|
len(x) | Length of a sequence | len("hello") returns 5 |
range(start, stop, step) | Generate a range of numbers | list(range(5)) returns [0, 1, 2, 3, 4] |
enumerate(iterable) | Pairs each item with its index | list(enumerate(["a", "b"])) returns [(0, 'a'), (1, 'b')] |
zip(iter1, iter2, ...) | Combine iterables element-wise | list(zip([1,2], ["a","b"])) returns [(1, 'a'), (2, 'b')] |
sorted(iterable) | Return a new sorted list | sorted([3, 1, 2]) returns [1, 2, 3] |
reversed(seq) | Reverse iterator | list(reversed([1, 2, 3])) returns [3, 2, 1] |
map(func, iterable) | Apply function to each item | list(map(str, [1, 2])) returns ['1', '2'] |
filter(func, iterable) | Keep items where func is True | list(filter(bool, [0, 1, "", "a"])) returns [1, 'a'] |
Boolean and Comparison Functions
| Function | Description | Example | Result |
|---|---|---|---|
any(iterable) | True if any item is truthy | any([0, False, 3]) | True |
all(iterable) | True if all items are truthy | all([1, True, "a"]) | True |
isinstance(obj, type) | Check type | isinstance(42, int) | True |
Type and Inspection Functions
| Function | Description | Example |
|---|---|---|
type(obj) | Return the type of an object | type(42) returns <class 'int'> |
id(obj) | Return the memory address | id(42) returns an integer |
dir(obj) | List attributes and methods | dir([]) returns list methods |
help(obj) | Display documentation | help(len) shows len's docstring |
I/O Functions
| Function | Description | Example |
|---|---|---|
print(*args) | Output to console | print("Hello", "World") |
input(prompt) | Read user input as string | name = input("Name: ") |
# Practical examples of built-in functions
# enumerate — get index and value
fruits = ["apple", "banana", "cherry"]
for i, fruit in enumerate(fruits, start=1):
print(f"{i}. {fruit}")
# 1. apple
# 2. banana
# 3. cherry
# zip — combine parallel lists
names = ["Priya", "Rahul", "Sneha"]
scores = [95, 87, 92]
for name, score in zip(names, scores):
print(f"{name}: {score}")
# Priya: 95
# Rahul: 87
# Sneha: 92
# any and all
numbers = [2, 4, 6, 8]
print(all(x % 2 == 0 for x in numbers)) # True (all are even)
print(any(x > 7 for x in numbers)) # True (8 > 7)
print(any(x > 10 for x in numbers)) # False (none > 10)
Closures
A closure is a function that "remembers" variables from its enclosing scope, even after the outer function has finished executing. Closures are created whenever a nested function references a variable from its enclosing function.
How Closures Work
def make_greeter(greeting):
"""Return a function that uses the given greeting."""
def greeter(name):
return f"{greeting}, {name}!" # 'greeting' is from the enclosing scope
return greeter
hello = make_greeter("Hello")
namaste = make_greeter("Namaste")
print(hello("Priya")) # Hello, Priya!
print(namaste("Rahul")) # Namaste, Rahul!
print(hello("Sneha")) # Hello, Sneha!
Here, greeter is a closure. It "closes over" the variable greeting from make_greeter. Even after make_greeter finishes, the inner function retains access to greeting.
Factory Functions
Closures are commonly used to create factory functions — functions that produce other functions with pre-configured behavior:
def make_power(exponent):
"""Create a function that raises numbers to the given power."""
def power(base):
return base ** exponent
return power
square = make_power(2)
cube = make_power(3)
print(square(5)) # 25
print(cube(5)) # 125
print(square(10)) # 100
def make_validator(min_length, require_digit=False):
"""Create a password validator with custom rules."""
def validate(password):
if len(password) < min_length:
return False, f"Must be at least {min_length} characters"
if require_digit and not any(c.isdigit() for c in password):
return False, "Must contain at least one digit"
return True, "Valid password"
return validate
# Create different validators for different contexts
basic_validator = make_validator(6)
strict_validator = make_validator(12, require_digit=True)
print(basic_validator("hello")) # (False, 'Must be at least 6 characters')
print(basic_validator("hello!")) # (True, 'Valid password')
print(strict_validator("shortpass")) # (False, 'Must be at least 12 characters')
print(strict_validator("MySecurePass1")) # (True, 'Valid password')
Counter Using a Closure
def make_counter(start=0):
"""Create a counter that remembers its state."""
count = start
def increment():
nonlocal count
count += 1
return count
def get():
return count
def reset():
nonlocal count
count = start
return count
# Return a dict of functions sharing the same closure
return {"increment": increment, "get": get, "reset": reset}
counter = make_counter(10)
print(counter["increment"]()) # 11
print(counter["increment"]()) # 12
print(counter["increment"]()) # 13
print(counter["get"]()) # 13
print(counter["reset"]()) # 10
Inspecting Closure Variables
You can inspect what a closure has captured:
def make_adder(n):
def adder(x):
return x + n
return adder
add_5 = make_adder(5)
print(add_5(10)) # 15
# Inspect the closure
print(add_5.__closure__) # (<cell at 0x...>,)
print(add_5.__closure__[0].cell_contents) # 5
Decorators (Introduction)
A decorator is a function that takes another function, extends or modifies its behavior, and returns the modified function. Decorators use the @ syntax in Python.
Understanding the Pattern
Before decorators, you would wrap functions manually:
def my_decorator(func):
def wrapper():
print("Something before the function.")
func()
print("Something after the function.")
return wrapper
def say_hello():
print("Hello!")
# Manual wrapping
say_hello = my_decorator(say_hello)
say_hello()
Output:
Something before the function.
Hello!
Something after the function.
The @ Syntax
The @decorator syntax is shorthand for the manual wrapping pattern:
def my_decorator(func):
def wrapper():
print("Before the function.")
func()
print("After the function.")
return wrapper
@my_decorator # Same as: say_hello = my_decorator(say_hello)
def say_hello():
print("Hello!")
say_hello()
Output:
Before the function.
Hello!
After the function.
Decorators That Handle Arguments
To make a decorator work with any function (regardless of its parameters), use *args and **kwargs:
def log_call(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@log_call
def add(a, b):
return a + b
@log_call
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
add(3, 5)
greet("Priya", greeting="Namaste")
Output:
Calling add with args=(3, 5), kwargs={}
add returned 8
Calling greet with args=('Priya',), kwargs={'greeting': 'Namaste'}
greet returned Namaste, Priya!
Preserving Function Metadata with functools.wraps
Without functools.wraps, the decorated function loses its original name and docstring:
import functools
def log_call(func):
@functools.wraps(func) # Preserves func's name, docstring, etc.
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def add(a, b):
"""Add two numbers and return the result."""
return a + b
print(add.__name__) # add (without @wraps, this would be 'wrapper')
print(add.__doc__) # Add two numbers and return the result.
Always use @functools.wraps(func) in your decorators.
Practical Example: Timing Decorator
import functools
import time
def timer(func):
"""Measure and print how long a function takes to execute."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f} seconds")
return result
return wrapper
@timer
def slow_sum(n):
"""Sum numbers from 1 to n with a deliberate pause."""
total = 0
for i in range(1, n + 1):
total += i
return total
result = slow_sum(1_000_000)
print(f"Sum: {result}")
Practical Example: Retry Decorator
import functools
import time
def retry(max_attempts=3, delay=1):
"""Retry a function up to max_attempts times on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"Attempt {attempt} failed: {e}")
if attempt < max_attempts:
time.sleep(delay)
raise Exception(f"All {max_attempts} attempts failed")
return wrapper
return decorator
@retry(max_attempts=3, delay=0.5)
def unreliable_api_call():
"""Simulate an unreliable API."""
import random
if random.random() < 0.7:
raise ConnectionError("Server unavailable")
return {"status": "success"}
# This will retry up to 3 times before giving up
# result = unreliable_api_call()
Recursion
Recursion is when a function calls itself. Every recursive function needs:
- Base case — a condition that stops the recursion
- Recursive case — the function calls itself with a smaller/simpler input
Factorial — The Classic Example
def factorial(n):
# Base case
if n <= 1:
return 1
# Recursive case
return n * factorial(n - 1)
print(factorial(5)) # 120
print(factorial(0)) # 1
print(factorial(1)) # 1
print(factorial(10)) # 3628800
How it works step by step:
factorial(5)
= 5 * factorial(4)
= 5 * 4 * factorial(3)
= 5 * 4 * 3 * factorial(2)
= 5 * 4 * 3 * 2 * factorial(1)
= 5 * 4 * 3 * 2 * 1
= 120
Fibonacci Sequence
def fibonacci(n):
"""Return the nth Fibonacci number (0-indexed)."""
if n <= 0:
return 0
if n == 1:
return 1
return fibonacci(n - 1) + fibonacci(n - 2)
# First 10 Fibonacci numbers
for i in range(10):
print(fibonacci(i), end=" ")
# 0 1 1 2 3 5 8 13 21 34
Warning: The naive recursive Fibonacci is very slow for large
nbecause it recalculates the same values many times. We will fix this with memoization in the Practical Examples section.
Sum of a List (Recursion)
def recursive_sum(numbers):
"""Sum a list using recursion."""
if len(numbers) == 0: # Base case: empty list
return 0
return numbers[0] + recursive_sum(numbers[1:]) # First + rest
print(recursive_sum([1, 2, 3, 4, 5])) # 15
Flatten a Nested List
def flatten(nested_list):
"""Recursively flatten a nested list."""
result = []
for item in nested_list:
if isinstance(item, list):
result.extend(flatten(item)) # Recurse into sublists
else:
result.append(item)
return result
data = [1, [2, 3], [4, [5, 6]], 7, [8, [9, [10]]]]
print(flatten(data)) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Recursion Depth Limit
Python has a default recursion limit (usually 1000) to prevent infinite recursion from crashing the program:
import sys
print(sys.getrecursionlimit()) # 1000 (default)
# You can increase it, but be cautious
# sys.setrecursionlimit(2000)
# Infinite recursion — hits the limit
def infinite():
return infinite()
# infinite() # RecursionError: maximum recursion depth exceeded
Recursion vs Iteration
| Feature | Recursion | Iteration |
|---|---|---|
| Mechanism | Function calls itself | Uses loops (for, while) |
| Termination | Base case | Loop condition becomes False |
| Memory | Each call uses stack space | Constant memory |
| Readability | Elegant for tree/graph problems | Better for linear sequences |
| Performance | Can be slower (function call overhead) | Generally faster |
| Risk | Stack overflow for deep recursion | Infinite loops |
When to use recursion:
- Tree traversal (file systems, HTML/XML parsing)
- Divide-and-conquer algorithms (merge sort, quicksort)
- Problems with naturally recursive structure (fractals, permutations)
When to use iteration:
- Simple counting or sequential processing
- Performance-critical code
- Very deep recursion (Python has a stack limit)
# Iterative factorial — more efficient in Python
def factorial_iterative(n):
result = 1
for i in range(2, n + 1):
result *= i
return result
print(factorial_iterative(5)) # 120
print(factorial_iterative(100)) # Works fine — no recursion limit
Function Annotations / Type Hints
Python 3.5+ supports type hints — annotations that indicate what types a function expects and returns. They do not enforce types at runtime but greatly improve code readability and enable tools like mypy to catch type errors before you run the code.
Basic Syntax
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def is_adult(age: int) -> bool:
return age >= 18
name: strindicatesnameshould be a string-> strindicates the function returns a string
Default Values with Type Hints
def greet(name: str, times: int = 1) -> str:
return (f"Hello, {name}! " * times).strip()
print(greet("Priya")) # Hello, Priya!
print(greet("Priya", 3)) # Hello, Priya! Hello, Priya! Hello, Priya!
Complex Types with typing Module
For more complex types, use the typing module (though Python 3.9+ supports many of these natively):
# Python 3.9+ — use built-in types directly
def process_scores(scores: list[int]) -> dict[str, float]:
return {
"average": sum(scores) / len(scores),
"highest": max(scores),
"lowest": min(scores),
}
# Python 3.5-3.8 — use typing module
from typing import List, Dict, Tuple, Optional, Union
def process_scores_old(scores: List[int]) -> Dict[str, float]:
return {
"average": sum(scores) / len(scores),
"highest": max(scores),
"lowest": min(scores),
}
Optional — Value or None
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
"""Return user dict if found, None if not."""
users = {1: {"name": "Priya"}, 2: {"name": "Rahul"}}
return users.get(user_id) # Returns None if not found
result = find_user(1) # {'name': 'Priya'}
result = find_user(99) # None
Optional[dict] is equivalent to dict | None in Python 3.10+.
Union — Multiple Possible Types
from typing import Union
def double(value: Union[int, float, str]) -> Union[int, float, str]:
if isinstance(value, str):
return value * 2
return value * 2
print(double(5)) # 10
print(double(3.14)) # 6.28
print(double("ha")) # haha
# Python 3.10+ shorthand
def double_new(value: int | float | str) -> int | float | str:
if isinstance(value, str):
return value * 2
return value * 2
Callable Type Hint
from typing import Callable
def apply_operation(func: Callable[[int, int], int], a: int, b: int) -> int:
"""Apply a function to two integers."""
return func(a, b)
print(apply_operation(lambda x, y: x + y, 10, 5)) # 15
print(apply_operation(lambda x, y: x * y, 10, 5)) # 50
Type Hints Are Not Enforced at Runtime
def add(a: int, b: int) -> int:
return a + b
# Python does NOT prevent this — type hints are purely informational
print(add("hello", " world")) # hello world (no error at runtime!)
Use tools like mypy to check types statically:
# Install mypy
# pip install mypy
# Run type checking
# mypy my_script.py
Docstrings
A docstring is a string literal that appears as the first statement in a function (or class, or module). It documents what the function does, what parameters it takes, and what it returns.
Basic Docstring
def add(a, b):
"""Add two numbers and return the result."""
return a + b
Multi-line Docstrings
For more detailed documentation, use a multi-line docstring:
def calculate_bmi(weight_kg, height_m):
"""
Calculate Body Mass Index (BMI).
BMI is a measure of body fat based on height and weight.
A BMI between 18.5 and 24.9 is considered normal.
Args:
weight_kg: Weight in kilograms (must be positive).
height_m: Height in meters (must be positive).
Returns:
BMI as a float, rounded to one decimal place.
Raises:
ValueError: If weight or height is not positive.
"""
if weight_kg <= 0 or height_m <= 0:
raise ValueError("Weight and height must be positive")
return round(weight_kg / (height_m ** 2), 1)
Google Style Docstrings
This is one of the most popular styles, used by Google and many open-source projects:
def fetch_data(url, timeout=30, retries=3):
"""Fetch data from a remote URL.
Makes an HTTP GET request to the specified URL with
configurable timeout and retry behavior.
Args:
url: The URL to fetch data from.
timeout: Maximum seconds to wait for a response.
Defaults to 30.
retries: Number of retry attempts on failure.
Defaults to 3.
Returns:
A dictionary containing the response data with keys:
- 'status': HTTP status code (int)
- 'body': Response body (str)
- 'headers': Response headers (dict)
Raises:
ConnectionError: If all retry attempts fail.
ValueError: If the URL is malformed.
Example:
>>> result = fetch_data("https://api.example.com/data")
>>> print(result['status'])
200
"""
pass
NumPy Style Docstrings
Popular in the scientific Python community (NumPy, SciPy, pandas):
def calculate_statistics(data):
"""
Calculate basic statistics for a dataset.
Parameters
----------
data : list of float
A list of numerical values. Must not be empty.
Returns
-------
dict
A dictionary with keys 'mean', 'median', 'std_dev',
each mapping to a float.
Raises
------
ValueError
If the data list is empty.
Examples
--------
>>> calculate_statistics([1, 2, 3, 4, 5])
{'mean': 3.0, 'median': 3.0, 'std_dev': 1.414}
"""
pass
Accessing Docstrings
def greet(name):
"""Greet a person by name with a friendly message."""
return f"Hello, {name}!"
# Access the docstring programmatically
print(greet.__doc__)
# Greet a person by name with a friendly message.
# Or use help()
help(greet)
# Help on function greet in module __main__:
#
# greet(name)
# Greet a person by name with a friendly message.
Docstring Best Practices
| Do | Don't |
|---|---|
| Start with a one-line summary | Write the summary across multiple lines |
| Use imperative mood ("Calculate...", "Return...") | Use descriptive mood ("This function calculates...") |
| Document parameters, return values, exceptions | Leave complex functions undocumented |
| Include examples for non-obvious behavior | Include obvious details like "This is a function" |
| Keep the summary under 79 characters | Write a novel in the first line |
Practical Examples
Calculator with Dispatch Dictionary
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):
if b == 0:
return "Error: Division by zero"
return a / b
def power(a, b):
return a ** b
def modulo(a, b):
if b == 0:
return "Error: Division by zero"
return a % b
# Dispatch dictionary — maps operator to function
operations = {
"+": add,
"-": subtract,
"*": multiply,
"/": divide,
"**": power,
"%": modulo,
}
def calculate(expression):
"""
Evaluate a simple math expression.
Args:
expression: A string like "10 + 5" or "2 ** 8"
Returns:
The result of the calculation, or an error message.
"""
parts = expression.split()
if len(parts) != 3:
return "Error: Use format 'a operator b'"
try:
a = float(parts[0])
op = parts[1]
b = float(parts[2])
except ValueError:
return "Error: Invalid numbers"
if op not in operations:
return f"Error: Unknown operator '{op}'"
result = operations[op](a, b)
return result
# Test the calculator
print(calculate("10 + 5")) # 15.0
print(calculate("20 - 8")) # 12.0
print(calculate("6 * 7")) # 42.0
print(calculate("15 / 4")) # 3.75
print(calculate("2 ** 10")) # 1024.0
print(calculate("17 % 5")) # 2.0
print(calculate("10 / 0")) # Error: Division by zero
Password Validator
def validate_password(password, min_length=8, require_upper=True,
require_lower=True, require_digit=True,
require_special=True):
"""
Validate a password against configurable rules.
Args:
password: The password string to validate.
min_length: Minimum required length (default 8).
require_upper: Require at least one uppercase letter.
require_lower: Require at least one lowercase letter.
require_digit: Require at least one digit.
require_special: Require at least one special character.
Returns:
A tuple of (is_valid: bool, messages: list[str]).
"""
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
errors = []
if len(password) < min_length:
errors.append(f"Must be at least {min_length} characters (got {len(password)})")
if require_upper and not any(c.isupper() for c in password):
errors.append("Must contain at least one uppercase letter (A-Z)")
if require_lower and not any(c.islower() for c in password):
errors.append("Must contain at least one lowercase letter (a-z)")
if require_digit and not any(c.isdigit() for c in password):
errors.append("Must contain at least one digit (0-9)")
if require_special and not any(c in special_chars for c in password):
errors.append("Must contain at least one special character (!@#$...)")
is_valid = len(errors) == 0
return is_valid, errors
# Test the validator
passwords = ["short", "alllowercase", "NoDigitsHere!", "Secure@Pass1"]
for pwd in passwords:
valid, errors = validate_password(pwd)
status = "PASS" if valid else "FAIL"
print(f"\n[{status}] '{pwd}'")
for error in errors:
print(f" - {error}")
Output:
[FAIL] 'short'
- Must be at least 8 characters (got 5)
- Must contain at least one uppercase letter (A-Z)
- Must contain at least one digit (0-9)
- Must contain at least one special character (!@#$...)
[FAIL] 'alllowercase'
- Must contain at least one uppercase letter (A-Z)
- Must contain at least one digit (0-9)
- Must contain at least one special character (!@#$...)
[FAIL] 'NoDigitsHere!'
- Must contain at least one digit (0-9)
[PASS] 'Secure@Pass1'
Text Analyzer
def analyze_text(text):
"""
Perform a comprehensive analysis of a text string.
Args:
text: The input string to analyze.
Returns:
A dictionary with various statistics about the text.
"""
words = text.split()
sentences = [s.strip() for s in text.replace("!", ".").replace("?", ".").split(".") if s.strip()]
# Count word frequencies
word_freq = {}
for word in words:
cleaned = word.lower().strip(".,!?;:'\"")
if cleaned:
word_freq[cleaned] = word_freq.get(cleaned, 0) + 1
# Find most common words
sorted_words = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)
return {
"characters": len(text),
"characters_no_spaces": len(text.replace(" ", "")),
"words": len(words),
"sentences": len(sentences),
"paragraphs": text.count("\n\n") + 1,
"average_word_length": round(sum(len(w) for w in words) / len(words), 2) if words else 0,
"average_sentence_length": round(len(words) / len(sentences), 2) if sentences else 0,
"most_common_words": sorted_words[:5],
"unique_words": len(word_freq),
}
# Test the text analyzer
sample = """Python is a powerful programming language. Python is used for web
development, data science, and automation. Many developers love Python
because it is easy to learn and has a great community."""
stats = analyze_text(sample)
for key, value in stats.items():
print(f"{key}: {value}")
Output:
characters: 207
characters_no_spaces: 175
words: 31
sentences: 3
paragraphs: 1
average_word_length: 5.45
average_sentence_length: 10.33
most_common_words: [('python', 3), ('is', 3), ('a', 2), ('and', 2), ('powerful', 1)]
unique_words: 24
Fibonacci with Memoization
The naive recursive Fibonacci recalculates the same values over and over. Memoization stores results of previous calls to avoid redundant work.
# Naive — very slow for large n
def fibonacci_naive(n):
if n <= 0:
return 0
if n == 1:
return 1
return fibonacci_naive(n - 1) + fibonacci_naive(n - 2)
# Memoized — fast!
def fibonacci_memo(n, cache=None):
if cache is None:
cache = {}
if n in cache:
return cache[n]
if n <= 0:
return 0
if n == 1:
return 1
cache[n] = fibonacci_memo(n - 1, cache) + fibonacci_memo(n - 2, cache)
return cache[n]
# Using a decorator for memoization (cleanest approach)
from functools import lru_cache
@lru_cache(maxsize=None)
def fibonacci_lru(n):
if n <= 0:
return 0
if n == 1:
return 1
return fibonacci_lru(n - 1) + fibonacci_lru(n - 2)
# Test all three
import time
# fibonacci_naive(35) takes noticeable time
# fibonacci_memo(35) is instant
# fibonacci_lru(35) is instant
for n in [10, 20, 30, 35]:
start = time.perf_counter()
result = fibonacci_lru(n)
elapsed = time.perf_counter() - start
print(f"fibonacci({n}) = {result} ({elapsed:.6f}s)")
Output:
fibonacci(10) = 55 (0.000010s)
fibonacci(20) = 6765 (0.000003s)
fibonacci(30) = 832040 (0.000002s)
fibonacci(35) = 9227465 (0.000002s)
Common Mistakes
1. Mutable Default Arguments
This is the most common function-related bug in Python. We covered it in detail earlier, but it bears repeating because it catches everyone at least once.
# BUG
def append_to(item, target=[]):
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [1, 2] ← Unexpected!
# FIX
def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target
print(append_to(1)) # [1]
print(append_to(2)) # [2] ← Correct!
2. Forgetting return
If you forget return, the function returns None:
# BUG — missing return
def add(a, b):
result = a + b # Calculates but doesn't return!
total = add(5, 3)
print(total) # None ← Not what you expected
# FIX
def add(a, b):
result = a + b
return result # Or simply: return a + b
3. Modifying a List Argument Unintentionally
Since lists are passed by reference, modifying them inside a function affects the original:
# Surprise mutation
def remove_negatives(numbers):
for n in numbers[:]: # Iterate over a copy
if n < 0:
numbers.remove(n)
my_list = [1, -2, 3, -4, 5]
remove_negatives(my_list)
print(my_list) # [1, 3, 5] — original list is modified!
# Safer approach — return a new list
def remove_negatives_safe(numbers):
return [n for n in numbers if n >= 0]
my_list = [1, -2, 3, -4, 5]
clean = remove_negatives_safe(my_list)
print(my_list) # [1, -2, 3, -4, 5] ← Original unchanged
print(clean) # [1, 3, 5]
4. Infinite Recursion
Forgetting the base case or not making progress toward it causes infinite recursion:
# BUG — no base case
def countdown(n):
print(n)
countdown(n - 1) # Never stops!
# countdown(5) # RecursionError after ~1000 calls
# FIX — add a base case
def countdown(n):
if n <= 0:
print("Done!")
return
print(n)
countdown(n - 1)
countdown(5)
Output:
5
4
3
2
1
Done!
5. Shadowing Built-in Names
Using built-in function names as variable or function names breaks them:
# BUG — shadowing built-in 'list'
list = [1, 2, 3] # Now 'list' is a variable, not a function
# new_list = list("hello") # TypeError: 'list' object is not callable
# BUG — shadowing 'print'
# print = "hello"
# print("test") # TypeError: 'str' object is not callable
# FIX — use descriptive names
numbers_list = [1, 2, 3]
message = "hello"
6. Using == Instead of is for None Checks
# Not ideal
def process(data=None):
if data == None: # Works but not Pythonic
data = []
# Better — use 'is'
def process(data=None):
if data is None: # Idiomatic Python
data = []
is checks identity (same object), == checks equality (same value). None is a singleton, so is None is the correct check.
Practice Exercises
Exercise 1: Temperature Converter (Easy)
Write a function convert_temperature that takes a temperature value and the unit it is in ("C" for Celsius, "F" for Fahrenheit, "K" for Kelvin) and returns a dictionary with the temperature in all three units.
# Expected usage:
# convert_temperature(100, "C")
# Returns: {"celsius": 100, "fahrenheit": 212.0, "kelvin": 373.15}
Hint: C to F: (C * 9/5) + 32. C to K: C + 273.15
Exercise 2: Word Counter (Easy)
Write a function count_words that takes a string and returns a dictionary where keys are words (lowercase) and values are their frequencies. Ignore punctuation.
# Expected usage:
# count_words("the cat sat on the mat")
# Returns: {"the": 2, "cat": 1, "sat": 1, "on": 1, "mat": 1}
Exercise 3: Function Composition (Medium)
Write a function compose that takes two functions f and g and returns a new function that computes f(g(x)).
# Expected usage:
# double = lambda x: x * 2
# add_one = lambda x: x + 1
# double_then_add = compose(add_one, double)
# double_then_add(5) # add_one(double(5)) = add_one(10) = 11
Exercise 4: Decorator — Call Counter (Medium)
Write a decorator count_calls that tracks how many times a decorated function has been called. Store the count as an attribute on the function.
# Expected usage:
# @count_calls
# def greet(name):
# return f"Hello, {name}!"
#
# greet("Priya")
# greet("Rahul")
# print(greet.call_count) # 2
Exercise 5: Recursive Palindrome Checker (Medium)
Write a recursive function is_palindrome that checks whether a string is a palindrome, ignoring case and non-alphanumeric characters.
# Expected usage:
# is_palindrome("racecar") # True
# is_palindrome("A man, a plan, a canal: Panama") # True
# is_palindrome("hello") # False
Hint: Compare the first and last characters, then recurse on the middle.
Exercise 6: Memoization Decorator (Hard)
Write your own memoize decorator that caches function results based on arguments. Test it with the recursive Fibonacci function.
# Expected usage:
# @memoize
# def fibonacci(n):
# if n <= 1:
# return n
# return fibonacci(n - 1) + fibonacci(n - 2)
#
# print(fibonacci(50)) # Should be fast: 12586269025
Hint: Use a dictionary to cache results. The key can be (args, tuple(sorted(kwargs.items()))).
Summary
In this chapter, you learned the full landscape of Python functions:
- Defining functions with
defand calling them by name - Parameters vs arguments — positional, keyword, default values
- The mutable default argument gotcha — always use
Noneas a sentinel for mutable defaults *argsand**kwargsfor variable-length arguments, and*/**unpacking- Return values — single, multiple (tuple unpacking), implicit
None, early returns - Scope — local, enclosing, global, built-in (the LEGB rule),
globalandnonlocalkeywords - Lambda functions — anonymous single-expression functions for
sorted(),map(),filter() - Higher-order functions — passing and returning functions, dispatch dictionaries
- Built-in functions — the essential toolkit Python gives you for free
- Closures — functions that remember their enclosing scope, factory functions
- Decorators — the
@syntax for wrapping functions,functools.wraps, practical uses - Recursion — base case + recursive case, factorial, fibonacci, recursion limits
- Type hints — annotating parameter and return types for readability and static analysis
- Docstrings — documenting functions with Google style and NumPy style
Functions are the foundation of writing clean, organized Python code. As you move forward, you will use them to build classes (in the OOP chapter), organize code into modules, and create complex programs that remain readable and maintainable.
Next up: Modules and Packages — learn how to organize your functions into reusable files and libraries.