Functions, Closures & the Call Stack
By the end of this lesson you will be able to write functions with every kind of parameter Dart offers, read exactly what happens inside the machine when one function calls another (the call stack), write and trace recursive functions without getting lost, and explain why a closure keeps a variable alive long after the function that created it has finished running.
1. What is a function?
In Dart, a function has a name, a list of parameters it accepts, a return type (what kind of value it hands back), and a body (the code that runs). You define it once and call it (also called invoking it) as many times as you like.
The keyword void as a return type means "this function does not hand back a usable value" — it does something (prints, changes a list, saves a file) but there's no result to capture.
The same thing as runnable Dart — square, the arrow and brace forms of the cube function (proved equal), and a void function (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: a call costs the body's work plus a constant push/pop of one frame: 107 calls to square ≈ 107 steps, about 0.1 s.
Dart also lets you write a short function as a single expression using arrow syntax =>. int cube(int n) => n * n * n; is exactly the same function as writing int cube(int n) { return n * n * n; } — the arrow is pure sugar: whatever comes after => is automatically returned, and there is no { } block or explicit return keyword.
void square(int n) => n * n; and then trying to use the "result". Because the return type is void, Dart throws away whatever the arrow expression computes — there is nothing to capture. If you want a usable value, the return type must match what you're returning (here, int).void = "no return value to use". => is shorthand for a single-expression return.2. Parameters: positional, optional, named
tip: 20, contactless: true — like named parameters, which can be listed in any order because each one says its own name).Dart has three parameter styles, and you can mix them:
- Positional —
int add(int a, int b). Order matters; both are required unless marked optional. - Optional positional — wrapped in
[ ]:String greetOpt(String name, [int times = 1]). You may omittimes; then it takes its default value1. - Named — wrapped in
{ }:String describePerson({required String name, int age = 0}). You call it asdescribePerson(name: 'Asha', age: 30)— order of named arguments doesn't matter.requiredmeans the caller must supply it even though it's named; withoutrequired, a named parameter should usually get a default value or be nullable.
The same thing as runnable Dart — all three parameter styles, argument order for named parameters, and the two compile errors (as comments) (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: a call passes at most a handful of arguments, so parameters never matter for speed; what matters is whether a parameter is a number (copied) or a big object (only its reference is copied).
Now the part that trips almost every beginner up: how arguments are passed. Dart is strictly pass-by-value — but the "value" of an object like a List is a reference (a pointer to where the object lives on the heap), not the object's contents. So:
- Reassigning a parameter inside the function (
x = 100;) only changes the function's own local copy of that reference/value — the caller's variable is untouched. - Calling a mutating method on a parameter that refers to an object (like
list.add(99)) changes the one shared object that both the caller's variable and the parameter point at — so the caller does see that change.
The same thing as runnable Dart — reassigning versus mutating a parameter, replacing a whole list inside a function, and proof that the parameter is the same object (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: passing a list of 106 items copies ONE reference (O(1)), not 106 items — the callee sees and can change the same list.
int value on the native VM is a bit pattern copied directly into the parameter's stack slot. A List variable's stack slot instead holds a pointer (an address) into the heap. Copying the pointer costs a few bytes and is fast, no matter how huge the list is — that's why Dart (like almost every modern language) passes objects "by reference value" instead of duplicating the whole object on every call.3. The call stack, animated
Every running Dart program keeps a call stack. main() gets the first frame. Every function call pushes a new frame on top; every return pops the top frame off and hands its value back to whoever called it.
The same thing as runnable Dart — the same main → a → b shape as the animation (named csA/csB here), printing when each frame is pushed and popped (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: the stack holds one frame per active call: a chain of d nested calls needs O(d) memory, and the limit is roughly 104 frames — see the recursion section.
4. Recursion: base case, factorial, fibonacci, overflow
A recursive function is simply a function that calls itself, with two required parts:
- Base case — the condition that stops the recursion (the smallest doll). Without one, the function calls itself forever.
- Recursive case — the function calls itself with a "smaller" version of the problem, moving toward the base case.
factorial(n) = n × (n-1) × (n-2) × … × 1, and factorial(0) = 1 by definition:
The same thing as runnable Dart — factorial for n = 0…8 (the custom-input range), the definition n! = n·(n−1)! checked, and a depth-indented trace of factorial(3) showing the push phase then the unwind (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: n ≤ 20: 20! = 2432902008176640000 fits in a 64-bit int and 21! does not (silent wrap-around); depth n ≤ 5·103 is safe, so overflow of the VALUE (n = 21) strikes long before overflow of the STACK.
Fibonacci is a classic recursion example — but naive recursion recomputes the same smaller values over and over:
The same thing as runnable Dart — call counters for naive and memoized Fibonacci: 15 calls for fib(5) as in the tree, then the growth for n = 10, 20, 30, and the closed form calls(n) = 2·fib(n+1) − 1 (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: naive fib(n): n = 30 → 2692537 calls (fine), n = 40 → 3.3·108 calls (about a second), n = 50 → 4·1010 (too slow); memoized: 2n − 1 calls for any n ≤ 92 (the largest Fibonacci number that fits in 64 bits).
fib(n) result the first time it's computed, and every later call for the same n becomes an instant lookup instead of a re-computation. Naive fib(n) does roughly O(2ⁿ) work; memoized fib(n) does O(n). We'll build a general-purpose memoize() helper in the interview bank below, and go much deeper on this trade-off when we cover dynamic programming.Try your own input (whole numbers 0–8, so the animation stays readable — 0 is a valid input too, since factorial(0) = 1 by definition):
What if a recursive function never reaches its base case?
The same thing as runnable Dart — a recursion with no base case caught as StackOverflowError, the depth it reached (thousands, not millions), and a correct recursion at depth 5000 (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: the stack overflows at roughly 104 frames for a tiny function (it varies with frame size and JIT state), so recursion depth must stay ≤ 5·103; for depth 105 or more use a loop or an explicit stack.
StackOverflowError. Unlike some functional languages, Dart does not perform tail-call optimization (TCO) — even if you rewrite a recursive function so the recursive call is the very last thing it does (a "tail call", like accumulating into a parameter instead of after the call returns), Dart still pushes a brand-new frame for every call. A tail-recursive rewrite does not save you from a stack overflow on deep enough input; only genuinely converting to an explicit loop (or an explicit stack, shown in the interview bank) does.The same thing as runnable Dart — the "tail-recursive" sum that still overflows at depth 107, the loop that does not, and the closed form n(n+1)/2 (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: n = 107 loop passes ≈ 0.1 s; the same n as recursion needs 107 frames — roughly 1000× past the limit — so a loop is the only option.
5. First-class functions
A few concrete forms. An anonymous function (also called a lambda) is a function with no name, written right where it is needed, like (n) => n * 2:
The same thing as runnable Dart — a function stored in a variable, a lambda passed to map, the tear-off forEach(print), applyTwice and the typedef (the // => lines are the exact output, checked by verify/d07.dart):
list.forEach(print) is a tear-off: instead of writing a wrapper lambda that just forwards its argument to print, you hand Dart a direct reference to the print function itself. It behaves identically but is shorter and avoids an unnecessary extra function object.
A typedef like typedef IntTransformer = int Function(int); doesn't change behavior at all — it just gives the type "a function that takes an int and returns an int" a short, readable name, useful in signatures like applyTwice above.
6. Closures
The same thing as runnable Dart — makeCounter, two independent counters, and a closure that captures a variable (not its value) (the // => lines are the exact output, checked by verify/d07.dart):
When makeCounter() returns, its own stack frame is popped — normally a local variable would simply vanish along with its frame. But the little anonymous function still needs count to work. So Dart keeps count in a small object on the heap (a closure context) that the returned function holds a permanent reference to. (To keep the picture simple the animation shows count as an ordinary local first and then "moves" it; in practice the VM decides at compile time that count is captured and puts it in the context from the start. The exact placement is an implementation detail; the visible behaviour is the same.) This is exactly why the counter keeps working correctly long after makeCounter itself has returned.
Because every call to makeCounter() creates a brand-new heap context, two counters never interfere with each other — each one is capturing its own count.
A subtlety that trips people up coming from older languages: what does a closure capture inside a loop?
The same thing as runnable Dart — a loop variable declared in the header (fresh per iteration) next to one declared outside the loop (shared by all closures) (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: creating k closures costs k small heap objects; k = 106 closures ≈ 106 allocations (fast), and each keeps its captured variables alive until the closure itself is unreachable.
for loops in pre-2015 JavaScript, for example), all closures created inside one loop would share a single loop-counter box, so calling them afterward would print the loop's final value three times (e.g. 3, 3, 3). Dart's for loop gives every iteration a fresh, independent copy of the loop variable when it is declared in the loop header (for (var i = ...)), so each closure safely captures its own value. This is verified directly in verify/d07.dart — it is a real, checked fact about Dart 3.x, not folklore.for loop variables are each captured separately.7. Higher-order functions preview
Dart's collections come with several ready-made higher-order functions. This is only a preview — the full treatment (including exactly how "laziness" affects performance and infinite sequences) is in D08 · Collections Internals.
The same thing as runnable Dart — the animated pipeline, plus counters that prove map is lazy and any stops early (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: a pipeline over n items is O(n) — n = 106 ≈ 107 steps; chaining where → map → fold makes one lazy pass, not three, as long as you only call toList() at the end.
where and map return lazy Iterables: the filtering/transforming logic is only described, not run, until something actually asks for the values — such as calling .toList(), looping with for..in, or calling .fold()/.reduce(). any also short-circuits: it stops at the very first element that matches, without checking the rest. every stops at the first element that fails the test.map transforms, where filters, fold/reduce combine everything into one value, any/every test a condition across the whole collection. All of these accept a function as an argument — that's what makes them "higher-order".8. Lexical scope & shadowing
The same thing as runnable Dart — shadowing, lexical scope, and a closure that reads a variable changed AFTER the closure was created (the // => lines are the exact output, checked by verify/d07.dart):
Input size → what's feasible: name lookup is resolved by the compiler from where the code is written, so scope has zero runtime cost at any input size.
Shadowing happens when an inner scope declares a variable with the same name as one in an outer scope — inside that inner scope, every use of the name refers to the new, closer variable; the outer one is temporarily hidden (never modified). Lexical scoping (no shadowing involved) is what lets inner() read z from its enclosing function outerScopeDemo and outerX from the top level, purely because of where inner is written — not because of who calls it.
x is this?".Quiz
Interview questions
Cheat sheet
| Concept | Syntax / rule |
|---|---|
| Define a function | ReturnType name(params) { ...; return value; } |
| Arrow function | ReturnType name(params) => expr; (no braces, no return) |
| No usable return value | void as the return type |
| Positional parameter | f(int a, int b) — required, order matters |
| Optional positional | f(int a, [int b = 0]) — [ ], default value |
| Named parameter | f({required int a, int b = 0}) — { }, call as f(a: 1, b: 2) |
| Pass-by-value semantics | Reassigning a parameter never affects the caller; mutating a passed object (e.g. List.add) is visible to the caller |
| Call stack | Each call pushes a frame (params + locals + return address); each return pops it — last called, first returned (LIFO) |
| Recursion | Needs a reachable base case; Dart has NO tail-call optimization; unbounded recursion → StackOverflowError |
| First-class functions | Store in a variable, pass as an argument, return from a function; tear-off = pass an existing function by reference (list.forEach(print)) |
| Function type / typedef | int Function(int) is a type; typedef Name = int Function(int); names it |
| Closure | A function + the captured variables it needs, kept alive on the heap after the enclosing function returns; each call to the outer function makes an independent capture |
| Loop-variable capture | Dart's for (var i = ...) gives each iteration its own fresh i — closures inside the loop don't share one box |
| Higher-order functions | map transform, where filter, fold/reduce combine, any/every test — where/map are lazy until iterated |
| Scope & shadowing | A name resolves to the nearest enclosing declaration written in the source (lexical scope); an inner declaration with the same name shadows, never modifies, the outer one |