---
title: '`void` and Opaque Pointers'
source: 'https://academia.sh/en/courses/c-memory/void-and-opaque-pointers'
course: 'Memory and Pointers in C'
language: en
updated: '2026-09-26T03:02:43+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# `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.

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

```c
// 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**.

```c
// 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.

```c
// 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
```

```c
// 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;
}
```

```c
// 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
```

```c
// 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;
}
```

```c
// 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`.

```c
// 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.
