Functions & Execution Stacks
Master First-Class Objects, the LEGB naming rule, Call Stack memory
allocation, and pointer packing (*args).
Functions are blocks of reusable code designed to perform a single, specific action. In
mathematical terms, a function f(x) ingests variables, computes logic inside a
sealed biological chamber, and excretes a result.
In Python, Functions are First-Class Citizens. This means a function is not
just a grammatical structure; it is a physical Object existing in RAM (specifically, a
PyFunctionObject). You can assign a function to a variable, pass a function
into another function, or return a function from a function.
Imagine a factory assembly line. A function is a highly specialized robotic arm on that line.
The code outside the function knows nothing about how the robotic arm works internally. It simply places raw materials (Arguments) onto the conveyor belt heading towards the arm. The arm operates inside a sealed black box (Local Scope). When finished, it places a finished product (Return Value) back onto the conveyor belt and shuts down.
# Scenario 1: Scope and the LEGB Rule
x = 10 # Global Scope
def outer():
x = 20 # Enclosing Scope
def inner():
x = 30 # Local Scope
print(x)
return inner
# Scenario 2: *args Pointer Packing
def train_model(model_name, *hyperparameters):
print(f"Training: {model_name}")
for p in hyperparameters:
print(f"Applying pointer setting: {p}")
train_model("ResNet50", 0.01, 64, "Adam")
| Code Line | Explanation |
|---|---|
def outer(): |
Python allocates a massive PyFunctionObject structure into RAM. It
compiles the internal code into Bytecode string arrays, attaches a nametag called
`outer`, and stores it in the Global Dictionary. The code *inside* the function is
NOT executed yet. |
return inner |
Because functions are First-Class Objects, the `outer()` function returns a direct C-pointer pointing to the newly constructed `inner` function object, allowing the global script to access the trapped robotic arm. |
def train_model(name, *hyper): |
The * is the crucial Packing Operator. Python intercepts all
overflowing arguments (`0.01`, `64`, `"Adam"`), physically packs all of their memory
pointers into a brand new immutable `Tuple`, and assigns the nametag
`hyperparameters` to that Tuple. |
Input: def f(x=None): return id(x) called via f().
Transformation: The namespace is built. The CPU searches for the argument
`x`. Finding none provided, it looks to the function signature and finds the default `None`
object. It ties the local pointer `x` to Python's universal `None` memory address. The
id() function extracts that physical integer address.
Output State: The integer `140728956274488` is returned and the function's local memory environment drops entirely out of RAM.
When you call a function my_func(), Python freezes the current global execution
script.
It physically pushes a new Frame Object onto the C Call Stack. This Frame contains:
- A brand new Local Dictionary (A blank slate for variables).
- A back-pointer to the Global Dictionary (So it can read outside).
- An Instruction Pointer indicating exactly where to resume the global script.
When the function hits a return statement, Python evaluates the return value,
instantly obliterates the Local Dictionary (destroying all internal variables),
pops the Frame off the stack, and resumes execution.
When you type a variable name x inside a function, Python must figure out which
memory address x belongs to. It violently searches through 4 distinct layered
dictionaries in exactly this order:
- L (Local): First checks the function's internal blank-slate dictionary.
- E (Enclosing): Check any parent functions that wrap this function.
- G (Global): Checks the script's main top-level dictionary.
- B (Built-in): Checks Python's absolute core C-functions (like
len(),print()).
If it fails all 4, it throws a NameError.
Memory representation of Keyword Argument packing **kwargs:
config_model(epochs=10, lr=0.01)
[ Function Stack Frame ]
kwargs ------> [ PyDictObject ]
"epochs" : -> [PyLong: 10]
"lr" : -> [PyFloat: 0.01]
The ** operator physically intercepts all `name=value` pairs typed into the
function call, allocates a massive Hash Table (Dictionary), and dynamically loads the
pointers into it.
A Python function ALWAYS returns exactly ONE object.
If you write return with no value, it returns None.
If you don't write a return statement at all, Python silently injects
return None at the deepest bytecode level.
If you write return a, b, c, Python instantly builds a hidden
Tuple, packs the three pointers inside it, and returns the single Tuple
object.
The Mutable Default Argument Disaster:
def append_log(msg, log=[]):
log.append(msg)
return log
What Happens: The first time you call append_log("Hi"), it
returns ["Hi"]. The second time you call append_log("Hello"), it
returns ["Hi", "Hello"]. Why did the memory carry over?!
Because default arguments are evaluated EXACTLY ONCE at compile time, NOT at
function call time. That [] list object is permanently glued to the
PyFunctionObject in memory. Every subsequent call mutates the exact same global
list pointer. Fix: Always use
def func(log=None): if log is None: log = [].
Generators (yield):
If you replace return with yield, the function completely changes
architecture.
Instead of destroying its Local Dictionary and popping off the Call Stack, a
yield statement FREEZES the function. It pauses the robotic
arm mid-motion, hands a value out, and waits. Calling next() on it explicitly
unfreezes the arm to compute the next step. This allows for processing terabytes of Data
Science logs using only megabytes of RAM.
Mistake: Trying to mutate Global Integers from inside a function.
count = 0def increment(): count += 1
Why does this crash?: count += 1 is shorthand for
count = count + 1. Because there is an equals sign, the Python compiler falsely
assumes you are creating a BRAND NEW Local Variable called `count`. But because it tries to
read `count + 1` before creating it, it crashes with UnboundLocalError.
Fix: You must explicitly tell the compiler to bypass the L-scope and tap
directly into G-scope by writing global count at the top of the function.
Calling a function in Python adds immense overhead (allocating dictionaries, pushing Call
Stack frames). If you have a massive math calculation happening 10 million times inside a
tight for loop, you should NEVER abstract that math out into a separate
def math_calc(). You must inline the math directly into the loop to prevent
millions of Call Stack frame creations.
Challenge: Write a decorator function called @timer that wraps
any target function. It should record time.time() before the target executes,
run the target, record time.time() after, print the difference, and return the
target's result.
Expected Concept: A Decorator is just a function that accepts a Function Object as an argument, defines a wrapper function internally, and returns that wrapper pointer!
Closures and the Cell Object:
If you have an inner() function that relies on variables in an
outer() function, what happens when `outer()` finishes and returns `inner`?
Theoretically, `outer`'s Local Dictionary is annihilated, so `inner` should lose access to
the variables!
Python intercepts this. If it detects `inner` needs a variable from `outer`, Python creates a
C-level Cell Object. It locks the memory pointer inside this titanium Cell
and permanently glues it to the `inner` function object (stored in a hidden attribute called
__closure__). This guarantees the memory survives the garbage collector,
allowing Data Scientists to create highly dynamic parameterized ML pipeline functions!