Opaque Types and Encapsulation

Good APIs hide their guts. In C, the technique is called the "opaque pointer" or "handle" pattern — you forward-declare a struct in a header and only define it in the implementation file. In Rust, the compiler enforces privacy by default. This chapter shows both approaches and why the difference matters for large codebases.

The Problem: Leaking Implementation Details

When you put a full struct definition in a header, every file that includes it can reach into the struct's fields. Change a field name and you recompile the world. Worse, callers start depending on layout details you never promised.

+-------------------------------+
|  widget.h                     |
|  struct widget {              |
|      int x;   <-- exposed     |
|      int y;   <-- exposed     |
|      char *name; <-- exposed  |
|  };                           |
+-------------------------------+
        |           |
   file_a.c     file_b.c
   w->x = 5;   free(w->name);  <-- both reach inside

Every consumer is now coupled to the exact layout. This is fragile.

C: The Opaque Pointer Pattern

The fix in C is simple: forward-declare the struct in the header, define it only in the .c file, and expose functions that operate on pointers to it.

The Header (widget.h)

/* widget.h -- public interface only */
#ifndef WIDGET_H
#define WIDGET_H

#include <stddef.h>

/* Forward declaration -- callers never see the fields */
typedef struct widget widget_t;

/* Constructor / destructor */
widget_t *widget_create(const char *name, int x, int y);
void      widget_destroy(widget_t *w);

/* Accessors */
const char *widget_name(const widget_t *w);
int         widget_x(const widget_t *w);
int         widget_y(const widget_t *w);

/* Mutator */
void widget_move(widget_t *w, int dx, int dy);

#endif /* WIDGET_H */

The Implementation (widget.c)

/* widget.c -- only this file knows the struct layout */
#include "widget.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct widget {
    char *name;
    int   x;
    int   y;
};

widget_t *widget_create(const char *name, int x, int y)
{
    widget_t *w = malloc(sizeof(*w));
    if (!w)
        return NULL;

    w->name = strdup(name);
    if (!w->name) {
        free(w);
        return NULL;
    }
    w->x = x;
    w->y = y;
    return w;
}

void widget_destroy(widget_t *w)
{
    if (!w)
        return;
    free(w->name);
    free(w);
}

const char *widget_name(const widget_t *w)
{
    return w->name;
}

int widget_x(const widget_t *w)
{
    return w->x;
}

int widget_y(const widget_t *w)
{
    return w->y;
}

void widget_move(widget_t *w, int dx, int dy)
{
    w->x += dx;
    w->y += dy;
}

A Caller (main.c)

/* main.c */
#include <stdio.h>
#include "widget.h"

int main(void)
{
    widget_t *w = widget_create("button", 10, 20);
    if (!w) {
        fprintf(stderr, "widget_create failed\n");
        return 1;
    }

    printf("Widget '%s' at (%d, %d)\n",
           widget_name(w), widget_x(w), widget_y(w));

    widget_move(w, 5, -3);
    printf("After move: (%d, %d)\n", widget_x(w), widget_y(w));

    /* w->x = 99;  <-- compile error: incomplete type */

    widget_destroy(w);
    return 0;
}

Compile and run:

gcc -Wall -c widget.c -o widget.o
gcc -Wall main.c widget.o -o main
./main

Output:

Widget 'button' at (10, 20)
After move: (15, 17)

The caller cannot access w->x directly because the compiler only sees the forward declaration — the struct is an incomplete type in main.c.

Caution: The opaque pointer pattern in C relies entirely on programmer discipline. Nothing stops someone from copying the struct definition into their own file. It is a convention, not a guarantee.

Handles You Already Know

The C standard library and POSIX use this pattern everywhere:

+------------------+-----------------------------+
| Handle           | Hidden struct               |
+------------------+-----------------------------+
| FILE *           | struct _IO_FILE (glibc)     |
| DIR *            | struct __dirstream           |
| pthread_t        | unsigned long (or struct)    |
| sqlite3 *        | struct sqlite3               |
+------------------+-----------------------------+

You call fopen() and get a FILE *. You never allocate a FILE yourself. You never inspect its fields. You pass it to fread, fwrite, fclose. That is the handle pattern.

Driver Prep: The Linux kernel uses opaque handles constantly. A struct file * is passed through the VFS layer. Driver authors implement operations behind function pointers without exposing internal state to userspace.

Try It: Add a widget_rename function that changes the widget's name. Make sure the old name is freed. Only modify widget.c and widget.h — the caller should not need to know how names are stored.

Rust: Privacy by Default

Rust flips the default. Struct fields are private unless you say pub.

// widget.rs (or lib.rs in a library crate)

pub struct Widget {
    name: String,   // private -- only this module can touch it
    x: i32,         // private
    y: i32,         // private
}

impl Widget {
    /// Constructor -- the only way to create a Widget from outside
    pub fn new(name: &str, x: i32, y: i32) -> Self {
        Widget {
            name: name.to_string(),
            x,
            y,
        }
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn x(&self) -> i32 {
        self.x
    }

    pub fn y(&self) -> i32 {
        self.y
    }

    pub fn move_by(&mut self, dx: i32, dy: i32) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    let mut w = Widget::new("button", 10, 20);
    println!("Widget '{}' at ({}, {})", w.name(), w.x(), w.y());

    w.move_by(5, -3);
    println!("After move: ({}, {})", w.x(), w.y());

    // w.x = 99;  // compile error: field `x` is private
}

Compile and run:

rustc widget.rs && ./widget

Output:

Widget 'button' at (10, 20)
After move: (15, 17)

Rust Note: In Rust, privacy is enforced at the module level, not the file level. All code in the same module can access private fields. But code outside the module cannot, even within the same crate, unless fields are marked pub.

Module Visibility in Detail

Rust gives you fine-grained control:

mod engine {
    pub struct Motor {
        pub horsepower: u32,        // anyone can read/write
        pub(crate) serial: u64,     // only this crate
        pub(super) temperature: f64,// only the parent module
        rpm: u32,                   // only this module
    }

    impl Motor {
        pub fn new(hp: u32) -> Self {
            Motor {
                horsepower: hp,
                serial: 12345,
                temperature: 90.0,
                rpm: 0,
            }
        }

        pub fn start(&mut self) {
            self.rpm = 800;
        }

        pub fn rpm(&self) -> u32 {
            self.rpm
        }
    }
}

fn main() {
    let mut m = engine::Motor::new(250);
    m.start();
    println!("HP: {}, RPM: {}", m.horsepower, m.rpm());

    // m.rpm = 9000;         // error: field `rpm` is private
    // m.serial = 0;         // error: field `serial` is private (pub(crate) --
    //                       //   same crate but different module path depending
    //                       //   on context in a multi-file project)
}

The visibility ladder:

+---------------------+--------------------------------+
| Visibility          | Who can access                 |
+---------------------+--------------------------------+
| (none) / private    | current module only            |
| pub(self)           | same as private (explicit)     |
| pub(super)          | parent module                  |
| pub(crate)          | anywhere in the same crate     |
| pub                 | anyone, including other crates |
+---------------------+--------------------------------+

The Newtype Pattern

Sometimes you want a distinct type that wraps a primitive. In C you use typedef, but it creates only an alias — not a separate type.

C: typedef is a Weak Alias

/* c_newtype.c */
#include <stdio.h>

typedef int user_id;
typedef int product_id;

void print_user(user_id uid)
{
    printf("User: %d\n", uid);
}

int main(void)
{
    user_id   u = 42;
    product_id p = 99;

    print_user(u);   /* correct */
    print_user(p);   /* compiles fine -- oops! */

    return 0;
}

The compiler treats user_id and product_id as the same type. No warning. No error. Just a bug waiting to happen.

Rust: Newtype Is a Real Type

// newtype.rs
struct UserId(i32);
struct ProductId(i32);

fn print_user(uid: &UserId) {
    println!("User: {}", uid.0);
}

fn main() {
    let u = UserId(42);
    let _p = ProductId(99);

    print_user(&u);     // ok
    // print_user(&_p); // compile error: expected `&UserId`, found `&ProductId`
}

Compile and run:

rustc newtype.rs && ./newtype

Output:

User: 42

The newtype wrapper has zero runtime overhead — it is the same size as the inner value. But the compiler treats them as distinct types.

Memory layout (both are identical at runtime):

  UserId(42)      ProductId(99)
  +----------+    +----------+
  | 42 (i32) |    | 99 (i32) |
  +----------+    +----------+
  4 bytes         4 bytes

But the type system sees them as DIFFERENT types.

Try It: Add a Meters and Feet newtype in Rust. Write a function add_meters(Meters, Meters) -> Meters. Verify that passing a Feet value is a compile error.

C: Opaque Handle with Function Pointers (vtable-style)

A more advanced C pattern combines opaque types with function pointers. This is how the Linux kernel implements polymorphism (e.g., struct file_operations).

/* stream.h */
#ifndef STREAM_H
#define STREAM_H

#include <stddef.h>

typedef struct stream stream_t;

/* Operations table -- like a vtable */
typedef struct {
    int  (*read)(stream_t *s, void *buf, size_t len);
    int  (*write)(stream_t *s, const void *buf, size_t len);
    void (*close)(stream_t *s);
} stream_ops_t;

stream_t *stream_create(const stream_ops_t *ops, void *private_data);
void      stream_destroy(stream_t *s);
int       stream_read(stream_t *s, void *buf, size_t len);
int       stream_write(stream_t *s, const void *buf, size_t len);

#endif
/* stream.c */
#include "stream.h"
#include <stdlib.h>

struct stream {
    const stream_ops_t *ops;
    void               *private_data;
};

stream_t *stream_create(const stream_ops_t *ops, void *private_data)
{
    stream_t *s = malloc(sizeof(*s));
    if (!s)
        return NULL;
    s->ops = ops;
    s->private_data = private_data;
    return s;
}

void stream_destroy(stream_t *s)
{
    if (s && s->ops->close)
        s->ops->close(s);
    free(s);
}

int stream_read(stream_t *s, void *buf, size_t len)
{
    if (!s || !s->ops->read)
        return -1;
    return s->ops->read(s, buf, len);
}

int stream_write(stream_t *s, const void *buf, size_t len)
{
    if (!s || !s->ops->write)
        return -1;
    return s->ops->write(s, buf, len);
}

This is the same architecture as struct file_operations in the Linux kernel. The caller never sees the internal struct. The operations table provides polymorphism.

Driver Prep: When you write a Linux driver, you fill in a struct file_operations with function pointers for read, write, open, release, and ioctl. The kernel calls your functions through these pointers. The opaque-handle-plus-vtable pattern is the foundation of the entire VFS layer.

Rust: Traits as the Clean Equivalent

// stream_trait.rs
use std::io;

trait Stream {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
    fn write(&mut self, buf: &[u8]) -> io::Result<usize>;
}

struct MemoryStream {
    data: Vec<u8>,
    pos: usize,
}

impl MemoryStream {
    fn new(data: Vec<u8>) -> Self {
        MemoryStream { data, pos: 0 }
    }
}

impl Stream for MemoryStream {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        let remaining = &self.data[self.pos..];
        let n = buf.len().min(remaining.len());
        buf[..n].copy_from_slice(&remaining[..n]);
        self.pos += n;
        Ok(n)
    }

    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
        self.data.extend_from_slice(buf);
        Ok(buf.len())
    }
}

fn read_all(stream: &mut dyn Stream) -> io::Result<Vec<u8>> {
    let mut result = Vec::new();
    let mut buf = [0u8; 64];
    loop {
        let n = stream.read(&mut buf)?;
        if n == 0 {
            break;
        }
        result.extend_from_slice(&buf[..n]);
    }
    Ok(result)
}

fn main() {
    let mut ms = MemoryStream::new(b"Hello, opaque world!".to_vec());
    let data = read_all(&mut ms).unwrap();
    println!("{}", String::from_utf8_lossy(&data));
}

Compile and run:

rustc stream_trait.rs && ./stream_trait

The trait object &mut dyn Stream is Rust's version of the vtable pattern. The compiler generates a vtable automatically. No manual function-pointer tables needed.

Side-by-Side Comparison

+--------------------------+----------------------------+
| C (opaque pointer)       | Rust (private fields)      |
+--------------------------+----------------------------+
| Forward-declare struct   | Fields private by default  |
| Define in .c file only   | Define in module           |
| Expose via pointer       | Expose via pub methods     |
| Convention-based         | Compiler-enforced          |
| Caller can cast around   | Caller cannot bypass       |
| typedef = weak alias     | Newtype = real distinct type|
| Function ptr table       | Trait object (dyn Trait)   |
+--------------------------+----------------------------+

Knowledge Check

  1. In C, what makes a struct "opaque" to callers? What compiler concept prevents the caller from accessing fields?

  2. In Rust, what is the default visibility of a struct field? How do you make it accessible outside the module?

  3. Why is a Rust newtype safer than a C typedef for preventing argument mix-ups?

Common Pitfalls

  • Forgetting the destructor in C opaque types. The caller cannot free the internals because they cannot see them. You must provide a destroy function.
  • Leaking the definition by putting the struct in a "private" header that someone else includes anyway. In C, there is no enforcement.
  • Making all fields pub in Rust "just to get it compiling." This throws away the safety you get for free.
  • Returning mutable references to private fields from Rust methods. This leaks internal state just as badly as making the field public.
  • Confusing typedef with a newtype. In C, typedef int foo does not create a new type. The compiler still treats it as int.