3 min read
On this page

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 create allocates, users need a way to free. Without destroy, every use leaks memory.
  • Returning internal pointers. If kvstore_get returns a pointer to an internal string and the user holds it past a kvstore_delete call, 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 .c file 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 .c file.
  • 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.