Skip to content
academia.sh

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 the TaggedValue example — 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.c shows 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: when record_create fails, it frees the block it allocated itself, and the caller’s record_destroy(NULL) call is only a safe no-op.
  • A loan (record_name, which returns const char *) and a copy (record_name_copy, which returns char *) 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close