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?

Think of a function like a vending machine. You put coins in (the inputs, called parameters), the machine does fixed internal work, and a snack drops out (the output, called the return value). You never need to know the gears inside — you just need to know what to feed it and what comes back. A recipe is the same idea: ingredients in, a dish out, and you can reuse the same recipe as many times as you like without rewriting it.

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.

A common beginner mistake: writing 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).
A function is a reusable, named block of instructions. void = "no return value to use". => is shorthand for a single-expression return.

2. Parameters: positional, optional, named

A food-delivery form has some fields you must fill (address — this is like a required positional parameter), some you can skip because they have a sensible default (spice level, defaults to "medium" — like an optional parameter with a default value), and some that are filled by label, not by their position on the form (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:

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:

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.

"Pass by value" does NOT mean "the caller never sees any change". It means the box (variable) itself can't be swapped out from inside the function. But if that box holds a reference to a mutable object, reaching through the reference and mutating the object is visible everywhere that object is reachable from.
Under the hood: a Dart 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

Picture a stack of trays in a cafeteria. Every time a function is called, a new tray (a stack frame) is placed on top of the pile, holding that call's parameters, local variables, and a note of exactly where to resume once it's done (the return address). When a function finishes, its tray is lifted straight off the top — you can only ever remove the most recently added tray first. This "last in, first out" rule is why it's called a stack.

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.

Frames are pushed in call order and popped in the exact reverse order (LIFO — last in, first out). The function called last is always the one that returns first.

4. Recursion: base case, factorial, fibonacci, overflow

Recursion is like a set of Russian nesting dolls: to open the big doll you first have to open the slightly smaller doll inside it, and so on, until you reach the smallest doll that doesn't open any further (the base case). Once you hit that smallest doll, you start closing them back up in reverse order, one inside the next.

A recursive function is simply a function that calls itself, with two required parts:

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).

That repeated work is exactly the problem memoization solves: cache each 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.

Every pushed frame reserves real memory. If recursion never bottoms out, frames keep piling up until the call stack's fixed-size memory region is exhausted, and Dart throws a 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.

Every recursive function needs a base case that is actually reachable. Recursion depth in Dart is bounded by the call stack size, and Dart never turns recursion into a loop for you.

5. First-class functions

In Dart, a function is a value just like a number or a string — you can put it in a box (a variable), hand it to someone else (pass it as an argument), or get one back as a prize (return it from another function). This is what "functions are first-class" means: they aren't second-class citizens restricted to only being called by name.

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.

Functions can be stored, passed, and returned like any other value. A tear-off reuses an existing function by reference; a typedef names a function type, not a function.

6. Closures

Imagine a function that carries a backpack. When the function is created, the variables it uses from its surroundings are put into the backpack — the real variables, not photographs of their values — and the backpack travels with the function. Even after the surrounding function has finished and its stack frame is gone, the backpack is still there, and if the variable inside changes, the function sees the change. That function-plus-backpack is a closure.

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.

In some older languages (classic C-style 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.
A closure keeps its captured variables alive on the heap after the enclosing function returns. Each invocation of the outer function creates a fresh, independent set of captured variables. Dart's per-iteration for loop variables are each captured separately.

7. Higher-order functions preview

A higher-order function is a function that takes another function as an ingredient (or hands one back), the way a factory line can be reconfigured by swapping in a different tool at one station without rebuilding the whole line.

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

"Lexical" scope means Dart decides which variable a name refers to by looking at where the code is physically written on the page — not by who happens to be calling it at runtime. It's like a nested set of rooms: standing in the innermost room, you can see everything in the rooms that contain it, but nobody outside can see into your room.

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.

Shadowing a variable is legal in Dart but can make code confusing to read — if you find yourself naming a local variable the same as one already visible, consider renaming one of them so a reader (including future you) never has to ask "which x is this?".
Scope is about where a name is visible. Shadowing is a new declaration hiding an outer one by name, inside its own scope only — the outer variable is never touched.

Quiz

Interview questions

Cheat sheet

ConceptSyntax / rule
Define a functionReturnType name(params) { ...; return value; }
Arrow functionReturnType name(params) => expr; (no braces, no return)
No usable return valuevoid as the return type
Positional parameterf(int a, int b) — required, order matters
Optional positionalf(int a, [int b = 0]) — [ ], default value
Named parameterf({required int a, int b = 0}) — { }, call as f(a: 1, b: 2)
Pass-by-value semanticsReassigning a parameter never affects the caller; mutating a passed object (e.g. List.add) is visible to the caller
Call stackEach call pushes a frame (params + locals + return address); each return pops it — last called, first returned (LIFO)
RecursionNeeds a reachable base case; Dart has NO tail-call optimization; unbounded recursion → StackOverflowError
First-class functionsStore in a variable, pass as an argument, return from a function; tear-off = pass an existing function by reference (list.forEach(print))
Function type / typedefint Function(int) is a type; typedef Name = int Function(int); names it
ClosureA 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 captureDart's for (var i = ...) gives each iteration its own fresh i — closures inside the loop don't share one box
Higher-order functionsmap transform, where filter, fold/reduce combine, any/every test — where/map are lazy until iterated
Scope & shadowingA 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