C and C++ Communication: The Opaque Pointer Pattern
April 24, 2026 · Luciano Muratore
C and C++ are close relatives, but they are not the same language. C has no concept of classes, objects, constructors, or vtables. C++ has all of these, but it also does something the C linker does not expect: it encodes the parameter types of each function directly into the symbol name, so that functions with the same name but different parameters can coexist. This is how C++ supports overloading, but the resulting symbol names are nothing like what the C linker will look for.
This means that if a C program tries to directly include a C++ header that declares a class, it will fail to compile. And if a C program tries to link against a C++ function with an encoded symbol name, the linker will not find it.
The opaque pointer pattern is the standard solution to both problems. It is used throughout systems libraries, most famously in FFmpeg, where the entire public API is built around handles like AVCodecContext* and AVFormatContext* that C programs hold without ever seeing their internal layout.
What Is an Opaque Pointer?
An opaque pointer is a pointer to something whose internal structure is hidden from the caller.
In practice, it is a void*, a raw memory address with no associated type. The caller holds it, passes it around, and gives it back to the library when needed. The caller never dereferences it directly. The library is the only one that knows what is actually stored at that address.
The word opaque means “you cannot see through it.” From C’s perspective, the pointer is just a number, an address. What lives at that address is invisible to C.
The Three Files
The pattern is implemented across three files: a shared header (wrapper.h), a C++ implementation (engine.cpp), and a C program (main.c).
wrapper.h — The Bridge Contract
// Wrapper.h
#ifndef WRAPPER_H
#define WRAPPER_H
#ifdef __cplusplus
extern "C" {
#endif
// This is the "Opaque Pointer".
// To C, it's just a generic pointer (void*).
// To C++, we will cast it back to a real Class.
typedef void* EngineHandle;
// The Bridge Functions
EngineHandle create_engine(int id);
void engine_process(EngineHandle handle, float data);
void destroy_engine(EngineHandle handle);
#ifdef __cplusplus
}
#endif
#endif
This header is the only file that both sides see. Two things make it work.
First, typedef void* EngineHandle gives the opaque pointer a readable name. To C, EngineHandle is just an alias for void*, a generic address with no type information attached. C cannot see what lives at that address, and it does not need to.
Second, the #ifdef __cplusplus / extern "C" guards solve the symbol naming problem. When the C++ compiler processes this header, extern "C" tells it to emit the function symbols with plain, undecorated names, create_engine, engine_process, destroy_engine, exactly the names the C linker will look for.
engine.cpp — The C++ Side
// Engine.cpp
#include "Wrapper.h"
#include <iostream>
// A simple C++ Class that C cannot see
class VideoEngine {
public:
int id;
VideoEngine(int i) : id(i) {}
void do_work(float val) {
std::cout << "Engine " << id << " processing data: " << val << std::endl;
}
};
// Implementation of the Bridge functions
extern "C" {
EngineHandle create_engine(int id) {
// We create a NEW C++ object and return the pointer as a void*
return static_cast<EngineHandle>(new VideoEngine(id));
}
void engine_process(EngineHandle handle, float data) {
// We cast the "Opaque Pointer" back to a C++ Object pointer
VideoEngine* engine = static_cast<VideoEngine*>(handle);
engine->do_work(data);
}
void destroy_engine(EngineHandle handle) {
delete static_cast<VideoEngine*>(handle);
}
}
class VideoEngine is never declared in any header that C sees. It exists only inside engine.cpp, completely invisible to the C world.
The bridge functions use static_cast in two directions.
Outward: create_engine allocates a real C++ object with new VideoEngine(id), which produces a VideoEngine*. Before returning it, static_cast<EngineHandle> converts that typed pointer into a void*, deliberately erasing the type information. C will receive a raw address and nothing else.
Inward: engine_process and destroy_engine receive that void* back from C and use static_cast<VideoEngine*>(handle) to recover the original type. This is safe because we know with certainty that the address came from new VideoEngine, we put it there ourselves.
static_cast is not a return type. It is a casting operator that performs an explicit type conversion. Its presence in the code is a deliberate signal: “I am intentionally changing the type of this pointer, and I know what I am doing.”
main.c — The C Side
// Main.c
#include "Wrapper.h"
#include <stdio.h>
int main() {
printf("Starting C program...\n");
// 1. Ask the bridge to create the "Object"
// We don't know WHAT 'handle' is, we just hold it.
EngineHandle my_engine = create_engine(101);
// 2. Use the "Object" by passing the handle back
engine_process(my_engine, 42.5f);
// 3. Clean up
destroy_engine(my_engine);
printf("Done.\n");
return 0;
}
main.c includes only wrapper.h. It never sees class VideoEngine. It never dereferences my_engine. It simply receives an address, stores it in a variable of type EngineHandle (which is void*), and passes it back to the bridge functions when needed.
This is the opaque pointer pattern working as intended: C participates in the object’s lifecycle, creating it, using it, destroying it, without ever knowing what the object actually is.
The Full Journey of the Pointer
The address below is illustrative — the heap allocator assigns a different one every time the program runs. What matters is that the same address travels through every step unchanged.
C++: new VideoEngine(101) → address 0x55a3f2 of type VideoEngine*
static_cast<EngineHandle> → same address 0x55a3f2 typed as void*
returned to C
C: EngineHandle my_engine → holds 0x55a3f2 — has no idea what is there
engine_process(my_engine, 42.5f) → passes 0x55a3f2 back to C++
C++: static_cast<VideoEngine*>(handle) → 0x55a3f2 typed as VideoEngine* again
engine->do_work(42.5f) → normal C++ method call
The address never changes. What changes is only the type label the compiler attaches to it. C holds it as void* (anonymous). C++ casts it back to VideoEngine* (concrete) whenever it needs to use it.
Summary
- There is a
typedef void* EngineHandlein the shared header, this is the opaque pointer, the only type both sides agree on. - There is a
class VideoEnginein C++, it is completely invisible to C. - There is
extern "C"wrapping the bridge functions — it tells the C++ compiler to emit plain, undecorated symbol names so the C linker can find them. - The bridge functions use
static_castin both directions:VideoEngine*→void*when sending a handle out to C, andvoid*→VideoEngine*when recovering the object back inside C++. - C participates in the full object lifecycle, create, use, destroy, without ever knowing what the object is.
This is the same principle behind FFmpeg’s public API, and behind most C libraries that wrap C++ internals. The opaque pointer is the handshake that lets both languages work together without either one having to become the other.