Chapter 3 Summary and Quiz
Review function declarations and pass-by-value, the stack-frame model of calls and recursion, pointer syntax and out-parameters, and the lifetime rules.
Functions and the call stack recap
Chapter 2 gave you everything a program needs to compute, and every one of those programs still lived inside a single main, in the one stack frame chapter 1 described. This chapter gave you more than one frame, and one picture explains all four lessons: a call pushes a frame, that frame holds the call's own parameters and locals, and a return pops it. Pass by value falls straight out of that picture, an address held as a value is the one thing that reaches through it, and a frame that pops while an address to it is still held is the most famous bug in C. Let's review each lesson before you test yourself.
Writing Functions
A function gives a computation a name so you write it once and call it wherever you need it. Its first line is the signature, and it reads left to right as a return type, a name in snake_case, and a parameter list, which is not a list of names but a list of typed slots, each a declaration in its own right, which is why int area(int width, int height) spells int twice rather than sharing it. Two words that sound interchangeable are not: the parameters are the variables named in the definition and the arguments are the values written at the call, and they are matched by position and never by name. return ends the function and hands one value back, so a call to a value-returning function is an expression of that type and can sit inside any larger one, which is what makes area(4, 3) + area(2, 5) an ordinary addition. A void return type says nothing comes back, so such a call is a statement with no value to store, and a bare return; ends it early. The other void, the one between the parentheses, is a different claim entirely, because in C empty parentheses mean "unspecified parameters" rather than "no parameters" and switch argument checking off. That is why chapter 1 insisted on int main(void), and it was never a rule about main: it holds for every function you write. A signature stated on its own and ended with a semicolon is a declaration, or, of a function, a prototype; the body is the definition. Calling a function the compiler has not met is not a warning but error: implicit declaration of function, a prototype must always carry its full parameter list because int area(); checks nothing, and inside one file you can skip prototypes entirely by defining every helper above main. Then comes the fact the rest of the chapter is built on. C passes every argument by value. The call copies the argument into the parameter, the parameter is a separate variable that merely starts life holding an equal value, and a function that assigns to it changes only its own copy. There is no exception anywhere in the language and nothing you can write in a parameter list changes it, which is exactly why printf("%d\n", score) works on a copy while scanf("%d", &score) needed that &. Last, a non-void function must return on every path: falling off the end and then using the result is undefined behaviour, reported by -Wall as control reaches end of non-void function [-Wreturn-type], and the 0 that turns up is the trap precisely because it looks like an answer. Only main gets its return 0; supplied for it.
The Call Stack and Recursion
Lesson 1 left one question open: where does the copy live? A call pushes a frame holding that one call's parameters and locals, and return pops it, so the frame is the whole lifetime of a local variable. Read a diagram of frames as a picture of order and lifetime rather than of addresses, because which end of memory the stack sits at and which way it grows are the implementation's business. The picture makes a prediction you can check with %p, and printing &score in the caller beside &n in the callee gives two different addresses within a single run: n is not another name for score but a second variable in a second frame handed a copy of the first one's value. Pass by value stops being a rule to memorize at that point and becomes geography. Nothing limits this to two frames, so when main calls outer and outer calls inner there are three alive at once, and they come off in the reverse of the order they went on. A function may therefore call itself without paradox, since a frame does not care which function issued the call. Recursion needs two parts, and you write the base case first, the input simple enough to answer outright with no further call, then the recursive step, which does one piece of the work and hands a strictly smaller problem to another call. The countdown trace proved each frame keeps its own copy: the lines printed on the way back out resume in the reverse order, each finding its own n undisturbed by everything the deeper calls did. Change one character so the step climbs away from the base case and the program compiles without a single warning, because nothing there is wrong as C and the compiler cannot know which values will arrive. The stack is a region of finite size and every live frame costs some of it, so frames that keep going on and never come off eventually run past its end. C defines no depth limit, no error and no exception; what turns it into a readable report is AddressSanitizer, which this platform builds into every program and which reported stack-overflow, where a build without a sanitizer usually gives a bare segmentation fault. Never read the depth reached as a budget. The rule that follows is that every recursive step must move the argument toward the base case by an amount that cannot skip it, both halves mattering, since stepping n - 2 from an odd number sails straight past zero. C also gives no tail-call guarantee, so keep recursion shallow and bounded, and prefer a loop: it costs one frame however many times it runs, and recursion earns its place only when the problem is genuinely self-similar.
Pointers: Passing Addresses Around
A pointer is a variable whose value is an address, and the rest is consequences. Read int *p = &score; as an ordinary declaration with a type, a name and an initializer, where the type int * is the one gcc already used on you in chapter 1 when a scanf call was missing its &. In an expression * is the dereference operator meaning "the object at this address", so *p is not a copy of score, it is score reached by another route, and *p = 42; changes a variable the line never mentions. That is one character doing two unrelated jobs, since in a declaration * shapes the type instead, and telling them apart is most of learning to read pointer code. It also settles the spacing: write int *p;, because int* a, b; declares one pointer and one plain int, the star having attached to a alone, and nothing warns you at that line. You find out later as error: assignment to 'int' from 'int *'. The pointer's type is what makes a dereference mean anything, deciding how many bytes are touched and how they are read, which is the difference between sizeof p and sizeof *p. NULL is a pointer value guaranteed not to be the address of any object, and initialize every pointer, because an uninitialized pointer is not NULL but an arbitrary number that will be treated as an address. Dereferencing a null pointer is undefined behaviour, and here AddressSanitizer reported SEGV on unknown address 0x000000000000, line for line the report chapter 1 produced from scanf("%d", age) with the & left off: one mistake seen from two ends, something handed a number to write through and the number was zero. So check with if (p != NULL) whenever a pointer can be null, and decide what the null path actually does. Then the payoff. Pass by value is not repealed, and nothing about it changes; what changes is what you copy. Copy an address into a parameter and the function holds a second route to an object it does not own, and a pointer parameter a function writes through is an out-parameter. It is how one call produces several results, which return alone cannot do. Read split_seconds(200, &minutes, &seconds) and then read scanf("%d", &score): they are the same pattern, and the & you have typed since chapter 1 was building a pointer argument for an out-parameter every single time. Finally, const int * is a pointer a function may read but not write through, enforced as error: assignment of read-only location, costing nothing at run time and making a slip of the finger a build error. Make it your default for any pointer parameter you only read.
Lifetime and Dangling Pointers
Two questions that beginners fuse into one: scope asks where in the source text a name is visible and is settled at compile time, while storage duration, or lifetime, asks when the object exists at run time. Locals and parameters have automatic storage duration, and for them both answers arrive at the same closing brace, which is exactly what makes the two easy to confuse. About names the compiler is absolute, and reading a variable declared in an inner block after that block closes is error: 'temp' undeclared. Writing static on a local changes one answer and leaves the other alone: a static local has block scope and static storage duration, invisible outside the function yet created before the program starts and alive until it ends, sitting in no frame that a return could pop, which is how a call counter remembers. Its = 0 is not an assignment run on every call, because a static-duration object is initialized once, and that is where chapter 1's loose end gets tied: objects with static storage duration are zero-initialized and objects with automatic storage duration are not, so name the storage duration every time you make the claim. On a declaration outside any function the same keyword means something unrelated, which chapter 6 covers. Now put the two axes together. A pointer holding the address of an automatic local does not go anywhere when the frame pops, and it now names memory belonging to nobody: it dangles, and using its value is undefined behaviour even when the value appears to survive. Never return the address of an automatic local. gcc sees the common case coming and warns function returns address of local variable [-Wreturn-local-addr]; at -O2 it then handed back a null pointer, so AddressSanitizer reported that same SEGV on unknown address 0x000000000000, which is a choice the standard leaves open rather than a behaviour to rely on. The outcome worth fearing is the opposite one, where the dead frame still happens to hold the right number, the test passes, and it breaks months later. Return the value, or take an out-parameter so the caller supplies an object that is already alive, or give the object a longer storage duration. A lifetime can also end long before the function does: a pointer set to a variable in an inner block dangles the moment that block closes, and that program builds in silence and is reported at run time as stack-use-after-scope. Compare the two diagnostics from the same shape of block. The compiler guards names; nothing in the language guards lifetimes. Here is the chapter in one program.
#include <stdio.h>
int split_seconds(int total, int *minutes, int *seconds)
{
static int calls = 0;
if (minutes == NULL || seconds == NULL)
{
return 0;
}
++calls;
*minutes = total / 60;
*seconds = total % 60;
return calls;
}
int main(void)
{
int minutes = 0;
int seconds = 0;
int call_number = 0;
call_number = split_seconds(200, &minutes, &seconds);
printf("call %d: %d minutes %d seconds\n", call_number, minutes, seconds);
call_number = split_seconds(45, &minutes, &seconds);
printf("call %d: %d minutes %d seconds\n", call_number, minutes, seconds);
printf("refused call returned %d\n", split_seconds(90, NULL, NULL));
return 0;
}
call 1: 3 minutes 20 seconds
call 2: 0 minutes 45 seconds
refused call returned 0
Every call pushed a frame whose total, minutes and seconds are copies, and the two copied addresses still reached main's variables, which is pass by value and out-parameters coexisting in one line. The guard decides the null path rather than merely noticing it, and the status comes back the ordinary way, by value. calls sits in no frame at all, so it survived both pops and counted only the calls that did work.
Key Terminology
- Signature: a return type, a name and a parameter list of typed slots, each slot needing its own type
- Parameter and argument: the variable named in the definition and the value written at the call, matched by position
- Prototype: a signature ended with a semicolon, where the definition carries the body, and it must give the full parameter list because empty parentheses mean unspecified parameters rather than none
- Pass by value: every argument is copied into the parameter, with no exception anywhere in the language
- Frame: the patch of stack a call pushes for its parameters and locals, popped by
return, and the whole lifetime of a local - Recursion: a function calling itself, needing a base case written first and a step that moves strictly toward it, since a base case never reached exhausts the finite stack and is reported here as
stack-overflow - Pointer: a variable whose value is an address, declared
int *p;with the star on the name - Dereference:
*in an expression, naming the object at that address, so*preads and*p = 42;writes - NULL: a pointer value guaranteed not to be any object's address, and not what an uninitialized pointer holds
- Out-parameter: a pointer parameter a function writes a result through, which
scanfhas been all along const T *: a pointer parameter a function may read but not write through, enforced at compile time- Scope and storage duration: where a name is visible, decided at compile time, and when the object exists, at run time
- Static local: block scope with static storage duration, initialized once and zero-initialized by default
- Dangling pointer: one holding the address of an object whose lifetime has ended, whose use is undefined behaviour
Looking Forward
You can now name a computation, follow it onto the stack and back off, hand a function an address so it can write where it could otherwise only read, and ask of any pointer the question this chapter is made of: is the object still alive at the moment I use this? Every object so far has had its lifetime chosen for it, by a frame or by the whole run. Chapter 4 takes that decision back: arrays as contiguous memory, the pointer arithmetic and array decay that connect them to this chapter, and then the heap, where malloc and free give an object a lifetime tied to nothing but your own decision to end it. With that comes ownership, the leaks that follow when nobody ends it, and a lesson on reading the AddressSanitizer reports you have so far only been handed.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Chapter 3 Summary and Quiz - Quiz
Test your understanding of the lesson.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!