Opaque Types & Data Hiding
The Problem: Exposing Implementation Details
When a header file contains a struct definition, every file that includes the header can access every field. This creates tight coupling: changing a field name or type forces recompilation of every file that touches the struct, and nothing prevents users from reaching into internals.
/* widget.h - everything is exposed */
struct Widget {
int x;
int y;
int width;
int height;
char *label;
int internal_state; /* users should not touch this */
};
Any code that includes this header can read or write internal_state. C has no private keyword, but it has a technique that achieves the same effect.
Forward Declarations & Opaque Pointers
The technique is simple: declare the struct in the header without defining it. Users get a pointer they cannot dereference. Only the implementation file knows the struct layout.
The Header (Public Interface)
/* widget.h */
#ifndef WIDGET_H
#define WIDGET_H
/* Forward declaration - no definition */
struct Widget;
/* Users only work with pointers */
struct Widget *widget_create(int x, int y, int w, int h, const char *label);
void widget_destroy(struct Widget *w);
/* Accessors */
int widget_get_x(const struct Widget *w);
int widget_get_y(const struct Widget *w);
void widget_set_position(struct Widget *w, int x, int y);
const char *widget_get_label(const struct Widget *w);
void widget_draw(const struct Widget *w);
#endif
Users see struct Widget; but not its fields. They can declare pointers (struct Widget *w) but cannot dereference them (w->x is a compile error).
The Implementation (Private Details)
/* widget.c */
#include "widget.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct Widget {
int x;
int y;
int width;
int height;
char *label;
int internal_state;
};
struct Widget *widget_create(int x, int y, int w, int h, const char *label) {
struct Widget *widget = malloc(sizeof(*widget));
if (!widget) {
return NULL;
}
widget->x = x;
widget->y = y;
widget->width = w;
widget->height = h;
widget->internal_state = 0;
widget->label = strdup(label);
if (!widget->label) {
free(widget);
return NULL;
}
return widget;
}
void widget_destroy(struct Widget *w) {
if (w) {
free(w->label);
free(w);
}
}
int widget_get_x(const struct Widget *w) {
return w->x;
}
int widget_get_y(const struct Widget *w) {
return w->y;
}
void widget_set_position(struct Widget *w, int x, int y) {
w->x = x;
w->y = y;
w->internal_state++; /* track how many times position changed */
}
const char *widget_get_label(const struct Widget *w) {
return w->label;
}
void widget_draw(const struct Widget *w) {
printf("Drawing \"%s\" at (%d, %d) size %dx%d\n",
w->label, w->x, w->y, w->width, w->height);
}
Using the Opaque Type
/* main.c */
#include "widget.h"
#include <stdio.h>
int main(void) {
struct Widget *btn = widget_create(10, 20, 100, 30, "OK");
if (!btn) {
fprintf(stderr, "Failed to create widget\n");
return 1;
}
widget_draw(btn);
widget_set_position(btn, 50, 60);
widget_draw(btn);
/* btn->x = 99; <-- compile error: incomplete type */
printf("Label: %s\n", widget_get_label(btn));
widget_destroy(btn);
return 0;
}
Drawing "OK" at (10, 20) size 100x30
Drawing "OK" at (50, 60) size 100x30
Label: OK
The user cannot access btn->x directly. The compiler reports an error about an incomplete type. This is encapsulation in C.
The Create/Destroy/Get/Set Pattern
Opaque types follow a consistent API pattern:
| Function | Purpose |
|---|---|
type_create |
Allocate and initialize |
type_destroy |
Clean up and free |
type_get_* |
Read a property |
type_set_* |
Modify a property |
This pattern makes ownership explicit. The create function allocates, so the caller must eventually call destroy.
/* database.h */
struct Database;
struct Database *db_open(const char *path);
void db_close(struct Database *db);
int db_execute(struct Database *db, const char *sql);
int db_get_last_error(const struct Database *db);
const char *db_get_error_message(const struct Database *db);
The user never needs to know whether struct Database contains a file descriptor, a socket, an in-memory hash table, or all three. The implementation can change freely.
Real-World Examples
FILE*
The most famous opaque type in C is FILE. You never allocate a FILE yourself; you call fopen, which returns a pointer. You interact with it through fread, fwrite, fprintf, fclose. The internal structure varies between C library implementations (glibc, musl, MSVC), but your code works on all of them.
FILE *f = fopen("data.txt", "r");
/* f->_flags is not portable and not part of the API */
fclose(f);
SQLite
SQLite uses multiple opaque types:
sqlite3 *db;
sqlite3_open("test.db", &db);
sqlite3_stmt *stmt;
sqlite3_prepare_v2(db, "SELECT * FROM users", -1, &stmt, NULL);
while (sqlite3_step(stmt) == SQLITE_ROW) {
const char *name = (const char *)sqlite3_column_text(stmt, 0);
printf("User: %s\n", name);
}
sqlite3_finalize(stmt);
sqlite3_close(db);
You never see the fields of sqlite3 or sqlite3_stmt. The library can change its internals across versions without breaking your code.
OpenSSL
OpenSSL uses opaque types for cryptographic contexts:
SSL_CTX *ctx = SSL_CTX_new(TLS_method());
SSL *ssl = SSL_new(ctx);
/* configure, connect, read, write */
SSL_free(ssl);
SSL_CTX_free(ctx);
A Complete Example: A Key-Value Store
/* kvstore.h */
#ifndef KVSTORE_H
#define KVSTORE_H
struct KVStore;
struct KVStore *kvstore_create(int capacity);
void kvstore_destroy(struct KVStore *store);
int kvstore_set(struct KVStore *store, const char *key, const char *value);
const char *kvstore_get(const struct KVStore *store, const char *key);
int kvstore_delete(struct KVStore *store, const char *key);
int kvstore_count(const struct KVStore *store);
#endif
/* kvstore.c */
#include "kvstore.h"
#include <stdlib.h>
#include <string.h>
struct Entry {
char *key;
char *value;
};
struct KVStore {
struct Entry *entries;
int capacity;
int count;
};
struct KVStore *kvstore_create(int capacity) {
struct KVStore *store = malloc(sizeof(*store));
if (!store) return NULL;
store->entries = calloc(capacity, sizeof(struct Entry));
if (!store->entries) {
free(store);
return NULL;
}
store->capacity = capacity;
store->count = 0;
return store;
}
void kvstore_destroy(struct KVStore *store) {
if (!store) return;
for (int i = 0; i < store->count; i++) {
free(store->entries[i].key);
free(store->entries[i].value);
}
free(store->entries);
free(store);
}
int kvstore_set(struct KVStore *store, const char *key, const char *value) {
/* Check if key exists */
for (int i = 0; i < store->count; i++) {
if (strcmp(store->entries[i].key, key) == 0) {
char *new_val = strdup(value);
if (!new_val) return -1;
free(store->entries[i].value);
store->entries[i].value = new_val;
return 0;
}
}
if (store->count >= store->capacity) return -1;
store->entries[store->count].key = strdup(key);
store->entries[store->count].value = strdup(value);
if (!store->entries[store->count].key || !store->entries[store->count].value) {
free(store->entries[store->count].key);
free(store->entries[store->count].value);
return -1;
}
store->count++;
return 0;
}
const char *kvstore_get(const struct KVStore *store, const char *key) {
for (int i = 0; i < store->count; i++) {
if (strcmp(store->entries[i].key, key) == 0) {
return store->entries[i].value;
}
}
return NULL;
}
int kvstore_delete(struct KVStore *store, const char *key) {
for (int i = 0; i < store->count; i++) {
if (strcmp(store->entries[i].key, key) == 0) {
free(store->entries[i].key);
free(store->entries[i].value);
store->entries[i] = store->entries[store->count - 1];
store->count--;
return 0;
}
}
return -1;
}
int kvstore_count(const struct KVStore *store) {
return store->count;
}
The user works entirely through kvstore_* functions. The internal structure (linear search, array of entries) can be replaced with a hash table without changing the header.
Common Pitfalls
- Forgetting to provide a destroy function. If
createallocates, users need a way to free. Withoutdestroy, every use leaks memory. - Returning internal pointers. If
kvstore_getreturns a pointer to an internal string and the user holds it past akvstore_deletecall, the pointer dangles. Document lifetime rules clearly. - Making the opaque type too opaque. If users constantly need five getters to do basic work, the API is too restrictive. Balance hiding with usability.
- Stack allocation is impossible. Because users do not know the struct size, they cannot allocate it on the stack. Every instance requires heap allocation. For performance-critical inner loops, this can matter.
- Header-only libraries cannot use opaque types. The technique requires a separate
.cfile with the struct definition. Header-only libraries must expose the full struct.
Key Takeaways
- Opaque types are C's mechanism for encapsulation: declare a struct in the header, define it only in the
.cfile. - Users get a pointer they cannot dereference. All access goes through API functions.
- The create/destroy/get/set pattern gives a clean, consistent interface and makes ownership explicit.
- Real C libraries use this pattern extensively:
FILE*, SQLite handles, OpenSSL contexts. - The tradeoff is mandatory heap allocation and function call overhead for every access. For most code, this cost is negligible compared to the maintainability benefits.