๐Ÿ’พ

C Intermediate

A Complete 7-Chapter Programming Course

Topics covered:
Structs & unions · Dynamic memory & C's own ownership model
Memory bugs & real tooling (valgrind/ASan) · The preprocessor
Multi-file projects & Makefiles · File I/O · Function pointers & hand-rolled dispatch

Exercises: 21 hands-on exercises with worked solutions
Format: A4 · Dark-theme code examples · framed throughout against Rust
Course 2 of 3 · the Advanced course follows

Table of Contents

  1. Structs & Unions
  2. Dynamic Memory
  3. Memory Bugs
  4. The Preprocessor
  5. Multi-File Projects & Makefiles
  6. File I/O
  7. Function Pointers
Chapter 1 of 7

Structs & Unions

Course 2 ยท Ch 1
Structs & Unions
Composite data โ€” and the one place C quietly stops tracking what's actually there

Course 1 covered scalars, arrays, and the pointers connecting them. Course 2 opens with C's way of grouping several values into one โ€” and, in the same chapter, its way of letting several values share one.

Declaring & Using Structs

struct Point { int x; int y; }; struct Point p = {3, 4}; printf("%d, %d\n", p.x, p.y);

typedef

Referring to struct Point everywhere gets verbose fast. typedef gives the type a shorter name โ€” an idiom so common in real C that it's effectively the default style.

typedef struct { int x; int y; } Point; Point p = {3, 4}; // no "struct" keyword needed anymore

Rust never needed this ceremony โ€” a struct's own name is always the type, directly, with no separate aliasing step required.

Structs Are Pure Data

A C struct has no methods attached to it โ€” no impl block equivalent from Rust. Any function operating on a struct is written separately, typically taking a pointer to it as a parameter. Course 2's own Function Pointers chapter (Ch.7) later shows one way C approximates method-like dispatch without a language feature for it at all.

Unions

A union looks like a struct syntactically, but its members share the same memory โ€” only one is genuinely "active" at a time. A union's sizeof equals its largest member's size, not the sum of all members, unlike a struct.

union Value { int as_int; float as_float; }; union Value v; v.as_int = 42; // v.as_float now reads the SAME bytes, reinterpreted as a float โ€” not 42.0

The Danger of Type Punning via Unions

Reading a union member that wasn't the one last written is, in most cases, undefined behavior โ€” the union itself carries no record of which member is currently valid. Compare this directly to Rust's own data-carrying enums (rust1-6): a Rust enum stores a hidden discriminant tracking which variant is actually active, so accessing the wrong one is caught as a compile-time type error through match, not silently allowed at runtime. A C union tracks nothing at all โ€” the programmer is expected to remember.

ConceptRust enumC union
Tracks which variant/member is activeyes โ€” a hidden discriminant, checked by matchno โ€” nothing tracks it at all
Reading the "wrong" onenot possible โ€” match forces handling every caseundefined behavior in most cases
Sizelargest variant + discriminant tagexactly the largest member โ€” no tag
typedef struct is the idiomatic default
Combining an anonymous struct definition with a typedef in one statement is extremely common in real C code โ€” expect to see it constantly when reading existing codebases.
Reading the wrong union member isn't "reading garbage" โ€” it's UB
This is genuinely stronger than "you'll get a nonsense value." Because it's undefined behavior, the compiler is free to assume it never happens at all, and can optimize the surrounding code in ways that produce surprising results well beyond just a wrong number โ€” exactly the same class of danger as Course 1's signed-overflow warning.

Coding Challenges

Challenge 1

Define a Point struct with x and y int fields using typedef struct, create an instance with values 5 and 10, and print both fields.

๐Ÿ“„ View solution
Challenge 2

Define a union with an int member and a float member. Write 100 into the int member, then print both the int member and the float member. Explain why they don't show a related value.

๐Ÿ“„ View solution
Challenge 3

Explain how Rust's enum discriminant makes reading the "wrong" variant impossible at compile time, while a C union has no equivalent safeguard at all โ€” and why this makes the union version genuinely undefined behavior rather than just an inconvenience.

๐Ÿ“„ View solution

Chapter 1 Quick Reference

  • struct groups multiple values; access members with .
  • typedef struct { ... } Name; โ€” the idiomatic default, avoiding repeated struct keywords
  • C structs carry no methods โ€” functions operating on them are always separate, taking a pointer
  • union members share memory โ€” sizeof is the largest member's size, not the sum
  • Reading the wrong union member is undefined behavior โ€” no discriminant tracks which one is active, unlike Rust's enums
  • Next chapter: dynamic memory โ€” malloc/calloc/realloc/free, and C's own version of Rust's ownership model
Chapter 2 of 7

Dynamic Memory

Course 2 ยท Ch 2
Dynamic Memory
The second half of this track's central payoff โ€” what Rust's ownership model does automatically, done entirely by hand

c1-7 named dangling pointers as the exact bug class Rust's borrow checker exists to prevent. This chapter shows where those dangling pointers actually come from โ€” and hands you the same responsibility Rust's ownership system otherwise carries for you.

Stack vs. Heap

The stack is automatic: a local variable's memory exists for exactly as long as its function is running, freed the instant that function returns โ€” this is precisely what made returning &local_var in c1-7 a dangling pointer. The heap is manual: memory allocated there exists until something explicitly releases it, with no connection to any particular function's lifetime, and effectively unlimited size.

malloc

Allocates raw, uninitialized memory of a given byte size, returning void * โ€” cast or assigned to a typed pointer. On failure, it returns NULL, which must always be checked; Rust, by contrast, typically aborts automatically on allocation failure rather than returning a value the caller might forget to verify.

int *nums = malloc(5 * sizeof(int)); if (nums == NULL) { // allocation failed โ€” must be handled, not assumed away }

calloc

Like malloc, but zero-initializes the memory, and takes element count and element size as two separate arguments. The zero-init distinction genuinely matters โ€” malloc's memory is garbage until written, and reading it before initializing is its own class of bug.

int *nums = calloc(5, sizeof(int)); // all 5 ints start at 0

realloc

Resizes a previous allocation โ€” and may move it to an entirely new address. The returned pointer can genuinely differ from the one passed in, and the old pointer becomes invalid the instant realloc succeeds.

int *temp = realloc(nums, 10 * sizeof(int)); if (temp == NULL) { // realloc failed โ€” nums is still valid and unchanged; don't overwrite it } else { nums = temp; // only reassign after confirming success }

free

Releases heap memory back to the system. Immediately afterward, the pointer that referenced it becomes a genuine dangling pointer โ€” this is the exact mechanism c1-7 warned about, closing the loop directly: freeing is how dangling pointers are most commonly created in real code.

free(nums); // nums now points at freed memory โ€” using it here is undefined behavior

Manual Memory Management vs. Rust's Ownership

This is the chapter's real subject. In C, you decide when to call free, and nothing enforces it. Forget it โ€” a leak. Call it twice on the same pointer โ€” a double-free, undefined behavior. Use the pointer after freeing โ€” use-after-free, also undefined behavior. Rust's ownership model (rust1-3) tracks, at compile time, exactly when a value's owner goes out of scope, and automatically inserts the equivalent of free at precisely that point โ€” no leaks in safe code, no double-frees, no use-after-free, guaranteed by the compiler rather than the programmer's own discipline. Every rule this chapter just described by hand is what Rust's ownership system enforces silently, every time, without exception.

ConceptRustC
When memory is freedautomatically, when the owner goes out of scopeonly when you explicitly call free()
Forgetting to freenot possible in safe codea memory leak
Freeing twicenot possible โ€” ownership moves, can't double-dropa double-free โ€” undefined behavior
Using memory after it's freedcompile error โ€” the borrow checker rejects ita use-after-free โ€” undefined behavior
Write the free right after the allocation
A genuinely effective habit: write the matching free immediately after an allocation, before writing the code that uses the memory in between โ€” then move the free to its real, correct location. It's much harder to forget a free you already wrote once than one you were planning to add "later."
Never reassign realloc's result directly into the original pointer
ptr = realloc(ptr, new_size); is a real, common bug: if realloc fails and returns NULL, that NULL overwrites ptr โ€” permanently losing the only reference to the still-valid original allocation, which now leaks with no way to free it. Always assign to a temporary variable first.

Coding Challenges

Challenge 1

Allocate an array of 5 ints with malloc, fill it with values 1 through 5, print them, then free the memory.

๐Ÿ“„ View solution
Challenge 2

Allocate an array of 3 ints with calloc, print all three values before writing anything to them, then explain what they show and why, contrasted with what malloc's uninitialized memory would show instead.

๐Ÿ“„ View solution
Challenge 3

Explain, precisely, why Rust's ownership model makes a double-free structurally impossible in safe code, rather than merely unlikely or discouraged.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • Stack โ€” automatic, freed on function return; heap โ€” manual, freed only by explicit action
  • malloc โ€” uninitialized memory, must check for NULL
  • calloc โ€” zero-initialized memory, count and size given separately
  • realloc โ€” may move the allocation; always assign to a temp variable first, never the original pointer
  • free โ€” releases memory; the pointer becomes dangling immediately afterward
  • Leaks, double-frees, use-after-free โ€” all manual failure modes in C; all structurally prevented by Rust's ownership model at compile time
  • Next chapter: Memory Bugs โ€” the tooling (valgrind) that catches exactly these mistakes
Chapter 3 of 7

Memory Bugs

Course 2 ยท Ch 3
Memory Bugs
Catching at runtime, with tools, what Rust's compiler catches for free

Every bug this chapter names has already appeared somewhere in this course โ€” c1-6's buffer overflow, c1-7's dangling pointer, c2-2's leaks and double-frees. This chapter gathers them into one catalog, and introduces the tools real C developers actually use to catch them.

The Bug Catalog, Named Precisely

  • Use-after-free โ€” dereferencing a pointer after its memory was freed (c1-7, c2-2)
  • Double-free โ€” calling free twice on the same allocation (c2-2)
  • Memory leak โ€” allocated memory that's never freed, with no remaining pointer to it at all (c2-2)
  • Buffer overflow โ€” reading or writing past an array or allocation's actual bounds, stack or heap (c1-6, c1-8)
  • Uninitialized read โ€” reading memory (from malloc, or a declared-but-unset variable) before anything was written to it

A Genuine Leak Example

void process() { int *buf = malloc(100 * sizeof(int)); // ... uses buf ... // no free(buf) โ€” the pointer goes out of scope, the memory doesn't } for (int i = 0; i < 1000000; i++) { process(); // leaks 100 ints, one million times over }

Each call leaks a small, fixed amount โ€” individually harmless-looking, but accumulating without bound. Rust makes this specific pattern impossible: a value's Drop runs automatically the moment its owner goes out of scope, with no equivalent "forgot to call it" failure mode in safe code.

valgrind

A dynamic analysis tool โ€” run an already-compiled program under it, no recompilation needed. It reports leaks, invalid reads/writes, and use-after-free, each with a stack trace showing exactly where the bad allocation, access, or free occurred.

$ valgrind --leak-check=full ./program ==12345== HEAP SUMMARY: ==12345== definitely lost: 400 bytes in 1 blocks ==12345== ==12345== 400 bytes in 1 blocks are definitely lost ==12345== at malloc (in /usr/lib/valgrind/...) ==12345== by 0x4006B7: process (program.c:3)

AddressSanitizer (ASan)

A compile-time instrumentation approach โ€” add -fsanitize=address and recompile. It catches much of the same bug class, but faster than valgrind, and crashes immediately with a detailed report at the exact moment the bad access happens, rather than only summarizing at program exit.

$ gcc -fsanitize=address -g program.c -o program $ ./program ==12345==ERROR: AddressSanitizer: heap-use-after-free READ of size 4 at 0x602000000010 thread T0 #0 0x... in main program.c:8
ToolRequires recompilingSpeed
valgrindnoslower โ€” full emulation
AddressSanitizeryes โ€” a compiler flagmuch faster, precise timing

Why This Chapter Exists

This is the honest cost of the tradeoff Chapter 1 named: C's speed and control come with these exact bug classes as a genuine, permanent possibility โ€” not a temporary tooling gap that will eventually be fixed. Rust's compiler catches most of this entire category at compile time, before the program ever runs. C requires catching it at runtime, with tools like these, ideally before shipping โ€” a real and lasting asymmetry between the two languages, not a difference in maturity or polish.

Bug classRustC
Use-after-freecompile-time โ€” borrow checkerruntime โ€” valgrind/ASan, if you run them
Double-freecompile-time โ€” single ownershipruntime โ€” valgrind/ASan, if you run them
Memory leaknot possible in safe, non-cyclic coderuntime โ€” valgrind's leak checker, if you run it
Buffer overflowruntime panic โ€” always checkedruntime โ€” ASan, if you run it; otherwise silent UB
Run these tools routinely, not just when something looks wrong
Many memory bugs produce no visible symptom most of the time. Building the habit of running valgrind or an ASan-instrumented build regularly during development โ€” not only when chasing a specific crash โ€” catches problems long before they become a mystery in production.
Undefined behavior is not guaranteed to crash
A program with a genuine memory bug can appear to work correctly for a long time โ€” sometimes indefinitely, on a given machine and compiler. Undefined behavior means the standard makes no promise at all, not "it will crash reliably." This unpredictability is exactly what makes this bug class so dangerous in practice: the absence of a crash is not evidence of correctness.

Coding Challenges

Challenge 1

Write a small program with a deliberate memory leak (malloc without a matching free), compile it, and run it under valgrind --leak-check=full. Report what the leak summary shows.

๐Ÿ“„ View solution
Challenge 2

Write a small program with a deliberate use-after-free (free a pointer, then dereference it), compile it with -fsanitize=address, and run it. Report what AddressSanitizer's error output shows.

๐Ÿ“„ View solution
Challenge 3

Explain why "the program didn't crash" is not proof a C program is free of memory bugs, and why the same reasoning doesn't apply to a Rust program that compiles successfully.

๐Ÿ“„ View solution

Chapter 3 Quick Reference

  • Bug catalog: use-after-free, double-free, memory leak, buffer overflow, uninitialized read
  • valgrind --leak-check=full โ€” no recompile needed, slower, reports leaks/invalid access with stack traces
  • -fsanitize=address โ€” requires recompiling, much faster, crashes precisely at the moment of the bad access
  • Rust catches most of this category at compile time; C requires catching it at runtime, with tools
  • Undefined behavior is not guaranteed to crash โ€” a program can "work" for a long time despite a real bug
  • Next chapter: the preprocessor โ€” #define, macros, and conditional compilation
Chapter 4 of 7

The Preprocessor

Course 2 ยท Ch 4
The Preprocessor
A text-substitution pass that runs before the compiler even sees real C

#include <stdio.h> has appeared in nearly every example since Chapter 1. This chapter finally explains what it โ€” and every other line starting with # โ€” actually does.

The Preprocessor Runs Before Compilation

The preprocessor is a genuinely separate pass: pure text substitution over the source file, completed entirely before parsing or type-checking ever begins. Rust has no preprocessor at all โ€” its macro system (rust3-4) operates on the actual parsed syntax tree, with real awareness of types and structure, not raw text. This is a fundamental difference in kind, not just syntax.

#define โ€” Object-like Macros

#define MAX_SIZE 100

Every occurrence of MAX_SIZE in the file is replaced, textually, with 100 โ€” before the compiler ever sees it as a number. No type checking happens at this stage at all.

#define โ€” Function-like Macros

#define SQUARE(x) ((x) * (x))

Looks like a function; is pure text substitution. Without full parenthesization, this breaks badly:

// #define SQUARE(x) x * x (missing parens) SQUARE(a + b) // expands to: a + b * a + b โ€” wrong, due to operator precedence

Even with correct parentheses, macros carry a trap no real function has: arguments can be evaluated more than once.

int i = 5; SQUARE(i++); // expands to ((i++) * (i++)) โ€” i is incremented TWICE, not once

#include

Now the honest explanation: #include pastes the entire contents of the named file, textually, in place of the #include line itself โ€” nothing more sophisticated than that. It's not an "import" in Rust's sense at all; there's no module system underneath, just literal text insertion before compilation.

Header Guards

Including the same header twice โ€” common once a project has several source files each including several headers โ€” pastes its contents twice, causing duplicate definitions and a real compile error. The classic fix:

#ifndef MYHEADER_H #define MYHEADER_H // header contents #endif

The modern, widely-supported alternative is a single line:

#pragma once

Rust's module system has no equivalent problem at all โ€” use never textually pastes anything, so there's nothing to accidentally duplicate.

Conditional Compilation

#ifdef/#ifndef/#if/#else/#endif compile different code depending on which macros are defined โ€” commonly used for platform-specific code, or separating debug and release builds.

#ifdef DEBUG printf("Debug: value = %d\n", value); #endif
ConceptRustC
Preprocessornone โ€” macros operate on the syntax treea genuine separate text-substitution pass
Module inclusionuse โ€” a real module reference, no text pasting#include โ€” literal text pasting
Double-inclusion risknot possible โ€” no text pasting involvedreal โ€” requires header guards or #pragma once
Prefer #pragma once in new code
Simpler than the #ifndef/#define/#endif pattern, and supported by every major compiler โ€” though technically a compiler extension, not part of the C standard itself.
Macro arguments can be evaluated more than once
Always fully parenthesize both the parameters and the entire macro body โ€” but even then, remember that a function-like macro is not a real function: an argument with a side effect (like i++) can be silently evaluated multiple times, a class of bug that simply cannot occur with a real function call.

Coding Challenges

Challenge 1

Define a macro SQUARE(x) without any parentheses around x or the whole expression. Call it as SQUARE(2 + 3) and show what it actually expands to and evaluates as, compared to the mathematically correct answer (25).

๐Ÿ“„ View solution
Challenge 2

Write a header file with a struct definition, protected by a header guard using #ifndef/#define/#endif. Explain what compile error would occur if the guard were removed and the header were included twice from the same source file.

๐Ÿ“„ View solution
Challenge 3

Explain why SQUARE(i++), even with SQUARE fully and correctly parenthesized as ((x) * (x)), is still a bug โ€” and why the equivalent call to a real function square(i++) would never have this problem.

๐Ÿ“„ View solution

Chapter 4 Quick Reference

  • The preprocessor is a text-substitution pass, complete before compilation begins โ€” unlike Rust, which has none
  • #define NAME value โ€” object-like macro, pure text substitution
  • Function-like macros need full parenthesization โ€” and can still evaluate arguments multiple times
  • #include literally pastes a file's contents โ€” not a real module import
  • Header guards (#ifndef/#define/#endif or #pragma once) prevent duplicate-inclusion errors
  • #ifdef/#if/#else/#endif โ€” conditional compilation for platform/debug-vs-release code
  • Next chapter: Multi-File Projects & Makefiles โ€” separate compilation and linking, for real
Chapter 5 of 7

Multi-File Projects & Makefiles

Course 2 ยท Ch 5
Multi-File Projects & Makefiles
Resolving Chapter 1's own open question: what fills the gap where cargo would be

c1-1 named the gap directly โ€” no official build tool. c1-5 introduced header files in passing. c2-4 explained exactly what #include does. This chapter puts all three together, for real, on a genuine multi-file project.

Why Split a Project Across Files

Organization at real scale, and something more practical: faster rebuilds. Recompiling one changed file is far cheaper than recompiling an entire project from scratch every time โ€” but only if the build process actually takes advantage of that.

Header Files as Contracts

A header declares what a source file provides, without exposing how. Per c2-4's literal explanation, those declarations get pasted, verbatim, into every file that includes the header โ€” giving each one enough information to call the functions without ever seeing their implementation.

// math_utils.h int add(int a, int b);

Separate Compilation

This is c1-1's compile-then-link model, made concrete across multiple files. Each .c file compiles independently into its own object file (.o); only afterward are all the object files linked into one executable.

A Worked Example

// math_utils.c #include "math_utils.h" int add(int a, int b) { return a + b; } // main.c #include <stdio.h> #include "math_utils.h" int main() { printf("%d\n", add(2, 3)); return 0; }
$ gcc -c math_utils.c -o math_utils.o $ gcc -c main.c -o main.o $ gcc math_utils.o main.o -o program $ ./program 5

The Linking Step, In Detail

main.o contains a call to add() with no body โ€” an unresolved external symbol. math_utils.o contains add()'s actual definition. The linker's job is matching them up. Forget to link math_utils.o in at all, and the result is one of C's most recognizable real-world errors:

$ gcc main.o -o program undefined reference to `add' collect2: error: ld returned 1 exit status

A Real Makefile

make is the unofficial-but-standard answer to c1-1's own open question โ€” targets, dependencies, and rules, rebuilding only what actually changed.

program: main.o math_utils.o gcc main.o math_utils.o -o program main.o: main.c math_utils.h gcc -c main.c -o main.o math_utils.o: math_utils.c math_utils.h gcc -c math_utils.c -o math_utils.o clean: rm -f *.o program

Run make, and it rebuilds only the files whose dependencies (listed after the colon) are newer than their target โ€” editing only math_utils.c recompiles just that file and re-links, leaving main.o untouched.

ConceptRust / cargoC / make
Build toolofficial, built inunofficial โ€” make/CMake fill the gap
Incremental rebuildsautomaticexplicit โ€” dependency rules must be written correctly
Unresolved symbolcompile error โ€” caught before linking exists as a concepta distinct linker error โ€” "undefined reference"
Recognize "undefined reference" as a linker error
It means the declaration was found (the header compiled fine) but the definition was never linked in โ€” a genuinely distinct failure mode from a compile error, worth recognizing immediately rather than re-reading the source for a typo that isn't there.
An incomplete dependency list silently builds stale files
If a Makefile rule doesn't list a header as a dependency, changing that header won't trigger a rebuild of files that include it โ€” make will report success while quietly building against outdated object files. Always list every header a .c file includes as part of its rule's dependencies.

Coding Challenges

Challenge 1

Create the three files from this chapter's worked example (math_utils.h, math_utils.c, main.c), compile and link them manually with separate gcc -c and gcc linking commands, and run the result.

๐Ÿ“„ View solution
Challenge 2

Deliberately compile only main.c into an executable, omitting math_utils.o from the final link command. Report the exact error and explain what it means.

๐Ÿ“„ View solution
Challenge 3

Write a Makefile for the three-file project from Challenge 1, following the chapter's own pattern. Explain what happens, in terms of which files actually get recompiled, if you run make twice in a row with no changes in between.

๐Ÿ“„ View solution

Chapter 5 Quick Reference

  • A header declares what a source file provides โ€” a contract, not an implementation
  • gcc -c file.c -o file.o โ€” compiles independently into an object file
  • Linking matches unresolved calls (in one .o) against real definitions (in another)
  • "undefined reference" โ€” a linker error, distinct from a compile error; the definition was never linked in
  • make โ€” the unofficial standard build tool, rebuilding only what its dependency rules say changed
  • Every header a source file includes must be listed as a dependency, or changes to it won't trigger a rebuild
  • Next chapter: File I/O โ€” fopen/fread/fwrite/fclose, text vs. binary mode
Chapter 6 of 7

File I/O

Course 2 ยท Ch 6
File I/O
Another manually-acquired, manually-released resource โ€” and the function C's own standard body removed entirely

Every program so far has lived entirely in memory. This chapter opens the door to the filesystem โ€” and introduces a resource-management discipline that should already feel familiar from c2-2's malloc/free pairing.

Opening a File

FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { // failed to open โ€” must be checked, same discipline as malloc }

File Modes

The mode string controls both direction and format: "r" read, "w" write (truncating), "a" append โ€” each with a b variant ("rb", "wb", "ab") for binary.

Text Mode vs. Binary Mode

Text mode may translate line endings depending on the platform โ€” on Windows, \n can be translated to/from \r\n on read/write. Binary mode transfers bytes exactly as-is, with no translation at all. This is genuinely platform-dependent behavior, worth knowing explicitly rather than discovering by accident. Rust's std::fs has no equivalent mode distinction at the language level โ€” it's byte-oriented by default, with no automatic translation to opt out of.

Reading & Writing Text

fprintf(fp, "%d\n", 42); char line[256]; fgets(line, sizeof(line), fp); // bounded โ€” safe

fgets takes a maximum buffer size, exactly the bounds-checking discipline c1-8 covered. Its predecessor, gets(), took no size argument at all โ€” it was so fundamentally unfixable that C11 removed it from the standard entirely.

Reading & Writing Binary Data

int nums[5] = {1, 2, 3, 4, 5}; size_t written = fwrite(nums, sizeof(int), 5, fp); // written may be less than 5 โ€” a short write is possible and must be checked

fread/fwrite operate on raw bytes: a pointer, the size of each element, and how many elements. Both return the actual number of items transferred โ€” which can genuinely be less than requested, and isn't automatically an error condition to ignore.

Closing a File

fclose(fp);

Flushes any buffered writes and releases the underlying OS file handle. Forgetting it is the same class of mistake as c2-2's forgotten free โ€” a resource acquired manually that must be released manually, just a file descriptor instead of heap memory.

ConceptRustC
Text/binary mode distinctionnone โ€” byte-oriented by defaultexplicit โ€” "r" vs "rb", genuinely platform-dependent
Closing a fileautomatic โ€” Drop closes it when the handle goes out of scopemanual โ€” fclose must be called explicitly
Unbounded line readingnot available โ€” read_line always takes a growable buffergets() existed, then was removed entirely in C11
Check fopen exactly like malloc
A failed fopen returns NULL โ€” the same discipline c2-2 established for malloc. A missing file, wrong permissions, or a full disk are all real, common causes; never assume the file opened successfully.
gets() was removed from the C standard โ€” the strongest statement C makes about a function
gets() read a line with no way to specify a maximum length at all โ€” genuinely, unfixably unsafe, no matter how carefully called. C11 didn't just discourage it; it removed it from the standard outright. Use fgets, always.

Coding Challenges

Challenge 1

Write a program that opens a file for writing, writes three lines of text to it with fprintf, closes it, then reopens the same file for reading and prints each line back out with fgets.

๐Ÿ“„ View solution
Challenge 2

Write a program that writes an array of 5 ints to a file in binary mode with fwrite, then reads them back into a different array with fread, printing both arrays to confirm they match.

๐Ÿ“„ View solution
Challenge 3

Explain specifically why gets() could never be made safe with better documentation or careful usage alone, and why fgets's extra parameter is what actually fixes the underlying problem.

๐Ÿ“„ View solution

Chapter 6 Quick Reference

  • fopen(name, mode) โ€” returns NULL on failure, must always be checked
  • "r"/"w"/"a" plus a b variant for binary โ€” text mode may translate line endings, platform-dependently
  • fgets โ€” bounded, safe; gets() โ€” unbounded, removed entirely from C11
  • fread/fwrite โ€” return the actual items transferred, which can be less than requested
  • fclose โ€” manual, exactly like free; forgetting it leaks a file descriptor
  • Next chapter: Function Pointers โ€” syntax, callbacks, and a vtable-style dispatch pattern
Chapter 7 of 7

Function Pointers

Course 2 ยท Ch 7
Function Pointers
What C++ classes and Rust's dyn Trait do automatically, built here entirely by hand

c2-1 named this chapter directly: "one way C approximates method-like dispatch without a language feature for it at all." Course 2 closes by actually building that mechanism โ€” the same one every object-oriented language's runtime dispatch is quietly built from underneath.

Function Pointer Syntax

int (*operation)(int, int);

A pointer to a function taking two ints and returning int. The parentheses around *operation are mandatory โ€” without them, this parses as something else entirely, a genuine syntax trap worth memorizing rather than re-deriving each time.

Assigning & Calling Through a Function Pointer

int add(int a, int b) { return a + b; } int (*operation)(int, int) = add; // a function name decays to a pointer to itself int result = operation(2, 3); // calling through the pointer looks identical

A function name decaying to a pointer to itself is genuinely the same phenomenon as array-to-pointer decay from c1-6, just applied to functions instead of arrays.

Callbacks

Passing a function pointer so another function can call it later โ€” a real, common C idiom.

void apply_to_all(int *arr, int n, void (*fn)(int)) { for (int i = 0; i < n; i++) { fn(arr[i]); } }

A Simple vtable-Style Dispatch Pattern

The real payoff: store a function pointer alongside data, and let different instances point at different implementations.

typedef struct { float radius; float (*area)(void *self); } Circle; float circle_area(void *self) { Circle *c = (Circle *)self; return 3.14159f * c->radius * c->radius; } Circle c = {5.0f, circle_area}; float a = c.area(&c); // dispatches to circle_area at runtime

A second shape type populates its own struct's function pointer with a different implementation entirely โ€” calling shape->area(shape) dispatches to the correct one, purely because the pointer was set to point there.

What C++ and Rust Formalize

This is not just an analogy โ€” it's the literal mechanism. A C++ class with virtual methods, and Rust's own dyn Trait, both work by storing a table of function pointers alongside the data, dispatched through one level of indirection โ€” exactly what was just built by hand above. The difference is who manages the table: here, the programmer wires it up manually and must get it right; in C++ and Rust, the compiler generates the table automatically and guarantees it's always fully and correctly populated.

ConceptRust dyn TraitC function pointer struct
Who builds the dispatch tablethe compiler, automaticallythe programmer, by hand
Guaranteed fully populated?yes โ€” enforced at compile timeno โ€” nothing checks it at all
Calling an unset entrynot possible โ€” compile errorundefined behavior โ€” a NULL or garbage call
A genuinely real technique, not just a teaching exercise
This hand-rolled pattern is exactly how real, large C codebases implement polymorphism โ€” the Linux kernel's own driver interfaces are built from structs of function pointers just like this one.
Nothing checks that the function pointer was set correctly
Forgetting to populate a struct's function pointer, or assigning the wrong function to it, produces undefined behavior the moment it's called โ€” a NULL call, or a call through a mismatched signature, with no compiler check whatsoever. This is precisely what Rust's dyn Trait and C++'s virtual mechanism guarantee away entirely.

Coding Challenges

Challenge 1

Write two functions, add(int, int) and subtract(int, int), and a single function pointer variable that can be pointed at either one. Call it once through each, printing both results.

๐Ÿ“„ View solution
Challenge 2

Write a function apply_to_all(int *arr, int n, void (*fn)(int)) that calls fn on every element of an array, and a print_doubled(int) function to pass as the callback that prints each value doubled.

๐Ÿ“„ View solution
Challenge 3

Extend the chapter's Circle vtable-style example with a second shape (e.g. a Rectangle) that has its own area function, both sharing a common calling pattern. Then explain what specifically would happen if a third shape's function pointer were left unset (NULL) and its area were called.

๐Ÿ“„ View solution

Chapter 7 Quick Reference โ€” Course 2 Complete

  • return_type (*name)(param_types); โ€” the parentheses around *name are mandatory
  • A function name decays to a pointer to itself โ€” the same phenomenon as c1-6's array decay
  • Callbacks โ€” passing a function pointer so another function can call it later
  • A struct of data plus a function pointer is a hand-rolled vtable โ€” real dispatch, written manually
  • C++'s virtual and Rust's dyn Trait are the exact same mechanism, compiler-managed and guaranteed correct โ€” this chapter's version is neither
  • Course 2 complete. Course 3 covers bit manipulation, hand-built data structures, concurrency, undefined behavior in full depth, debugging tooling, and a real capstone CLI project