Skip to content
Courses

Types, Functions, and Control Flow in C

Programming FoundationsLesson 6 of 8105 minutesLessonBuilds on: C Programs and CompilationNo lab next

The question: which declaration or branch produced this result?

Section titled “The question: which declaration or branch produced this result?”

Python let us concentrate on behavior while the runtime tracked value types on our behalf. In C, declarations supply the type information the compiler uses to choose operations, reserve storage, pass arguments, and interpret returned values. A type affects the compiled program, so we need to read it as carefully as the operation beside it.

Those declarations help us investigate low-level behavior, but they don’t check all of our assumptions. The compiler can check whether a lot of operations match their declarations. It cannot prove that every numeric conversion preserves meaning, and it certainly can’t prove that some branch expresses the rule you actually had in mind when you wrote it. Connecting declarations, control flow, and output is our job.

We’ll keep one complete program running through this whole lesson. It classifies three scores, and then shows what happens when a value gets converted into a much smaller unsigned type. The names and the build settings stay fixed throughout, so every change in the output has a cause you can trace.

Let’s work in a dedicated directory again:

Terminal window
mkdir -p ~/underdunn-course/exercises/c-types
cd ~/underdunn-course/exercises/c-types

Create classify.c with this complete source:

#include <stddef.h>
#include <stdio.h>
enum { LONG_LIMIT = 8 };
static const char *classify(int score) {
if (score < 0) {
return "invalid";
}
if (score >= LONG_LIMIT) {
return "long";
}
return "short";
}
static void show_narrowing(void) {
unsigned int wide = 300;
unsigned char narrow = (unsigned char)wide;
printf("wide=%u narrow=%u\n", wide, (unsigned int)narrow);
}
int main(void) {
int scores[] = {-1, 7, 8};
size_t count = sizeof scores / sizeof scores[0];
for (size_t index = 0; index < count; index++) {
printf("score=%d class=%s\n", scores[index], classify(scores[index]));
}
show_narrowing();
return 0;
}

Before you build it, get your bearings in the file. #include makes library declarations available to the compiler, while the enum attaches the name LONG_LIMIT to an integer constant, so that the decision rule reads as something meaningful rather than as a stray 8 sitting in the middle of a comparison. Each static function stays private to this one source file, and main is the program’s entry function, reached only after the C runtime has finished preparing the process.

There are three layers of control flow in here. The for loop picks each array element in turn, classify chooses exactly one return path for that value, and printf turns whatever was selected into visible text on your terminal. Read those layers separately: which value does the loop select, which branch does the function take, and what does printf show us?

Write out all four predicted output lines. For each score, name the first condition that decides its return value, since only one of them ever gets to decide. And for that final line, work out whether 300 can even be represented by an unsigned char on the supported course environment.

Build with warnings enabled and run:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -O0 -g classify.c -o classify
./classify

Expected output on the supported Ubuntu x86-64 environment:

score=-1 class=invalid
score=7 class=short
score=8 class=long
wide=300 narrow=44

If the compilation prints a warning, stop and compare your whole file against the supplied source. This baseline should compile completely silently.

A declaration tells both the compiler and the reader how a name is allowed to be used. I’d start with the name, then identify its type, parameters, and return value where applicable.

Declaration Contract used here
int score classify receives one signed integer value.
const char * It returns an address identifying characters the function does not modify.
void parameter list show_narrowing accepts no arguments.
size_t count The count uses the unsigned type intended for object sizes.
int main(void) The program accepts no command-line data and returns an integer status.

A declaration does not prove the implementation is correct. It only tells the compiler and the reader how the value is meant to be used.

const char *classify(int score) deserves a slower reading than the rest. The function is named classify, it receives one int called score, and it returns a pointer to characters this code treats as read-only. Those returned string literals live long enough for printf to use them, and the function isn’t handing back a pointer into some temporary local array that’s about to disappear.

size_t is the type sizeof produces, and it’s intended for object sizes and counts (not for arbitrary arithmetic). It is unsigned, so it cannot represent a negative size at all. That doesn’t make every unsigned value a valid size, and it does mean the type’s range and its comparisons have to be understood on their own terms.

The format string is a contract as well. %d expects an int, %s expects a pointer to a zero-terminated string, and %u expects an unsigned int. The arguments must agree with the format. Compiler warnings can catch many mismatches, but printf does not check the argument types at runtime. The explicit casts in show_narrowing are there to make that agreement visible.

Control flow is simply the order statements execute in. An if does not label a value forever, and instead evaluates a condition at that one particular moment and then chooses whether or not to run its body. A return ends the current function call immediately, and hands a value back to the caller.

For score = 7:

  1. score < 0 is false.
  2. score >= LONG_LIMIT is false because the limit is 8.
  3. The final return "short" runs.
  4. printf receives 7 and the returned string address.

The loop repeats that same function call for every array element, and index < count gets checked before each iteration.

The loop starts with index at zero, and after each pass through the body index++ adds one to it and the condition gets tested all over again. Once index reaches count the condition turns false, and it does so before anything gets a chance to read an out-of-range element. That repeated boundary check is the relationship worth preserving when you modify the array later on.

Copy the baseline and change only LONG_LIMIT from 8 to 10:

Terminal window
cp classify.c classify-limit-10.c

Predict which output rows are going to change, and then build and run the copy:

Terminal window
gcc -std=c11 -Wall -Wextra -Wpedantic -O0 -g classify-limit-10.c -o classify-limit-10
./classify-limit-10

Only the row for score 8 should flip from long to short, and the input array, the loop, and the narrowing observation should all have stayed exactly as they were.

Integer types have finite ranges, because they’re built from a finite number of bits. On the supported environment an unsigned char has eight value bits and represents 0 through 255. The value 300 fits comfortably in an unsigned int, and not in that destination range at all.

Converting 300 to that type produces 44 because unsigned conversion wraps modulo 256.

Modulo 256 just means keeping the remainder after division by 256 (here, 300 = 1 × 256 + 44). That result is defined for conversion to an unsigned integer type. It is emphatically not permission to assume that every overflow or out-of-range conversion in C wraps around safely, because a good number of them are undefined behavior instead.

So the observation is that the printed wide value stays sitting at 300 the whole time, while narrow comes back as 44. The conclusion is that conversion to a narrower type can produce a different value without changing the original variable.

Don’t go generalizing this one result to every C type conversion. Check the source type, the destination type, the value, and the result you actually observed, and leave the generalizing for later.

Use the type and conversion sections of GNU C language manual to check the rule behind the observed result. One run is evidence about this build, not a substitute for the language contract.

In a fresh copy, add score 10 to the array, and predict both the new output line and the new iteration count. Build it with exactly the same command, run it, and compare the two. Don’t touch the threshold during this practice, since that’s a different variable.

  • Compiler reports an expected token or missing brace: replace the file with the complete baseline, build it, then reapply one intended edit.
  • ./classify is stale: rebuild after every source edit, and check the compiler command actually names the source and output files you intended.
  • You’ve started ignoring a warning: treat warnings as evidence. Restore the baseline until it builds with none at all.
  • The threshold copy changed several rows unexpectedly: run diff -u classify.c classify-limit-10.c, where only the enum value should differ.
  • The directory contains confusing binaries: remove only classify, classify-limit-10, and your practice binary, then rebuild.

Use the output from your added case to follow the condition and return value that produced it. At the threshold, changing <= to < should change the boundary decision. If it does not, check the input and rebuild the edited source.