> "The art of programming is the art of organizing complexity." — Edsger W. Dijkstra
In This Chapter
- 8.1 What Is Scope? (Visibility Rules and Why They Matter)
- 8.2 Block Structure and Lexical Scope
- 8.3 Global vs. Local Variables
- 8.4 Variable Shadowing
- 8.5 The Call Stack: What Really Happens When You Call a Procedure
- 8.6 Stack Frame Anatomy
- 8.7 Parameter Passing Revisited: Under the Hood
- 8.8 Designing Good Parameter Lists
- 8.9 Nested Subprograms and Scope Chains
- 8.10 Common Scope Bugs and How to Fix Them
- 8.11 Project Checkpoint: PennyWise Scope Design
- 8.12 Chapter Summary
- Spaced Review
Chapter 8: Scope, Parameters, and the Call Stack: How Programs Really Work
"The art of programming is the art of organizing complexity." — Edsger W. Dijkstra
In Chapter 7, we learned how to write procedures and functions — how to carve a program into named, reusable pieces. We called them, passed arguments, got results back. Everything worked. But did we truly understand why it worked? What actually happens inside the machine when one procedure calls another? Where do local variables live? Why can't a procedure see a variable declared inside a different procedure? And what, exactly, is the difference between passing a parameter by value versus by reference, at the level of memory?
This chapter pulls back the curtain. We are going to explore the machinery that makes subprograms possible: scope rules, which govern what names are visible where; the call stack, which is the runtime data structure that gives each subprogram its own private workspace; and parameter passing mechanisms, understood not just as syntax but as operations on memory. By the end, you will be able to trace through a program's execution the way a debugger does — frame by frame, variable by variable — and you will design parameter lists with the confidence that comes from knowing exactly what the compiler does with each one.
This is one of those chapters where Pascal's pedagogical design really shines (Theme 1: Pascal teaches programming right). Pascal's strict, explicit scope rules and its clear distinction between value, var, and const parameters are not limitations — they are a teaching machine. Languages like C, Python, and JavaScript each handle scope and parameter passing differently, and students who first learn in those languages often carry subtle misconceptions for years. Pascal makes the invisible visible. Once you understand scope and the call stack in Pascal, every other language's approach becomes a variation on a theme you already know (Theme 2: the discipline transfers).
We have a lot of ground to cover, so let us start with the most fundamental question.
8.1 What Is Scope? (Visibility Rules and Why They Matter)
Every variable, constant, type, procedure, and function in a Pascal program has a scope — the region of the source code where that name is visible and can be used. Outside its scope, the name simply does not exist as far as the compiler is concerned. You cannot use it, reference it, or even accidentally modify it. The compiler will reject the code with an error.
Consider a simple analogy. You are in a building with multiple offices. Each office has a whiteboard with notes written on it. When you are inside Office A, you can read Office A's whiteboard — but not Office B's. The whiteboards are local to each office. However, there is also a whiteboard in the main hallway that everyone can see from any office. That hallway whiteboard is global.
Scope rules answer three critical questions:
- Visibility: Can I use this name here? If a variable is declared inside procedure
P, can I use it inside procedureQ? Can I use it in the main program? - Lifetime: When does this variable come into existence, and when does it disappear? A variable that exists only while a procedure is running has a very different character from one that exists for the entire program.
- Uniqueness: If two different places define a variable with the same name, which one do I get? If procedure
Phas a variable calledXand the main program also has a variable calledX, whichXam I referring to when I writeX := 5insideP?
Why do scope rules matter so much? Consider a world without them. Every variable in a program would be visible everywhere, and any piece of code could read or modify any variable. In a 50-line program, you might keep track of that. In a 500-line program, you would start losing track. In a 50,000-line program — which is small by industry standards — it would be absolute chaos. Any procedure could interfere with any other procedure's data. Renaming a variable would require checking the entire program. Adding a new procedure with a local variable named Count could break a distant procedure that also uses Count. The program would be a house of cards.
Scope rules are the walls and doors that keep a program's data organized, protected, and comprehensible. They allow you to think about one subprogram at a time, confident that it cannot accidentally interfere with — or be interfered with by — code you are not looking at.
Pascal uses lexical scope (also called static scope), which means a variable's scope is determined entirely by where it appears in the source text — not by the order in which procedures happen to be called at runtime. This is a deliberate design decision by Niklaus Wirth, and it makes programs dramatically easier to reason about. You can determine scope just by reading the code — you never have to run the program to figure out which variable a name refers to.
The alternative, dynamic scope, determines visibility based on the calling sequence at runtime. A few older languages (early Lisps, some shell languages) used dynamic scope, but it has largely been abandoned because it makes programs extremely difficult to understand. When you hear the term "spaghetti code," dynamic scope is part of what gave rise to that metaphor.
8.2 Block Structure and Lexical Scope
Pascal is a block-structured language. A block is a region of code that begins with declarations (constants, types, variables, procedures, functions) and ends with a compound statement (begin ... end). The main program is a block. Every procedure and function is also a block. And in Pascal, blocks can be nested — you can declare a procedure inside another procedure.
Here is the fundamental scope rule:
A name declared in a block is visible throughout that block and in any blocks nested inside it, unless a nested block declares the same name (shadowing).
This rule is simple but has far-reaching consequences. Let us see it in action with a concrete example:
program ScopeDemo;
var
X: Integer; { Scope: entire program }
procedure Outer;
var
Y: Integer; { Scope: Outer and anything nested inside Outer }
procedure Inner;
var
Z: Integer; { Scope: Inner only }
begin
{ Can see: X (global), Y (from Outer), Z (own local) }
X := 1;
Y := 2;
Z := 3;
end;
begin
{ Can see: X (global), Y (own local) }
{ CANNOT see: Z (it belongs to Inner) }
Inner;
end;
begin
{ Can see: X (own global) }
{ CANNOT see: Y or Z }
Outer;
end.
We can visualize the scope levels as nested boxes:
+--------------------------------------------------+
| Program ScopeDemo |
| var X: Integer |
| |
| +--------------------------------------------+ |
| | Procedure Outer | |
| | var Y: Integer | |
| | | |
| | +--------------------------------------+ | |
| | | Procedure Inner | | |
| | | var Z: Integer | | |
| | | Can see: X, Y, Z | | |
| | +--------------------------------------+ | |
| | | |
| | Can see: X, Y | |
| +--------------------------------------------+ |
| |
| Can see: X |
+--------------------------------------------------+
Each nested level can see everything declared at its own level and all enclosing levels. It cannot see anything declared in a deeper (inner) block — that would violate the encapsulation that makes blocks useful.
Scope levels in Pascal are numbered from the outside in:
| Level | Block | Sees |
|---|---|---|
| 0 | Program (global) | Own declarations |
| 1 | Outer procedure | Own + Level 0 |
| 2 | Inner procedure | Own + Level 1 + Level 0 |
The compiler resolves every name reference by searching from the current level outward. If it finds the name at level 2, it uses that. If not, it checks level 1, then level 0. If it never finds the name, you get a compilation error. This inside-out search is what makes lexical scope work — and it is deterministic. The compiler can always tell you exactly which variable a name refers to, because the answer depends only on the textual structure of the source code.
A note on sibling scopes: If two procedures are declared at the same level (both inside the main program, for instance), neither can see the other's local variables. They are siblings, not parent-and-child. For example:
program SiblingScope;
procedure Alpha;
var A: Integer;
begin
{ Can see A and any globals }
{ CANNOT see B from Beta }
end;
procedure Beta;
var B: Integer;
begin
{ Can see B and any globals }
{ CANNOT see A from Alpha }
end;
begin
Alpha;
Beta;
end.
Alpha and Beta each have their own private workspace. They cannot peek into each other's local variables. If they need to share data, they must do so through parameters or through a global variable — and we will argue strongly that parameters are the better choice.
8.3 Global vs. Local Variables
A global variable is declared at scope level 0, in the main program's var section. It is visible throughout the entire program, including inside every procedure and function. It exists from the moment the program starts until the moment the program ends.
A local variable is declared inside a procedure or function. It exists only while that subprogram is executing, and it is invisible outside it. When the subprogram returns, the local variable ceases to exist — its memory is reclaimed and may be reused for something else.
Here is a rule that will serve you well throughout your entire programming career, regardless of language:
Prefer local variables. Use global variables only when you have a specific, justified reason.
Why? Global variables create invisible connections between distant parts of your program. When procedure A modifies a global variable and procedure B reads it, there is no indication in either procedure's signature that they are communicating. This is called coupling through shared state, and it is one of the most common sources of bugs in real-world software.
Consider this example — a small program that calculates a price after tax and discount:
program GlobalDanger;
var
Total: Real;
procedure AddTax;
begin
Total := Total * 1.08; { Modifies global directly }
end;
procedure ApplyDiscount;
begin
Total := Total * 0.90; { Also modifies global directly }
end;
begin
Total := 100.00;
AddTax; { Total is now 108.00 }
ApplyDiscount; { Total is now 97.20 }
WriteLn('Final: ', Total:0:2);
end.
This works. It produces the correct output: 97.20. So what is the problem?
The problem is fragility. Consider these scenarios:
-
Reordering calls: If someone swaps the order to
ApplyDiscountfirst, thenAddTax, the result changes (from 97.20 to 97.20 in this case — but with different rates, the order matters). There is nothing in the code that documents or enforces the required order. -
Adding a third procedure: If a new procedure
ApplyShippingis added and it also reads/writesTotal, you must carefully reason about all possible orderings. The number of potential interactions grows with each procedure that touches the global. -
Processing multiple items: If you need to calculate totals for two different items simultaneously, you cannot — there is only one
Total. You would need to add a second global variable, and then make sure every procedure knows which one to use. -
Testing in isolation: You cannot test
AddTaxby itself without first setting up the globalTotal. And afterAddTaxruns, the global is modified, so testingApplyDiscountrequires either re-initializing the global or knowing whatAddTaxleft behind.
The disciplined version uses parameters and function return values:
program LocalSafety;
function AddTax(Amount: Real): Real;
begin
AddTax := Amount * 1.08;
end;
function ApplyDiscount(Amount: Real): Real;
begin
ApplyDiscount := Amount * 0.90;
end;
var
Total: Real;
begin
Total := 100.00;
Total := ApplyDiscount(AddTax(Total));
WriteLn('Final: ', Total:0:2);
end.
Now each function is self-contained. It takes input through its parameter, produces output through its return value, and touches nothing else. You can read each function in isolation and understand exactly what it does. You can reorder them, compose them (as we did with ApplyDiscount(AddTax(Total))), process multiple items simultaneously, and test each function independently. This is the essence of Theme 5: Algorithms + Data Structures = Programs — and good programs keep their data flows explicit.
When are global variables acceptable? There are legitimate uses:
- Program-wide constants (declared with
const) are always fine. They cannot be modified, so they create no coupling. - Core data structures in small programs — like an array of records that multiple procedures need to access — may be global, especially before you learn units and objects. But document why, and minimize the number of procedures that modify (as opposed to read) the global.
- Configuration values that are set once at startup and never changed can reasonably be global.
The guideline is this: if a value is used by only one or two subprograms, pass it as a parameter. If it is truly needed by many subprograms and represents a single piece of program-wide state, a global may be appropriate — but make the decision consciously, not by default.
8.4 Variable Shadowing
Shadowing occurs when an inner scope declares a variable with the same name as a variable in an outer scope. The inner declaration "hides" or "shadows" the outer one within that inner scope. The outer variable still exists — it is not destroyed or modified — but it becomes invisible, unreachable by its name.
program ShadowExample;
var
X: Integer;
procedure Demo;
var
X: Integer; { This X shadows the global X }
begin
X := 42;
WriteLn('Inside Demo: X = ', X); { Prints 42 }
end;
begin
X := 10;
WriteLn('Before Demo: X = ', X); { Prints 10 }
Demo;
WriteLn('After Demo: X = ', X); { Still prints 10! }
end.
Output:
Before Demo: X = 10
Inside Demo: X = 42
After Demo: X = 10
Let us trace what happens step by step:
- The main program sets the global
Xto 10 and prints it. Demois called. A new stack frame is created forDemo, and it includes space forDemo's localX.- Inside
Demo, the nameXrefers toDemo's localX— the globalXis shadowed.Demosets its localXto 42 and prints it. Demoreturns. Its stack frame (including its localX) is destroyed.- Back in
main, the nameXrefers to the globalX, which has been 10 the entire time. No one modified it.
Is shadowing intentional or accidental? It can be either, and that is precisely the danger. Sometimes a programmer deliberately reuses a common name like I or Count or Temp in a local scope, knowing the global version is irrelevant inside that subprogram. The shadowing is intentional, and it is harmless — even beneficial, because the local variable prevents accidental modification of the global.
Other times, the programmer does not realize there is a global variable with the same name. They declare a local variable, modify it, and are puzzled when the global remains unchanged. Or worse, they intend to modify the global but accidentally shadow it, and the program silently produces wrong results.
Here is a particularly treacherous scenario:
program TreacherousShadow;
var
I: Integer;
procedure PrintNumbers;
var
I: Integer; { Shadows the loop counter! }
begin
for I := 1 to 5 do
WriteLn(I);
end;
begin
for I := 1 to 3 do
begin
WriteLn('Outer iteration: ', I);
PrintNumbers;
{ I is still fine here — PrintNumbers has its own I }
end;
end.
In this case the shadowing is actually saving us. PrintNumbers has its own I, so the outer loop's I is not corrupted by the inner for loop. The outer loop runs exactly 3 times, as expected. But what if PrintNumbers did not declare its own I? Then the for loop inside it would use the global I, modifying it as a side effect. After PrintNumbers returned, the outer loop's I would have an unexpected value, and the outer loop would behave erratically — possibly skipping iterations, running forever, or terminating early. This is exactly the kind of bug that global variables invite, and exactly the kind of bug that local variables prevent.
Best practices for avoiding shadowing problems:
- Give your variables descriptive, distinct names. If you have a global
Count, do not also have a localCountunless you are certain you never need the global version inside that scope. UseItemCount,StudentCount,LoopCount, etc. - Always declare loop variables locally. Every procedure that uses a
forloop should declare its own loop variable. Never rely on a global loop counter. - Enable compiler warnings. In Free Pascal, the
-vhflag (hint messages) and-vnflag (notes) will warn you about shadowed variables and other suspicious constructs. Use them. - When in doubt, use parameters. If you are tempted to access a global variable inside a procedure, consider passing it as a parameter instead. The parameter name in the procedure can be whatever you want — there is no shadowing risk.
8.5 The Call Stack: What Really Happens When You Call a Procedure
We have been talking about scope at the source-code level — what the compiler sees, how it resolves names, what is visible where. Now let us shift to what happens at runtime, when the program is actually executing. This is where the call stack enters the picture.
The call stack (often just called "the stack") is a region of memory that the program uses to manage subprogram calls. It operates on a Last-In, First-Out (LIFO) principle: the most recently called subprogram is on top, and it must finish before control returns to the caller beneath it. Think of a stack of dinner plates: you always add a new plate on top, and you always remove the top plate first.
Let us trace through a concrete example:
program StackTrace;
procedure C;
begin
WriteLn('In C');
{ Stack: [main] [A] [B] [C] <-- C is on top }
end;
procedure B;
begin
WriteLn('In B - before C');
C;
WriteLn('In B - after C');
end;
procedure A;
begin
WriteLn('In A - before B');
B;
WriteLn('In A - after B');
end;
begin
WriteLn('In main - before A');
A;
WriteLn('In main - after A');
end.
Here is the call stack at each stage, shown as a series of snapshots. The stack grows upward; the bottom is always main:
Step 1: Program starts Step 2: main calls A Step 3: A calls B
+--------+ +--------+ +--------+
| | | A | <-- current | B | <-- current
+--------+ +--------+ +--------+
| main | <-- current | main | | A |
+--------+ +--------+ +--------+
| main |
+--------+
Step 4: B calls C Step 5: C returns Step 6: B returns
+--------+ +--------+ +--------+
| C | <-- current | B | <-- current | A | <-- current
+--------+ +--------+ +--------+
| B | | A | | main |
+--------+ +--------+ +--------+
| A | | main |
+--------+ +--------+
| main |
+--------+
Step 7: A returns
+--------+
| main | <-- current
+--------+
Notice the LIFO pattern: C was the last one pushed onto the stack, so it is the first one popped off. This is not a metaphor — the program's memory literally works this way. The operating system gives your program a region of memory for the stack, and a CPU register called the stack pointer tracks where the top of the stack currently is. When a subprogram is called, the stack pointer moves to allocate space; when it returns, the stack pointer moves back.
The output of this program is:
In main - before A
In A - before B
In B - before C
In C
In B - after C
In A - after B
In main - after A
Read the output carefully. Each subprogram picks up exactly where it left off after the inner call returns. B prints "before C," then calls C, and when C finishes, B resumes with "after C." This is possible because the call stack preserves each subprogram's execution context — its local variables, its parameters, and the address of the instruction to return to.
Why LIFO? The stack discipline works because subprogram calls are inherently nested: if A calls B and B calls C, then C must finish before B can continue, and B must finish before A can continue. This nesting naturally forms a LIFO order. The stack is the simplest and most efficient data structure for managing this pattern.
What about recursion? The stack handles recursion beautifully. When a function calls itself, a new frame is pushed for each recursive call. Each frame has its own copy of the function's local variables and parameters. We will explore recursion in depth in a later chapter, but the call stack is the mechanism that makes it possible.
8.6 Stack Frame Anatomy
Each time a subprogram is called, a stack frame (also called an activation record) is created and pushed onto the call stack. A stack frame contains everything the subprogram needs to execute. When the subprogram returns, the frame is popped off and the memory is reclaimed.
Here is the anatomy of a stack frame:
+------------------------------------------+
| Stack Frame for Procedure P |
+------------------------------------------+
| Return address |
| (where to resume in the caller) |
+------------------------------------------+
| Parameters |
| (copies for value params, |
| addresses for var params) |
+------------------------------------------+
| Local variables |
| (space for all vars declared in P) |
+------------------------------------------+
| Saved registers |
| (CPU state to restore on return) |
+------------------------------------------+
| Static link (or display pointer) |
| (for accessing enclosing scope — this |
| is how Pascal handles nested procs) |
+------------------------------------------+
Let us make this concrete. Consider the following simple program:
program FrameDemo;
function Add(A, B: Integer): Integer;
var
Sum: Integer;
begin
Sum := A + B;
Add := Sum;
end;
begin
WriteLn(Add(3, 7));
end.
When Add(3, 7) is called, here is what the stack frame looks like:
+------------------------------------------+
| Stack Frame for Add |
+------------------------------------------+
| Return address: (back to main's WriteLn) |
+------------------------------------------+
| Parameter A: 3 (value copy) |
| Parameter B: 7 (value copy) |
+------------------------------------------+
| Local variable Sum: ? (uninitialized) |
+------------------------------------------+
| Function result space: ? |
+------------------------------------------+
After Sum := A + B executes:
+------------------------------------------+
| Stack Frame for Add |
+------------------------------------------+
| Return address: (back to main's WriteLn) |
+------------------------------------------+
| Parameter A: 3 |
| Parameter B: 7 |
+------------------------------------------+
| Local variable Sum: 10 |
+------------------------------------------+
| Function result: 10 |
+------------------------------------------+
When Add returns, this entire frame is popped off the stack. The memory is reclaimed. Sum, A, and B no longer exist. The return value (10) has been passed back to the caller (typically through a CPU register or a designated location), and execution resumes at the WriteLn in main.
Let us look at a more complex example with multiple frames:
program MultiFrame;
function Square(N: Integer): Integer;
begin
Square := N * N;
end;
function SumOfSquares(X, Y: Integer): Integer;
var
SX, SY: Integer;
begin
SX := Square(X);
SY := Square(Y);
SumOfSquares := SX + SY;
end;
begin
WriteLn(SumOfSquares(3, 4));
end.
When Square(X) is called from inside SumOfSquares, the stack looks like this:
+------------------------------------------+
| Frame: Square |
| Return address: back to SumOfSquares |
| Parameter N: 3 (copy of X) |
+------------------------------------------+
| Frame: SumOfSquares |
| Return address: back to main |
| Parameter X: 3 |
| Parameter Y: 4 |
| Local SX: ? (not yet assigned) |
| Local SY: ? (not yet assigned) |
+------------------------------------------+
| Frame: main |
+------------------------------------------+
After Square returns with 9, SumOfSquares resumes and calls Square(Y):
+------------------------------------------+
| Frame: Square |
| Return address: back to SumOfSquares |
| Parameter N: 4 (copy of Y) |
+------------------------------------------+
| Frame: SumOfSquares |
| Return address: back to main |
| Parameter X: 3 |
| Parameter Y: 4 |
| Local SX: 9 |
| Local SY: ? (not yet assigned) |
+------------------------------------------+
| Frame: main |
+------------------------------------------+
Notice that the second call to Square creates a completely new frame with N = 4. The fact that Square was previously called with N = 3 is irrelevant — that frame was destroyed. This is why local variables do not retain values between calls.
Why understanding stack frames matters practically:
-
Local variables are lost when a subprogram returns — because their stack frame is destroyed. If you need a value after a subprogram returns, you must explicitly pass it out through a
varparameter or a function return value. -
Each recursive call gets its own copy of local variables — because each call creates a new stack frame. This is what makes recursion work correctly without one call's variables interfering with another's.
-
A stack overflow occurs when you recurse too deeply — because the stack has a finite size (typically a few megabytes), and each call adds a frame. Infinite recursion (or very deep recursion) will exhaust the stack.
-
Uninitialized local variables contain garbage — because the stack memory may contain leftover data from a previous frame that has been popped. Pascal does not zero out stack memory when a new frame is allocated. Always initialize your local variables before using them.
-
Functions with large local arrays consume significant stack space — declaring
var BigArray: array[1..1000000] of Integerinside a procedure allocates four megabytes on the stack per call. For very large data structures, global or heap allocation is necessary.
8.7 Parameter Passing Revisited: Under the Hood
In Chapter 7, we learned the syntax for value parameters, var parameters, and const parameters. Now we can understand the mechanism — what actually happens in memory when each type of parameter is used. This understanding will make you a better programmer in any language, because every language has some form of parameter passing, and the choices are always variations on the same underlying ideas.
Value Parameters (Pass by Value)
When you pass a value parameter, the compiler generates code that copies the argument's value into the stack frame. The parameter inside the subprogram is a completely independent copy — modifying it has no effect on the original.
procedure Double(X: Integer);
begin
X := X * 2;
WriteLn('Inside: ', X);
end;
var
N: Integer;
begin
N := 5;
Double(N);
WriteLn('Outside: ', N);
end.
What happens in memory, step by step:
Before call: During call: After call:
+------------------+
| Frame: Double |
| X: 5 -> 10 | (frame destroyed)
+------------------+
+------------------+ +------------------+ +------------------+
| Frame: main | | Frame: main | | Frame: main |
| N: 5 | | N: 5 (unchanged) | | N: 5 (unchanged) |
+------------------+ +------------------+ +------------------+
The call Double(N) copies the value 5 from N into a new location in Double's stack frame, labeled X. Inside Double, X is multiplied by 2, changing the copy to 10. But N in main was never touched. When Double returns, the frame is destroyed, and the copy is gone. Output:
Inside: 10
Outside: 5
The cost of copying: For simple types — Integer (4 bytes), Real (8 bytes), Char (1 byte), Boolean (1 byte) — copying is extremely cheap. The compiler generates a single instruction. For larger types like strings, arrays, and records, copying can be expensive — a 1000-element integer array would require copying 4000 bytes onto the stack. This is one reason const parameters exist.
What can you pass as a value parameter? Anything that produces a value of the right type: a variable, a constant, a literal, or an expression. For example, Double(N), Double(5), Double(N + 3), and Double(Square(N)) are all valid because value parameters only need a value to copy.
Var Parameters (Pass by Reference)
When you pass a var parameter, the compiler does not copy the value. Instead, it places the memory address of the original variable into the stack frame. Every read or write to the parameter inside the subprogram follows that address to reach the original variable directly.
procedure Double(var X: Integer);
begin
X := X * 2;
WriteLn('Inside: ', X);
end;
var
N: Integer;
begin
N := 5;
Double(N);
WriteLn('Outside: ', N);
end.
Memory view:
Before call: During call: After call:
+------------------+
| Frame: Double |
| X: @addr(N) ----+--+ (frame destroyed)
+------------------+ |
+------------------+ +------------------+ | +------------------+
| Frame: main | | Frame: main | | | Frame: main |
| N: 5 | | N: 10 <---------+--+ | N: 10 |
+------------------+ +------------------+ +------------------+
The @addr(N) notation means "the address of N in memory." When Double executes X := X * 2, it does not have its own copy of X. Instead, the compiler generates code that says "go to the address stored in X's slot in the frame, read the value there (5), multiply by 2, and write the result (10) back to that address." The result is that N in main is directly modified.
Output:
Inside: 10
Outside: 10
This is why var parameters require a variable as the argument. You cannot pass a literal like 5 to a var parameter because literals do not have addresses — they are not stored in a modifiable memory location. Similarly, you cannot pass an expression like N + 3 because the result of an expression is a temporary value, not a variable with a persistent address. The compiler enforces this at compile time.
When to use var parameters: Use var when the purpose of the subprogram is to modify the argument. Classic examples include:
- ReadLn(X) — the built-in ReadLn uses var parameters because its purpose is to fill the variable with input.
- Swap(A, B) — a swap procedure must modify both arguments.
- Any procedure that computes a result and stores it in a caller-provided variable.
Const Parameters (Pass by Reference, Read-Only)
A const parameter tells the compiler: "This subprogram needs to read this value but promises not to modify it." Under the hood, for large data types (arrays, strings, records), the compiler typically passes the address (like var) but prevents any assignment to the parameter — the generated code does not include any write instructions to the parameter. For small types, the compiler may choose to copy instead — the optimization is up to the compiler, and the programmer does not need to know or care.
procedure PrintDouble(const X: Integer);
begin
WriteLn('Double is: ', X * 2);
{ X := 10; } { This would cause a COMPILE ERROR }
end;
The attempted assignment X := 10 is caught at compile time. The error message will say something like "Can't assign values to const variable." This is the compiler acting as your safety net — you declared your intent (read-only), and the compiler holds you to it.
When to use const: Whenever a subprogram needs to read a parameter but not modify it, and especially for large data types where copying would be wasteful. Const parameters are a declaration of intent that also enables optimization. Get in the habit of using const for all read-only parameters — it makes your code safer and often faster.
Summary Table
| Mechanism | What goes on stack | Can modify original? | Can pass literals/expressions? | Best for |
|---|---|---|---|---|
| Value | Copy of value | No | Yes | Small inputs you do not need to change |
| Var | Address of variable | Yes | No (must be a variable) | Outputs, in-out parameters |
| Const | Implementation choice | No (compiler-enforced) | Yes | Large read-only inputs, safety |
8.8 Designing Good Parameter Lists
Now that we understand the mechanisms, we can articulate principles for designing effective parameter lists. This is where Pascal's explicit parameter modes give you an advantage — they force you to think about data flow, which is exactly what good software design requires. These principles apply in every language, but Pascal makes them concrete.
Principle 1: Make Data Flow Explicit
Every piece of data that a subprogram needs should arrive through its parameters or be available through its enclosing scope (for nested subprograms). Every piece of data it produces should leave through a var parameter or a function return value. Avoid accessing global variables inside subprograms whenever possible.
{ BAD: hidden data flow through global }
var
TaxRate: Real;
procedure CalculateTotal(Price: Real; var Total: Real);
begin
Total := Price + (Price * TaxRate); { Where does TaxRate come from?? }
end;
{ GOOD: all data flows visible in signature }
procedure CalculateTotal(Price, TaxRate: Real; var Total: Real);
begin
Total := Price + (Price * TaxRate); { Every input is a parameter }
end;
When you read the good version's header, you know everything it needs: a price and a tax rate. The bad version hides a dependency on a global variable. To understand the bad version, you must read the entire implementation. To understand the good version, you only need to read the first line.
Principle 2: Use the Right Parameter Mode
- Pure inputs (data flowing in, not changed): use value parameters for small types,
constfor large types or when you want compiler-enforced immutability. - Pure outputs (data flowing out, not read first): use
varparameters. The subprogram will assign a value but does not read the parameter's initial value. - In-out (data flowing in, modified, and flowing back out): use
varparameters. The subprogram reads the current value, modifies it, and the caller sees the modification. - Function return values for single outputs when the result has a natural "answer" meaning (e.g.,
CalculateAreareturns the area).
Principle 3: Keep Parameter Lists Short
If a subprogram needs more than five or six parameters, it is often a sign that:
- The subprogram is doing too much and should be split into smaller pieces.
- Related parameters should be grouped into a record type (we will learn records in a later chapter). For example, instead of passing StudentName, StudentID, StudentGPA, and StudentYear separately, you would pass a single TStudent record.
- Some "parameters" are really global configuration that could be accessed differently.
A long parameter list is not inherently wrong, but it is a code smell — a hint that the design might benefit from restructuring.
Principle 4: Order Parameters Consistently
Adopt a convention and stick with it. A common and effective convention is: 1. Input parameters first (value/const) — the data the subprogram reads 2. Input-output parameters next (var, read then modified) 3. Pure output parameters last (var, only written)
procedure ProcessTransaction(
const CustomerID: Integer; { input: identifies the customer }
const Amount: Real; { input: transaction amount }
var Balance: Real; { in-out: read and modified }
var TransactionID: Integer; { output: assigned inside }
var Success: Boolean { output: status flag }
);
Reading this header, you immediately know that the procedure needs a customer ID and amount (inputs), will read and update the balance (in-out), and will produce a transaction ID and a success flag (outputs). The parameter list is the documentation.
Principle 5: Name Parameters Meaningfully
The parameter name is the first thing another programmer reads. A and B tell you nothing. Subtotal and TaxRate tell you everything. N is acceptable for a count in a short, mathematical function. CustomerCount is better in a business logic procedure. Take the extra five seconds to choose a good name — your future self (and your teammates) will thank you.
Putting It All Together
Here is an example that violates every principle, followed by the corrected version:
{ TERRIBLE: Global dependency, wrong modes, bad names, no documentation }
var G: Real;
procedure Calc(var A: Real; B: Real; var C: Real);
begin
C := (A + B) * G;
A := 0;
end;
{ GOOD: Self-documenting, correct modes, explicit data flow }
function CalculateWithdrawalFee(
const WithdrawalAmount: Real;
const FeeRate: Real
): Real;
begin
CalculateWithdrawalFee := WithdrawalAmount * FeeRate;
end;
The good version is a pure function: no side effects, no globals, no var parameters. You can call it, compose it, test it, and reason about it with complete confidence.
8.9 Nested Subprograms and Scope Chains
Pascal allows you to declare procedures and functions inside other procedures and functions. This creates nested scopes, and the inner subprogram can access variables from the enclosing subprogram's scope — without those variables being passed as parameters. This is a powerful feature that supports encapsulation: you can hide a helper subprogram inside the one subprogram that uses it, making it invisible to the rest of the program.
program NestedDemo;
procedure Outer;
var
OuterVar: Integer;
procedure Inner;
begin
OuterVar := OuterVar + 1; { Legal: Inner can see OuterVar }
WriteLn('Inner sees OuterVar = ', OuterVar);
end;
begin
OuterVar := 10;
Inner;
WriteLn('Outer sees OuterVar = ', OuterVar);
end;
begin
Outer;
end.
Output:
Inner sees OuterVar = 11
Outer sees OuterVar = 11
Inner can see and modify OuterVar because Inner is declared inside Outer. This is not parameter passing — it is lexical scope access. The compiler knows at compile time that Inner is nested inside Outer, so it arranges for Inner's stack frame to include a static link (also called a scope pointer or access link) that points to Outer's stack frame. When Inner references OuterVar, the generated code follows the static link to find it.
Stack during Inner's execution:
+---------------------------+
| Frame: Inner |
| Static link: ----+ |
+---------------------------+
|
v
+---------------------------+
| Frame: Outer |
| OuterVar: 10 -> 11 |
+---------------------------+
+---------------------------+
| Frame: main |
+---------------------------+
The static link is a pointer (a memory address) stored in Inner's frame that says "my enclosing scope's frame is located at this address." When Inner needs to read or write OuterVar, the generated machine code uses this pointer to navigate to the right location. All of this happens automatically — the programmer writes OuterVar := OuterVar + 1 and the compiler handles the rest.
This mechanism can extend through multiple levels of nesting. If you have three levels deep, the innermost procedure follows a chain of static links to reach variables at any enclosing level. This chain is called the scope chain or static chain.
program DeepNesting;
procedure Level1;
var A: Integer;
procedure Level2;
var B: Integer;
procedure Level3;
var C: Integer;
begin
C := 30;
B := 20; { Follows one static link to Level2's frame }
A := 10; { Follows two static links: Level3 -> Level2 -> Level1 }
end;
begin
B := 0;
Level3;
WriteLn('A=', A, ' B=', B);
end;
begin
A := 0;
Level2;
end;
begin
Level1;
end.
Level3 accesses B by following one static link (to Level2's frame) and A by following two (to Level2, then to Level1). The compiler counts the nesting levels at compile time and generates the appropriate number of pointer dereferences.
When to use nested subprograms:
- When a helper subprogram is only needed by one specific subprogram. Nesting makes it invisible to the rest of the program, which is proper encapsulation.
- When the helper needs access to the parent's local state. Rather than passing many parameters, the helper can directly access the enclosing scope's variables.
- When you want to keep related logic physically close in the source code.
When to avoid deep nesting:
- Nesting more than two levels deep (procedure inside procedure inside procedure) can become hard to read and understand.
- If the inner subprogram does not need access to the enclosing scope's variables, consider making it a sibling instead of a nested child. This makes the data flow more explicit (through parameters).
Comparison with other languages: Many modern languages (Python, JavaScript) do not support nested named subprograms in the same way, but they support nested anonymous functions (called closures or lambdas) that capture enclosing variables using the same underlying mechanism. If you understand Pascal's nested subprograms and static links, you already understand closures — one of the most important concepts in modern programming.
8.10 Common Scope Bugs and How to Fix Them
Let us catalog the most frequent scope-related bugs. These are the bugs that waste hours of debugging time, because the program compiles and runs — it just produces wrong results. Knowing these patterns will help you recognize them instantly.
Bug 1: Accidental Shadowing
program AccidentalShadow;
var
Count: Integer;
procedure Tally(Items: Integer);
var
Count: Integer; { Oops — shadows the global Count }
begin
Count := Items * 2;
WriteLn('Tally set Count to ', Count);
end;
begin
Count := 0;
Tally(5);
WriteLn('Global Count is ', Count); { Still 0! Not 10! }
end.
The bug: The programmer intended Tally to modify the global Count, but accidentally declared a local Count that shadows it.
The fix: Remove the local declaration, or (better) make Count a var parameter so the data flow is explicit:
procedure Tally(Items: Integer; var Count: Integer);
begin
Count := Items * 2;
end;
Bug 2: Unintended Global Access
program UnintendedGlobal;
var
I: Integer;
procedure PrintStars(N: Integer);
begin
for I := 1 to N do { Uses global I — dangerous! }
Write('*');
WriteLn;
end;
begin
for I := 1 to 5 do
begin
Write('Row ', I, ': ');
PrintStars(I);
end;
end.
The bug: PrintStars uses the global I as its loop counter. When PrintStars finishes its loop, the global I has been modified. Back in the outer loop, I may have an unexpected value, causing the outer loop to terminate early, skip iterations, or behave unpredictably. In standard Pascal, modifying a for loop's control variable inside the loop body is undefined behavior, which makes this even more dangerous.
The fix: Declare a local loop variable inside PrintStars:
procedure PrintStars(N: Integer);
var
J: Integer; { Local loop variable — safe }
begin
for J := 1 to N do
Write('*');
WriteLn;
end;
Bug 3: Using a Variable After Its Scope Has Ended
This one does not compile in Pascal, which is actually a feature — the compiler protects you:
procedure Initialize;
var
Temp: Integer;
begin
Temp := 42;
end;
begin
Initialize;
WriteLn(Temp); { COMPILE ERROR: Identifier not found "Temp" }
end.
The fix: If you need the value after the procedure returns, pass it out as a var parameter or use a function:
function Initialize: Integer;
begin
Initialize := 42;
end;
begin
WriteLn(Initialize); { Prints 42 }
end.
Bug 4: Forgetting That Value Parameters Are Copies
procedure ResetValue(X: Integer); { Value parameter — this is a COPY }
begin
X := 0;
end;
var
Score: Integer;
begin
Score := 100;
ResetValue(Score);
WriteLn(Score); { Prints 100, not 0! }
end.
The bug: The programmer expected ResetValue to zero out Score, but X is a value parameter — only the copy is zeroed. Score in the caller remains 100.
The fix: Use var if you intend to modify the original:
procedure ResetValue(var X: Integer);
begin
X := 0;
end;
Bug 5: Passing a Literal to a Var Parameter
procedure Increment(var X: Integer);
begin
X := X + 1;
end;
begin
Increment(5); { COMPILE ERROR }
end.
Why it fails: var parameters need a memory address to write to. The literal 5 is an immediate value in the machine code — it has no writable memory location.
The fix: Use a variable as the argument:
var
N: Integer;
begin
N := 5;
Increment(N); { Now N = 6 }
end.
Bug 6: Assuming Local Variables Are Initialized
procedure PrintTotal;
var
Sum: Integer; { What value does Sum start with? }
I: Integer;
begin
{ Sum is NOT zero! It contains whatever garbage was in this stack memory. }
for I := 1 to 10 do
Sum := Sum + I; { Adding to garbage! }
WriteLn('Sum = ', Sum); { Wrong result }
end;
The fix: Always explicitly initialize local variables before use:
Sum := 0;
for I := 1 to 10 do
Sum := Sum + I;
Defensive Coding Habits
Based on these common bugs, here are habits that will prevent the vast majority of scope-related errors:
- Always declare loop variables locally inside the subprogram that uses them. Never rely on a global loop counter.
- Use
constfor parameters you do not intend to modify. The compiler will catch accidental modifications before the program runs. - Enable compiler warnings. In Free Pascal, use
-vh(hints) and-vn(notes) to catch shadowing, unused variables, and uninitialized variables. - Minimize global variables. When you must use them, give them distinctive names (e.g.,
gUserCountor prefix with the module name). - Read your subprogram headers as documentation. The parameter list should tell the complete story of what data the subprogram needs and what it produces. If you have to read the implementation to understand the interface, the interface needs improvement.
- Initialize every local variable before using it. Do not assume any initial value.
8.11 Project Checkpoint: PennyWise Scope Design
Time to apply everything we have learned to our ongoing PennyWise personal finance manager. In previous chapters, we built the core data structures and basic I/O. Now we are going to redesign the program's architecture with proper scope management. This is the kind of structural improvement that does not change what the program does — it changes how maintainable and understandable the code is.
Design Goals
- Minimal globals: Only truly program-wide state (the expense array and count) should be global. All computation should flow through parameters.
- Clear parameter interfaces: Every procedure and function should declare exactly what it needs and what it produces, using the correct parameter mode.
- No accidental shadowing: Unique, descriptive names throughout.
- Logical nesting: Helper procedures that serve only one parent should be nested inside it.
- Local loop variables: Every subprogram that uses a loop declares its own counter.
Architecture Sketch
Program PennyWise
|
|-- Global: Expenses array, ExpenseCount
| (Justification: core shared state, needed by most operations)
|
|-- Function GetMenuChoice(const MaxChoice: Integer): Integer
| (Pure function, no globals needed)
|
|-- Procedure AddExpense(var Exps: TExpenseArray; var Count: Integer)
| |-- Function ValidateAmount(const Amt: Real): Boolean
| | (Nested: only AddExpense needs validation logic)
| |-- Function ValidateCategory(const Cat: String): Boolean
| | (Nested: only AddExpense needs category checking)
|
|-- Procedure ShowSummary(const Exps: TExpenseArray; const Count: Integer)
| |-- Function CalculateTotal(const E: TExpenseArray;
| | const C: Integer): Real
| | (Nested: only ShowSummary displays totals)
| |-- Function CalculateCategoryTotal(const E: TExpenseArray;
| | const C: Integer; const Category: String): Real
| | (Nested: only ShowSummary needs per-category totals)
|
|-- Procedure ShowBudgetStatus(const Exps: TExpenseArray;
| const Count: Integer; const Budget: Real)
| (All inputs via parameters — no hidden dependencies)
|
|-- Procedure DisplayMenu
| (Pure display — no parameters, no state)
Notice the parameter modes and what they communicate:
- AddExpense takes var Exps and var Count because it modifies the expense data (adds an entry and increments the count).
- ShowSummary takes const parameters because it only reads data to display — it never modifies the expense array.
- ShowBudgetStatus takes const for all three parameters (expenses, count, budget) — purely a read and display operation.
- Validation functions are nested inside AddExpense because no other part of the program needs them.
- Calculation functions are nested inside ShowSummary because only the summary display uses category breakdowns.
- GetMenuChoice and DisplayMenu are top-level because multiple places might use them.
Call Stack During a ShowSummary Session
When the user selects "View Summary," the call stack builds like this:
Step 1: main loop calls ShowSummary
+----------------------------+
| ShowSummary |
| Exps: const ref to array |
| Count: const ref (value 3) |
+----------------------------+
| main |
+----------------------------+
Step 2: ShowSummary calls nested CalculateTotal
+----------------------------+
| CalculateTotal |
| E: const ref to array |
| C: const ref (value 3) |
+----------------------------+
| ShowSummary |
+----------------------------+
| main |
+----------------------------+
Step 3: CalculateTotal returns (Result: 247.50)
ShowSummary calls CalculateCategoryTotal('Food')
+----------------------------+
| CalculateCategoryTotal |
| E: const ref to array |
| C: const ref (value 3) |
| Category: 'Food' |
+----------------------------+
| ShowSummary |
+----------------------------+
| main |
+----------------------------+
Each frame is created when the call is made and destroyed when the subprogram returns. The const parameters mean the expense data is accessed by reference (no copying of the entire array) but is guaranteed read-only within each subprogram — the compiler will reject any attempt to modify the data.
What Changes From Previous Checkpoints
Compare this to a naive version where every procedure just reaches into global variables:
| Aspect | Before (naive) | After (scope-conscious) |
|---|---|---|
| How procedures get data | Read globals directly | Receive through parameters |
| How procedures return data | Write to globals | Use var params or return values |
| How you understand a procedure | Read the implementation | Read the parameter list |
| Effect of reordering calls | May break due to shared state | Safe — each call is self-contained |
| Testing a procedure | Must set up globals | Pass test values as arguments |
| Helper visibility | All helpers visible globally | Nested helpers hidden inside parents |
The scope-conscious version is longer (more parameter lists to write), but it is dramatically easier to understand, test, modify, and debug. This trade-off — a little more typing for a lot more clarity — is one of the central lessons of software engineering, and Pascal makes it tangible.
See code/project-checkpoint.pas for the full implementation.
8.12 Chapter Summary
Let us consolidate what we have learned in this chapter.
Scope is the region of source code where a name is visible. Pascal uses lexical (static) scope, meaning visibility is determined by the textual structure of the program, not the runtime call sequence. You can always determine scope by reading the source code — you never have to run the program.
Block structure means that every procedure and function creates a new scope level. Blocks can be nested, creating a hierarchy of scopes. The compiler resolves names by searching from the innermost scope outward. If two names match, the innermost one wins.
Global variables are visible everywhere but create hidden dependencies between distant parts of the program. Local variables are visible only within their declaring block and exist only while that block is executing. The strong preference is for local variables and parameter-based communication, with globals reserved for program-wide shared state that is genuinely needed by many subprograms.
Shadowing occurs when an inner scope declares a name that matches an outer scope name. The inner declaration hides the outer one within that scope. Shadowing is legal but is a common source of bugs — avoid it by using descriptive, unique names and by always declaring loop variables locally.
The call stack is a runtime data structure that manages subprogram calls using a Last-In, First-Out discipline. Each call creates a stack frame (activation record) containing the return address, parameters, local variables, saved registers, and a static link for nested scope access. When a subprogram returns, its frame is destroyed and the memory is reclaimed.
Parameter passing mechanisms have clear memory-level explanations: - Value parameters copy the argument into the stack frame — changes to the parameter do not affect the original. You can pass any expression. - Var parameters place the argument's address in the stack frame — changes go directly to the original variable. You must pass a variable. - Const parameters guarantee read-only access, typically using pass-by-reference for efficiency — the compiler prevents any modification.
Good parameter list design follows five principles: make data flow explicit, use the right parameter mode, keep lists short, order parameters consistently (inputs, then in-out, then outputs), and name parameters meaningfully.
Nested subprograms access enclosing scope variables through a static link in the stack frame, forming a scope chain that the generated code follows at runtime. Nesting is appropriate for helper subprograms that are only needed by one parent and that benefit from access to the parent's local state.
Common scope bugs include accidental shadowing (local hides global), unintended global access (especially with loop variables), forgetting that value parameters are copies, passing literals to var parameters, and assuming local variables are initialized. All of these can be prevented through disciplined coding habits and compiler warnings.
These concepts are not Pascal-specific. Every programming language you will ever encounter has scope rules, a call stack (or equivalent), and parameter passing mechanisms. By learning them explicitly in Pascal — where they are visible, strictly enforced, and syntactically clear — you have built a mental model that transfers directly to C, Java, Python, JavaScript, Rust, Go, and beyond (Theme 2: the discipline transfers). The habit of thinking about data flow — what goes in, what comes out, what is visible where — is the foundation of writing correct, maintainable programs at any scale. It is not just a Pascal skill; it is a programming skill, and one of the most important you will ever develop.
Spaced Review
Before moving on, test your retention of earlier material:
From Chapter 6: What are the three types of loops in Pascal and when do you use each?
Answer:
forloops are for a known number of iterations (counting up or down with a definite start and end).whileloops test the condition before the first iteration — use them when the loop might not execute at all (the condition could be false from the start).repeat..untilloops test the condition after the first iteration — use them when the loop must execute at least once, such as input validation or menu-driven programs.
From Chapter 4: How do you format a Real number to show exactly 2 decimal places?
Answer: Use the colon-width-decimal format specifier:
WriteLn(Amount:0:2);. The first number after the colon (here0) specifies the minimum total field width — using0means use the minimum width needed, with no padding. The second number (:2) specifies exactly how many decimal places to display. For example,WriteLn(3.14159:0:2)outputs3.14, andWriteLn(3.1:0:2)outputs3.10(the trailing zero is preserved).