Scope & State
Where names live and who can change them.
Scope & State
Where a name is defined and how long it lives are two of the most important questions any program asks. Get them wrong and you get bugs that are very hard to track down.
Scope
A scope is a region of code where a name is meaningful. The standard rules in most languages:
- Module / file scope — names defined at the top level. Visible to everything in the file. Visible to other files only if exported.
- Function scope — parameters and
let/vardeclared inside a function. Visible only within the function. - Block scope — names declared inside
{ }(or Pythonwith/for/if— though Python is the odd one out:if/forblocks DON'T create a new scope).
function f() {
let x = 1; // function scope
if (true) {
let y = 2; // block scope
console.log(x, y); // 1 2
}
console.log(y); // ReferenceError
}
Python is unusual:
def f():
if True:
y = 2
print(y) # 2 — y is in function scope, not block scope
Lookup order
When you write print(x), the runtime walks an enumerated list of scopes looking for x. In Python (LEGB):
- Local — the current function
- Enclosing — any outer function (for closures)
- Global — the module
- Builtin —
print,len, etc.
JavaScript walks: local → enclosing functions → global. Same idea.
The first match wins. Define a local x and you've shadowed any outer x for the rest of that scope:
x = "global"
def f():
x = "local"
print(x) # local
print(x) # global
Closures
A closure is a function that captures variables from an enclosing scope. The inner function carries those variables with it, even after the outer function returns:
def make_counter():
count = 0
def increment():
nonlocal count # opt in to mutating the outer scope
count += 1
return count
return increment
counter = make_counter()
counter() # 1
counter() # 2
counter() # 3
count is "closed over" by increment. The original call to make_counter() is long gone, but its count lives on inside the closure.
JavaScript closures are everywhere — every callback you pass captures the surrounding scope:
function setupButton(label) {
document.getElementById("btn").onclick = () => {
console.log(`clicked: ${label}`); // captures `label`
};
}
State and mutation
Two terms that get conflated:
- State = "the values your program is tracking right now" — variables, fields, files, database rows.
- Mutation = "changing a value in place" —
xs[0] = 5,user.email = "...",count += 1.
You can have state without mutation (rebind names instead of changing values in place — the functional style). You can have mutation without state changes that surprise you (mutate values you fully own and just created).
The bugs come from shared mutable state: two pieces of code that hold a reference to the same value, and one mutates it without telling the other.
config = {"theme": "dark"}
def show_dashboard(config):
config["theme"] = "light" # surprise!
render(config)
show_dashboard(config)
config["theme"] # "light" — caller didn't ask for this
Defensive copying
When in doubt, copy:
def safe_show(config):
config = config.copy() # local snapshot
config["theme"] = "light"
render(config)
In statically-typed languages with explicit ownership (Rust), the compiler forces you to think about this. In dynamic languages it's on you.
Global state
Module-level mutable state is the flag at the top of the "things to avoid" list. It makes code hard to test (every test sees the previous test's mutations), hard to reason about (any function might have changed it), and impossible to parallelise safely.
When you really do need module-level state — config, caches, registries — keep it behind a function so you can swap the backing storage and test in isolation:
_settings = None
def get_settings():
global _settings
if _settings is None:
_settings = load_from_disk()
return _settings
def reset_settings_for_tests():
global _settings
_settings = None
Tip
If a function takes more than four or five parameters, or if it reads/writes more than two pieces of outer state, it's probably doing too much. Split it.