⚡ Phase 2 · Control Flow 🟡 Beginner+ MODULE 10

Python Functions

⏱️ 45 min read
📖 Theory + Code
🧩 5 Quiz Questions
🏗️ 1 Challenge
Phase 2 progress83%
🎯 What you'll learn: How to define reusable code blocks with def. Parameters vs arguments, default values, *args and **kwargs, return values, multiple returns. Variable scope — the LEGB rule. Lambda functions. Docstrings. And the most important principle in programming: Don't Repeat Yourself (DRY).

Why Functions?

A function is a named, reusable block of code that performs a specific task. Without functions, every time you need to calculate a grade or validate a password, you'd copy-paste the same code — creating programs that are hard to read, maintain, and fix.

Functions give you three superpowers: reusability (write once, call anywhere), abstraction (hide complexity behind a name), and testability (test one piece independently). Every professional codebase is built from functions.

def greet(name, greeting="Hello"):← def keyword, function name, parameters
    """Return a personalised greeting string."""← docstring (optional but recommended)
    return f"{greeting}, {name}!"← return sends a value back to the caller
functions_intro.py
PYTHON
# Without functions — repeated code (bad)
tax1 = 1500 * 0.17
tax2 = 2800 * 0.17
tax3 = 950  * 0.17

# With a function — write once, use anywhere (DRY)
def calculate_tax(amount):
    """Calculate 17% sales tax on an amount."""
    return amount * 0.17

tax1 = calculate_tax(1500)   # 255.0
tax2 = calculate_tax(2800)   # 476.0
tax3 = calculate_tax(950)    # 161.5

# Calling the function
print(f"Tax: PKR {tax1:.2f}")

# Functions without a return statement return None
def say_hello():
    print("Hello, BitWithBite!")   # does work, returns None

result = say_hello()
print(result)   # None

Parameters & Arguments

Parameters are the variable names in the function definition. Arguments are the actual values you pass when calling the function. Python has four parameter types — each solving a different problem.

positional
Required, by position
Must be provided in the correct order. The most basic type.
def add(a, b): ... → add(3, 5)
default
Optional with a fallback
Caller can omit it and the default value is used instead.
def greet(name, msg="Hi"): ... → greet("Ali")
*args
Variable positional
Collects any number of positional arguments into a tuple.
def total(*nums): ... → total(1, 2, 3, 4)
**kwargs
Variable keyword
Collects any number of keyword arguments into a dict.
def profile(**info): ... → profile(name="Ali", age=22)
parameters.py
PYTHON
# ── Positional and default parameters ────────
def describe_student(name, age, city="Lahore", grade="A"):
    return f"{name}, {age}, from {city}, Grade {grade}"

print(describe_student("Ali", 20))                    # uses defaults
print(describe_student("Sara", 22, "Karachi"))          # overrides city
print(describe_student("Zara", 21, grade="B"))           # keyword arg

# ── *args — collect any number of positional args ──
def total(*numbers):
    """Sum any number of arguments."""
    return sum(numbers)   # numbers is a tuple

print(total(1, 2, 3))           # 6
print(total(10, 20, 30, 40))    # 100
print(total(5))                  # 5

# ── **kwargs — collect keyword arguments as a dict ──
def build_profile(**info):
    """Print a profile from any keyword arguments."""
    for key, val in info.items():
        print(f"  {key.capitalize()}: {val}")

build_profile(name="Fatima", age=20, city="Islamabad", gpa=3.9)

# ── Combining all types (correct order!) ─────
def mixed(required, optional="default", *args, **kwargs):
    print(required, optional, args, kwargs)
⚠️
Mutable default argument trap — never use [ ] or { } as defaults
def append_to(item, lst=[]) is a classic Python bug. The default list is created once at function definition and shared across all calls. Use None as the default and create the list inside: if lst is None: lst = []. This trips up even experienced developers.

Return Values

The return statement sends a value back to the caller and immediately exits the function. A function can return any Python object — a number, string, list, dict, tuple, or even another function.

return_values.py
PYTHON
# Single return value
def square(n):
    return n ** 2

print(square(7))   # 49

# Early return — guard clause pattern
def divide(a, b):
    if b == 0:
        return None    # exit early on bad input
    return a / b

print(divide(10, 2))   # 5.0
print(divide(10, 0))   # None

# Multiple return values (returns a tuple)
def stats(numbers):
    """Return min, max, average of a list."""
    return min(numbers), max(numbers), sum(numbers)/len(numbers)

lo, hi, avg = stats([8, 3, 15, 1, 9])
print(f"Min: {lo}, Max: {hi}, Avg: {avg:.1f}")   # 1 15 7.2

# Return a dict — named results
def analyse_text(text):
    words = text.split()
    return {
        "chars"  : len(text),
        "words"  : len(words),
        "unique" : len(set(words)),
    }

r = analyse_text("to be or not to be that is the question")
print(r)   # {'chars': 40, 'words': 10, 'unique': 8}
Return a dict for named results — cleaner than tuples
When a function returns 3+ values, tuples become hard to remember (was it min, max, avg or avg, min, max?). Return a dict instead — callers access results by name: result["avg"]. This is how professional APIs work and makes code self-documenting.

Variable Scope — The LEGB Rule

When Python encounters a variable name, it searches for it in a specific order of scopes. This order is called the LEGB rule — Local → Enclosing → Global → Built-in.

LetterScopeWhereExample
LLocalInside the current functionVariables created inside def
EEnclosingInside any enclosing function (closures)Outer function's variables
GGlobalModule level (top of the file)Variables defined outside functions
BBuilt-inPython's built-in namespaceprint, len, range, int
scope_legb.py
PYTHON
# Global scope
APP_NAME = "BitWithBite"   # G — global

def show_app():
    version = "2.0"          # L — local to show_app
    print(APP_NAME, version)  # can read global APP_NAME

show_app()
# print(version)  ← NameError: version not defined here

# Global variables are READ-ONLY inside functions by default
counter = 0

def increment():
    global counter         # declare intent to MODIFY global
    counter += 1

increment()
increment()
print(counter)   # 2

# ── Best practice: avoid global — use return instead ──
def increment_v2(count):
    return count + 1         # pure function — no side effects

counter = increment_v2(counter)  # caller controls the global

# Enclosing scope (closure preview)
def outer():
    msg = "hello from outer"
    def inner():
        print(msg)   # E — enclosing scope
    inner()
💡
Avoid global — it's a code smell
Using global makes functions depend on external state, which makes them hard to test and debug. Professional Python code passes values as arguments and returns new values instead of modifying globals. A function that only uses its parameters and return values is called a pure function — aim for this.

Lambda Functions & Higher-Order Functions

A lambda is a small anonymous function written in one line. It's syntactic sugar for a simple function — useful when you need a short function as an argument to another function, like sorted() or map().

Higher-order functions are functions that take other functions as arguments — map(), filter(), and sorted() are the most common. Combining these with lambda makes code elegant and concise.

lambda_hof.py
PYTHON
# Lambda syntax: lambda params: expression
square = lambda x: x ** 2
print(square(5))   # 25

add = lambda a, b: a + b
print(add(3, 7))   # 10

# ── sorted() with key lambda ──────────────────
students = [("Ali", 85), ("Sara", 92), ("Zara", 78)]
by_score = sorted(students, key=lambda s: s[1], reverse=True)
print(by_score)   # [('Sara', 92), ('Ali', 85), ('Zara', 78)]

names = ["banana", "Apple", "mango", "cherry"]
print(sorted(names, key=lambda x: x.lower()))   # case-insensitive sort

# ── map() — apply a function to every item ───
prices  = [100, 250, 80, 320]
with_tax = list(map(lambda p: p * 1.17, prices))
print(with_tax)   # [117.0, 292.5, 93.6, 374.4]

# ── filter() — keep items where function is True ─
marks   = [55, 42, 88, 31, 76, 90]
passing = list(filter(lambda m: m >= 50, marks))
print(passing)    # [55, 88, 76, 90]

# Passing a def function as argument
def is_even(n): return n % 2 == 0
evens = list(filter(is_even, range(10)))
print(evens)      # [0, 2, 4, 6, 8]

Docstrings & Best Practices

A docstring is a string literal placed as the first statement of a function. It describes what the function does, its parameters, and what it returns. Python's help() function and most IDEs display docstrings automatically.

docstrings_best_practices.py
PYTHON
# One-line docstring (simple functions)
def celsius_to_fahrenheit(c):
    """Convert Celsius to Fahrenheit."""
    return c * 9/5 + 32

# Multi-line docstring (complex functions)
def calculate_grade(score, total=100):
    """
    Calculate a letter grade from a score.

    Args:
        score (float): The marks obtained.
        total (float): Maximum possible marks. Default 100.

    Returns:
        str: Letter grade A+, A, B, C, or F.

    Examples:
        >>> calculate_grade(92)
        'A+'
        >>> calculate_grade(45, 100)
        'F'
    """
    pct = score / total * 100
    if   pct >= 95: return "A+"
    elif pct >= 85: return "A"
    elif pct >= 70: return "B"
    elif pct >= 55: return "C"
    else:            return "F"

# Access the docstring programmatically
print(calculate_grade.__doc__)
help(calculate_grade)   # formatted output in terminal
📏
Single responsibility
Each function should do exactly one thing. If you can't describe it in one sentence without "and", split it into two functions.
🔤
Name with a verb
Good function names are verbs: calculate_tax(), validate_email(), get_user(), send_message(). Noun names suggest it should be a class.
📦
Pure functions first
Functions that take inputs and return outputs without side effects are easiest to test, debug, and reuse. Avoid reading/writing global state inside functions.
🔢
Keep it short
If a function exceeds ~20 lines, it's probably doing too much. Extract the extra logic into helper functions. Short functions are easier to understand at a glance.
🧩 Knowledge Check
5 questions — Python Functions
1. What does a function return if it has no return statement?
2. What does *args collect inside a function?
3. In which order does Python look up variable names (LEGB)?
4. What does lambda x: x * 2 create?
5. What is the output of def f(a, b=10): return a + b called as f(5)?
🏗️
Coding Challenge — Student Analytics Library
Build a reusable function library using all parameter types
Task: Build a student analytics module with these functions:

1. get_grade(score, total=100) — returns letter grade A+/A/B/C/F with docstring
2. class_stats(*scores) — takes any number of scores, returns dict with min/max/avg/count
3. build_student(**info) — takes keyword args (name, age, gpa, etc.), returns formatted student card string
4. top_students(students, n=3, key="gpa") — takes list of dicts, returns top n sorted by key
5. A lambda is_distinction = lambda s: s >= 85 used inside a list comprehension
6. A main block that creates 5 student dicts, calls all functions, and prints a full report
💡 Show hints
  • *scores inside function is a tuple — use sum(scores)/len(scores) for avg
  • **info is a dict — loop .items() to build the card
  • top_students: use sorted(students, key=lambda s: s[key], reverse=True)[:n]
  • Distinction list: [s["name"] for s in students if is_distinction(s["gpa"]*25)]
student_analytics.py — Sample Solution
PYTHON
# ── Student Analytics Library ─────────────────

def get_grade(score, total=100):
    """Return letter grade. Defaults to /100."""
    p = score / total * 100
    if   p >= 95: return "A+"
    elif p >= 85: return "A"
    elif p >= 70: return "B"
    elif p >= 55: return "C"
    else:           return "F"

def class_stats(*scores):
    """Return stats dict for any number of scores."""
    if not scores: return {}
    return {"count": len(scores), "min": min(scores),
            "max": max(scores), "avg": sum(scores)/len(scores)}

def build_student(**info):
    """Format student info from keyword arguments."""
    lines = [f"  {k.capitalize():10}: {v}" for k, v in info.items()]
    return "┌─ Student Card ─────────┐\n" + "\n".join(lines) + "\n└────────────────────────┘"

def top_students(students, n=3, key="gpa"):
    """Return top n students sorted by key."""
    return sorted(students, key=lambda s: s[key], reverse=True)[:n]

is_distinction = lambda gpa: gpa >= 3.7

# ── Main ─────────────────────────────────────
students = [
    {"name": "Ali",    "gpa": 3.85, "score": 88},
    {"name": "Sara",   "gpa": 3.95, "score": 96},
    {"name": "Zara",   "gpa": 3.40, "score": 72},
    {"name": "Omar",   "gpa": 3.60, "score": 79},
    {"name": "Fatima", "gpa": 3.75, "score": 91},
]

scores      = [s["score"] for s in students]
cs          = class_stats(*scores)
distinctions= [s["name"] for s in students if is_distinction(s["gpa"])]
top         = top_students(students, n=3)

print(f"Class: {cs['count']} students | Avg: {cs['avg']:.1f} | Range: {cs['min']}–{cs['max']}")
print(f"Distinctions: {', '.join(distinctions)}")
print("\nTop 3 by GPA:")
for s in top:
    print(f"  {s['name']:10} GPA {s['gpa']}  {get_grade(s['score'])}")
🎉
Lesson 10 Complete!
Functions mastered — the single most important skill in programming. Now build real projects with everything from Phase 2!
← Course Home
Phase 2 · Control FlowLesson 10 of 6