Python Basics - 04. Functions

Functions are reusable blocks of code that make programs cleaner, easier to test, and easier to maintain.

In this notebook, you will learn:

  • how to define and call functions

  • how different parameter types work

  • how return values are used

  • common pitfalls and best practices

  • practical advanced patterns like recursion and decorators

Download Notebook

Download this notebook

1. Defining and Calling Functions

Use the def keyword to define a function. A function may take input parameters and may return a value.

def greet(name):
    # Return a formatted greeting message
    return f"Hello, {name}!"

print(greet("Python"))
print(greet("Alice"))
Hello, Python!
Hello, Alice!

2. Parameters: Positional, Keyword, and Default Values

You can pass arguments in several ways:

  • positional arguments: matched by position

  • keyword arguments: matched by parameter name

  • default values: used when an argument is not provided

def power(base, exponent=2):
    # default exponent=2 means square by default
    return base ** exponent

print(power(3))                 # positional, uses default exponent
print(power(2, 3))              # positional arguments
print(power(base=4, exponent=2))  # keyword arguments
9
8
16

3. Return Values and Multiple Results

Functions can return any object, including tuples for multiple values.

def divide_and_remainder(a, b):
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide_and_remainder(17, 5)
print(f"quotient={q}, remainder={r}")
quotient=3, remainder=2

4. Variable Scope (Local vs Global)

  • Local variables exist only inside a function.

  • Global variables are defined outside functions.

Prefer passing values through parameters instead of relying on globals.

message = "global"

def show_scope():
    message = "local"
    print("Inside function:", message)

show_scope()
print("Outside function:", message)
Inside function: local
Outside function: global

5. Recursion

A recursive function calls itself with a smaller subproblem. Every recursive function must have a base case.

def factorial(n):
    if n < 0:
        raise ValueError("n must be non-negative")
    if n in (0, 1):
        return 1
    return n * factorial(n - 1)

print(factorial(5))
120

6. Variable-Length Arguments: *args and **kwargs

  • *args collects positional arguments into a tuple.

  • **kwargs collects keyword arguments into a dictionary.

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

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

print(sum_all(1, 2, 3, 4, 5))
show_profile(name="Alice", role="Engineer", level="Beginner")
15
name: Alice
role: Engineer
level: Beginner

7. Lambda Functions and Decorators (Step by Step)

Lambda functions

A lambda is a short anonymous function written in one line.

Use lambda when:

  • the logic is very small

  • you only need the function once

  • readability is still clear

Example pattern:

  • regular function: def square(x): return x * x

  • lambda version: lambda x: x * x

Decorators

A decorator is a function that takes another function and returns a new function with extra behavior.

You can use decorators to:

  • log function calls

  • measure runtime

  • check permissions/inputs

  • avoid repeating the same pre/post logic in many functions

Think of it as “wrapping” a function with additional steps before and after it runs.

# ---------- Part A: Lambda (easy comparison) ----------
# Regular function version
def square_def(x):
    return x * x

# Lambda version (same logic, shorter syntax)
square_lambda = lambda x: x * x

print("square_def(6):", square_def(6))
print("square_lambda(6):", square_lambda(6))


# ---------- Part B: Decorator step by step ----------
# Step 1) A normal function
def add(a, b):
    return a + b

print("\nNormal add:", add(10, 20))


# Step 2) A decorator that adds logging around any function
def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"[LOG] Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"[LOG] {func.__name__} returned {result}")
        return result
    return wrapper


# Step 3) Manual wrapping (without @ syntax)
logged_add_manual = log_call(add)
print("\nManual wrapped add:")
logged_add_manual(3, 5)


# Step 4) Decorator syntax sugar (@log_call)
@log_call
def multiply(a, b):
    return a * b

print("\n@decorator wrapped multiply:")
multiply(4, 6)
square_def(6): 36
square_lambda(6): 36

Normal add: 30

Manual wrapped add:
[LOG] Calling add with args=(3, 5), kwargs={}
[LOG] add returned 8

@decorator wrapped multiply:
[LOG] Calling multiply with args=(4, 6), kwargs={}
[LOG] multiply returned 24
24