C Interoperability
Reviewed & published by Brayan K
By the end of this lesson you'll be able to call C libraries from C++ and expose C++ functions to C using extern "C", write a header both languages can include, pass structs and pointers across the boundary safely, and avoid the traps — name mangling, escaping exceptions, and handing C++ objects to C.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Explain name mangling and why C and C++ symbols differ
- Use extern "C" to give functions plain C linkage
- Write the #ifdef __cplusplus header both languages can include
- Call classic C APIs (qsort, strcpy, malloc) from C++
- Expose C++ to C and pass structs and pointers across the boundary
- Avoid the pitfalls: escaping exceptions, overloading, C++ objects in C
💡 Real-World Analogy
Think of a function name as a label on a parcel. C++ writes a detailed label that encodes the argument types — add(int,int) becomes something like _Z3addii — so it can tell two functions called add apart (that's overloading). C writes a plain label: just add. When a C courier goes looking for the parcel labelled add but C++ filed it under _Z3addii, delivery fails — that's a linker error. extern "C" tells the C++ side to use the plain label, so both couriers agree on the address. Everything in this lesson is about keeping the labels matched.
📊 What Crosses the Boundary — and What Doesn't
| Crosses cleanly | Does NOT cross |
|---|---|
| Numbers: int, double, char | std::string, std::vector (C++ types) |
| Pointers: int*, const char*, void* | References (int&) — C has none |
| POD structs (plain data, no methods) | Classes with methods / virtuals |
| Return codes, out-parameters | C++ exceptions (throw) |
| Unmangled extern "C" functions | Overloads, templates, namespaces |
Rule of thumb: anything C understands is plain data and plain functions. The C++ features that need name mangling (overloading, templates, namespaces) are exactly the ones that can't cross.
1. Name Mangling & extern "C"
Name mangling is how C++ encodes a function's argument types into its symbol name so that print(int) and print(double) can coexist — that's what makes overloading possible. C doesn't mangle: a function is just its bare name. So if C++ and C try to link to the same function, the names won't match. extern "C" is the fix: it tells the C++ compiler to give a function C linkage — the plain, unmangled name and C calling convention — so both languages can find it. Run this and notice the functions behave normally; the difference is invisible until link time.
#include <iostream>
using namespace std;
// C++ "mangles" function names to encode their argument types, which is
// how overloading works. C does NOT mangle — a function is just its name.
// extern "C" says: "give this C-linkage, the plain unmangled name."
// A single function with C linkage:
extern "C" int add(int a, int b) {
return a + b;
}
// A whole BLOCK of functions with C linkage — the usual style:
extern "C" {
int square(int x) { return x * x; }
int cube(int x) { return x * x * x; }
}
int main() {
// You call them exactly like normal C++ functions.
cout << "add(2, 3) = " << add(2, 3) << endl; // add(2, 3) = 5
cout << "square(4) = " << square(4) << endl; // square(4) = 16
cout << "cube(3) = " << cube(3) << endl; // cube(3) = 27
// The DIFFERENCE is invisible here but vital at LINK time:
// 'add' exports the symbol add (not a mangled name like _Z3addii),
// so a C compiler — or any other language — can find and call it.
return 0;
}
// ✅ Expected output:
// add(2, 3) = 5
// square(4) = 16
// cube(3) = 27In a real project the declarations live in a header shared by both languages. The trick is the __cplusplus macro, which only a C++ compiler defines. You wrap the prototypes in extern "C" only when C++ is reading the header, and a C compiler skips those lines entirely. This is the interop pattern — memorise its shape.
#include <iostream>
using namespace std;
// THE portable header pattern. A real project puts this in mathlib.h so
// BOTH a C compiler and a C++ compiler can include the same file.
//
// #ifndef MATHLIB_H
// #define MATHLIB_H
//
// #ifdef __cplusplus // __cplusplus is defined ONLY by C++ compilers
// extern "C" { // so C++ sees: wrap these in C linkage
// #endif
//
// int add(int a, int b); // plain C declarations
// double average(const int* v, int n);
//
// #ifdef __cplusplus
// } // close the extern "C" block (C never sees it)
// #endif
//
// #endif // MATHLIB_H
//
// A C compiler skips the extern "C" lines entirely and just sees the
// prototypes. A C++ compiler wraps them so the symbols stay unmangled.
// Here is the matching implementation, compiled in this one file:
extern "C" {
int add(int a, int b) { return a + b; }
double average(const int* v, int n) {
int sum = 0;
for (int i = 0; i < n; i++) sum += v[i];
return n ? static_cast<double>(sum) / n : 0.0;
}
}
int main() {
int nums[] = {10, 20, 30, 40};
cout << "add(7, 8) = " << add(7, 8) << endl; // 15
cout << "average(nums) = " << average(nums, 4) << endl; // 25
return 0;
}
// ✅ Expected output:
// add(7, 8) = 15
// average(nums) = 25Your turn. The program below won't link cleanly for a C caller because two functions are still mangled. Add C linkage where the comments point — fill in the two ___ blanks, then run it.
#include <iostream>
using namespace std;
int main() {
// 🎯 YOUR TURN — give two functions C linkage so a C program could
// link against them. Replace each ___ then press "Try it Yourself".
return run();
}
// 1) Wrap this function so its name is NOT mangled.
// 👉 put extern "C" in front of the return type
___ int multiply(int a, int b) {
return a * b;
}
// 2) Wrap a whole block of two functions with C linkage.
// 👉 the block opens with extern "C" {
___ {
int negate(int x) { return -x; }
int absVal(int x) { return x < 0 ? -x : x; }
}
int run() {
cout << "multiply(6, 7) = " << multiply(6, 7) << endl;
cout << "negate(5) = " << negate(5) << endl;
cout << "absVal(-9) = " << absVal(-9) << endl;
return 0;
}
// ✅ Expected output:
// multiply(6, 7) = 42
// negate(5) = -5
// absVal(-9) = 92. Passing Structs & Pointers Across the Boundary
Once the names line up, you still have to pass data C understands. C has no references and no std::string, so you use pointers and POD structs — "plain old data", a struct of fields with no methods or constructors, laid out the same way in both languages. To return a value you write through a pointer (an out-parameter), and arrays travel as a pointer plus a length, because a raw array carries no size of its own.
#include <iostream>
#include <cstring>
using namespace std;
// Across the C boundary you may only pass PLAIN DATA: numbers, pointers,
// and "POD" structs (plain-old-data — no methods, no constructors).
// A POD struct both C and C++ agree on, byte for byte:
struct Point {
double x;
double y;
};
// C-style API: take a POINTER to the struct (C has no references),
// fill it in by writing through the pointer.
extern "C" void makePoint(Point* p, double x, double y) {
p->x = x; // write through the pointer
p->y = y;
}
// Take an ARRAY as pointer + length — C strings/arrays carry no size.
extern "C" double distanceFromOrigin(const Point* p) {
// (no <cmath> needed for the idea — just show the data crossed fine)
return p->x * p->x + p->y * p->y; // squared distance, kept simple
}
// A C-string out-parameter: caller owns the buffer, you fill it.
extern "C" void describe(const Point* p, char* out, int outSize) {
snprintf(out, outSize, "(%.1f, %.1f)", p->x, p->y);
}
int main() {
Point p;
makePoint(&p, 3.0, 4.0); // pass the ADDRESS with &
cout << "x=" << p.x << " y=" << p.y << endl; // x=3 y=4
cout << "dist^2 = " << distanceFromOrigin(&p) << endl; // 25
char label[32];
describe(&p, label, sizeof(label)); // caller-owned buffer
cout << "label = " << label << endl; // label = (3.0, 4.0)
return 0;
}
// ✅ Expected output:
// x=3 y=4
// dist^2 = 25
// label = (3.0, 4.0)3. Exposing C++ to C (Safely)
Going the other way — letting C call your C++ code — adds one hard rule: no C++ exception may escape into C. A C stack frame has no idea how to unwind one, so an escaping throw means undefined behaviour (usually a crash). The pattern is a thin extern "C" wrapper that try/catches everything and reports problems the C way: a return code plus an out-parameter for the result. The rich C++ logic stays safely behind the wrapper.
#include <iostream>
#include <stdexcept>
using namespace std;
// A C++ function that can THROW. C code cannot survive a C++ exception,
// so we never let it cross the boundary.
double cppDivide(int a, int b) {
if (b == 0) throw runtime_error("divide by zero");
return static_cast<double>(a) / b;
}
// The C-facing wrapper: it CATCHES everything and reports errors the
// C way — a return code (0 = ok, non-zero = error) plus an out-param.
extern "C" int safe_divide(int a, int b, double* result) {
try {
*result = cppDivide(a, b); // call into the C++ world
return 0; // success
} catch (const exception& e) {
cout << " [caught in wrapper] " << e.what() << endl;
return 1; // failure — NO exception escapes
}
}
int main() {
double r = 0.0;
// A C caller would write exactly this style: check the return code.
if (safe_divide(10, 2, &r) == 0)
cout << "10 / 2 = " << r << endl; // 10 / 2 = 5
if (safe_divide(10, 0, &r) != 0) // triggers the throw
cout << "second call reported an error" << endl;
return 0;
}
// ✅ Expected output:
// 10 / 2 = 5
// [caught in wrapper] divide by zero
// second call reported an errorNow you try. Below is a C++ helper and a half-finished C-facing wrapper. Give the wrapper C linkage and make it report bad input with a return value instead of throwing — fill in the two blanks:
#include <iostream>
using namespace std;
// A C++ helper. It uses C++ features, so it must stay behind a wrapper.
int cppFactorial(int n) {
int result = 1;
for (int i = 2; i <= n; i++) result *= i;
return result;
}
// 🎯 YOUR TURN — finish the C-facing wrapper.
// 1) Give it C linkage so a C program can call it.
// 👉 prefix the line with extern "C"
___ int c_factorial(int n) {
// 2) Report invalid input the C way: return -1 instead of throwing.
// 👉 replace ___ with -1
if (n < 0) return ___;
return cppFactorial(n); // delegate to the C++ helper
}
int main() {
cout << "c_factorial(5) = " << c_factorial(5) << endl;
cout << "c_factorial(-3) = " << c_factorial(-3) << endl;
return 0;
}
// ✅ Expected output:
// c_factorial(5) = 120
// c_factorial(-3) = -14. Calling Classic C APIs from C++
You'll use C libraries constantly — SQLite, OpenSSL, zlib, and POSIX are all C. The C++ standard library even ships the C headers for you: <string.h> becomes <cstring>, <stdlib.h> becomes <cstdlib>, and they already wrap their declarations in extern "C". A classic example is qsort, which takes a function pointer as a callback. Note a quirk: a capturing lambda has hidden state and cannot become a plain function pointer; a stateless lambda (no captures) can.
#include <iostream>
#include <cstring> // C string functions: strlen, strcpy, strcmp...
#include <cstdlib> // C memory + utilities: malloc, free, qsort, atoi
using namespace std;
// C library headers in C++ get a 'c' prefix and drop the .h:
// <string.h> -> <cstring>, <stdlib.h> -> <cstdlib>
// They already wrap their declarations in extern "C" for you.
int compareInts(const void* a, const void* b) {
// qsort hands you void*; cast back to the real type, then compare.
int x = *static_cast<const int*>(a);
int y = *static_cast<const int*>(b);
return (x > y) - (x < y); // -1, 0, or +1 without overflow
}
int main() {
// C string functions still work on char arrays:
char buf[32];
strcpy(buf, "Hello"); // copy into the buffer
strcat(buf, ", C!"); // append
cout << "buf = " << buf << " (len " << strlen(buf) << ")" << endl;
// qsort: a pure-C sort that takes a function POINTER as its callback.
int data[] = {42, 17, 93, 5, 68};
int n = sizeof(data) / sizeof(data[0]);
qsort(data, n, sizeof(int), compareInts);
cout << "sorted: ";
for (int i = 0; i < n; i++) cout << data[i] << " "; // 5 17 42 68 93
cout << endl;
// atoi: a C helper to turn text into a number.
cout << "atoi(\"123\") + 1 = " << (atoi("123") + 1) << endl; // 124
return 0;
}
// ✅ Expected output:
// buf = Hello, C! (len 9)
// sorted: 5 17 42 68 93
// atoi("123") + 1 = 124🔎 Deep Dive: what extern "C" changes — and what it doesn't
extern "C" affects linkage only — the symbol name and calling convention. It does not turn off C++ inside the function body: you can still use std::string, classes, and the STL in there, as long as none of it leaks across the boundary as a parameter, return type, or escaping exception.
Because the name is unmangled, you lose the features that depend on mangling: no overloading (two functions would share one symbol), no namespaces in the symbol, no templates. Each C-facing function needs one unique, bare name.
extern "C" int parse(const char* s); // ✅ plain name "parse"
extern "C" int parse(double d); // ❌ same symbol — collision!
extern "C" int run() {
std::string log = "ok"; // ✅ C++ INSIDE is fine
return (int)log.size(); // ✅ only an int crosses out
}Pro Tips
- 💡 Guard every public header: the #ifdef __cplusplus / extern "C" pattern makes one header work for both languages — make it your default for any C-facing API.
- 💡 Keep wrappers thin and total: a C-facing function should try/catch everything and never let an exception out. Report errors with a return code.
- 💡 Pass data, not objects: hand C a const char* from str.c_str() and a pointer+length from vec.data()/vec.size() — never the std::string or std::vector itself.
- 💡 Match the allocator: memory from C's malloc must be released with free, never delete. Mixing the pair is undefined behaviour.
Common Errors (and the fix)
- Forgetting extern "C" → linker error: the linker says undefined reference to 'add' (or add referenced but not defined) because C++ looked for the mangled name _Z3addii while the C object exported plain add. Wrap the declaration in extern "C" so both sides use the same symbol.
- Throwing across the C boundary: letting a C++ exception escape an extern "C" function is undefined behaviour — it typically calls std::terminate and crashes. Wrap the body in try/catch and return an error code instead of throwing.
- Passing C++ objects to C: handing a std::string or std::vector straight to a C function makes C read a layout it doesn't understand. Pass str.c_str() and vec.data() + vec.size() — plain pointers and lengths.
- Overloading an extern "C" function: error: conflicting declaration / duplicate symbol, because both overloads compile to the same unmangled name. Give each C-facing function a unique name.
- Mismatched allocator: calling delete on malloc memory (or free on new memory) is undefined behaviour. Always match the pair: malloc/free, new/delete.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| One C-linkage function | extern "C" int add(...) | Unmangled name |
| A block of them | extern "C" { ... } | All get C linkage |
| Dual-language header | #ifdef __cplusplus | Wrap prototypes |
| C++ string → C | str.c_str() | const char* |
| C vector data → C | vec.data(), vec.size() | ptr + length |
| Return a value to C | void f(T* out) | Out-parameter |
| Report an error to C | return errorCode; | Never throw |
Mini-Challenge: C-Callable Temperature Converter
No blanks this time — just a brief and a blank canvas (with an outline to keep you on track). Build a POD struct and an extern "C" function that fills it in through a pointer, then run it and check your output against the example in the comments.
#include <iostream>
using namespace std;
int main() {
// 🎯 MINI-CHALLENGE: a C-callable temperature converter
//
// 1. Write a POD struct Reading with two fields:
// double celsius;
// double fahrenheit;
//
// 2. Write an extern "C" function:
// void toFahrenheit(Reading* r);
// It reads r->celsius and fills in r->fahrenheit
// using f = c * 9.0 / 5.0 + 32.0; (write THROUGH the pointer).
//
// 3. In main: make a Reading with celsius = 100, pass its ADDRESS
// with &, then print both fields.
//
// Remember: pass the struct by POINTER (C has no references), and
// keep the function exception-free so a C caller could use it.
//
// ✅ Expected output:
// 100C = 212F
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Name mangling encodes C++ argument types into the symbol; C uses plain names
- ✅ extern "C" gives a function (or a whole block) C linkage — the unmangled name
- ✅ The #ifdef __cplusplus header lets one file serve both C and C++ callers
- ✅ Across the boundary pass plain data: numbers, pointers, and POD structs — not std::string or classes
- ✅ A C-facing wrapper must try/catch everything and report errors with a return code, never a throw
- ✅ No overloading across the boundary, and match allocators (malloc/free)
Practice quiz
What does extern "C" actually change about a function?
- It rewrites the body in C
- It makes the function faster
- It gives the function C linkage: a plain, unmangled symbol name
- It disables the C++ standard library inside it
Answer: It gives the function C linkage: a plain, unmangled symbol name. extern "C" affects linkage only — the symbol name and calling convention — so C and C++ agree on the name.
Why does a missing extern "C" cause an error only at link time, not while compiling?
- Compiling checks the declaration exists; linking checks the matching definition's symbol
- The compiler ignores C functions
- C++ never compiles C calls
- Linking happens first
Answer: Compiling checks the declaration exists; linking checks the matching definition's symbol. The C++ caller compiles fine against a declaration but the linker can't find the mangled name the C object never exported.
Can you overload a function declared extern "C"?
- Yes, always
- Only with different return types
- Only inside a namespace
- No — C has no mangling, so two overloads would share one symbol and collide
Answer: No — C has no mangling, so two overloads would share one symbol and collide. Overloading needs mangling to make distinct symbols; with C linkage the names collide, so each C-facing function needs a unique name.
What happens if a C++ exception escapes into C code?
- C catches it normally
- Behaviour is undefined — typically std::terminate and a crash
- It is silently ignored
- It converts to a return code automatically
Answer: Behaviour is undefined — typically std::terminate and a crash. A C frame can't unwind a C++ exception, so a C-facing wrapper must catch everything and report errors via a return code.
In the dual-language header pattern, what is the role of the __cplusplus macro?
- It is defined only by C++ compilers, so only C++ wraps the prototypes in extern "C"
- It enables optimizations
- It is defined only by C compilers
- It includes the C standard library
Answer: It is defined only by C++ compilers, so only C++ wraps the prototypes in extern "C". A C compiler skips the extern "C" lines entirely; a C++ compiler sees __cplusplus and wraps the declarations.
How should you pass a std::string's text to a C function?
- Pass the std::string object directly
- Cast it to void*
- Pass str.c_str() — a const char*
- Pass &str
Answer: Pass str.c_str() — a const char*. C doesn't know std::string's layout; pass plain data: str.c_str() for the text, vec.data()/vec.size() for arrays.
Across the C boundary, how does a C-style API return a value into a struct?
- By reference (Point&)
- By writing through a pointer (an out-parameter)
- By throwing it
- By returning a std::string
Answer: By writing through a pointer (an out-parameter). C has no references, so you pass a pointer and write through it, e.g. void makePoint(Point* p, ...).
What kind of struct is safe to pass across the C boundary?
- Any C++ class
- A class with virtual functions
- A template struct
- A POD struct: plain fields, no methods, no constructors
Answer: A POD struct: plain fields, no methods, no constructors. Only plain-old-data structs are laid out identically in both languages and safe to share byte-for-byte.
When calling C's qsort from C++, the comparator must be:
- A capturing lambda
- A plain function pointer (a stateless/no-capture lambda is acceptable)
- A std::function
- A member function
Answer: A plain function pointer (a stateless/no-capture lambda is acceptable). qsort takes a function pointer; a capturing lambda has hidden state and can't convert, but a stateless lambda can.
Memory allocated with C's malloc must be released with:
- delete
- delete[]
- free
- Either delete or free
Answer: free. Always match allocators: malloc/free and new/delete. Mixing them is undefined behaviour.
Continue this course
- Previous: The C++ ABI, Name Mangling & Linking Internals
- Next: Advanced Debugging: Valgrind, GDB, ASan, UBSan — Find memory errors, undefined behaviour, and crashes with debug tools
- Quick reference: C++ cheat sheet
Frequently asked questions
What does extern "C" actually do?
It tells the C++ compiler to use C linkage for the names inside it — no name mangling and the C calling convention. The result is a symbol the C linker recognises, so C++ code can call a C function and C code can call a C++ function you have exposed.
Why do I get "undefined reference" only at link time, not while compiling?
Compiling checks that a declaration exists; linking checks that the matching definition exists. If a C++ caller looks for a mangled name like _Z3addii but the C object only defines add, compilation passes and the linker fails. Wrapping the C declaration in extern "C" makes both sides agree on the symbol name.
Do I need extern "C" when I compile everything as C++?
Only when you call into something that was compiled as C — a precompiled .o/.a/.so or a C source file built by a C compiler. If every file is built by the C++ compiler, the names already match. But marking your public headers anyway is good practice, since it keeps them usable from real C callers.
Can I overload a function that is declared extern "C"?
No. C has no name mangling, so two extern "C" functions would produce the same symbol and collide. Overloading, namespaces, and templates all rely on mangling, so they cannot cross the C boundary. Give each C-facing function a unique name.
What happens if a C++ exception escapes into C code?
Behaviour is undefined — a C frame has no idea how to unwind a C++ exception, so it typically calls std::terminate and crashes. Any function exposed to C must catch everything and report errors through a return code or an out-parameter instead.
Can I pass a C++ std::string or std::vector to a C function?
Not as the object itself — C does not know their layout, and it depends on your compiler's ABI. Pass plain data instead: str.c_str() and vec.data() / vec.size() give you the const char* and pointer+length that C understands.