GadaaLabs
Python Mastery — From Zero to AI Engineering
Lesson 2

Functions, Scope & Modules

26 min

Why Functions Are the Unit of Thought

A function is not just a way to avoid repeating code. It is a named, callable unit of computation that takes inputs, does work, and returns outputs. Good Python code is almost entirely functions and classes working together — understanding them deeply is the single biggest lever you have for writing better code.

Python functions are first-class objects. They can be stored in variables, passed as arguments, returned from other functions, and stored in data structures. This is not a curiosity — it enables entire programming paradigms: callbacks, decorators, functional pipelines.

Defining Functions

The def keyword creates a function object and binds it to a name. The body runs only when called.

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

message = greet("Ada")   # calls the function
print(message)           # Hello, Ada!

A function without an explicit return statement returns None. Always be intentional about return values.

Basic function definition
Click Run to execute — Python runs in your browser via WebAssembly

Docstrings

Every non-trivial function should have a docstring. Python treats the first string literal in a function body as its documentation. Tools like help(), IDEs, and documentation generators read it automatically.

python
def calculate_bmi(weight_kg, height_m):
    """
    Calculate Body Mass Index.

    Args:
        weight_kg: Weight in kilograms.
        height_m: Height in metres.

    Returns:
        BMI as a float, rounded to 1 decimal place.
    """
    return round(weight_kg / height_m ** 2, 1)

Parameters: The Full Picture

Python has five distinct parameter types. Most Python developers only use three, but knowing all five prevents confusion when you encounter them.

Positional and Keyword Arguments

Any parameter can be passed positionally (by order) or by keyword (by name). Keyword arguments can appear in any order.

Positional vs keyword arguments
Click Run to execute — Python runs in your browser via WebAssembly

Default Parameter Values

Parameters with defaults are optional at the call site. Always put defaulted parameters after non-defaulted ones.

Critical rule: Never use a mutable object (list, dict) as a default value. Python creates the default object once when the function is defined, not each time it is called.

Default values — the mutable default trap
Click Run to execute — Python runs in your browser via WebAssembly

*args and **kwargs

*args collects extra positional arguments into a tuple. **kwargs collects extra keyword arguments into a dict. They let you write functions that accept a variable number of arguments.

*args and **kwargs
Click Run to execute — Python runs in your browser via WebAssembly

Keyword-Only and Positional-Only Parameters

Parameters after a bare * are keyword-only. Parameters before a / are positional-only. These are useful for designing clean APIs.

Keyword-only and positional-only parameters
Click Run to execute — Python runs in your browser via WebAssembly

Scope: The LEGB Rule

When Python encounters a name, it searches four scopes in order: Local, Enclosing, Global, Built-in. This is the LEGB rule. Understanding it eliminates an entire class of bugs.

  • Local — names defined inside the current function
  • Enclosing — names in the enclosing function (for nested functions)
  • Global — names defined at module level
  • Built-in — Python's built-in names (len, print, range, etc.)
LEGB scope rules
Click Run to execute — Python runs in your browser via WebAssembly

global and nonlocal

Use global to rebind a module-level name from inside a function. Use nonlocal to rebind an enclosing function's name. Both are code smells in large programs — prefer returning values or using classes — but they have legitimate uses.

global and nonlocal
Click Run to execute — Python runs in your browser via WebAssembly

Closures: Functions That Remember

A closure is a function that captures variables from its enclosing scope even after that scope has finished executing. The function object carries a reference to those variables.

This is how Python implements things like decorators, factory functions, and stateful callbacks without needing a class.

Closures and factory functions
Click Run to execute — Python runs in your browser via WebAssembly

First-Class Functions

Because functions are objects, you can do anything with them that you can do with any other value.

First-class functions and higher-order functions
Click Run to execute — Python runs in your browser via WebAssembly

Lambda Expressions

A lambda is an anonymous function limited to a single expression. It is syntactic sugar — everything a lambda can do, a regular def can do better. Use lambdas only for short throwaway functions passed to sorted, map, filter, or similar.

When NOT to use lambda: if the body is complex, if you need a docstring, if you need to name it and reuse it, or if it would be clearer as a def.

Lambda expressions — good and bad uses
Click Run to execute — Python runs in your browser via WebAssembly

Modules: Organizing Code

A module is simply a Python file. When you write import math, Python finds math.py in its search path (sys.path), executes it, and binds the resulting module object to the name math in your current namespace.

python
import math                    # import the whole module
from math import sqrt, pi      # import specific names
from math import sqrt as sq    # alias to avoid name conflicts
import numpy as np             # conventional alias

The __name__ == "__main__" Guard

When Python runs a file directly, it sets __name__ to "__main__". When a file is imported as a module, __name__ is set to the module's name. The guard lets you write code that only runs when the file is executed directly, not when imported.

Modules and the standard library
Click Run to execute — Python runs in your browser via WebAssembly

The Standard Library Highlights

Python's standard library is enormous. Here are the modules you will use constantly:

collections, itertools, datetime
Click Run to execute — Python runs in your browser via WebAssembly

PROJECT: Text Statistics Analyzer

A real utility that computes several statistics about a piece of text — demonstrating functions, collections.Counter, string methods, and module-level organization.

PROJECT: Text Statistics Analyzer
Click Run to execute — Python runs in your browser via WebAssembly

PROJECT: Utility Module Structure

In a real project, you would organize reusable functions into a module. Here is what a utility module looks like — the structure and import patterns:

PROJECT: Utility module
Click Run to execute — Python runs in your browser via WebAssembly

Challenge

Test your understanding with these exercises:

  1. Memoization from scratch — write a memoize(func) function that wraps any function and caches its results in a dict. Call memoize(fibonacci)(35) and compare the speed to the naive recursive version.

  2. Partial application — implement your own partial(func, *partial_args) function (then compare to functools.partial). Use it to create double and triple from a multiply(a, b) function.

  3. Scope puzzle — predict what this prints before running it:

python
x = 1
def f():
    x = 2
    def g():
        print(x)
    return g
f()()
  1. Text analyzer extension — add a flesch_reading_ease score to the Text Statistics Analyzer. The formula is:
206.835 - 1.015 * (words/sentences) - 84.6 * (syllables/words)

Estimate syllable count by counting vowel groups per word.

  1. Module design — design a validators module with functions: is_email, is_url, is_phone_number, is_strong_password. Each returns True/False. Add a validate(value, *validator_fns) function that runs all validators and returns a list of failures.

Key Takeaways

  • Functions are objects — storing, passing, and returning them unlocks powerful patterns
  • The LEGB rule governs every name lookup; understanding it eliminates mysterious bugs
  • Never use mutable default arguments — use None and create inside the function
  • *args and **kwargs make APIs flexible; keyword-only parameters make them safe
  • Closures capture variables from enclosing scope — they are the foundation of decorators and factory functions
  • Lambda is for short, throwaway key functions — reach for def otherwise
  • The __name__ == "__main__" guard separates executable scripts from importable modules
  • collections.Counter, collections.defaultdict, and itertools solve 90% of common iteration problems elegantly