C Part I · Module 5 · Functions and Program Decomposition
Designing Small, Testable Functions
Every program in this module arrived with its decomposition already decided. The functions were there, named, with their parameters chosen, and the chapters were about what the machinery does rather than why those particular functions exist. That is the last thing missing. Deciding which functions a program should have is a real engineering skill, it is mostly not a matter of taste, and most of it reduces to a single question you can ask about any candidate function and answer concretely. This chapter establishes that question, applies it to a worked refactor whose output does not change at all, and then covers the three things a caller actually depends on: what a function promises, how many things it asks for, and whether its name is telling the truth.
Intermediate18 min readCPart IFundamentalsFunctions
Part I · Module 5 · Chapter 5.9 · Designing Small, Testable Functions
1. The Question That Settles Most of It
Chapter 5.1 argued that a function's value is the boundary it creates, and demonstrated it by exercising count_set_bits at four inputs the surrounding program would never have produced. That was an observation. Here it becomes the criterion:
What can I now check, on its own, without running the rest of the program?
Almost every design question in this chapter is answered by applying it. It is concrete, it does not depend on anyone's taste, and the answer is a number you can count.
2. A Function Doing Four Jobs
#include <stdio.h>
int accepted_total = 0;
void handle(unsigned int code, unsigned int noise)
{
if (code > 4095u) {
printf("code %u rejected: out of range\n", code);
return;
}
if (noise > 50u) {
printf("code %u rejected: too noisy\n", code);
return;
}
unsigned int mv = (code * 3300u) / 4095u;
accepted_total = accepted_total + 1;
if (mv >= 3000u) {
printf("code %u = %u mV HIGH\n", code, mv);
} else {
printf("code %u = %u mV ok\n", code, mv);
}
}
int main(void)
{
handle(2048u, 10u);
handle(5000u, 10u);
handle(4000u, 10u);
printf("accepted %d\n", accepted_total);
return 0;
}Output:
code 2048 = 1650 mV ok
code 5000 rejected: out of range
code 4000 = 3223 mV HIGH
accepted 2It is correct. It is twenty lines. Nothing in it is clumsy, and the name handle is the first clue that something is wrong — it is the name you choose when there is no single thing to call it.
Apply §1's question. What can you check on its own? Nothing. handle returns void, so there is no result to compare against anything. It prints, so checking it means reading output. It modifies a file-scope object, so running it twice does not give the same state. And it does four separable things:
1. decide whether the sample is acceptable
2. convert a code to millivolts
3. decide whether that is high
4. report the outcome
(and, in passing, count the accepted ones)Four jobs. Chapter 5.1's heuristic — if the name needs the word and, it is more than one function — applies with some force here, since the honest name would be validate_and_convert_and_classify_and_report_and_count.
3. The Same Program, Decomposed
#include <stdio.h>
const int sample_ok = 0;
const int sample_out_of_range = 1;
const int sample_too_noisy = 2;
int classify_sample(unsigned int code, unsigned int noise)
{
if (code > 4095u) {
return sample_out_of_range;
}
if (noise > 50u) {
return sample_too_noisy;
}
return sample_ok;
}
unsigned int code_to_millivolts(unsigned int code)
{
return (code * 3300u) / 4095u;
}
int millivolts_are_high(unsigned int millivolts)
{
return millivolts >= 3000u;
}
void report_sample(unsigned int code, int verdict, unsigned int millivolts)
{
if (verdict == sample_out_of_range) {
printf("code %u rejected: out of range\n", code);
} else if (verdict == sample_too_noisy) {
printf("code %u rejected: too noisy\n", code);
} else if (millivolts_are_high(millivolts)) {
printf("code %u = %u mV HIGH\n", code, millivolts);
} else {
printf("code %u = %u mV ok\n", code, millivolts);
}
}
int handle_sample(unsigned int code, unsigned int noise)
{
int verdict = classify_sample(code, noise);
report_sample(code, verdict, code_to_millivolts(code));
return verdict == sample_ok;
}
int main(void)
{
int accepted = 0;
accepted = accepted + handle_sample(2048u, 10u);
accepted = accepted + handle_sample(5000u, 10u);
accepted = accepted + handle_sample(4000u, 10u);
printf("accepted %d\n", accepted);
return 0;
}Output:
code 2048 = 1650 mV ok
code 5000 rejected: out of range
code 4000 = 3223 mV HIGH
accepted 2Byte-identical to §2. As in Chapter 5.1, that is deliberate: the comparison is about structure alone, with no possibility that the second version looks better because it does more.
Three changes carried all of it.
The file-scope counter is gone. handle_sample returns 1 or 0 and main adds it up. The count now lives where the loop over samples lives, which is Chapter 5.6's advice applied — and it means handle_sample gives the same answer every time it is called with the same arguments, which the old handle did not.
Deciding is separated from reporting. classify_sample works out what is true; report_sample works out what to say. That split is the single most valuable one in the refactor, and §5 is about why.
Each piece got a name that is a claim. millivolts_are_high reads as a question with a yes-or-no answer, which is exactly what it returns.
4. Counting What You Gained
Apply §1's question to both versions.
| Version | Functions that can be exercised alone | How |
|---|---|---|
| Before | 0 | handle returns nothing and prints |
| After | 4 | each returns a value for given arguments |
classify_sample is the clearest case:
printf("(0, 0) -> %d\n", classify_sample(0u, 0u));
printf("(4095, 50) -> %d\n", classify_sample(4095u, 50u));
printf("(4096, 0) -> %d\n", classify_sample(4096u, 0u));
printf("(0, 51) -> %d\n", classify_sample(0u, 51u));
printf("(4096, 51) -> %d\n", classify_sample(4096u, 51u));(0, 0) -> 0
(4095, 50) -> 0
(4096, 0) -> 1
(0, 51) -> 2
(4096, 51) -> 1Five inputs, chosen at the boundaries exactly as Chapter 4.7 argued — the last accepted value, the first rejected one, and both failures at once. None of them requires a sensor, a printf, or the rest of the program.
The last line is the interesting one and is the reason this matters. With both faults present, the function answers out_of_range rather than too_noisy, because the range test is written first. That is a real behaviour with real consequences for anything reading the verdict, no caller could have guessed it from the signature, and it only became visible because the function could be asked. In §2's version that precedence existed too, buried, and there was no way to discover it short of reading the body.
5. Separate Deciding From Doing
The split in §3 generalises into the most useful rule in the chapter.
A function should either compute a value or have an effect — and as far as possible, not both.
A function that computes can be checked by comparing its result against what you expected. A function that has an effect can only be checked by observing the effect. Mixing them means the computing part inherits the second problem for no reason.
| Computes | Has an effect | |
|---|---|---|
| Examples here | classify_sample, code_to_millivolts, millivolts_are_high | report_sample |
| Checked by | comparing the returned value | observing what it did |
| Same arguments, same result? | yes | not necessarily |
| Safe to call twice? | yes | depends |
| Can be called from a test | yes | awkwardly |
The practical shape this produces is the one §3 ended up with: a layer of functions that work things out, a thin layer that acts on the answers, and a caller that joins them. Most of the logic lands in the first layer, where it is cheapest to check.
handle_sample is the deliberate exception. It does both — it reports and returns whether the sample was accepted — because something has to join the two layers. Keeping that joining function short and free of logic of its own is what stops the exception from spreading.
6. Parameters Are a Cost
Every parameter is something the caller must supply correctly. That cost is not theoretical:
#include <stdio.h>
int clamp(int value, int low, int high)
{
if (value < low) {
return low;
}
if (value > high) {
return high;
}
return value;
}
int main(void)
{
printf("clamp(150, 0, 100) = %d\n", clamp(150, 0, 100));
printf("clamp(150, 100, 0) = %d\n", clamp(150, 100, 0));
return 0;
}Output:
clamp(150, 0, 100) = 100
clamp(150, 100, 0) = 0The second call has low and high the wrong way round. It returns 0 — a plausible-looking number that is completely wrong — and the compiler reports nothing, at any warning level used in this curriculum. It cannot: all three parameters are int, and the call is perfectly well formed.
Same-typed adjacent parameters are an unchecked interface. The compiler verifies the count and the types, which is exactly what Chapter 5.5 established, and the types are identical, so there is nothing left to verify.
Four things reduce the exposure, in rough order of usefulness.
Fewer parameters. A function needing six things is usually doing more than one job; look there first.
Order them so the mistake is implausible. clamp(value, low, high) at least reads in a natural order. clamp(low, high, value) would invite the error.
Give them different types where the types are genuinely different. An unsigned int code and an int verdict cannot be swapped silently, because the compiler would have something to say about it.
Group related parameters into one value. A low and a high that always travel together are really one thing — a range. C's way of saying that is a structure, which is Chapter 10.4's subject, and it is the real fix for a long parameter list rather than a workaround.
Until then, keep the list short and the names honest.
7. Honest Names
A name is a promise to the next reader. Four kinds of promise appear in this module.
| Kind | Reads as | Examples |
|---|---|---|
| Produces a value | a noun for the value | code_to_millivolts, first_divisor, next_backoff |
| Answers yes or no | a question | reading_is_valid, millivolts_are_high |
| Has an effect | a verb | report_sample, describe |
| Both | a verb, and kept short | handle_sample |
Two naming mistakes are worth naming explicitly.
A verb for a function that returns a value and changes nothing. Chapter 5.3 hit this: increment(count) sounds like it increments count and cannot, while incremented(count) describes the value it hands back. A verb promises an effect; if there is no effect, do not promise one.
A name that is true but useless. handle, process, do_work, manage are all accurate about every function ever written. They are the names you reach for when the function has no single job, which makes them a useful signal: if you cannot name it, you are not finished designing it.
8. The Contract
Everything above comes together in one idea. A function's contract is what a caller may rely on, and it is larger than the signature:
int classify_sample(unsigned int code, unsigned int noise);
needs: a raw code and a noise measure
gives back: sample_ok, sample_out_of_range, or sample_too_noisy
promises: the range is checked before the noise, so a sample that
fails both is reported as out of range
assumes: nothing — any values of either parameter are acceptable
does not: print, modify anything, or depend on previous callsThe signature carries the first two lines. The compiler enforces only those, as Chapter 5.5 showed. The rest is what the function actually promises — and §4 demonstrated that the third line was discoverable only by asking.
Three habits keep a contract honest.
Make it total where you can. classify_sample accepts any pair of values and always returns one of three answers. A function with no rejected inputs has no preconditions for a caller to get wrong.
Where it cannot be total, state what is assumed. A function that only works for a positive n should say so, in a comment above it, in the terms a caller cares about.
Keep the promise narrow. Every extra thing a function guarantees is a thing you cannot change later. classify_sample promising its precedence is useful; it promising a particular numeric value for sample_too_noisy would not be.
Chapter 13 returns to this with the tools for checking contracts at run time. The design habit comes first, because a tool cannot check a promise nobody wrote down.
9. What You Should Be Able to Say Now
- The design question is what can I check on its own, and the answer is a number.
- The refactor in §3 produced byte-identical output and went from 0 checkable functions to 4.
- A function with one describable job is the target; the
andtest finds the ones that are not. - Separate computing from having an effect. Computing functions are cheap to check; effect functions are not.
- A joining function that does both is fine if it is short and has no logic of its own.
- Returning a value beats updating file-scope state — same arguments, same answer, every time.
- Every parameter is a cost.
clamp(150, 100, 0)returns 0 with no diagnostic at any warning level. - Reduce that exposure with fewer parameters, sensible order, distinct types, and eventually grouping (Chapter 10.4).
- Names are promises: nouns for values, questions for predicates, verbs for effects.
handleandprocessmean you are not finished. - Small is not the criterion — one job is. A contract is larger than a signature.
10. Exercises
Exercise 1 — count the jobs. List every separable job in §2's handle, then say which §3 function took each one over, and which job main ended up with.
Exercise 2 — predict the boundaries. Without compiling, give the verdict classify_sample returns for (4095, 51), (4096, 50), (0, 50) and (4096, 51). Say which part of its contract the last one depends on.
Exercise 3 — apply the name test. For each, say whether the name is honest, and rename it if not.
int validate(int n); /* returns 1 or 0 */
void compute_total(int a, int b); /* prints the sum */
int process(unsigned int code); /* returns millivolts */
int is_ready(void); /* returns 1 or 0 */
int update_and_report(int n); /* changes state, prints, returns a count */Exercise 4 — find the swapped call. Given this function, say which of the four calls express is 50 inside the range 0 to 100, what each actually returns, and why the compiler accepts all of them.
int in_range(int value, int low, int high)
{
return (value >= low) && (value <= high);
}in_range(50, 0, 100)
in_range(0, 50, 100)
in_range(50, 100, 0)
in_range(100, 0, 50)Exercise 5 — split one. Rewrite this into two functions, one that computes and one that reports, and say what you can check after the split that you could not before.
void show_average(int total, int count)
{
if (count == 0) {
printf("no samples\n");
return;
}
printf("average %d\n", total / count);
}Exercise 6 — argue against a split. A reviewer asks you to break classify_sample into code_in_range and noise_acceptable. Give one argument for doing it and one against, then say which you would choose and what the deciding consideration is.
Exercise 7 — write the contract. Write the full contract, in §8's format, for next_backoff from Chapter 5.3 §6. Include at least one thing it promises that the signature does not say, and one thing it deliberately does not do.
Exercise 8 — redesign from scratch. A function is described as: reads a raw code, rejects it if out of range, converts it to millivolts, adds it to a running total, prints a line, and returns the number of samples accepted so far. Decide which functions should exist, give each an honest name and signature, say which are computing and which have effects, and name the one thing in that description you would move out of the function set entirely and into the caller.
11. Summary
- The design criterion is what can be checked on its own — a countable answer, not a matter of taste.
- §3's refactor changed nothing about the output and took the program from 0 independently checkable functions to 4.
- Aim for one describable job; the
andtest identifies the failures. - Separate computing from acting. Keep the joining function short.
- Return values instead of updating file-scope state, so the same arguments always give the same answer.
- Parameters are a cost:
clamp(150, 100, 0)is silently wrong, because the compiler has nothing left to check. - Names are promises — a verb promises an effect, and
handlepromises nothing. - Small is not the goal. One job is, and over-decomposition is a real failure mode.
- A contract is what a caller may rely on, and most of it is not in the signature.
Module 5 is complete. A program is no longer one block of statements but a set of named units with boundaries, each taking values, producing values, and holding its own objects for as long as its call lasts. Every one of those units has so far dealt in single values — one code, one count, one verdict at a time — and that is the limit the next module removes. Working with many values of the same kind raises a question this module never had to answer, because a run of storage holding fifty readings is a different kind of thing from an int, and what happens when you hand one to a function turns out to be the most surprising consequence of the rule Chapter 5.3 established.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
