Chapter 7 Summary and Quiz
Review floating-point comparison, failure reporting and cleanup paths, assert and diagnostics, and the undefined behaviour catalogue that closes out the course.
Floating point, errors, and undefined behaviour recap
Chapter 6 got a program built and organized, and every chapter before it got something working. This chapter is about the distance between a program that works and a program you would trust, and each lesson closes part of that distance by taking something you had been assuming and either earning it or naming it as unearnable. The double goes first, since it stores an approximation and may be trusted only where the format actually promises something, which turns every equality test on a computed value into a question about tolerance. Then the error system you have been using since chapter 2 gets named rather than introduced, because C has no exceptions and there was never a second channel waiting to be discovered, only return values, the stream they are reported on, and errno with its two rules. assert then sorts every condition you could write into three bins that never swap, and the same lesson turns the compiler's output from noise into a five part message you can read. The fourth lesson gathers every undefined behaviour the course has named into the last of three catalogues, and its point is not the list but the sentence above it, that undefined behaviour is a property of the program rather than of a run, so no quantity of successful runs reports on it. The capstone then writes one complete program in which each of those rules does the deciding, and the silence AddressSanitizer keeps at the end of it was designed in rather than debugged in. Let's review each lesson before you test yourself.
Floating Point in Depth
Chapter 1 said a double holds a very close approximation and promised a later chapter would deal with the consequences, and the consequence is one line: 0.1 + 0.2 prints as 0.30000000000000004 under %.17g while the literal 0.3 prints as 0.29999999999999999, so sum == 0.3 is 0. This is not a compiler bug and not C's fault. A double spells numbers as binary fractions, with 52 fraction bits beside a sign bit and an 11 bit exponent, and binary can only spell fractions whose denominator is a power of two, so one tenth repeats forever in binary exactly as one third repeats in decimal, fills the bits and gets rounded. Adding two roundings gives a third, with no reason for it to match the rounding you get from writing 0.3 directly. It is IEEE 754 rather than C, which is why Python, JavaScript and Java all print that same number. %.17g is the specifier that stops hiding it, seventeen significant digits being enough to tell any two double values apart, and %.2f is how the error stays invisible for years, since both of those round to 0.30. A great deal is exact and knowing which parts keeps this from becoming superstition: powers of two are stored perfectly, so 0.5, 0.25 and 87.5 are exact, and whole numbers are exact up to 2 to the 53rd, which is 9007199254740992, so a double counting items or cents is not approximating anything. Use double by default; a float is 4 bytes with roughly 7 significant digits against a double's 15, for memory or bandwidth pressure only, and note the asymmetry that printf promotes a float argument to double so %f prints either while scanf is handed an address and needs %f for a float * and %lf for a double *. The rule that follows has no exceptions in this course: never compare computed floating point values with ==. Ask whether they are close enough with fabs(a - b) < tolerance, fabs living in <math.h>, wrapped in a named nearly_equal so the name records what the comparison means, with the tolerance held in a named const double. That tolerance is a choice with a working range rather than a constant of nature: near 1e16 two adjacent doubles are already several units apart so 1e-9 calls them different, and near 1e-12 two values a factor of three apart sit well inside 1e-9 so the same test calls them equal, and the general answer is a relative comparison scaled to the magnitudes involved. The error also accumulates, which is how the invisible becomes a bug: ten additions of 0.1 give 0.99999999999999989, which prints as 1.00 under %.2f and still fails == 1.0, so a loop written while (total != 1.0) over a total built by adding fractions may never finish. One thing here is not a hole in the language. Floating point division by zero is defined: 1.0 / 0.0 is inf, which compares as you would hope, and 0.0 / 0.0 is nan, which spreads through every arithmetic operation it touches and does not equal itself, so isnan from <math.h> is how you ask. Integer 1 / 0 remains undefined behaviour, and it is the operand types that decide which of the two you wrote.
Handling Failure
C has no exceptions. Every failure travels back to the caller as a return value the caller must check, nothing unwinds the stack on your behalf, and nothing stops a program carrying on with a value that was never produced. That is the whole system, you have been using it since chapter 2, and the catalogue is short: scanf returns the number of conversions it completed, fgets returns the buffer or NULL, malloc returns the block or NULL, chapter 6's make_book returns the struct or NULL having freed what it already held, and main returns the exit status. Two shapes cover all five. A function whose result is a pointer or a count can report failure with a sentinel, a value that could never be a real answer, NULL or -1; anything else returns a status and writes the real result through an out-parameter, exactly as scanf returns a count and writes the number into &value. Which you pick matters far less than holding to one, because mixing conventions is what makes C error handling feel arbitrary rather than mechanical, and the rule beside the convention now covers everything: check the return value of every allocation and every I/O call, not the ones that look risky. Then the piece that was missing, which is where the complaint goes. Diagnostics belong on standard error, written with fprintf(stderr, ...), the same function as printf with a leading argument naming the stream. Standard output is the program's output, the thing whoever ran it asked for, which is what makes ./program > results.txt and ./program | sort work; standard error is the program's commentary about itself, and a diagnostic printed to stdout has been written into the results for the next program in the pipe to sort. The second reason settles it: standard output is fully buffered when it is not a terminal, so text printed into a pipe sits in memory until the buffer fills or the program exits normally, while standard error leaves at once, and the moment you most want a diagnostic is the moment the program is least likely to reach a clean exit. On this platform there is a third reason, since the sandbox compares standard output only, so a printf diagnostic fails an otherwise correct exercise while the same text on stderr is ignored by the check and still visible to you. A return value can say a call failed but rarely why, and errno from <errno.h> is where the library puts the reason, with two rules that are not optional. Rule one: errno is meaningful only after a call that documents setting it has already reported failure through its return value, because nothing in C clears errno on success, so a nonzero errno after a call that worked means nothing at all. Rule two: set errno = 0 immediately before a call whose failure is signalled only through errno, which is what turns "errno is ERANGE" into "this call set it to ERANGE". Render the message, do not print the code: strerror(errno) from <string.h> returns the text and drops into any format string, and perror("context") prints context: message in one call, straight to standard error where it belongs. strtol is the parser that needs rule two, because every long it could return is also a legitimate answer, and where sscanf says how many conversions happened strtol says how far it got: end == text means no digits, *end != '\0' means it stopped at trailing junk, and errno == ERANGE means the digits were fine and the value did not fit, with LONG_MAX or LONG_MIN returned. Check them in that order, errno last, in keeping with rule one. That is also the case against atoi, which cannot tell "0" from "oops". Every failure you detect adds an exit, and every exit has to release everything acquired so far, which is chapter 4's single cleanup: label seen in failure-handling terms: pointers NULL at the top, forward jumps to one place, frees in reverse order, so a function has one place where a free is written and one place to check when you read it back.
assert and Reading Diagnostics
assert from <assert.h> does nothing at all when its expression is true, and when it is false prints the file, the line, the enclosing function and the source text of the expression to standard error and aborts on the spot. Notice what is missing: no way to carry on, no status to return, no caller to inform. assert is not error handling. Failures genuinely happen and a program that reports one is doing its job; an assertion is for the opposite case, a condition guaranteed by the rest of your own code, so that if it is ever false the program is not in trouble, it is wrong. That gives the three bins, and sorting a condition into the right one is most of the skill. User input gets validated, because a user is free to type anything, so a check, a diagnostic on standard error and a nonzero exit. Library calls get checked, because malloc really can return NULL, and failure is a legitimate outcome. Invariants get asserted, because you are the one who guaranteed them. The reason the sort is not a matter of taste is NDEBUG: compiling with -DNDEBUG does not disable assertions, it removes them, the macro expands to nothing and the expression is never compiled at all. Two consequences follow and neither is optional. Never put work inside an assertion, since assert(--remaining > 0) decrements in one build and not in the other, and a program whose behaviour depends on its assertions is a program with two meanings of which you tested only one. An assertion is never a substitute for a real check, precisely because it may not be there, so assert(values != NULL) after a malloc looks like diligence and lets the release build walk into the dereference. This sandbox does not define NDEBUG, so an assert here is a real gate, and since a failed assertion aborts without returning, without cleanup and without flushing standard output, an assertion failure is a failed exercise. The other half of diagnostic fluency is the compiler, whose output has as fixed a shape as a sanitizer report. A gcc diagnostic has five parts: the location as file:line:column, with the column pointing at the exact token; the severity, where a warning still produces a program and an error means none was produced; the message; the [-Wflag-name] naming the check that fired, which is the most useful thing on the line because it is what you search for and it says a documented rule fired rather than the compiler having a hunch; and the excerpt, your own source with a caret under the offending token, often with a suggested fix that is worth taking seriously and never as authority. Read them in the right order: fix the first error and recompile, because after an error the compiler is guessing and the rest is usually cascade, while warnings are independent of each other so read every one. You have accumulated a working set of those flags across the course, -Wformat, -Wmaybe-uninitialized, -Wsign-compare, -Wsequence-point, -Wshift-count-overflow, -Wparentheses, -Wempty-body, -Wmisleading-indentation, -Wimplicit-fallthrough, -Wtype-limits, -Wreturn-type, -Wreturn-local-addr, -Warray-bounds, -Wstringop-overflow, -Wsizeof-pointer-div and -Wswitch, with implicit declaration of function being an error rather than a warning in C17. Real projects stop relying on discipline and add -Werror, which promotes every warning to an error so one cannot be ignored, the same rule with the honour system removed. And the sentence that sets up the next lesson: gcc works at compile time on the text of your program and AddressSanitizer works at run time on the addresses your program touches, so between them they cover a great deal and neither covers everything.
The Undefined Behaviour Catalogue
This is the third and last of the catalogues, and the words come first because almost everyone softens them. Undefined behaviour means the standard imposes no requirement at all. Not that the result is unpredictable, not that you get whatever the bytes happened to hold, not that the program crashes; those describe what sometimes happens and each is a promise the standard never made. From that follows the sentence the lesson exists to deliver: undefined behaviour is a property of the program, not of a run. A program that reads an uninitialized int has no defined meaning on the run where it printed the number you wanted, on the run under the debugger, and on the thousand tests that passed, so you were never observing it behave correctly, only observing one thing a program with no defined behaviour was permitted to do. Three terms have to stay apart, and the difference is what the implementation owes you: undefined requires nothing whatsoever; unspecified means one of several valid behaviours chosen freely with no documentation owed, as with which of f() and g() runs first in printf("%d %d\n", f(), g()); implementation-defined means chosen freely and the choice must be documented, as with whether plain char is signed, or -8 >> 1 being -4 here. Only the first is a hole. Implementation-defined behaviour can be looked up and relied on if you accept the reliance is on your compiler rather than on C; unspecified behaviour cannot be looked up, so you write code that does not depend on the choice; undefined behaviour offers neither option, so the only response is not to write it. The hole is a price list rather than a list of oversights, because C emits no check you did not ask for, values[i] being an address computation and a + b one instruction, and the unchecked cases have to be called something. The half that surprises people is that the compiler may generate code assuming undefined behaviour never occurs, which is the only reading available for a program that has no meaning to preserve. The demonstration is two flags and one file: int next = x + 1; printf("next > x is %d\n", next > x); prints next > x is 1 at -O2 and next > x is 0 at -O0 when x is INT_MAX, because at -O2 gcc may assume the addition never overflows, decide the comparison while compiling and never consult x at all. Resist the obvious reading of the -O0 answer, since that 0 is not the true result that optimization concealed but what you get when the compiler declines to reason and the hardware wraps, and the standard required neither. Same program, two flags, two answers, and no third thing to appeal to, with no warning from either build and nothing from the sanitizer. The fix is not a flag, it is asking the question before the overflow rather than after. The catalogue then splits the way the tools split it. Eight entries are memory errors and mostly AddressSanitizer's territory: reading an uninitialized object, indexing outside an array, dereferencing one past the end, use after free, double free, using a dangling pointer to an automatic, dereferencing a null pointer, and modifying a string literal. Two rows there repay a second look. Reading an uninitialized object is the one memory entry the sanitizer does not catch, and gcc catches it only when the mistake is visible in the text. And the one past the end rule is finer than "out of bounds is bad", since forming the address one past the last element is explicitly legal because loops need it as a limit, and it is dereferencing it that is undefined. The other seven are the gap, and AddressSanitizer says nothing about any of them: signed integer overflow, shifting by a negative amount or by at least the width of the type, left-shifting a signed value into its sign bit, integer division or remainder by zero, modifying an object twice between sequence points, a mismatched printf conversion, and falling off the end of a non-void function and using the result. Calling a function through a pointer of an incompatible type belongs there too, taken on trust since function pointers are beyond this course, and noted as evidence that the catalogue continues past what a first course can show. Read the gcc column and the pattern is that it fires when the mistake is visible in the text, a constant array bound or a constant shift count, which is why the same mistake with a value from scanf is not diagnosed. The honest summary is that a warning-free compile and a clean sanitizer run rule out roughly half of this list and are silent about the other half, so the third check is not another tool. It is writing code whose meaning is defined in the first place, which is what "initialize every variable and every pointer", "carry a count beside every pointer", "set a pointer to NULL after freeing it", "one side effect per statement", "do bit work on unsigned types" and "check the divisor before you divide" have all been.
Capstone: A Complete Program
The last program of the course has nothing new in it, which is the point: an inventory that reads add, find and total commands from standard input, keeps a list of items in memory, and gives every byte back before it exits, with every decision in it being one of the course's rules doing the deciding. The first decision is the one C forces before any other, which is what lives on the heap and who is responsible for freeing it. struct Item holds a char *name, so it owns a string rather than containing one, and C supplies neither a constructor to allocate it nor a destructor to release it, so make_item and free_item are those two absent things written by hand and named as a pair so a reader sees they are one. make_item sizes with malloc(strlen(name) + 1), checks the result against NULL before anything touches it, and takes chapter 7 lesson 2's shape of a status returned with the result written through an out-parameter, writing nothing at all on the failure path so a caller who ignores the status never finds a half-built item; free_item frees the member and sets the pointer to NULL, which is load bearing rather than superstition because free(NULL) does nothing and a second pass is therefore harmless. struct Inventory is chapter 5's capacity and length pair with a struct for an element type, and growing it is the one genuinely new construction: the request is capacity * sizeof *grown rather than a byte count, the result lands in a checked temporary before inventory->items is overwritten because p = realloc(p, n) destroys the only pointer to a live block on the day the allocator says no, capacity doubles so n items cost about log2(n) reallocations, and realloc(NULL, size) is malloc so the first growth needs no special case. Three lines then carry the chapter. assert(inventory->count <= inventory->capacity) is the third bin and not the first, a claim about this file's own code rather than about anything a user typed, and it is what licenses the == on the next line to mean "full" rather than the defensive >= you would write if you did not know. inventory->items[inventory->count] = *item; is a struct assignment, so it copies all three members including the name pointer, which is chapter 6's shallow copy happening on purpose as an ownership transfer: the caller's item and the array element name the same string for one instant, and from the next instant the array element is the owner and the local is a stale duplicate to be neither used nor freed. On the failure path ownership never moved, so one free_item(&item) covers both the case where make_item failed and the case where inventory_add did. And teardown runs in the order that built it, inverted, every name first and then the block holding the structs, since freeing items first would leave the names unreachable and reading them back would be heap-use-after-free: count the allocations and there are N names plus one array, which is exactly N + 1 frees. The command loop is chapter 5's fgets bounded by sizeof(line) with sscanf on what it produced, with no newline strip because %s, %d and %lf all skip whitespace and stop at the next, and with %15s and %63s carrying the field widths that make a %s conversion safe, each one less than its array to leave the terminator its byte. Every branch guards on the returned count as well as on the word, dispatch is strcmp(command, "add") == 0 because strcmp returns an ordering and not a boolean, and every complaint goes to standard error while no complaint ends the program, which is the recovery that reading whole lines buys you. The honest note at the end is chapter 7 lesson 1 arriving in the output: total $9.90 is %.2f doing its job over a double holding 9.9000000000000004, because 0.25 is exact in binary and 0.10 is not, and %.2f rounds for display and changes nothing about the stored value. Here are the chapter's first three lessons in one program small enough to read at once.
#include <assert.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
static const double TOLERANCE = 1e-9;
static int nearly_equal(double a, double b, double tolerance)
{
assert(tolerance > 0.0);
return fabs(a - b) < tolerance;
}
int main(void)
{
int count = 0;
if (scanf("%d", &count) != 1 || count <= 0)
{
fprintf(stderr, "expected a positive count\n");
return 1;
}
double *readings = malloc(count * sizeof *readings);
if (readings == NULL)
{
fprintf(stderr, "could not allocate\n");
return 1;
}
double total = 0.0;
for (int i = 0; i < count; ++i)
{
readings[i] = 0.1;
total += readings[i];
}
printf("%d additions of 0.1 give %.17g\n", count, total);
printf("shown with %%.2f that is %.2f\n", total);
printf("total == 1.0 is %d, nearly_equal is %d\n", total == 1.0, nearly_equal(total, 1.0, TOLERANCE));
free(readings);
return 0;
}
Given 10 on standard input it prints 10 additions of 0.1 give 0.99999999999999989, then shown with %.2f that is 1.00, then total == 1.0 is 0, nearly_equal is 1, with standard error empty, exit status 0 and no report from AddressSanitizer or LeakSanitizer. All three bins appear once each and none of them could take another's job. count is validated because a user typed it, with the complaint on standard error and a nonzero exit, and a 0 here produces expected a positive count and nothing at all on standard output. malloc is checked because it can genuinely fail, and that check has to survive -DNDEBUG, which is why it is an if and not an assertion. tolerance > 0.0 is asserted because nearly_equal is called only from this file and only with TOLERANCE, so a zero or negative tolerance would mean this file's own code had gone wrong rather than that anything failed. The last two lines are lesson 1 in miniature: the accumulated total is wrong by about 1.1e-16, %.2f reports 1.00 and hides it completely, == 1.0 is 0, and the tolerant comparison is 1. Nothing in the program is in the undefined behaviour catalogue either, and each of those absences was a decision: count is positive before it sizes an allocation, every element is written before it is read, the loop bound is the count that sized the block, and the one allocation gets exactly one free. The silence is the result.
Key Terminology
- Binary fraction, tolerance and
%.17g: adoublestores the nearest representable binary fraction, so most decimal fractions round,0.1 + 0.2is0.30000000000000004and0.3is0.29999999999999999; powers of two and whole numbers up to 9007199254740992 are exact; never compare computed values with==, writefabs(a - b) < tolerancefrom<math.h>behind a namednearly_equalwith a namedconst doubletolerance, and remember the tolerance is a choice with a working range that a relative comparison generalizes;%.17gdistinguishes any two doubles where%.2frounds the error out of sight, and error accumulates so a loop must never test a floating total with!= inf, NaN and division by zero:1.0 / 0.0isinfand0.0 / 0.0isnan, both defined by IEEE 754 and both printable and testable, with a NaN spreading through arithmetic and not equalling itself soisnanis the question to ask; integer division or remainder by zero remains undefined behaviour, and the operand types decide which one you wrote- Status, sentinel, out-parameter and standard error: C has no exceptions, so every failure is a return value and you check every allocation and every I/O call; return a status with the result through an out-parameter, or a sentinel such as
NULLor-1where the result is a pointer or a count, and hold to one convention; diagnostics go to standard error, which is the program's commentary rather than its results and which is not fully buffered, so a dying program keeps them, and on this platform only standard output is compared; keep one cleanup path with pointersNULLat the top, forward jumps to a single label and frees in reverse order errno's two rules,perrorandstrtol'send:errnois meaningful only after a call that documents setting it has already reported failure, since nothing clears it on success, and it must be set to 0 before any call whose only failure signal it is; render the message, not the number, withstrerror(errno)orperror("context");strtolreports how far it got,end == textfor no digits,*end != '\0'for trailing junk anderrno == ERANGEfor a value that did not fit, checked in that order- The three bins,
NDEBUGand the five parts of a diagnostic: input is validated, library results are checked, invariants you guaranteed are asserted, and the bins never swap because-DNDEBUGremoves assertions entirely, so an assertion may hold no work and may never stand in for a real check; a failed assertion aborts with no return, no cleanup and no flush of standard output; a gcc diagnostic isfile:line:column, severity, message, the[-Wflag-name]that fired and the source excerpt, so fix the first error and recompile while reading every warning, and-Werroris that rule with the honour system removed - Undefined, unspecified, implementation-defined, and property of the program: nothing required, versus a free choice with no documentation owed, versus a free choice that must be documented; undefined behaviour is a property of the program rather than of a run, so a passing test says nothing, and the compiler may generate code assuming it never happens, which is how one file prints
1at-O2and0at-O0; AddressSanitizer covers most of the memory group and none of the arithmetic group, gcc fires only when the mistake is visible in the text, and the third check is writing defined code - Ownership transfer by shallow copy and the
N + 1count: a struct holding achar *owns a string, needs amake_and afree_written by hand as a pair, and is copied shallowly by assignment, which the capstone uses deliberately so the array element becomes the owner and the local must be neither used nor freed, while on the failure path ownership never moved and the local is the one to release;reallocgoes into a checked temporary with capacity doubling, and teardown frees every name before the block holding the structs, which forNitems and one array is exactlyN + 1frees
The Course, Closed
Seven chapters ago a program was a printf and a return 0, and the arc since then has been one question asked at larger and larger scale: what exactly is in memory, who put it there, and who gives it back. Chapter 1 named the object and its bytes, chapter 2 named what the operators are allowed to do to them, chapter 3 gave the call stack its frames and its lifetimes, chapter 4 handed you the heap and the obligation that comes with it, chapter 5 showed what a string really is once you accept it is an array with a terminator, chapter 6 gathered fields into one object and one program into several files, and this chapter dealt with the trust you place in all of it. You now have the patterns that make real C readable, and they will jump out of other people's code: the pointer and count travelling together because C never tells a function how long anything is, the make_ and free_ pair standing in for a constructor and destructor the language does not have, the single cleanup: label with its frees in reverse order, and the three catalogues, the sanitizer's reports, the compiler's warning flags and the undefined behaviour list, which between them explain almost every failure you will meet. Reading real C is the honest next step, and small self-contained programs are where to start, because the vocabulary is now yours even where the domain is not. Plenty was left out on purpose. Files on disk are a whole subject beyond the standard input this course read from, function pointers were named once in the catalogue and never used, threads and the concurrency rules that come with them were not touched, and the build systems that turn many translation units into one binary were left at the point where chapter 6 explained what the linker is doing. Those are the next four things to learn rather than gaps in what you have, and each of them will assume exactly what you now know: that a program is objects with lifetimes, that failure arrives as a return value you have to check, and that the standard's silence is something to design around rather than to test your way past.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Chapter 7 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!