Skip to content
academia.sh

Lesson 05 / 13

Compiler Warnings and Standards

A warning can only be born at the compiler stage, because only that stage sees the types and expressions inside a single unit. What the standard guarantees is set side by side with what it leaves to the implementation — sizeof(char) == 1 is a guarantee, other sizes are not. Nor can a program that compiles without a warning be said to be correct; to show this, a computation that prints no warning but produces a wrong result is run.

Published Updated

In this lesson

  • Justifies why a warning can only be born at the compiler stage
  • Distinguishes with an example the behavior the standard guarantees from what it leaves to the implementation
  • Shows with a running example that compiling without a warning is not a guarantee of correctness
Contents

The previous lesson deepened the preprocessor’s four decisions and at its close brought out one observation: through these four lessons no code block left behind a warning, but this was not a coincidence, it was a consistency provided by hand. This lesson looks at how that consistency is checked — at which stage a warning is born, and what the standard guarantees and what it leaves to the implementation. At its opening, the course named the four stages; this final lesson uses those four names once more, but this time not for a “decision” but for a “check”: only one of the four stages, staying within the bounds of its own input, can think that the source is probably wrong. What this lesson measures is where that thought is born and how reliable that thought is.

Where a Warning Is Born

A warning can only be born at the compiler stage. Because the preprocessor knows no types, it cannot notice that a type is used incorrectly — all it has in hand is text. The linker only matches names, it never examines the content of an expression. Runtime, in turn, no longer carries the source itself; all it has in hand is data, there is nothing for it to warn about. The compiler does exactly the opposite of this — it sees the type of every expression, the use of every variable, and which value every control path returns, all inside a single unit, and as a result of this seeing it can think that something is suspicious.

This is an extension of the observation in the Compilation Stages lesson: each of the four stages looked only at its own input, and where an error would be caught was bound by that input as well. A warning is exactly a check that stays within the bounds of the compiler’s own input — it flags something it can notice by looking at only a single unit. If an error spreads across multiple units (like the declaration/definition mismatch in the Header Files lesson), it is not possible for the compiler to catch it with a warning either — because that error lies entirely outside the compiler’s input.

Being suspicious is not the same thing as being wrong. A warning typically points to one of these patterns: a constant value assigned directly to a type too narrow to hold it, a variable defined but never used, a signed number compared directly with an unsigned number, or a function that never returns a value on some paths although it is expected to. All four carry the same property: the source is in a compilable state, but the compiler, by seeing the types and flow inside a single unit, has derived a risk from the pattern it saw.

What these four patterns share is that none of them is an error on its own. An unused variable does not stop the source from running; comparing a signed number with an unsigned one is a valid C expression; a function not returning a value on some paths does not make the source uncompilable. The compiler rejects none of these patterns, it only flags them — because each can be a sign of something the programmer did not actually want, but does not have to be. This also explains why a warning must be turned on with an optional flag (like -Wall, -Wextra): the standard does not determine which patterns the compiler will count as suspicious, it determines only which patterns the program can remain compilable with.

Every code example in this course avoided these four patterns, but avoiding them was a choice made by hand — the language itself kept no example from falling into these patterns. If all twenty-one code blocks in this topic stayed free of warnings, the reason is not that the compiler was lenient, but that every variable was used, every comparison was made with types of the same sign, and every function returned a value on every path.

Maintaining this consistency will remain the same obligation through the rest of the course. When a warning appears, that warning points to a single unit, a single expression, in the source — the first step in fixing the source is knowing which unit the warning belongs to. This confirms once more why a warning is bound so tightly to the compiler class: a warning disappears only once the unit that produced it is recompiled; no other stage has the power to silence it.

What Is Guaranteed, What Is Not

The C standard (C17) guarantees some things with certainty and leaves others to the implementation. The difference between the two can be shown by running it — but only the guaranteed side, because a number that is not guaranteed is never printed anywhere in this course.

This distinction is not arbitrary, it is a direct consequence of the language’s closeness to hardware. Four separate pieces of hardware can offer four separate integer widths; instead of imposing a fixed width so that the language can run on every piece of hardware, the standard imposes a lower bound and leaves the rest to the compiler, and therefore indirectly to the hardware. How many bytes int is on this machine is therefore not a promise of the language but a decision of the compiler — and it is an observation never printed anywhere through this course.

// guarantee.c — what the standard guarantees and what it leaves, side by side
#include <limits.h>
#include <stdio.h>

int main(void) {
    printf("sizeof(char) == 1: %s\n", (sizeof(char) == 1) ? "true" : "false");
    printf("CHAR_BIT >= 8: %s\n", (CHAR_BIT >= 8) ? "true" : "false");
    printf("INT_MAX >= 32767: %s\n", (INT_MAX >= 32767) ? "true" : "false");
    printf("LONG_MAX >= 2147483647: %s\n", (LONG_MAX >= 2147483647L) ? "true" : "false");
    return 0;
}
sizeof(char) == 1: true
CHAR_BIT >= 8: true
INT_MAX >= 32767: true
LONG_MAX >= 2147483647: true

LC13. All four lines print the result of a comparison, none prints a raw number. sizeof(char), CHAR_BIT, INT_MAX, LONG_MAX’s own values can vary by implementation — the standard guarantees only a lower bound, it leaves the upper bound free. How many bytes int actually is, how many bits long actually carries, was never printed anywhere in this course and never will be; what is printed is only the true/false answer to the question “is this value at least this much,” because this answer is the same in every C17 compiler that conforms to the standard.

All four of these lines came out true, because all four are genuinely guaranteed lower bounds. What the standard does not guarantee never appears in the lines at all: sizeof(int)’s exact value, whether int is actually wider than short, whether char is counted as signed or unsigned. All of these are left to the implementation, and this is why none of them was run in any lesson of this course.

This difference between the lower and upper bound resembles the “guarantee versus excerpt” distinction the course saw earlier: a header file could share a declaration, but did not guarantee that the definition would match it (the Header Files lesson); here too, the standard gives a lower bound, but does not guarantee where within that bound the actual value will stand. The same pattern repeats in both: the promise the language gives is always a minimum, and what is added on top of it is left to the implementation, that is, to the compiler and the hardware.

None of these four lines produced a warning either, and this is not a coincidence. A comparison like CHAR_BIT >= 8 is a comparison of two constant expressions, and the compiler sees no pattern in it to find suspicious — both sides of the comparison belong to the same broad integer class, there is no mixing between signed and unsigned, no value is narrowed. Measuring the side the standard guarantees is therefore both correct and safe: it neither leaks an environment-dependent number, nor falls into a pattern the compiler is suspicious of.

This distinction has a practical consequence: a source built only on the side the standard guarantees gives the same result regardless of which compiler and which hardware it is compiled with. sizeof(char) == 1 is true everywhere, because the standard already makes this mandatory. But had a source rested on an assumption like sizeof(int) == 4, that assumption could turn out true on some implementations and false on others — and since the compiler cannot see this assumption in the source, it would not print a warning where it turns out wrong either. A source that rests only on the guaranteed side keeps its correctness regardless of which compiler and which hardware it is compiled with; a source that rests on a side that is not guaranteed can, even if the compiler prints no warning at all, silently give a wrong result on different hardware.

The Bounding Measurement

A warning is not an error; the compiler continues compiling the program even after it prints a warning, and the program runs. The mirror image of this is also true: a program that compiles without a warning cannot be said to be correct. These two sentences together define not what a warning is, but what it is not: a warning is neither proof of an error, nor is its absence proof of correctness — it is only a piece of information about what the compiler noticed, nothing more. The program below compiles without leaving a single warning, but its second line gives an unexpected result.

// ratio.c — a program that compiles without a warning does not guarantee it is correct
#include <stdio.h>

int main(void) {
    int part = 2;
    int total = 5;

    int integer_ratio = part / total * 100;
    double float_ratio = (double)part / total * 100;

    printf("ratio computed with integers: %d\n", integer_ratio);
    printf("ratio computed with floating point: %.1f\n", float_ratio);

    return 0;
}
ratio computed with integers: 0
ratio computed with floating point: 40.0

LC14. Both lines start from the same two numbers (2 and 5), and neither got a single warning from the compiler. But integer_ratio gave 0, because part / total was done between two ints, and 2 / 5 rounded toward zero gives 0 — the multiplication by 100 came after that, and multiplying a number that was already zero by a hundred still gives zero. float_ratio, on the other hand, converted part to double first, so the division was done in floating-point arithmetic and gave the correct ratio, 40.0. The compiler finds neither of these “wrong,” because both are consistent with their own type: the result of an int / int operation being int is a rule of C, not an error — this was already seen in this course’s second lesson, as the difference between 7 / 2 and 7 / 2.0.

The way to fix integer_ratio in the source, just as with float_ratio, is to convert one of the two sides to double before dividing — the operation the program runs stays the same, only which arithmetic will be used is stated explicitly in the source. The compiler cannot suggest this fix, because suggesting it would need the information “the programmer wanted a ratio here,” and this information is written nowhere. All the compiler can see is that both expressions are consistent with their own type.

This separates a guarantee the language gives from a decision the programmer makes. The language guarantees that int / int will give int, and applies this consistently — the compiler violates nothing here. What is wrong is assuming that the division of two integers will carry a ratio; this assumption is written nowhere in the source, it existed only in the programmer’s mind. The compiler cannot see what is in a mind, it sees only what is written in the source — and what was written in the source was a completely consistent, completely warning-free int division.

This is the final link of a pattern built up over four lessons: each stage looks only at its own input and accepts everything that is consistent within that input. The preprocessor found the text consistent, the compiler found the types consistent — but consistency is not the same thing as correctness. The variable integer_ratio is flawless from the compiler’s point of view; from a programmer’s point of view it is probably a mistake. The gap between these two is a gap no stage can fill — only the one who wrote the source can fill it.

This is a conclusion that also holds true for the last lesson of a framework built up over five lessons: none of the four stages proves that the source is correct — each checks only a consistency specific to its own stage. The preprocessor checks the form of the text, the compiler checks types and flow, the linker checks names, and runtime works only with the data it is given. What is obtained once all four are passed is a running program — but no stage says that a running program is the same as the wanted program.

Summary

  • A warning can only be born at the compiler stage; only this stage sees the types and expressions inside a single unit and can find a pattern suspicious.
  • A warning is not the same thing as an error: the program compiles and runs with the warning too.
  • The standard guarantees some things with certainty (sizeof(char) == 1, CHAR_BIT >= 8), and leaves others to the implementation (actual type sizes); this course printed only what is guaranteed.
  • A program that compiles without a warning does not show that it is correct: int / int division is a rule of the language, but taking its result to be a ratio is an assumption the programmer never wrote in the source.
  • The promise the standard gives is always a minimum; what is added on top is left to the compiler and the hardware, which is why only the minimum, guaranteed side was run through this course.
  • None of the four stages proves that the source is correct; each checks only a consistency specific to its own stage, and catching the difference between a running program and the wanted program is the job of the one who wrote the source, not the language.

Next Step

This is the last lesson of the Language and Compilation topic. Five lessons passed through the same core, the same four classes, and each one lit up a separate corner. The Place of C ran the core in full and named the four stages with the fourteen observations’ 4/4/3/3 distribution. Compilation Stages ran the same four classes side by side with a small excerpt, showed which input each looks at, and proved that the stages run in an order with no way back. Header Files separated declaration from definition and showed how a header carries the same declaration into multiple units, and what the include guard prevents. The Preprocessor revealed that four separate directives are in fact the appearance of a single job — text substitution — and proved that a macro can substitute its argument more than once. This final lesson looked at the fifth corner: that a warning can only be born at the compiler, and that compiling without a warning is not a guarantee of correctness.

The five lessons’ shared ground was that none of them measured again what another had already measured — each used the same four names, but each asked its own question. This shared ground will continue through the rest of the course: the eight lessons of the Basic Syntax topic will also use the same four classes, but each will look at a different corner of C — types, variables, operators, control flow, functions, arrays, record types, input and output.

The Basic Syntax topic does not change this framework, it adds C’s own types, variables, and operators to it; the first question is which stage settles the values a type carries.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close