TRT
Sign in

Foundations · Module 2 · Lesson 5 of 10

Functions: Parameters, Return Values and Scope

Define and call functions, follow an argument as it is copied into a parameter and a value comes back, and learn how declarations, scope and lifetime decide which variable a name refers to.

FreeAbout 8 minutes

By the end you can

  • Distinguish parameters from arguments and describe what happens to an argument during a call
  • Predict whether a change to a parameter affects the caller's variable
  • Explain the difference between declaring and defining a function
  • Determine where a name is visible and when its variable is destroyed

Builds on Loops: while, do-while and for.

01 · A question first

When you call a function with x, does the function receive your variable, or something else? When the function ends, where do its variables go? And how can two functions each have a variable called n without getting in each other's way?

Make a guess, then step through the trace below to check it.

02 · Watch it run

Calling square(x)step 1 of 7
int square(int n) {    n = n * n;    return n;} int main() {    int x = 4;    int y = square(x);    x = 10;}
  1. var 0x: 4(looked at in this step)
  2. var 1y: ?
  3. var 2n: --
  4. var 3return: --

main starts by creating x with the value 4. The cells show x and y from main, then n and the return value, which belong to square. Neither n nor the return value exists yet, and y has no value.

Machine work: store 4 into x

03 · The idea

Start with the problem

Programs grow. The same few lines appear in several places, a bug has to be fixed in each copy, and main becomes a wall of statements with no names for the ideas inside it. A function solves this by packaging code under a name. It takes inputs, does a job, and gives back a result:

cpp
#include <iostream>
 
int square(int n) {
    n = n * n;
    return n;
}
 
int main() {
    int x = 4;
    int y = square(x);
    std::cout << y << "\n";
}

The questions are all about what happens at the boundary: what travels into the function, what comes back, and what happens to the function's variables when it ends.

How the idea works

Anatomy. A function has a return type, a name, a parameter list and a body: int square(int n) followed by a block. The variable n in the definition is a parameter. The value x in the call square(x) is an argument.

What a call does. The trace follows the steps. The call evaluates its arguments, copies each one into the matching parameter, which is a new variable belonging to the function, jumps to the function's first instruction, runs the body, and returns to the instruction after the call. The value that was returned takes the place of the call expression, so square(x) can be used anywhere a number can: int y = square(x);.

Return. The statement return value; ends the function at once, even from inside a loop or an if, and hands the value back, converted to the declared return type. A function declared void returns nothing, and a bare return; leaves it early. If control runs off the end of a non-void function without a return, the behavior is undefined. GCC warns about it, and main is the only function that is allowed to do so.

Pass by value. By default each parameter is a copy of its argument, as steps 2 and 3 show: n changes and x does not. The function can use its parameters as scratch variables without disturbing the caller. Lesson 7 shows how to share a variable instead.

Declaration and definition. A name must be declared before it is used. A declaration such as int square(int n); states the name and the types. A definition supplies the body. This is the split from lesson 1: the compiler checks every call against the declaration, and the linker verifies that exactly one definition exists. Declaring a function near the top and defining it below main is a common layout.

Scope and lifetime. A name is visible from its declaration to the end of the block that contains it. That region is its scope. A local variable or parameter is created when the function runs its declaration, and destroyed at the end of the block, as n is in step 5. Two functions may each have an n, because each call creates its own. An inner declaration of the same name shadows the outer one inside its block, which is legal but confusing, and -Wshadow reports it.

Global variables. A variable declared outside every function lives for the whole program and is visible everywhere, and any function can change it. That makes the program hard to reason about, so prefer parameters and return values.

Why it is correct

A function call behaves like substitution with fresh variables. Each call creates its own parameters and locals, so two calls, such as square(3) and square(4), never share state, and a call can even be an argument to another: square(square(2)) is 16. This independence is what makes functions composable, and it follows directly from the value semantics of lesson 3.

Scope is resolved at compile time. For every name, the compiler searches from the innermost block outward and binds the name to one particular variable, so there is no lookup by name while the program runs. Tying a variable's lifetime to its block means its storage is reclaimed automatically, with no cleanup code. And because the compiler checks the number and types of arguments against the declaration, a call such as square("hello") is rejected before the program exists.

What it costs

A call is cheap. Passing a few arguments, jumping and returning take a handful of instructions, usually well under a nanosecond. On common platforms the first few arguments and the result travel in CPU registers. At -O2 the compiler often inlines small functions: it pastes the body into the caller and the call disappears. So the belief that functions are slow is rarely true, and clear structure should win.

Copies cost size. Copying an int is trivial. Copying something large for every call is not, and lesson 7 shows how to avoid it.

Stack space. Each call reserves memory for its parameters and locals while it runs. A few calls cost almost nothing, but very deep chains of calls can exhaust the stack, which lessons 6 and 9 cover.

Pitfalls and variations

Using a function before it is declared. The error 'square' was not declared in this scope means the compiler has not met the name yet. Declare it earlier or move the definition up.

Expecting a parameter to reach the caller. A function swap(int a, int b) that exchanges its two parameters changes nothing outside, because it swaps copies. Lesson 7 fixes this with references.

Forgetting to return. A non-void function that can reach its end without a return has undefined behavior. Turn on -Wall and fix every such warning.

Ignoring the result. The statement square(x); computes a value and throws it away. For functions whose result must be used, the attribute [[nodiscard]], from C++17, makes the compiler warn.

Mixing computing and printing. A function that calculates a result and also prints it is hard to reuse and to test. Return the result and let the caller decide what to do with it.

Variations. Parameters can have default values and several functions can share a name, which is lesson 8. The keyword constexpr lets a function run at compile time, and lambdas are functions written inline, both in later modules.

Where it is used

Everywhere. main is itself a function, called by the startup code. The standard library is made of functions such as std::sqrt and std::sort. Unit tests work by calling a function with chosen inputs and checking what it returns, and that is only possible because functions have a clear boundary.

The next lesson, "The Call Stack: What Happens When a Function Is Called", looks at the memory behind that boundary: where the parameters and locals live, and what happens when functions call other functions.

04 · Prove it

Question 1 of 4 · answer from memory

During the call `int y = square(x);`, what happens to the value of `x`?

05 · Practice

Something wrong with this lesson? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save