Environments and Scope
Back one last time to the weekend R study group: the five friends Mara, Dev, Ada, Theo and Iris, with their quiz scores 58, 91, 73, 49 and 84. All course long you have written functions like grade(score, cutoff = 60) that quietly "find" the names they need. This final lesson answers the question you have been leaning on the whole time: when a function uses a name, where exactly does R look for it, and in what order?
The answer is a single, dependable rule. R searches the function's own little workspace first, and only if the name is not there does it look outward to your global workspace. Get this rule and you will understand why functions can read your variables but cannot quietly overwrite them, the property that makes them safe to reuse anywhere.
By the end of this lesson you will be able to:
- Say what an environment is, and name the two that matter: your global workspace and a function's own local environment
- Trace how R resolves a name inside a function, local first then outward, and predict the result
- Explain shadowing: when a local and a global name collide, which one wins, and that the global is left untouched
- Predict whether assigning inside a function changes your global variables (it does not), and why that isolation is a feature
Prerequisites: you can [run R and assign with <-](R-Syntax-and-First-Objects.html), build and name a vector, and define and call a function with arguments.
Every function call gets its own workspace
Start with a name for the thing you have been typing into all along. An environment is just a named collection of bindings, each binding tying a name to a value, like x = 10. When you assign at the top level, pass_mark <- 60, that binding lives in the global environment: your workspace, the one ls() lists.
Here is the part that makes functions tick. Every time you call a function, R opens a brand-new local environment just for that call. It holds the function's arguments and any names the body creates, the body runs inside it, and the moment the function returns its value, that local environment is thrown away.
You can watch the local environment appear and disappear. The name note below is created inside the call, used, and then gone; it never reaches your workspace:
The function returned 60, but note does not exist afterward. That is the local environment being discarded: what happens inside a call stays inside the call.
How R resolves a name: local first, then outward
Now the central question. Inside show_mark, the body used pass_mark, a name it never created locally. So how did R find it? By following one rule, every single time.
When R needs the value of a name inside a function, it searches a sequence of environments in order and stops at the very first one that has the name:
\[ E_{\text{local}} \;\rightarrow\; E_{\text{global}} \;\rightarrow\; E_{\text{base}} \]
Here \(E_{\text{local}}\) is the function call's own environment, \(E_{\text{global}}\) is your workspace, and \(E_{\text{base}}\) is where R keeps its built-in functions like mean. R looks in \(E_{\text{local}}\) first; if the name is not there it steps outward to \(E_{\text{global}}\), and so on. This is called lexical scoping: the search always starts where the function was written.
Strip the rule down to two letters and watch it walk. Below, a global x and y live in your workspace, and a function f makes its own local y. Pick a name and see where R finds it: x is only global, so the search steps outward; y exists locally, so the search stops at once.
Where does R find it?
You have a global pass_mark <- 60. A function grade() uses pass_mark in its body but never creates a local one of its own. When you call grade(73), what value does R use for pass_mark?
Make a local name win
This week's quiz was brutal, so for one function you want to lower the pass mark to 50, without disturbing the school-wide pass_mark of 60 that everything else relies on. When a local name and a global of the same name collide, the local one wins and the global is hidden but unchanged: that is shadowing. Create a local pass_mark inside grade_tough by filling in the blank, then check.
Show answer
pass_mark <- 60
grade_tough <- function(score) {
pass_mark <- 50
if (score >= pass_mark) "pass" else "needs help"
}
grade_tough(52)
#> [1] "pass"
pass_mark
#> [1] 60Assigning inside a function leaves your variables alone
That last exercise showed something bigger than a lowered cut-off. Setting pass_mark inside grade_tough did not change the global pass_mark. This is the rule's mirror image: R reads names by walking outward, but the <- assignment always writes into the local environment. Reading reaches out; writing stays in.
Watch a counter that tries, and fails, to climb:
Each call reads the global tally (0), adds one in its own local environment, and returns 1. The global never changes, which is why calling add_one() a hundred times would still leave tally at 0.
<- inside a function writes locally, always. A function can read your workspace but cannot silently rewrite it. That isolation is exactly why you can drop a function into any script and trust it not to clobber your data.Did the global move?
Using that same tally <- 0 and add_one (which runs tally <- tally + 1 inside), you call add_one() three times in a row. Afterward you type tally at the top level. What prints?
<- writes locally every time, so the global tally is read but never changed: it stays 0.The escape hatch: super-assignment
So how would you make a counter that actually persists? R gives you a deliberate way to break the isolation: the super-assignment operator <<-. Where <- writes locally, <<- searches outward for an existing binding of that name and assigns there instead, reaching your global workspace if that is where the name lives.
Now the count climbs across calls, because every call edits the one global tally.
<<- rarely. A function that quietly rewrites your workspace is one of the hardest kinds of bug to track down, because nothing at the call site shows that it happened. The safe habit is the opposite: have the function return a value and reassign it yourself, so the change is visible in your code. Super-assignment has its place (a persistent counter inside a closure, a topic for later), but treat it as a sharp tool, not a default.Update a global the safe way
Here is the habit the warning recommends. Instead of <<-, write bump so it just returns one more than the current attempts, then you reassign it yourself at the top level. Inside the function, read the global attempts and add one. Fill in the blank.
Show answer
attempts <- 0
bump <- function() {
attempts + 1
}
new_count <- bump()
attempts <- new_count
c(new_count = new_count, attempts = attempts)
#> new_count attempts
#> 1 1References
A few authoritative, free places to take this further:
- Advanced R (2e), Environments - what an environment really is, the data structure that scoping is built on.
- Advanced R (2e), Functions: lexical scoping - the precise rules R follows to resolve a name, with the edge cases.
- An Introduction to R: Scope - the official manual on local versus global variables and the
<<-operator. - The R Language Definition: Scope of variables - the canonical specification of environments and how an assignment chooses one.
Lesson 5 complete
You now know the rule that ties this whole course together. A function call gets its own local environment; R resolves a name local-first, then outward to your global workspace (lexical scoping); a local name shadows a global of the same name while leaving that global untouched; and because <- always writes locally, a function can read your variables but never silently overwrite them, the isolation that makes functions safe to reuse. When you truly must reach out, <<- does it on purpose, and you saw why to prefer returning a value instead.
That is the programming core of R: you can write functions, give them flexible arguments, chain them with the pipe, and reason about exactly where their names live. From here the natural next step is getting data into R and reshaping it, where every one of these habits pays off.