C / STRUCTS, UNIONS AND ENUMS
Opaque structs and hiding implementation details
Hide a struct's layout in one .c file behind an incomplete type, so callers can only hold pointers and must go through functions you control.
What you will learn
- Declare typedef struct T T; in the header and define struct T only in the .c file
- Hand out constructor/destructor pairs because callers cannot size or free the object
- Route every field read and write through functions in the implementation file
- Recognise what an incomplete type forbids: no local objects, no sizeof, no -> access
Understanding Opaque structs and hiding implementation details
Writing struct Counter; declares a tag with no members. That is an incomplete type: the compiler knows the type exists and knows how to make a pointer to it, because every object pointer has the same size no matter what it points at, but it does not know the size of the object or the offset of any member. So Counter *c is accepted, while Counter c;, sizeof(Counter) and c->value are all rejected. That set of rejections is exactly the encapsulation you want, and it is enforced by the compiler rather than by a comment asking people to behave.
In real code the split is physical. The header holds typedef struct Counter Counter; plus prototypes that take and return Counter *; the .c file includes that header and then defines struct Counter { ... } above the functions. Only that one translation unit can compute member offsets, so every field access in the entire program is code you wrote. You can add, remove, reorder or rename members and no caller breaks, because no caller ever compiled an offset into its object code. This is why FILE * and sqlite3 * are shaped the way they are.
The price is real. The caller cannot put the object on the stack, cannot initialise one with a brace list, cannot make an array of them (only an array of pointers), and must call your allocator and your matching release function. Every field read becomes a cross-file function call the compiler usually cannot inline. Treat the handle as a claim ticket: you can pass it, copy it, compare it and hand it back, but you cannot look behind the counter yourself. That trade is worth it at a module or library boundary, and pointless for a local struct holding two coordinates.
<stdio.h>
<stdlib.h>
/* ===== counter.h: everything a caller is allowed to know ===== */
typedef struct Counter Counter; /* incomplete type: named, but no layout */
Counter *counter_new(long start);
void counter_bump(Counter *c);
long counter_value(const Counter *c);
long counter_bumps(const Counter *c);
void counter_free(Counter *c);
/* ===== caller code: down here the layout is still unknown ===== */
int main(void)
{
Counter *c = counter_new(10); /* a pointer is all we can hold */
if (c == NULL) return 1;
counter_bump(c);
counter_bump(c);
printf("value = %ld\n", counter_value(c));
printf("bumps = %ld\n", counter_bumps(c));
/* Counter one; -> error: storage size of 'one' isn't known */
/* c->value = 99; -> error: dereferencing pointer to incomplete type */
counter_free(c);
return 0;
}
/* ===== counter.c: the one place struct Counter has a layout ===== */
struct Counter {
long value;
long bumps;
};
Counter *counter_new(long start)
{
Counter *c = malloc(sizeof *c); /* sizeof works: layout is visible here */
if (c != NULL) {
c->value = start;
c->bumps = 0;
}
return c;
}
void counter_bump(Counter *c) { c->value += 1; c->bumps += 1; }
long counter_value(const Counter *c) { return c->value; }
long counter_bumps(const Counter *c) { return c->bumps; }
void counter_free(Counter *c) { free(c); }
An incomplete struct type gives callers a name they can point to but no layout they can depend on, so the compiler enforces the module boundary instead of convention.
Worked examples
An invariant the caller cannot break
Hiding the field means the only code that can write it is code that checks the range first.
<stdio.h>
<stdlib.h>
typedef struct Volume Volume;
Volume *volume_new(void);
void volume_set(Volume *v, int level);
int volume_get(const Volume *v);
void volume_free(Volume *v);
int main(void)
{
Volume *v = volume_new();
if (v == NULL) return 1;
volume_set(v, 200);
printf("asked for 200, got %d\n", volume_get(v));
volume_set(v, -5);
printf("asked for -5, got %d\n", volume_get(v));
volume_free(v);
return 0;
}
struct Volume { int level; }; /* invariant: 0 <= level <= 11 */
Volume *volume_new(void)
{
Volume *v = malloc(sizeof *v);
if (v != NULL) v->level = 0;
return v;
}
void volume_set(Volume *v, int level)
{
if (level < 0) level = 0;
if (level > 11) level = 11;
v->level = level;
}
int volume_get(const Volume *v) { return v->level; }
void volume_free(Volume *v) { free(v); }
Example explained
Line 1struct Volume { int level; }; appears below main, so main never learns that level exists.
Line 2volume_set is the only code able to assign level, which makes 0..11 a guarantee rather than a hope.
Line 3The clamp of 200 down to 11 happens inside the module; the caller has no v->level = 200 escape hatch.
Line 4volume_get takes const Volume *, promising the getter will not modify the object it was handed.
Swapping malloc for a fixed pool
The storage strategy changes completely while the caller's code stays byte for byte the same.
<stdio.h>
typedef struct Token Token;
Token *token_acquire(int id);
void token_release(Token *t);
int token_id(const Token *t);
int main(void)
{
Token *a = token_acquire(1);
Token *b = token_acquire(2);
Token *c = token_acquire(3);
printf("a=%d b=%d c=%s\n", token_id(a), token_id(b), c ? "token" : "NULL");
token_release(a);
Token *d = token_acquire(4);
printf("after release, d=%d\n", token_id(d));
token_release(b);
token_release(d);
return 0;
}
POOL_N
struct Token { int id; int in_use; };
static struct Token pool[POOL_N];
Token *token_acquire(int id)
{
for (int i = 0; i < POOL_N; i++) {
if (!pool[i].in_use) {
pool[i].in_use = 1;
pool[i].id = id;
return &pool[i];
}
}
return NULL;
}
void token_release(Token *t) { if (t != NULL) t->in_use = 0; }
int token_id(const Token *t) { return t->id; }
Example explained
Line 1The header half says nothing about where a Token lives, so main only ever handles a Token *.
Line 2static struct Token pool[POOL_N] means there is no malloc anywhere, yet no caller line changed.
Line 3token_acquire returns NULL once both slots are taken, which is why the third request prints NULL.
Line 4Releasing through token_release rather than free is what made the storage change safe: freeing a pool slot would be undefined behaviour.
Important notes
A pointer to an incomplete type can be stored, copied, compared and passed around, but handle + 1 and handle[1] are errors, because pointer arithmetic needs the object size.
Put the forward declaration at file scope. If struct Widget is first mentioned inside a prototype's parameter list, that tag has prototype scope only and its pointer type is incompatible with the real one, which surfaces later as a confusing incompatible-pointer warning.
Common mistakes
Putting struct Widget { ... } in the header 'so main can look at it': the type is complete again, so callers reach into fields directly and every layout change forces all of them to recompile.
Writing Widget w; or sizeof(Widget) in caller code and expecting it to work; gcc reports 'storage size of w isn't known' or 'invalid application of sizeof to incomplete type Widget', because only a declaration is in scope there.
Calling free(handle) in the caller instead of widget_free(handle): it works by accident while the implementation uses malloc, then corrupts memory the day it switches to a pool or an arena.
Try it yourself
Change, predict, then run
Take the counter example and give struct Counter a hidden limit field, with counter_new(long start, long limit) and a counter_bump that stops raising value once the limit is reached. Print both value and bumps to show the extra calls still happened, and change nothing in the header section except the counter_new prototype.
Open the C workspaceCheck your understanding
A library ships typedef struct Conn Conn; in its header and defines struct Conn only in conn.c. The next release adds three members and reorders the existing ones. Why does caller code that only calls the library's functions keep working?
- The typedef makes the compiler generate accessors that adapt to the new layout.
- For an incomplete type the compiler defers member offsets until link time.
- Callers only ever store and pass a Conn *, and a pointer's size and representation do not depend on the struct's members.
- The caller's copy of the struct definition is refreshed when it recompiles against the new header.
Show answer
Nothing in the caller's object code encodes an offset into struct Conn, because the caller was never shown the layout; a pointer is a pointer whatever it points at, so only the prototypes matter. Option 4 is tempting but there was never a definition in the header to refresh, and that absence is the entire design. Option 2 is wrong because C fixes member offsets when conn.c is compiled; there is no link-time adjustment of member access.