5. Chapter 5: Python Functions

5.1. Python Functions

5.1.1. 1. Defining a Basic Function

A function is defined using the def keyword followed by the function name and parentheses:

def greet():
    print("Hello, Python!")

greet()

5.1.2. 2. Function with Parameters

Parameters allow functions to accept external input:

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

greet_user("Alice")

5.1.3. 3. Function with Return Value

The return statement sends a value back to the caller:

def add(a, b):
    return a + b

result = add(5, 3)
print(result)  # Output: 8

5.1.4. 4. Default Parameters

Default values are used when no argument is provided:

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

greet()
greet("Bob")

5.1.5. 5. Keyword Arguments

Arguments can be passed by name for clarity:

def student_info(name, age):
    print(f"Name: {name}, Age: {age}")

student_info(age=20, name="Alice")

5.1.6. 6. *Variable-Length Arguments (args)

Allows passing multiple positional arguments:

def sum_numbers(*args):
    return sum(args)

print(sum_numbers(1, 2, 3, 4))  # Output: 10

5.1.7. 7. **Keyword Variable-Length Arguments (kwargs)

Accepts arbitrary keyword arguments:

def display_profile(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

display_profile(name="Alice", role="Engineer")

5.1.8. 8. Function with Both *args and kwargs

Supports flexible function signatures:

def complete_profile(*args, **kwargs):
    print("Positional:", args)
    print("Keyword:", kwargs)

complete_profile("Python", level="Advanced", year=2025)

5.1.9. 9. Lambda (Anonymous) Functions

Used for short, inline function definitions:

square = lambda x: x ** 2
print(square(5))  # Output: 25

5.1.10. 10. Recursive Functions

A function calling itself to solve repetitive problems:

def factorial(n):
    if n == 0:
        return 1
    return n * factorial(n - 1)

print(factorial(5))  # Output: 120

5.2. Python Function Arguments

5.2.1. 1. Positional Arguments

Arguments are passed in the order defined by the function:

def display_info(name, age):
    print(f"Name: {name}, Age: {age}")

display_info("Alice", 25)

5.2.2. 2. Keyword Arguments

Arguments are passed using parameter names, improving readability and flexibility:

def display_info(name, age):
    print(f"Name: {name}, Age: {age}")

display_info(age=25, name="Alice")

5.2.3. 3. Default Arguments

Default values are used when arguments are not supplied:

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

greet()
greet("Bob")

5.2.4. 4. Required Arguments

Arguments without defaults must be provided:

def login(username, password):
    print(f"User: {username}")

login("admin", "1234")
# login("admin")  # Raises TypeError

5.2.5. 5. *Variable-Length Positional Arguments (args)

*args collects extra positional arguments into a tuple:

def calculate_sum(*numbers):
    return sum(numbers)

print(calculate_sum(1, 2, 3))      # 6
print(calculate_sum(5, 10, 15, 5)) # 35

5.2.6. 6. **Variable-Length Keyword Arguments (kwargs)

**kwargs collects extra keyword arguments into a dictionary:

def user_profile(**details):
    for key, value in details.items():
        print(f"{key}: {value}")

user_profile(name="Alice", role="Developer", level="Senior")

5.2.7. 7. Combining Normal, *args, and kwargs

Order of parameters must follow: normal → *args → kwargs

def full_profile(name, *skills, **info):
    print("Name:", name)
    print("Skills:", skills)
    print("Details:", info)

full_profile("Alice", "Python", "ML", age=30, city="Toronto")

5.2.8. 8. Positional-Only Arguments (Python 3.8+)

Parameters before / must be positional-only:

def divide(a, b, /):
    return a / b

print(divide(10, 2))
# divide(a=10, b=2)  # Raises TypeError

5.2.9. 9. Keyword-Only Arguments

Parameters after * must be specified as keyword arguments:

def configure(*, mode="light"):
    print(f"Mode: {mode}")

configure(mode="dark")

5.2.10. 10. Argument Unpacking

  • and ** can unpack tuples/lists and dictionaries directly into function arguments:

def multiply(a, b, c):
    return a * b * c

values = (2, 3, 4)
print(multiply(*values))  # Output: 24

data = {"a": 1, "b": 2, "c": 3}
print(multiply(**data))   # Output: 6

5.3. Python Variable Scope

5.3.1. 1. Local Scope

Local variables exist only within the function where they are declared:

def show_value():
    x = 10  # Local variable
    print(x)

show_value()
# print(x)  # NameError: x is not defined

5.3.2. 2. Global Scope

Global variables are accessible throughout the module:

x = 20  # Global variable

def display():
    print(x)

display()
print(x)

5.3.3. 3. Local vs Global Variable Conflict

Local variables override global variables within function scope:

value = 50

def update():
    value = 10  # Local variable shadows global
    print("Inside function:", value)

update()
print("Outside function:", value)

5.3.4. 4. Using the global Keyword

The global keyword allows modification of global variables inside functions:

count = 0

def increment():
    global count
    count += 1

increment()
print(count)  # Output: 1

5.3.5. 5. Enclosing Scope (Nested Functions)

Nested functions can access variables from their enclosing scope:

def outer():
    message = "Hello"

    def inner():
        print(message)  # Accessing enclosing variable

    inner()

outer()

5.3.6. 6. Using the nonlocal Keyword

nonlocal allows modification of variables from the enclosing (but not global) scope:

def outer():
    count = 0

    def inner():
        nonlocal count
        count += 1
        print(count)

    inner()
    inner()

outer()

5.3.7. 7. LEGB Rule (Scope Resolution Order)

Python resolves variables using the LEGB rule:

  • Local

  • Enclosing

  • Global

  • Built-in

x = "Global"

def outer():
    x = "Enclosing"

    def inner():
        x = "Local"
        print(x)

    inner()

outer()

5.3.8. 8. Built-in Scope

Built-in functions and exceptions reside in the built-in scope:

print(len([1, 2, 3]))  # Built-in function

5.3.9. 9. Scope Inside Loops

Unlike some languages, Python loop variables are not block-scoped:

for i in range(3):
    loop_var = i

print(loop_var)  # Accessible outside loop

5.3.10. 10. Practical Example of Scope Management

Demonstrates coordinated use of local, enclosing, and global scopes:

total = 100

def calculate():
    total = 50

    def adjust():
        nonlocal total
        total += 10

    adjust()
    return total

print(calculate())  # Output: 60
print(total)        # Output: 100

5.4. Python Global Keyword

5.4.1. 1. Basic Use of Global Variables

Global variables are declared outside functions and are readable within functions by default:

count = 10

def display():
    print(count)

display()  # Output: 10

5.4.2. 2. Modifying Global Variable Without global (Error Case)

Assigning to a variable inside a function makes it local unless explicitly declared global:

value = 5

def update():
    value = value + 1  # UnboundLocalError

5.4.3. 3. Using global to Modify Global Variables

The global keyword allows direct modification of a global variable:

counter = 0

def increment():
    global counter
    counter += 1

increment()
print(counter)  # Output: 1

5.4.4. 4. Global Variable Across Multiple Functions

A global variable can be shared and updated across multiple functions:

status = "inactive"

def activate():
    global status
    status = "active"

def show_status():
    print(status)

activate()
show_status()  # Output: active

5.4.5. 5. Global Keyword Inside Nested Functions

global always refers to the module-level scope, even when used inside nested functions:

x = 100

def outer():
    def inner():
        global x
        x = 200

    inner()

outer()
print(x)  # Output: 200

5.4.6. 6. Difference Between global and nonlocal

  • global → Refers to module-level variable

  • nonlocal → Refers to nearest enclosing function scope

x = 50

def outer():
    x = 10

    def inner():
        global x
        x = 99

    inner()
    print("Outer x:", x)

outer()
print("Global x:", x)

5.4.7. 7. Tracking State Using Global Variables

Used for maintaining application-wide counters or shared state:

requests = 0

def handle_request():
    global requests
    requests += 1

handle_request()
handle_request()
print(requests)  # Output: 2

5.4.8. 8. Global Variable with Conditional Logic

Global variables often manage configuration state:

mode = "light"

def toggle_mode():
    global mode
    if mode == "light":
        mode = "dark"
    else:
        mode = "light"

toggle_mode()
print(mode)  # Output: dark

5.4.9. 9. Avoiding Global Abuse (Best Practice Warning)

Mutable objects can be modified without global, reducing global keyword dependency:

config = {"theme": "dark"}

def update_config(new_theme):
    config["theme"] = new_theme  # No global keyword needed

5.4.10. 10. Best Practice: Encapsulation Instead of Global

Encapsulation is preferred over global variables in production systems for maintainability and testability:

class Counter:
    def __init__(self):
        self.value = 0

    def increment(self):
        self.value += 1

counter = Counter()
counter.increment()
print(counter.value)  # Output: 1

5.5. Python Recursion

5.5.1. 1. Basic Recursive Function

A recursive function calls itself to solve a smaller instance of the same problem:

def print_numbers(n):
    if n == 0:
        return
    print(n)
    print_numbers(n - 1)

print_numbers(5)

5.5.2. 2. Base Case and Recursive Case

Every recursive function must define a base case to prevent infinite calls:

def factorial(n):
    if n == 0:          # Base case
        return 1
    return n * factorial(n - 1)  # Recursive case

print(factorial(5))  # Output: 120

5.5.3. 3. Recursive Sum of Numbers

Demonstrates problem decomposition through recursion:

def sum_n(n):
    if n == 1:
        return 1
    return n + sum_n(n - 1)

print(sum_n(5))  # Output: 15

5.5.4. 4. Recursive Fibonacci Series

Classic example of overlapping subproblems in recursion:

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(6))  # Output: 8

5.5.5. 5. Recursion vs Iteration Comparison

Iteration often provides better performance and memory efficiency than deep recursion:

def factorial_iterative(n):
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

print(factorial_iterative(5))  # 120

5.5.6. 6. Recursive Traversal of List

Recursion is useful for navigating hierarchical or nested data:

def print_list(lst):
    if not lst:
        return
    print(lst[0])
    print_list(lst[1:])

print_list([1, 2, 3, 4])

5.5.7. 7. Recursive String Reversal

Illustrates recursive manipulation of sequences:

def reverse_string(text):
    if len(text) == 0:
        return text
    return reverse_string(text[1:]) + text[0]

print(reverse_string("Python"))  # Output: nohtyP

5.5.8. 8. Recursion Limit Error

Python enforces a recursion limit to prevent stack overflow:

def infinite_recursion():
    return infinite_recursion()

# infinite_recursion()  # Raises RecursionError

5.5.9. 9. Checking Recursion Depth

System recursion limits can be queried and adjusted cautiously:

import sys

print(sys.getrecursionlimit())  # Default ~1000

5.5.10. 10. Optimizing Recursion with Memoization

Memoization dramatically improves performance by caching results:

from functools import lru_cache

@lru_cache(maxsize=None)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

print(fibonacci(30))  # Optimized recursion

5.6. Python Modules

5.6.1. 1. What is a Module

A module is a file containing Python code (functions, variables, classes) that can be imported and reused in other programs:

# file: math_utils.py
def add(a, b):
    return a + b

5.6.2. 2. Importing a Module

The import keyword loads the entire module and accesses its content using dot notation:

import math

print(math.sqrt(25))  # Output: 5.0

5.6.3. 3. Importing Specific Functions from a Module

Imports only selected items, reducing namespace clutter:

from math import sqrt, pow

print(sqrt(16))     # Output: 4.0
print(pow(2, 3))    # Output: 8.0

5.6.4. 4. Renaming a Module using as

Useful for improving readability or avoiding name conflicts:

import math as m

print(m.pi)     # Output: 3.141592653589793

5.6.5. 5. Creating and Using a Custom Module

Custom modules help organize reusable logic:

# file: calculator.py
def multiply(a, b):
    return a * b

# main.py
import calculator

print(calculator.multiply(4, 5))  # Output: 20

5.6.6. 6. Using __name__ in Modules

Ensures code runs only when the file is executed directly, not when imported:

# file: demo.py
def main():
    print("Main function executed")

if __name__ == "__main__":
    main()

5.6.7. 7. Built-in Modules Example

Python provides rich built-in modules like os, sys, math, datetime, and random:

import datetime

today = datetime.date.today()
print(today)

5.6.8. 8. Exploring Module Content with dir()

Lists all available attributes and methods inside a module:

import math

print(dir(math))

5.6.9. 9. Using help() for Module Documentation

Displays detailed documentation of a module and its components:

import math

help(math)

5.6.10. 10. Module Search Path (sys.path)

Shows directories Python searches to locate modules, including:

  • Current directory

  • Standard library path

  • Site-packages

import sys

print(sys.path)

5.7. Python Package

5.7.1. 1. What is a Package

A package is a collection of Python modules organized in a directory hierarchy, enabling structured and scalable code organization:

my_package/
│── __init__.py
│── module1.py
│── module2.py

The presence of __init__.py indicates that the directory is a Python package.

5.7.2. 2. Creating a Basic Package

Packages allow logical grouping of related modules:

# my_package/module1.py
def greet():
    return "Hello from module1"

# main.py
import my_package.module1

print(my_package.module1.greet())

5.7.3. 3. Importing from a Package

Enables direct access to specific functions or classes within a package:

from my_package.module1 import greet

print(greet())

5.7.4. 4. Using init.py for Initialization

__init__.py controls what gets exposed when the package is imported:

# my_package/__init__.py
from .module1 import greet

5.7.5. 5. Sub-packages Structure

Packages can contain nested sub-packages for large systems:

my_package/
│── __init__.py
│── analytics/
   ├── __init__.py
   └── stats.py

5.7.6. 6. Relative Imports Inside a Package

Relative imports use:

  • . current package

Ensures internal module cohesion:

from my_package.analytics.stats import calculate_mean

5.7.7. 7. Absolute Imports in Packages

Preferred in production for clarity and maintainability:

from my_package.analytics.stats import calculate_mean

5.7.8. 8. Installing External Packages with pip

External packages extend Python’s functionality:

pip install requests

import requests

response = requests.get("https://api.example.com")
print(response.status_code)

5.7.9. 9. Viewing Installed Packages

Displays all packages installed in the Python environment:

pip list

5.7.10. 10. Packaging a Custom Project (Setup File)

This enables distribution and installation of your package via PyPI or internal repositories:

# setup.py
from setuptools import setup, find_packages

setup(
    name="mypackage",
    version="1.0",
    packages=find_packages()
)

5.8. Python Main Function

5.8.1. 1. Purpose of the Main Function

In Python, the “main function” is a conventional entry point that controls program execution flow:

def main():
    print("Program started")

main()

This provides a clear structure and separation of execution logic.

5.8.2. 2. Using if __name__ == “__main__”

This ensures the main logic runs only when the file is executed directly, not when imported:

def main():
    print("Main function executed")

if __name__ == "__main__":
    main()

5.8.3. 3. Why __name__ Works

  • When run directly → __main__

  • When imported → module name

This enables safe reusability of scripts as modules:

print(__name__)

5.8.4. 4. Main Function with Parameters

The main function can accept arguments like any other function:

def main(name):
    print(f"Welcome, {name}")

if __name__ == "__main__":
    main("Alice")

5.8.5. 5. Main Function for Program Flow Control

The main function orchestrates program execution in a clean, readable order:

def process_data():
    print("Processing data...")

def main():
    print("Initializing system...")
    process_data()

if __name__ == "__main__":
    main()

5.8.6. 6. Using Main with Command-Line Arguments

Allows integration with command-line tools and automation workflows:

import sys

def main():
    print("Arguments received:", sys.argv)

if __name__ == "__main__":
    main()

5.8.7. 7. Separating Business Logic from Entry Point

Improves modularity and testability:

def calculate_total(a, b):
    return a + b

def main():
    result = calculate_total(10, 20)
    print("Total:", result)

if __name__ == "__main__":
    main()

5.8.8. 8. Main Function in Large Applications

Supports structured application life-cycle management:

def initialize():
    print("System initialized")

def run():
    print("Application running")

def shutdown():
    print("System shutdown")

def main():
    initialize()
    run()
    shutdown()

if __name__ == "__main__":
    main()

5.8.9. 9. Main Function with Error Handling

Encapsulates exception handling at the entry level:

def main():
    try:
        print("Executing task...")
    except Exception as e:
        print("Error occurred:", e)

if __name__ == "__main__":
    main()

5.8.10. 10. Production-Ready Main Template

Standard best practice for scalable, maintainable Python applications:

def main():
    """Application entry point"""
    print("Application started")

if __name__ == "__main__":
    main()