Opaque types and objects in C
A struct in a header exposes its layout: every file that includes the header can read and write the fields. An opaque type hides the definition in one source file and leaves only a pointer in the header, so the fields can change without recompiling the callers and the invariant of the object stays inside the module. This is the closest C gets to a class.
A type without a definition
The header names the type with a typedef struct Counter Counter; and declares functions that take or return a pointer to it. A pointer to an incomplete type is legal, which is the whole trick: the caller can hold the object but cannot see or size it. The build from lesson 19 compiles the header, the implementation and the main file together.
vi counter.h
#ifndef COUNTER_H
#define COUNTER_H
typedef struct Counter Counter;
Counter *counter_new(void);
void counter_free(Counter *counter);
void counter_add(Counter *counter, int value);
int counter_get(const Counter *counter);
#endif
The definition lives in the source file. The constructor allocates one object with malloc and sets the field, and the other functions read and write it through the pointer. Only this file knows that a counter is one int.
vi counter.c
#include <stdlib.h>
#include "counter.h"
struct Counter {
int total;
};
Counter *counter_new(void)
{
Counter *counter = malloc(sizeof *counter);
if (counter != NULL) {
counter->total = 0;
}
return counter;
}
void counter_free(Counter *counter)
{
free(counter);
}
void counter_add(Counter *counter, int value)
{
counter->total += value;
}
int counter_get(const Counter *counter)
{
return counter->total;
}
vi main.c
#include <stdio.h>
#include "counter.h"
int main(void)
{
Counter *counter = counter_new();
if (counter == NULL) {
return 1;
}
printf("start: %d\n", counter_get(counter));
counter_add(counter, 7);
counter_add(counter, 5);
printf("after two adds: %d\n", counter_get(counter));
counter_free(counter);
return 0;
}
gcc -Wall -Wextra counter.c main.c -o counter
./counter
start: 0
after two adds: 12
The caller never writes sizeof(Counter) and never names the field. The allocation and the zero initial value are the constructor's business, the release is the destructor's, and the header promises only the four functions.
Each object has its own state
Because the constructor returns a fresh block, two counters are two objects. The functions receive the object as their first argument, so they never share state through a hidden global. This is the property that makes the type safe to use in several places of a program.
#include <stdio.h>
#include "counter.h"
int main(void)
{
Counter *first = counter_new();
Counter *second = counter_new();
if (first == NULL || second == NULL) {
counter_free(first);
counter_free(second);
return 1;
}
counter_add(first, 10);
counter_add(second, 3);
counter_add(second, 4);
printf("first: %d\n", counter_get(first));
printf("second: %d\n", counter_get(second));
counter_free(first);
counter_free(second);
return 0;
}
gcc -Wall -Wextra counter.c two.c -o two
./two
first: 10
second: 7
The two calls to counter_new return two different blocks, so the additions do not mix. The failure path frees both pointers, which is safe because free(NULL) does nothing, the same rule as lesson 31.
Methods through function pointers
A struct can hold a pointer to a function that takes the struct as its first argument, which looks like a method call and dispatches at run time. The table of functions from lesson 29 becomes part of the object. The example keeps the struct visible; the same field can hide behind an opaque type when the interface is public.
#include <stdio.h>
struct Shape {
const char *name;
double width;
double height;
double (*area)(const struct Shape *self);
};
static double rectangle_area(const struct Shape *self)
{
return self->width * self->height;
}
static double triangle_area(const struct Shape *self)
{
return self->width * self->height / 2.0;
}
int main(void)
{
struct Shape rectangle = {"rectangle", 3.0, 4.0, rectangle_area};
struct Shape triangle = {"triangle", 3.0, 4.0, triangle_area};
printf("%s: %.1f\n", rectangle.name, rectangle.area(&rectangle));
printf("%s: %.1f\n", triangle.name, triangle.area(&triangle));
return 0;
}
gcc -Wall -Wextra shape.c -o shape
./shape
rectangle: 12.0
triangle: 6.0
Each object carries the function that knows its area, so the call site does not need a switch on the kind of shape. The cost is indirect call overhead and a function pointer that must be set correctly; the benefit is that a new shape is a new file, not an edit to every switch.
Using the incomplete type
The compiler needs the size of an object to reserve storage for it, and the header deliberately does not give one. Declaring a Counter by value is therefore an error, with a message that names the variable:
#include "counter.h"
int main(void)
{
Counter counter;
counter_add(&counter, 1);
return 0;
}
gcc -Wall -Wextra counter.c incomplete.c -o incomplete
incomplete.c: In function 'main':
incomplete.c:5:13: error: storage size of 'counter' isn't known
5 | Counter counter;
| ^~~~~~~
incomplete.c:5:13: warning: unused variable 'counter' [-Wunused-variable]
The second mistake reaches for a field. The field exists in the source file, but this translation unit has only the typedef, so the compiler cannot resolve the member access and says which type is incomplete:
#include <stddef.h>
#include "counter.h"
int main(void)
{
Counter *counter = counter_new();
if (counter == NULL) {
return 1;
}
counter->total = 5;
counter_free(counter);
return 0;
}
gcc -Wall -Wextra counter.c fields.c -o fields
fields.c: In function 'main':
fields.c:11:12: error: invalid use of incomplete typedef 'Counter'
11 | counter->total = 5;
| ^~
Both errors are the encapsulation working as intended. The way to change the total is the function the header declares, and the way to extend the type is a new function in counter.c; the field itself stays private.
Exercises
- Add a counter_reset function to the opaque type and call it from a new main.
- Hide the Shape struct behind an opaque type with a constructor that takes the width, the height and the area function.
- Explain what a caller must do when counter_new returns NULL, and write the code for it.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What does a header expose for an opaque type?
Answer
Only a typedef name and function declarations
The header leaves the struct incomplete, so callers can hold a pointer but cannot see the layout or create the object by value.
-
What is the role of
counter_newandcounter_free?Answer
They create and release the hidden object
The constructor allocates and initialises the object, and the destructor releases it, keeping both details inside the module.
-
Why do two counters created by
counter_newkeep separate totals?Answer
The constructor returns a fresh block for each
Each call allocates its own object, and the functions receive the object as their first argument.
-
What does a function pointer stored in a struct provide?
Answer
A method-like call chosen per object
Each object carries the function that knows how to handle it, so the call site needs no switch on the kind.
-
What does this program print?
#include <stdio.h> #include <stdlib.h> struct Counter { int total; }; static struct Counter *counter_new(void) { struct Counter *counter = malloc(sizeof *counter); if (counter != NULL) counter->total = 0; return counter; } static void counter_add(struct Counter *counter, int value) { counter->total += value; } int main(void) { struct Counter *counter = counter_new(); if (counter == NULL) return 1; counter_add(counter, 7); counter_add(counter, 5); printf("total: %d\n", counter->total); free(counter); return 0; }Answer
total: 12
The constructor zeroes the total, and the two additions bring it to 12.
-
What does this program print?
#include <stdio.h> #include <stdlib.h> struct Counter { int total; }; static struct Counter *counter_new(void) { struct Counter *counter = malloc(sizeof *counter); if (counter != NULL) counter->total = 0; return counter; } static void counter_add(struct Counter *counter, int value) { counter->total += value; } int main(void) { struct Counter *first = counter_new(); struct Counter *second = counter_new(); if (first == NULL || second == NULL) { free(first); free(second); return 1; } counter_add(first, 10); counter_add(second, 3); counter_add(second, 4); printf("first: %d\nsecond: %d\n", first->total, second->total); free(first); free(second); return 0; }Answer
first: 10 / second: 7
The first counter receives 10 and the second receives 3 plus 4, which is 7, so the states are independent.
-
What does this program print?
#include <stdio.h> struct Shape { const char *name; double width; double height; double (*area)(const struct Shape *self); }; static double rectangle_area(const struct Shape *self) { return self->width * self->height; } static double triangle_area(const struct Shape *self) { return self->width * self->height / 2.0; } int main(void) { struct Shape rectangle = {"rectangle", 3.0, 4.0, rectangle_area}; struct Shape triangle = {"triangle", 3.0, 4.0, triangle_area}; printf("%s: %.1f\n", rectangle.name, rectangle.area(&rectangle)); printf("%s: %.1f\n", triangle.name, triangle.area(&triangle)); return 0; }Answer
rectangle: 12.0 / triangle: 6.0
The rectangle multiplies 3 by 4, while the triangle halves the same product.
-
Why can a caller not declare
Counter counter;?Answer
The struct is incomplete, so its size is unknown
Storage for an object needs its size, and the header deliberately provides no definition, so the declaration is an error.
-
Why is
counter->total = 5rejected outsidecounter.cwhen the header only declarestypedef struct Counter Counter;?Answer
The struct is incomplete, so its members are unknown
This translation unit has only the typedef, so the compiler cannot resolve the member access.
-
How is the hidden total changed from another file?
Answer
By calling the function the header declares
The public function keeps the invariant inside the module; the field itself stays private to the implementation.
The code on this page was run with gcc (Alpine 15.2.0) 15.2.0 on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev