Skip to content
C++
Domain Track
Difficulty 3/5

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++

cpp
// 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

cpp
// 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

cpp
// 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
cpp
// 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

cpp
// 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_c

Wrapping a C++ Class for C

cpp
// 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

cpp
✅ 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/flags

Shared 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.

cpp
// 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);
}
cmake
# 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.

cpp
// 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()) + ">";
        });
}
cmake
# 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
# 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))   # 5

For 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
# 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
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.

ChangeABI safe?
Add new extern "C" function at endYes
Add a field to a struct passed by valueNo — sizeof changes
Rename a struct field (opaque pointer)Yes — hidden behind pointer
Change a function's return typeNo
Add a default parameter to a C++ functionNo — changes mangled name
Change virtual function order in a classNo — vtable layout changes
Add non-virtual method to a classYes (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.

cpp
// 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 overload

Calling 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.

toml
# Cargo.toml
[lib]
crate-type = ["cdylib", "staticlib"]

[dependencies]
# no extra deps needed for plain extern "C"
rust
// 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
}
bash
# 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
cpp
// 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
}
cmake
# 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.

bash
# bindgen workflow (C headers → unsafe Rust bindings)
cargo add bindgen --build
rust
// 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();
}
rust
// 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:

rust
// 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.

cmake
# 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)
cpp
// 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
// 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
swift
// 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.

cpp
#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");
}
python
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 copy

Go 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.

go
// 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:

RuleReason
Never store a Go pointer in C memory across callsGC can move Go objects
Always C.free memory allocated by C.CString / C.CBytesC heap, not GC-managed
Never pass Go pointers into C that outlive the cgo callViolates CGO pointer passing rules
Keep cgo call sites thin; do heavy work in Go or pure Ccgo calls are ~20× slower than Go calls
Use //export only for C-callable Go functions, in a separate fileMixing 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.

cpp
// 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
// 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.

bash
# 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
cpp
// 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:

python
# 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"])