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_renamefunction that changes the widget's name. Make sure the old name is freed. Only modifywidget.candwidget.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
MetersandFeetnewtype in Rust. Write a functionadd_meters(Meters, Meters) -> Meters. Verify that passing aFeetvalue 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_operationswith function pointers forread,write,open,release, andioctl. 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
-
In C, what makes a struct "opaque" to callers? What compiler concept prevents the caller from accessing fields?
-
In Rust, what is the default visibility of a struct field? How do you make it accessible outside the module?
-
Why is a Rust newtype safer than a C
typedeffor preventing argument mix-ups?
Common Pitfalls
- Forgetting the destructor in C opaque types. The caller cannot
freethe 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
pubin 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
typedefwith a newtype. In C,typedef int foodoes not create a new type. The compiler still treats it asint.