Skip to content

Functions

Functions are one of the most important parts of programming.

A function is a named block of code that performs a specific task and can be reused multiple times.

Instead of writing the same code again and again, we define a function once and call it whenever needed.

Functions enable:

  • Code reusability
  • Code organization and modularity
  • Easier debugging and testing
  • Abstraction of complexity

Functions are defined using the def keyword.

def greet(name):
print(f"Hello, {name}!")

Function Components:

  • def → keyword that defines a function
  • greet → function name (follow naming conventions: lowercase_with_underscores)
  • name → parameter (input to the function)
  • Function body → indented statements

To run the function, we call it:

greet("Alice") # Output: Hello, Alice!

Professional Practices:

  • Use descriptive function names that indicate what they do
  • Keep functions focused on a single responsibility (SRP)
  • Add docstrings to document your functions
  • Use type hints for clarity (Python 3.5+)

When a function is called, Python follows a clear process:

  1. It creates a new frame (memory space) for that function
  2. It assigns arguments to parameters
  3. It executes the function body
  4. It returns the result (if any)
  5. It removes the function from memory
graph TD Start --> Call["Function<br/>Call"] Call --> Frame["Create<br/>Stack Frame"] Frame --> Execute["Execute<br/>Function Body"] Execute --> Return["Return<br/>Value"] Return --> End["✓ Complete"]

Python uses a stack to manage function calls.

  • Last function called → executed first
  • When function finishes → removed from stack
def f1():
print("f1 start")
f2()
print("f1 end")
def f2():
print("f2 running")
f1()
graph TD Start["f1()"] --> f1["f1 executes"] f1 --> f2Call["calls f2()"] f2Call --> f2["f2 executes"] f2 --> f2Return["f2 returns"] f2Return --> f1Continue["f1 resumes"] f1Continue --> End["✓ f1 returns"]
Top of Stack
------------
f2()
f1()
------------
Bottom

After f2() finishes:

Top of Stack
------------
f1()
------------
Bottom

Functions can take inputs (parameters) and return outputs (return values).

def add(a, b):
return a + b
result = add(5, 3)
print(result) # Output: 8

Key Concepts:

  • Parameters → inputs to the function (a, b)
  • Arguments → actual values passed to the function (5, 3)
  • Return → output sent back to caller (a + b)
  • If no return statement → Python returns None

Return Value Semantics:

def divide(a, b):
if b == 0:
return None # Error case
return a / b
result = divide(10, 2)
print(result) # 5.0

Multiple Return Values:

def get_coordinates():
return 10, 20 # Returns a tuple
x, y = get_coordinates()
print(x, y) # Output: 10 20

You can provide default values to parameters, making them optional.

def greet(name="Guest"):
print(f"Hello, {name}")
greet() # Uses default: Hello, Guest
greet("Sahil") # Overrides default: Hello, Sahil

Key Characteristics:

  • Default parameters must come after non-default parameters
  • If no value is passed → default is used
  • If value is passed → default is ignored

Example with Mixed Parameters:

def create_user(username, email, role="user"):
print(f"User: {username}, Email: {email}, Role: {role}")
create_user("alice", "alice@example.com") # role="user"
create_user("bob", "bob@example.com", role="admin") # role="admin"

Sometimes you don’t know in advance how many arguments will be passed.

Allows passing any number of positional arguments to a function.

def total(*args):
print(f"Args: {args}") # args is a tuple
return sum(args)
print(total(1, 2, 3, 4)) # Output: 10
print(total(5, 10, 15)) # Output: 30

Key Points:

  • *args packs arguments into a tuple
  • Must come after regular parameters
  • Name args is convention (could be *numbers, etc.)

Allows passing any number of keyword arguments to a function.

def show(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
show(name="Alice", age=30, city="NYC")
# Output:
# name: Alice
# age: 30
# city: NYC

Key Points:

  • **kwargs packs arguments into a dictionary
  • Must come after *args and regular parameters
  • Name kwargs is convention (could be **options, etc.)
def process(required, default="value", *args, **kwargs):
print(f"Required: {required}")
print(f"Default: {default}")
print(f"Args: {args}")
print(f"Kwargs: {kwargs}")
process(1, 2, 3, 4, name="Alice", role="admin")
# Output:
# Required: 1
# Default: 2
# Args: (3, 4)
# Kwargs: {'name': 'Alice', 'role': 'admin'}
graph TD Input1["Input 1,2,3"] -->|*args| Tuple["Tuple<br/>(1,2,3)"] Input2["Input name=A,age=20"] -->|**kwargs| Dict["Dict<br/>{key:value}"] Tuple --> Process["Process & Use"] Dict --> Process

A function calling itself is called recursion. Essential for divide-and-conquer algorithms.

def factorial(n):
if n == 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # Output: 120

Recursion Components:

  • Base case → condition that stops recursion (e.g., n == 1)
  • Recursive case → function calls itself with modified argument

How It Works for factorial(3):

factorial(3)
= 3 * factorial(2)
= 3 * 2 * factorial(1)
= 3 * 2 * 1
= 6

Professional Considerations:

  • Risk of stack overflow if recursion is too deep
  • Python has a recursion limit (check with sys.getrecursionlimit())
  • Not always faster than iterative solutions
  • Use for naturally recursive problems (tree traversal, divide-and-conquer)
graph TD f3["factorial(3)"] --> f2["→ factorial(2)"] f2 --> f1["→ factorial(1)"] f1 --> R1["return 1"] R1 --> R2["2 x 1 = 2"] R2 --> R3["3 x 2 = 6"]
factorial(3)
factorial(2)
factorial(1)

Then it resolves back step by step.


Lambda functions are small, unnamed functions written in a single line. It is also known as an anonymous function.

add = lambda x, y: x + y
print(add(2, 3)) # Output: 5

Syntax: lambda parameters: expression

Characteristics:

  • Anonymous (no name binding required)
  • Single expression only (no statements)
  • Used for short operations
  • Implicitly returns the expression result

Common Use Cases:

# With map()
numbers = [1, 2, 3, 4]
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16]
# With filter()
even_numbers = list(filter(lambda x: x % 2 == 0, numbers))
print(even_numbers) # [2, 4]
# With sorted()
students = [("Alice", 90), ("Bob", 85), ("Charlie", 95)]
sorted_by_score = sorted(students, key=lambda x: x[1])

Higher-Order Functions: map(), filter(), reduce()

Section titled “Higher-Order Functions: map(), filter(), reduce()”

These functions take another function as an argument or return a function.

Applies a function to all items in an iterable.

numbers = [1, 2, 3]
result = list(map(lambda x: x * 2, numbers))
print(result) # [2, 4, 6]

Use Case: Transform all elements consistently

Selects items based on a condition (function returns True/False).

numbers = [1, 2, 3, 4, 5]
result = list(filter(lambda x: x % 2 == 0, numbers))
print(result) # [2, 4]

Use Case: Keep only elements matching a condition

Combines values into a single result through repeated application.

from functools import reduce
numbers = [1, 2, 3, 4]
result = reduce(lambda x, y: x + y, numbers)
print(result) # 10 (1+2+3+4)

Use Case: Aggregate values (sum, product, concatenation)

# Old way
result = list(map(lambda x: x * 2, numbers))
# Modern way (more readable)
:::note
Many prefer list comprehensions for readability:
:::

Arguments passed from the terminal when running a script.

import sys
print(sys.argv)

Running from Terminal:

Terminal window
python script.py hello world

Output:

['script.py', 'hello', 'world']

How sys.argv Works:

  • sys.argv[0] → script name (script.py)
  • sys.argv[1] → first argument (hello)
  • sys.argv[2] → second argument (world)

Practical Example:

import sys
if len(sys.argv) < 2:
print("Usage: python script.py <name>")
sys.exit(1)
name = sys.argv[1]
print(f"Hello, {name}!")

Professional Approach: Use argparse library for advanced argument parsing:

import argparse
parser = argparse.ArgumentParser(description='Greeting script')
parser.add_argument('name', help='Name to greet')
parser.add_argument('--greeting', default='Hello', help='Greeting word')
args = parser.parse_args()
print(f"{args.greeting}, {args.name}!")

Python follows the LEGB rule for variable resolution:

  1. Local → Variables inside current function
  2. Enclosing → Variables in enclosing function (nested functions)
  3. Global → Variables at module level
  4. Built-in → Python’s built-in names

Example:

x = "global" # Global scope
def outer():
x = "enclosing" # Enclosing scope
def inner():
x = "local" # Local scope
print(x)
inner()
print(x)
outer()
# Output:
# local
# enclosing

Professional Knowledge:

  • Python searches for variables in LEGB order
  • First found match is used
  • Helps understand variable access and modification

Common Issues:

x = 10
def modify():
x = x + 1 # UnboundLocalError!
modify()
# Error: local variable 'x' referenced before assignment
graph TD Local["🔍 Local"] --> Enclosing["🔍 Enclosing"] Enclosing --> Global["🔍 Global"] Global --> Builtin["🔍 Built-in"]

Declare that a function uses a global variable, allowing modification.

x = 10
def change():
global x # Declare intent to modify global x
x = 20
change()
print(x) # Output: 20

Use Case: When you need to modify a global state (generally avoid this)

Declare that a function uses a variable from an enclosing function.

def outer():
x = 10
def inner():
nonlocal x # Declare intent to modify enclosing x
x = 20
inner()
print(x) # Output: 20
outer()

Use Cases:

  • Closures that need to modify captured variables
  • Nested function scope modification

The return statement does two things:

  1. Sends a value back to the caller
  2. Stops execution of the function immediately
def check(n):
if n > 0:
return "Positive"
return "Negative"
print(check(5)) # Output: Positive
print(check(-3)) # Output: Negative

Important Behaviors:

def example():
print("Start")
return "Result"
print("This won't execute") # Unreachable code
example()
# Output: Start

Multiple Returns:

def validate(x):
if x < 0:
return "Negative"
if x == 0:
return "Zero"
return "Positive"

Early Return Pattern:

def process(data):
if not data:
return None # Early exit
# Process data...
return result

Documentation strings that describe what a function does. Essential for professional code.

def add(a, b):
"""Returns sum of two numbers"""
return a + b

Access Docstring:

print(add.__doc__) # Returns: "Returns sum of two numbers"
help(add) # Displays docstring with other info

Comprehensive Docstring:

def calculate_average(numbers):
"""Calculate the average of a list of numbers.
Args:
numbers (list): A list of numeric values.
Returns:
float: The average of the numbers.
Raises:
ValueError: If the list is empty.
Examples:
>>> calculate_average([1, 2, 3])
2.0
"""
if not numbers:
raise ValueError("List cannot be empty")
return sum(numbers) / len(numbers)

Docstring Formats:

  • Google style (recommended)
  • NumPy style
  • Sphinx style

Professional Practice: Every public function should have a docstring explaining its purpose, parameters, return value, and any exceptions it raises.


Understanding the difference helps write testable, predictable code.

A function that:

  • Always returns the same output for the same input
  • Has no side effects (doesn’t modify external state)
  • Same input → same output
  • No side effects
  • Does not modify external state (i.e. it does not change any variables outside its scope)
def add(x, y):
"""Pure function: always returns same result"""
return x + y
print(add(2, 3)) # Always 5
print(add(2, 3)) # Always 5

Benefits:

  • Easy to test
  • Predictable behavior
  • Cacheable results
  • Thread-safe

A function that:

  • Can return different outputs for the same input
  • Modifies external state (side effects)
  • Changes external state
  • Can return different outputs for same input
  • Has side effects (modifies global variable count)
count = 0
def increment():
global count
count += 1
return count
print(increment()) # Returns 1
print(increment()) # Returns 2

Side Effects Include:

  • Modifying global variables
  • File I/O operations
  • Network requests
  • Database modifications
  • Printing to console

Professional Practice: Minimize impure functions. Separate side effects from business logic for better testability.

def add(x, y):
return x + y
count = 0
def inc():
global count
count += 1