Lesson 05 / 15
`void` and Opaque Pointers
How a void pointer deliberately hides the type it points to, and how an opaque type hides its contents entirely, keeping ownership inside the callee module.
Published Updated
In this lesson
- Measures how a void pointer carries type information and how that information is recovered
- Explains how an opaque type keeps ownership inside the callee module by hiding its contents
- Shows that a void pointer cannot be dereferenced and must first be converted to a concrete type
Contents
The four lessons up to this point always knew explicitly what type a pointer’s pointed-to
value was: int *, double *. This lesson measures two conditions where the type is
deliberately hidden: void * — a pointer that can point at any object type but cannot
itself be dereferenced — and the opaque type — a struct whose contents never leak
outside the unit that defines it. Both form the foundation of the core’s owner.h/owner.c
unit; the class here is callee: the module holding the memory only lends it to the
caller.
void *: A Pointer That Carries a Type but Cannot Be Dereferenced
// void_pointer.c — a void pointer holds any type but must first convert to a concrete type
#include <stdio.h>
static void print_int(const void *data) {
const int *p = data;
printf("%s %d\n", "value read as int", *p);
}
static void print_double(const void *data) {
const double *p = data;
printf("%s %.1f\n", "value read as double", *p);
}
int main(void) {
int number = 17;
double fractional = 3.5;
void *general = &number;
printf("%s %d\n", "does the general pointer equal the number's address", (int)(general == &number));
print_int(general);
general = &fractional;
print_double(general);
return 0;
}
does the general pointer equal the number's address 1 value read as int 17 value read as double 3.5
PO21. general is a void * variable and points first at &number, then at
&fractional — the same variable can carry the address of two objects of different types.
The comparison general == &number gives 1; this shows a void * can be measured with
equality just like any other pointer. But general itself is never dereferenced as
*general anywhere — the print_int and print_double functions first cast it to their
own concrete types (const int *, const double *), and only then dereference it. This is
the rule of void *: it can hold any object pointer, but reading its pointed-to value
first requires converting it to a type.
void * Does Not Carry a Type: An External Tag Is Needed
The general variable in the previous section was always explicitly clear, to a person
reading the code, about which type it pointed to at each moment — first &number, then
&fractional. But at runtime, void * itself carries no type information at all; there is
no way to look at a void * variable and say whether it is an int * or a double *.
Programs usually close this gap with a separate tag.
// tagged_value.c — a separate tag accompanies a void pointer since it carries no type
#include <stdio.h>
typedef enum { TYPE_INT, TYPE_DOUBLE } Type;
typedef struct {
Type type;
void *data;
} TaggedValue;
static void print(const TaggedValue *v) {
if (v->type == TYPE_INT) {
const int *p = v->data;
printf("%s %d\n", "int-tagged value", *p);
} else {
const double *p = v->data;
printf("%s %.1f\n", "double-tagged value", *p);
}
}
int main(void) {
int number = 42;
double fractional = 2.5;
TaggedValue values[2] = {
{ TYPE_INT, &number },
{ TYPE_DOUBLE, &fractional }
};
for (int i = 0; i < 2; i++) {
print(&values[i]);
}
return 0;
}
int-tagged value 42 double-tagged value 2.5
PO22. The TaggedValue struct carries a Type type field alongside void *data; the
print function chooses the correct conversion by looking at the type field, not at
data itself — because data itself cannot say this. Each element of the values array
points at a different type but is held within the same TaggedValue type; this shows why
void * is usually used not alone, but paired with a tag. Had the tag been wrong (say,
an entry marked TYPE_DOUBLE but actually pointing at an int *), print would have
converted to the wrong type and read the wrong value — this kind of error is not caught by
the compiler, because void * compiles identically in either case.
This pattern also shows up in the C standard library: qsort and bsearch pass pairs of
const void * to their comparison functions, but never say anywhere what type they are
comparing — that information lives in the caller’s head, inside the comparison function’s
own body. TaggedValue moves this implicit contract into an explicit field: the type
information is no longer an assumption embedded in the function body, but a readable value
sitting next to the data. Both approaches carry the same risk — if the tag or the
assumption is wrong, the result is undefined — but TaggedValue at least makes that risk
visible, instead of assuming sizeof or parameter order will be correct on its own.
Opaque Type: A Struct Whose Contents Never Leak
The previous section hid void *‘s type but not its size — the caller still knew which
concrete type to convert to. An opaque type goes one step further: it does not show the
caller even the struct’s fields. This lesson measures this by rebuilding the core’s
owner.h/owner.c unit inside this lesson’s own files.
// counter.h — a wrapper that counts every allocation and every free #ifndef COUNTER_H #define COUNTER_H #include <stddef.h> void *count_alloc(size_t size); void count_free(void *block); int allocated_count(void); int freed_count(void); #endif
// counter.c — the counters are held only in this unit
#include <stdlib.h>
#include "counter.h"
static int allocated = 0;
static int freed = 0;
void *count_alloc(size_t size) {
void *block = malloc(size);
if (block != NULL) {
allocated++;
}
return block;
}
void count_free(void *block) {
if (block != NULL) {
freed++;
}
free(block);
}
int allocated_count(void) {
return allocated;
}
int freed_count(void) {
return freed;
}
// owner.h — the record's insides are hidden, the ownership contract is written in the comments #ifndef OWNER_H #define OWNER_H #include <stddef.h> typedef struct Record Record; /* The caller takes ownership and frees it with record_destroy. Returns NULL if the name does not fit the limit; the function itself frees any memory allocated up to that point. */ Record *record_create(const char *name, size_t limit); /* Frees the record and the name buffer inside it together. */ void record_destroy(Record *r); /* The record retains ownership: the returned pointer is valid until the record is destroyed, the caller does not free it. */ const char *record_name(const Record *r); /* The caller takes ownership: the returned copy is freed with count_free. */ char *record_name_copy(const Record *r); #endif
// owner.c — the unit where the contract is implemented
#include <string.h>
#include "owner.h"
#include "counter.h"
struct Record {
char *name;
size_t length;
};
Record *record_create(const char *name, size_t limit) {
Record *r = count_alloc(sizeof *r);
if (r == NULL) {
return NULL;
}
r->length = strlen(name);
if (r->length >= limit) {
count_free(r);
return NULL;
}
r->name = count_alloc(r->length + 1);
if (r->name == NULL) {
count_free(r);
return NULL;
}
memcpy(r->name, name, r->length + 1);
return r;
}
void record_destroy(Record *r) {
if (r == NULL) {
return;
}
count_free(r->name);
count_free(r);
}
const char *record_name(const Record *r) {
return r->name;
}
char *record_name_copy(const Record *r) {
char *copy = count_alloc(r->length + 1);
if (copy != NULL) {
memcpy(copy, r->name, r->length + 1);
}
return copy;
}
// opaque_record.c — the fields of the Record struct are defined only outside this unit
#include <stdio.h>
#include "owner.h"
#include "counter.h"
int main(void) {
Record *r = record_create("inventory", 32);
if (r == NULL) {
printf("%s\n", "creation failed");
return 1;
}
printf("%s %s\n", "record name", record_name(r));
char *copy = record_name_copy(r);
printf("%s %s\n", "copied name", copy != NULL ? copy : "none");
count_free(copy);
record_destroy(r);
printf("%s %d\n", "counter diff after freeing", allocated_count() - freed_count());
return 0;
}
record name inventory copied name inventory counter diff after freeing 0
PO23. The opaque_record.c file never directly accesses any field of
struct Record — owner.h only declares the Record * type and the four functions, the
struct’s contents stay inside the owner.c unit. The call record_name(r) lends the
name inside the struct — the lent name row in ownership.c used this same call, and its
counter diff came out 0, because a loan does not allocate memory. After the call
record_destroy(r), allocated_count() - freed_count() gives 0 — both the record
itself and the name buffer inside it were freed in full. Ownership here belongs to the
callee class: the unit holding Record (owner.c) both creates and destroys it, the
caller only takes a loan through record_name and does not free that loan.
The Same Contract Across Multiple Instances
The contract in owner.h must not hold for a single Record instance only — it must hold
identically for every instance, both for successful creations and for the failed call
where record_create returns NULL.
// multi_record.c — the same opaque contract stays valid across more than one Record instance
#include <stdio.h>
#include "owner.h"
#include "counter.h"
int main(void) {
Record *first = record_create("warehouse", 32);
Record *second = record_create("shipment", 32);
Record *failed = record_create("anamefartoolongforthislimit", 8);
printf("%s %d\n", "is the failed creation null", (int)(failed == NULL));
if (first != NULL && second != NULL) {
printf("%s %s\n", "first record name", record_name(first));
printf("%s %s\n", "second record name", record_name(second));
}
record_destroy(first);
record_destroy(second);
record_destroy(failed);
printf("%s %d\n", "counter diff after all frees",
allocated_count() - freed_count());
return 0;
}
is the failed creation null 1 first record name warehouse second record name shipment counter diff after all frees 0
PO24. failed comes back NULL because the given name
(anamefartoolongforthislimit) is longer than the given limit (8) —
is the failed creation null is 1. first and second are independent Record
instances; each allocates its own name buffer, and the record_name calls do not affect
each other. The call record_destroy(failed) operates on NULL — record_destroy in
owner.c already handles this with its r == NULL check, so no separate check needs to be
written here. After all three calls (two successful, one failed),
allocated_count() - freed_count() gives 0 — the failed call had already freed its own
r block inside record_create, so the outside record_destroy(NULL) call doing nothing
does not throw off the counters. This is another face of the callee class: the contract
cleans up its own allocated memory not only on the success path, but also on the failure
path.
The Bounding Measurement
void * retains type information but does not hide it — the caller must know which
concrete type to convert to, otherwise the conversion is wrong and the result is undefined.
An opaque type goes beyond this: the caller never knows at all how many fields Record
has, or what the fields are named or sized — it interacts only through the functions
declared by owner.h. This is a flexibility-safety trade-off: an opaque type makes it
possible to change its internal structure (add a new field) without breaking the outer
interface, but every access requires a function call — there is no direct field access.
This guarantee only holds as long as the side using void * stays disciplined. In
tagged_value.c, print looks at the v->type field and picks the correct conversion; but
the compiler does not check this match — had we converted a TYPE_DOUBLE value to
const int * and dereferenced it without ever reading the Type field, the bit pattern in
memory, written as a double, would have been read as an int. This block is deliberately
never run: its result is undefined and varies by platform, compiler, even optimization
level. The code might never crash, or it might silently print a wrong number — this is the
real danger of undefined behavior, the error passing without giving any visible signal.
TaggedValue’s type field does not remove this risk, it only makes it visible: the
wrong conversion is still possible, but it now becomes an error preventable with a check
like if (v->type == TYPE_INT). In a function taking a raw void * parameter, this check
is impossible, because the type information is not held anywhere; tagged_value.c fills
this gap by carrying the type alongside the pointer.
The same risk sits in a different place for the opaque type. Although first and second
in multi_record.c both carry the same Record * type, both of their data/name fields
are hidden inside owner.c’s file — the caller cannot even attempt to write
first->name, because the definition of the Record struct does not exist in owner.h,
only inside owner.c. This solves void *’s type-safety problem in a completely different
way: instead of removing the risk of a wrong conversion, it closes off the path to a
conversion at compile time. What the two approaches have in common is that both give the
same answer to the question of who owns the data: record_create and record_destroy
inside owner.c manage first‘s and second‘s lifetime from start to end themselves — the
caller only holds a handle (Record *) and never touches its contents. This is why the
lesson’s ownership class is callee: owner.c both allocates and frees, the caller’s
only job is to pass the handle it holds to the correct functions.
But this job is itself an obligation, and opacity does not hide it. An opaque type hides the
struct’s insides, not its ownership: handing the handle returned by record_create
back to record_destroy is still the caller’s job, and this obligation is written not in
the signature but only in the comment in owner.h. If the caller forgets this call, the
compiler says nothing; both blocks remain unowned.
Summary
void *can hold any object pointer but cannot be dereferenced itself; it must first be converted to a concrete type (int *,const double *, and so on).- A
void *variable can carry the address of objects of different types over time; equality comparison works the same as any other pointer. But because it does not carry its type itself, which type it converts to is not checked by the compiler without a separate tag, as in theTaggedValueexample — a wrong conversion is undefined behavior and is shown only by description, never run. - An opaque type (
Record) never leaks its fields outside the unit that defines it; the caller interacts only through the declared functions.multi_record.cshows this contract stays valid across multiple independent instances within the same program. - The unit holding an opaque type both creates and destroys it — class callee; only
loaned values (like
record_name) are left to the caller. This also holds on the failure path: whenrecord_createfails, it frees the block it allocated itself, and the caller’srecord_destroy(NULL)call is only a safe no-op. - A loan (
record_name, which returnsconst char *) and a copy (record_name_copy, which returnschar *) carry different ownership — one must not be freed, the other must be.
Next Step
The Pointers topic’s last lesson, Function Pointers, measures a kind separate from data
pointers: pointers that point at a function itself and can be passed as a parameter to
another function. This lesson measured the gap between void * carrying type information
without hiding it and an opaque type hiding it completely; a similar tension exists with
function pointers — a callback’s signature says what context it will take, but guarantees
nothing about the context’s contents or ownership. What context a callback carries and who
owns that context is this topic’s closing question.
To keep your progress and take notes, Log in
My notes
Log in to take notes.