C/C++ Interoperability (FFI)
C++ interoperability — extern C, calling C from C++, calling C++ from C, pybind11 for Python, shared library symbol visibility, ABI stability, name mangling, and wrapping legacy C libraries.
TL;DR
C++ and C share the same ABI for extern "C" functions. Use extern "C" to disable C++ name mangling. Wrap C libraries in RAII types. Expose C++ to C via a flat extern "C" API.
Calling a C Library from C++
// C header (zlib.h — unchanged)
// int compress(Bytef *dest, uLongf *destLen, const Bytef *source, uLong sourceLen);
// C++ caller: just include and use
#include <zlib.h>
std::vector<uint8_t> compress_data(std::span<const uint8_t> input) {
uLongf out_size = compressBound(input.size());
std::vector<uint8_t> output(out_size);
int rc = compress(output.data(), &out_size, input.data(), input.size());
if (rc != Z_OK) throw std::runtime_error("compress failed");
output.resize(out_size);
return output;
}RAII Wrapper for C Resources
// C API: sqlite3_open / sqlite3_close
// sqlite3* db;
// sqlite3_open("test.db", &db);
// sqlite3_close(db);
struct SqliteDeleter {
void operator()(sqlite3* db) const { sqlite3_close(db); }
};
using DbHandle = std::unique_ptr<sqlite3, SqliteDeleter>;
DbHandle open_database(const char* path) {
sqlite3* raw = nullptr;
if (sqlite3_open(path, &raw) != SQLITE_OK) {
throw std::runtime_error(sqlite3_errmsg(raw));
}
return DbHandle{raw};
}
void use_db() {
auto db = open_database("mydb.sqlite");
// db auto-closes when it goes out of scope
}extern "C" — Exporting C++ for C
// my_api.hpp — include-able from both C and C++
#ifdef __cplusplus
extern "C" {
#endif
typedef struct MyObject MyObject; // forward declare opaque type
MyObject* my_create(int value);
void my_destroy(MyObject* obj);
int my_get_value(const MyObject* obj);
void my_set_value(MyObject* obj, int value);
#ifdef __cplusplus
} // end extern "C"
#endif// my_api.cpp — C++ implementation behind the C API
#include "my_api.hpp"
#include "MyClass.hpp"
struct MyObject { MyClass impl; };
MyObject* my_create(int value) {
return new MyObject{MyClass{value}};
}
void my_destroy(MyObject* obj) {
delete obj;
}
int my_get_value(const MyObject* obj) {
return obj->impl.value();
}
void my_set_value(MyObject* obj, int value) {
obj->impl.set_value(value);
}Name Mangling
// C++ mangles function names to encode types:
void foo(int); // mangled to: _Z3fooi (GCC/Clang)
void foo(double); // mangled to: _Z3food
// See mangled names:
// nm mylib.a | grep foo
// c++filt _Z3fooi → void foo(int)
// extern "C" disables mangling — compatible with C:
extern "C" void foo_c(int); // name stays foo_cWrapping a C++ Class for C
// Pattern: opaque pointer + factory + free + method functions
// In the C header:
// typedef void* Calculator;
// Calculator calc_new();
// void calc_free(Calculator);
// double calc_add(Calculator, double a, double b);
extern "C" {
void* calc_new() {
return new Calculator{};
}
void calc_free(void* p) {
delete static_cast<Calculator*>(p);
}
double calc_add(void* p, double a, double b) {
return static_cast<Calculator*>(p)->add(a, b);
}
}ABI Compatibility Tips
✅ Use extern "C" for stable API boundaries
✅ Use opaque pointers (forward declared struct) for C-visible types
✅ Document calling convention in header if non-default
✅ Prefer plain C types in the extern "C" boundary (int, double, char*, size_t)
✅ Version your ABI: add _v2 suffix rather than changing existing functions
❌ Don't pass C++ objects (std::string, std::vector) across the boundary
❌ Don't throw exceptions across extern "C" boundaries (UB)
❌ Don't assume sizeof(struct) is stable across compilers/flagsShared Library Symbol Visibility
By default, GCC/Clang export every symbol in a shared library. Explicit visibility reduces link time, prevents symbol clashes, and hides implementation internals.
// visibility.h — include in every source file
#if defined(_WIN32)
#define EXPORT __declspec(dllexport)
#define IMPORT __declspec(dllimport)
#define LOCAL
#elif defined(__GNUC__) || defined(__clang__)
#define EXPORT __attribute__((visibility("default")))
#define IMPORT __attribute__((visibility("default")))
#define LOCAL __attribute__((visibility("hidden")))
#else
#define EXPORT
#define IMPORT
#define LOCAL
#endif
#ifdef MYLIB_BUILDING_SHARED
#define MYLIB_API EXPORT // compiling the library
#else
#define MYLIB_API IMPORT // consumer includes this header
#endif
// my_api.hpp
extern "C" {
MYLIB_API int mylib_init();
MYLIB_API void mylib_destroy();
MYLIB_API int mylib_compute(int x, int y);
}# CMakeLists.txt — hide all symbols by default, mark only API as visible
add_library(mylib SHARED src/impl.cpp)
set_target_properties(mylib PROPERTIES
C_VISIBILITY_PRESET hidden
CXX_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN ON
)
target_compile_definitions(mylib PRIVATE MYLIB_BUILDING_SHARED)Exposing C++ to Python — pybind11
pybind11 creates Python extension modules directly from C++ classes with minimal boilerplate. No code generation step needed.
// binding.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h> // auto-convert std::vector <-> Python list
#include "my_math.hpp"
namespace py = pybind11;
// C++ class to expose
class Matrix {
public:
Matrix(int rows, int cols) : rows_(rows), cols_(cols), data_(rows*cols, 0.0) {}
double get(int r, int c) const { return data_[r * cols_ + c]; }
void set(int r, int c, double v) { data_[r * cols_ + c] = v; }
int rows() const { return rows_; }
int cols() const { return cols_; }
private:
int rows_, cols_;
std::vector<double> data_;
};
PYBIND11_MODULE(mymath, m) {
m.doc() = "C++ math bindings";
// Bind free functions
m.def("add", &add, py::arg("a"), py::arg("b"), "Add two integers");
// Bind class
py::class_<Matrix>(m, "Matrix")
.def(py::init<int, int>(), py::arg("rows"), py::arg("cols"))
.def("get", &Matrix::get)
.def("set", &Matrix::set)
.def_property_readonly("rows", &Matrix::rows)
.def_property_readonly("cols", &Matrix::cols)
.def("__repr__", [](const Matrix& m) {
return "<Matrix " + std::to_string(m.rows()) +
"x" + std::to_string(m.cols()) + ">";
});
}# CMakeLists.txt
find_package(Python REQUIRED COMPONENTS Interpreter Development)
find_package(pybind11 REQUIRED)
pybind11_add_module(mymath binding.cpp)
target_link_libraries(mymath PRIVATE my_math_lib)# Python usage
import mymath
m = mymath.Matrix(3, 3)
m.set(0, 0, 1.0)
print(m.get(0, 0)) # 1.0
print(repr(m)) # <Matrix 3x3>
print(mymath.add(2, 3)) # 5For Python 3.12+ projects, consider nanobind — a reimplementation of pybind11 that compiles faster and produces smaller binaries.
Loading a C API from Python with ctypes
When you can't recompile the library, ctypes loads it dynamically at runtime from pure Python:
# Python side — zero C++ changes needed
import ctypes
lib = ctypes.CDLL("./libmylib.so") # or .dll on Windows
# Declare argument and return types (prevents UB from wrong conversions)
lib.mylib_compute.argtypes = [ctypes.c_int, ctypes.c_int]
lib.mylib_compute.restype = ctypes.c_int
result = lib.mylib_compute(10, 20)
print(result)ctypes works only with extern "C" functions (no mangling). Use nm -D libmylib.so to inspect exported symbols.
CMake: Mixed C/C++ Project
cmake_minimum_required(VERSION 3.20)
project(MyLib LANGUAGES C CXX)
add_library(mylib
src/my_api.cpp # C++ implementation
src/helper.c # C code
)
target_compile_features(mylib PUBLIC cxx_std_17)
# C consumers link against mylib, get extern "C" interface
add_executable(c_consumer src/main.c)
target_link_libraries(c_consumer PRIVATE mylib)ABI stability requirements
A stable ABI means binary-compatible releases: callers compiled against version 1.0 still work without recompilation against version 1.1. Breaking the ABI silently corrupts data at runtime, which is harder to debug than a link error.
| Change | ABI safe? |
|---|---|
Add new extern "C" function at end | Yes |
Add a field to a struct passed by value | No — sizeof changes |
Rename a struct field (opaque pointer) | Yes — hidden behind pointer |
| Change a function's return type | No |
| Add a default parameter to a C++ function | No — changes mangled name |
| Change virtual function order in a class | No — vtable layout changes |
| Add non-virtual method to a class | Yes (usually) |
The opaque handle pattern is the gold standard for stable C ABIs: expose only a forward-declared pointer; never let the caller see the struct layout.
// v1 header — struct layout hidden
typedef struct Engine Engine;
Engine* engine_create(const char* config);
void engine_destroy(Engine* e);
int engine_run(Engine* e, int cycles);
// v2 header — add new function, never change existing signatures
Engine* engine_create_v2(const char* config, int flags); // new overloadCalling Rust from C++
cargo-c compiles a Rust crate into a C-compatible shared library (libfoo.so) with a generated header, so C++ code can call Rust with zero hand-written FFI boilerplate. cbindgen generates the header from pub extern "C" function annotations.
# Cargo.toml
[lib]
crate-type = ["cdylib", "staticlib"]
[dependencies]
# no extra deps needed for plain extern "C"// src/lib.rs
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn rust_process_slice(data: *const f64, len: usize) -> f64 {
if data.is_null() || len == 0 { return 0.0; }
// SAFETY: caller guarantees valid slice of length `len`
let slice = unsafe { std::slice::from_raw_parts(data, len) };
slice.iter().copied().sum::<f64>() / len as f64
}# Build with cargo-c (installs header + lib into a prefix)
cargo install cargo-c
cargo cinstall --prefix /usr/local --destdir /tmp/myrust
# Or build manually
cargo build --release
# produces target/release/libmyrust.so + use cbindgen for the header
cbindgen --lang c --output myrust.h// C++ caller
#include "myrust.h" // cbindgen-generated
#include <vector>
#include <cstdio>
int main() {
std::printf("3 + 4 = %d\n", rust_add(3, 4));
std::vector<double> data = {1.0, 2.0, 3.0, 4.0, 5.0};
double avg = rust_process_slice(data.data(), data.size());
std::printf("avg = %.2f\n", avg); // 3.00
}# CMakeLists.txt
find_library(MYRUST_LIB myrust PATHS ${CMAKE_SOURCE_DIR}/target/release)
add_executable(app main.cpp)
target_include_directories(app PRIVATE ${CMAKE_SOURCE_DIR})
target_link_libraries(app PRIVATE ${MYRUST_LIB} dl pthread)Calling C++ from Rust with bindgen and cxx
bindgen generates Rust FFI bindings from C headers; the cxx crate goes further by providing a safe, zero-overhead bridge that checks types at compile time on both sides. Use bindgen for C libraries and cxx for C++ libraries where you control both sides.
# bindgen workflow (C headers → unsafe Rust bindings)
cargo add bindgen --build// build.rs
fn main() {
println!("cargo:rustc-link-lib=mylib");
let bindings = bindgen::Builder::default()
.header("wrapper.h") // includes your C/C++ header
.parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
.generate()
.expect("Unable to generate bindings");
let out = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
bindings.write_to_file(out.join("bindings.rs")).unwrap();
}// src/lib.rs — unsafe bindgen bindings
mod ffi {
#![allow(non_upper_case_globals, non_camel_case_types)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}
pub fn safe_compute(x: i32, y: i32) -> i32 {
// SAFETY: mylib_compute is thread-safe and never returns a pointer
unsafe { ffi::mylib_compute(x, y) }
}For C++ interop with the cxx crate, both sides declare a shared interface in one .rs file:
// src/bridge.rs
#[cxx::bridge]
mod ffi {
// Types shared across the boundary
struct Point { x: f64, y: f64 }
// C++ functions callable from Rust
unsafe extern "C++" {
include!("mylib/include/geometry.hpp");
fn distance(a: &Point, b: &Point) -> f64;
}
// Rust functions callable from C++
extern "Rust" {
fn rust_callback(value: i32) -> i32;
}
}
fn rust_callback(value: i32) -> i32 { value * 2 }Swift/C++ interoperability
Swift 5.9 introduced direct C++ interop: importing a C++ module into Swift gives access to classes, value types, and methods without writing Objective-C bridging headers. Enable it with a single compiler flag.
# CMakeLists.txt — build a C++ framework, enable Swift interop
add_library(geometry STATIC geometry.cpp)
target_compile_features(geometry PUBLIC cxx_std_17)
# Swift target that uses the C++ library
# (in practice, use Xcode or SwiftPM with interoperabilityMode)// geometry.hpp — exposed C++ types
#pragma once
#include <cmath>
struct Point {
double x, y;
double distance_to(const Point& other) const {
double dx = x - other.x, dy = y - other.y;
return std::sqrt(dx*dx + dy*dy);
}
};
class Circle {
public:
Circle(Point center, double radius) : center_(center), r_(radius) {}
bool contains(const Point& p) const {
return center_.distance_to(p) <= r_;
}
double area() const { return 3.14159265358979 * r_ * r_; }
private:
Point center_;
double r_;
};// Swift 5.9+ with -enable-experimental-cxx-interop
import CxxStdlib
import geometry // the C++ module
let center = Point(x: 0, y: 0)
let p = Point(x: 3, y: 4)
let dist = center.distance_to(p) // calls C++ method directly
print("distance: \(dist)") // 5.0
let circle = Circle(center, 5.0)
print(circle.contains(p)) // true
print(circle.area()) // ~78.5// Package.swift — enable C++ interop
let package = Package(
name: "MyApp",
targets: [
.target(name: "geometry", publicHeadersPath: "include"),
.executableTarget(
name: "MyApp",
dependencies: ["geometry"],
swiftSettings: [
.interoperabilityMode(.Cxx)
]
)
]
)pybind11 — operator overloading and NumPy buffers
pybind11 supports Python operator overloading and zero-copy NumPy array access through the buffer protocol, letting C++ number-crunching code integrate naturally with scientific Python. Include <pybind11/numpy.h> for array support.
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <pybind11/operators.h>
namespace py = pybind11;
struct Vec2 {
double x, y;
Vec2(double x, double y) : x(x), y(y) {}
Vec2 operator+(const Vec2& o) const { return {x+o.x, y+o.y}; }
Vec2 operator*(double s) const { return {x*s, y*s}; }
bool operator==(const Vec2& o) const { return x==o.x && y==o.y; }
std::string repr() const {
return "Vec2(" + std::to_string(x) + ", " + std::to_string(y) + ")";
}
};
// In-place NumPy operation: double every element
py::array_t<double> scale_array(py::array_t<double> arr, double factor) {
// Request a contiguous buffer — throws if not possible
auto buf = arr.request();
if (buf.ndim != 1) throw std::runtime_error("Expected 1-D array");
auto* ptr = static_cast<double*>(buf.ptr);
// Operate directly on NumPy memory — no copy
for (py::ssize_t i = 0; i < buf.shape[0]; i++)
ptr[i] *= factor;
return arr; // return the same array (modified in-place)
}
PYBIND11_MODULE(vecmath, m) {
py::class_<Vec2>(m, "Vec2")
.def(py::init<double, double>())
.def_readwrite("x", &Vec2::x)
.def_readwrite("y", &Vec2::y)
.def(py::self + py::self) // __add__
.def(py::self * double()) // __mul__
.def(py::self == py::self) // __eq__
.def("__repr__", &Vec2::repr);
m.def("scale_array", &scale_array,
py::arg("arr"), py::arg("factor"),
"Scale a NumPy array in-place");
}import numpy as np
import vecmath
v1, v2 = vecmath.Vec2(1, 2), vecmath.Vec2(3, 4)
print(v1 + v2) # Vec2(4.000000, 6.000000)
print(v1 * 3.0) # Vec2(3.000000, 6.000000)
data = np.array([1.0, 2.0, 3.0])
vecmath.scale_array(data, 2.0)
print(data) # [2. 4. 6.] — modified in-place, zero copyGo cgo integration
cgo lets Go programs call C (and thus extern "C" C++) functions by embedding C declarations in a special comment block immediately above import "C". Complex types, memory ownership, and GC interactions require careful discipline.
// bridge.go
package main
/*
#cgo LDFLAGS: -L. -lmylib -lstdc++
#include "my_api.h"
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
func Compute(x, y int) int {
return int(C.mylib_compute(C.int(x), C.int(y)))
}
// Passing a Go string to C: must convert and free
func ProcessName(name string) {
cname := C.CString(name) // allocates C memory
defer C.free(unsafe.Pointer(cname)) // MUST free — GC won't
C.my_process_name(cname)
}
// Receiving a C string: copy into Go memory immediately
func GetVersion() string {
cver := C.my_get_version() // returns static C string
return C.GoString(cver) // copies into Go-managed memory
}
func main() {
fmt.Println(Compute(10, 32)) // 42
ProcessName("robot")
fmt.Println(GetVersion())
}Key cgo rules:
| Rule | Reason |
|---|---|
| Never store a Go pointer in C memory across calls | GC can move Go objects |
Always C.free memory allocated by C.CString / C.CBytes | C heap, not GC-managed |
| Never pass Go pointers into C that outlive the cgo call | Violates CGO pointer passing rules |
| Keep cgo call sites thin; do heavy work in Go or pure C | cgo calls are ~20× slower than Go calls |
Use //export only for C-callable Go functions, in a separate file | Mixing with import "C" causes link errors |
Error handling across language boundaries
C++ exceptions must not cross extern "C" boundaries — doing so is undefined behaviour and will crash in most configurations. Convert all exceptions to error codes or result structs at the boundary.
// Pattern 1: error-code return + out-parameter
extern "C" {
enum MyError { MY_OK = 0, MY_ERR_INVALID = 1, MY_ERR_IO = 2, MY_ERR_NOMEM = 3 };
MyError my_open_file(const char* path, MyObject** out) {
try {
*out = new MyObject{};
(*out)->impl.open(path); // may throw std::ios_base::failure
return MY_OK;
} catch (const std::bad_alloc&) { return MY_ERR_NOMEM; }
catch (const std::ios_base::failure&){ return MY_ERR_IO; }
catch (...) { return MY_ERR_INVALID; }
}
// Pattern 2: thread-local error string (like errno)
static thread_local std::string tl_last_error;
int my_do_work(MyObject* obj, int param) {
try {
return obj->impl.work(param);
} catch (const std::exception& e) {
tl_last_error = e.what();
return -1;
} catch (...) {
tl_last_error = "unknown error";
return -1;
}
}
const char* my_last_error() { return tl_last_error.c_str(); }
} // extern "C"// Rust caller consuming the error-code pattern
#[repr(i32)]
enum MyError { Ok = 0, Invalid = 1, Io = 2, NoMem = 3 }
fn open_file(path: &str) -> Result<*mut MyObject, MyError> {
let cpath = std::ffi::CString::new(path).unwrap();
let mut obj: *mut MyObject = std::ptr::null_mut();
let rc = unsafe { my_open_file(cpath.as_ptr(), &mut obj) };
match rc {
0 => Ok(obj),
1 => Err(MyError::Invalid),
2 => Err(MyError::Io),
_ => Err(MyError::NoMem),
}
}Testing FFI boundaries
FFI boundary bugs — wrong type sizes, bad ownership, mismatched calling conventions — are invisible to language-level unit tests; they require contract tests that exercise the C API as a foreign caller would. AddressSanitizer and Valgrind catch the memory errors that follow.
# Build with AddressSanitizer to catch use-after-free across FFI
clang++ -fsanitize=address,undefined -g -shared -fPIC \
-o libmylib.so my_api.cpp
# Link test binary with the same sanitizer flags
clang++ -fsanitize=address,undefined -g \
test_ffi.cpp -L. -lmylib -o test_ffi
ASAN_OPTIONS=detect_leaks=1 ./test_ffi
# Valgrind — catches leaks when ASAN not available
valgrind --leak-check=full --error-exitcode=1 ./test_ffi// test_ffi.cpp — contract test: exercises the C API as a C caller
#include <cassert>
#include <cstring>
#include "my_api.h" // C header only — no C++ internals
int main() {
// Construction and destruction
MyObject* obj = my_create(42);
assert(obj != nullptr);
assert(my_get_value(obj) == 42);
// Mutation
my_set_value(obj, 100);
assert(my_get_value(obj) == 100);
// Double-free should not happen; test that destroy is idempotent
// (if the API guarantees it)
my_destroy(obj);
obj = nullptr;
// Null-safety (document and test your contract)
// my_destroy(nullptr); // should be safe if documented
// Ownership: create + forget → leak detector catches it
MyObject* leaked = my_create(1);
(void)leaked; // intentionally NOT destroyed → ASAN should report
}For cross-language contract testing, write the same test scenario in both the provider language (C++) and the consumer language (Rust/Go/Python), then assert identical observable behaviour:
# test_ffi_contract.py — Python side of the contract test
import ctypes, pytest
lib = ctypes.CDLL("./libmylib.so")
lib.my_create.restype = ctypes.c_void_p
lib.my_create.argtypes = [ctypes.c_int]
lib.my_get_value.restype = ctypes.c_int
lib.my_get_value.argtypes = [ctypes.c_void_p]
lib.my_set_value.argtypes = [ctypes.c_void_p, ctypes.c_int]
lib.my_destroy.argtypes = [ctypes.c_void_p]
def test_round_trip():
obj = lib.my_create(42)
assert lib.my_get_value(obj) == 42
lib.my_set_value(obj, 7)
assert lib.my_get_value(obj) == 7
lib.my_destroy(obj)
if __name__ == "__main__":
pytest.main([__file__, "-v"])