avatar

Andres Jaimes

Creating C-API Interfaces for C++ code

By Andres Jaimes

- 13 minutes read - 2584 words

At some point we’ll want our C++ code to interface with other languages, but since C is the standard, we have to find ways to adapt our C++ code to allow interaction via a C interface. A C API is a group of functions that allows other programs to call our C++ code using a well-known protocol. That means, we have to find ways to put our parameters and functions into a standard C wrapping so they can be used by other clients. By creating this interface, we can reuse our C++ code in multiple ways, for example, by creating dynamic libraries –dll’s, dylib’s or lib’s– and using them from different programs written in diverse languages.

In this article, I’ll talk about the different challenges that I’ve seen when creating C API interfaces and how I’ve solved them.

Class instantiation

I have seen multiple approaches to accomplish this. Among them, we can mention the following strategies:

  • Singletons
  • Factory pattern
  • Global variables

Singletons

A singleton is useful when we don’t need to store state in the class. Class attributes may lead to unwanted results, since there’s only one running instance, so clients may override these values.

 1class my_class {
 2public:
 3    static my_class* get_instance() {
 4        if (instance == nullptr) {
 5            instance = new my_class();
 6        }
 7        return instance;
 8    }
 9
10    static void destroy_instance() {
11        delete instance;
12        instance = nullptr;
13    }
14
15    void do_something() {
16        // ...
17    }
18
19private:
20    static my_class* instance;
21
22};

The C API interface would look like the following code.

1extern "C" {
2    void my_class_cleanup() {
3        my_class::destroy_instance();
4    }
5
6    void my_class_do_something() {
7        my_class::get_instance()->do_something();
8    }
9}

We can use references as a safer alternative in terms of memory management, since the library user doesn’t need to remember to explicitly destroy the instance. This version doesn’t require a cleanup function for memory, but one might still be needed if the class manages other resources, such as open files, sockets, or handles.

 1class my_class {
 2public:
 3    static my_class& get_instance() {
 4        static my_class instance;
 5        return instance;
 6    }
 7
 8    void do_something() {
 9        // ...
10    }
11};

C API

1void my_class_do_something() {
2    my_class::get_instance().do_something();
3}

Factory pattern

A factory pattern allows for better encapsulation, decoupling, and scenarios where we need more complex instantiations. Also, it allows us to create multiple class instances, thus, giving us the opportunity to keep state in the class, if we need it.

1class my_class {
2public:
3    void do_something() {
4        // ...
5    }
6}

Class factory.

1class my_class_factory {
2    static my_class* make_instance() {
3        return new my_class();
4    }
5
6    static void destroy_instance(my_class* instance) {
7        delete instance;
8    }
9}

C API.

 1extern "C" {
 2    my_class* my_class_make() {
 3        return my_class_factory::make_instance();
 4    }
 5
 6    void my_class_destroy(my_class* instance) {
 7        my_class_factory::destroy_instance(instance);
 8    }
 9
10    void my_class_do_something(myclass* instance) {
11        instance->do_something();
12    }
13}

Global variable

A global variable allows us to create multiple instances as well, while keeping the implementation simpler. However, global variables may lead to hard-to-manage code, so just keep that in mind.

1class my_class {
2public:
3    void do_something() {
4        // ...
5    }
6}

C API

 1// Global variable
 2my_class* instance = nullptr;
 3
 4extern "C" {
 5    my_class* my_class_init_instance() {
 6        if (instance == nullptr) {
 7            instance = new my_class();
 8        }
 9        return instance;
10    }
11
12    void my_class_free_instance(my_class* instance) {
13        delete instance;
14        instance = nullptr;
15    }
16
17    void my_class_do_something(my_class* instance) {
18        if (instance != nullptr) {
19            instance->do_something();
20        }
21    }
22}

Different clients expect different behavior

Let’s talk about MQL and Swift as clients to our C API.

MQL is a language similar to C++, and it’s a common task among developers the use of array resizing functions. On the other side, Swift is more of a declarative language with a strong focus on UI design, and many memory-management operations are automatically handled by its different classes and functions.

If we want to make our functions available to both users, we have to think about the way the target language is used. I can –but probably should not– expect Swift users to allocate memory from the heap so that our function can use it to store its results; and I’m not saying the language does not allow it; what I mean is that it’s not the kind of tasks most Swift developers perform on their daily routines. So with this in mind, we have to build a function that allows Swift users to call the function, use it, and maybe, call a second function to release memory, all this without the hassle of having to deal directly deal with memory allocation.

Let’s use the scenario where we have a function that takes a string as parameter and returns a modified string. We’ll start with MQL, a language where developers have to deal with array-resize operations frequently. A common pattern for it would be:

  • The developer allocates a memory buffer using MQL.
  • The developer provides our library with a pointer to the input string, a pointer to the allocated buffer –so we can write the result–, and the size of such allocated buffer.
  • Our library processes the string, then puts the result in the allocated buffer, keeping in mind it’s size to avoid memory allocation issues.
  • The developer reads the contents from the allocated buffer.
  • The developer releases the allocated buffer using MQL.

Our C-API function would look like this:

1void process_string(const char* input, char* output, size_t max_size) {
2    // Convert string from C to C++
3    std::string s_input(input);
4    // Process it
5    std::string result = some_processing_function(s_input);
6    // Use snprintf (or memcpy) to copy the string into the provided buffer
7    std::snprintf(output, max_size, "%s", result.c_str());
8}

Notice how we’re using snprintf to take care of 1. not going beyond the allocated space, and 2. adding the null terminator to the string.

Now, let’s look at an interface that feels more familiar for Swift users, where the expected pattern would be:

  • The developer calls our library, providing a pointer to the input string.
  • Our library processes the string, allocates memory to hold the result, and returns a pointer to the allocated buffer.
  • The developer reads the contents from the returned buffer.
  • The developer releases the buffer by calling a second function provided by our library.

The following code shows a sample implementation.

 1const char* process_string(const char* input) {
 2    // Convert string from C to C++
 3    std::string s_input(input);
 4    // Process it
 5    std::string result = some_processing_function(s_input);
 6    // Allocate memory to return the result
 7    char* c_result = static_cast<char*>(malloc(result.size() + 1));
 8    if (c_result != NULL) {
 9        // Use snprintf (or memcpy) to copy the string into the allocated buffer
10        std::snprintf(c_result, result.size(), "%s", result.c_str());
11    }
12    // Return a pointer to the allocated buffer
13    return c_result;
14}

However, since the memory was allocated by our library, then we need to provide developers with a way to release it. A simple function like the following will work.

1void free_string(const char* input) {
2    // Free the allocated memory
3    free(const_cast<char*>(input));
4}

In both cases, developers must remember to either manually deallocate the requested memory –like in MQL– or call the provided free function to release any memory allocated by our library. The snprintf can be replaced by the faster memcpy function, but I prefer the former because I just feel safer knowing that it’ll take care of the max allowed size and the null terminator.

Command Dispatcher Pattern

The Command Dispatcher pattern is a useful technique when exposing C++ functionality to external clients via a single entry-point API. In our implementation, we define a dispatch function that accepts UTF-8 encoded const char* command and JSON-formatted string arguments. This design provides a unified interface, while keeping an easy to maintain C-API interface.

Why Use It?

This pattern is useful when:

  • We want to expose our C++ code to multiple environments (Swift, .NET, MQL).
  • We prefer a single dynamic/shared library rather than multiple wrappers.
  • We want predictable, extensible input/output using structured data (JSON).

Anatomy of the Dispatcher

The core of the system is a C-compatible function that receives two const char* parameters and returns a const char* JSON response. The cmd parameter is a regular flat string that will be used to match the function we want to call. The s_args parameter receives a JSON string that includes the different arguments we’ll provide to the called function:

 1extern "C" DLL_EXPORT
 2const char* exec(const char* cmd, const char* s_args) {
 3    thread_local std::string result;
 4
 5    try {
 6        nlohmann::json args = nlohmann::json::parse(s_args);
 7        result = dispatch_command(cmd, args);
 8    } catch (std::exception& e) {
 9        result = make_error(e.what());
10    } catch (...) {
11        result = make_error("Unknown exception");
12    }
13
14    return result.c_str();
15}

Commands are matched to internal C++ functions using a series of conditions. No argument validation is performed in the following code, but should be added if the library is intended to be used by more people than just the creator.

 1std::string dispatch_command(const std::string& cmd, const nlohmann::json& args) {
 2    if (cmd == "add") {
 3        return make_result(add(args["x"], args["y"]));
 4    } else if (cmd == "greet") {
 5        return make_result(greet(args["name"]));
 6    } else if (cmd == "greet_people") {
 7        return make_result(greet_people(args["names"]));
 8    } else if (cmd == "version") {
 9        return make_result("1.0");
10    } else {
11        throw std::invalid_argument("Unknown command: \"" + std::string(cmd) + "\"");
12    }
13}

make_result is a function to convert a result into a JSON response.

By passing JSON to the library, each function can receive different types of arguments, for example:

 1int add(const int x, const int y) {
 2    return x + y;
 3}
 4
 5std::string greet(const std::string& name) {
 6    return "hello " + name + "!";
 7}
 8
 9std::vector<std::string> greet_people(const std::vector<std::string>& names) {
10    auto greetings = std::vector<std::string>();
11    for (const auto& name : names) {
12        greetings.push_back("hello " + name + "!");
13    }
14    return greetings;
15}

This design makes adding new commands as easy as writing a new condition in our dispatch_command function.

Rich Responses

Thanks to the flexibility of JSON, our responses can include more than just a single value. We can return logs, timestamps, error diagnostics, debug data, or even nested results, for example:

1{ 
2    "result": 5,
3    "execution-time": 1200,
4    "timestamp": "2025-06-22T14:37:08.003Z"
5}

or rich error messages

1{
2    "error": "Invalid argument",
3    "description": "Function 'calculate_age' does not accept values larger than 150.",
4    "execution-time": 650,
5    "timestamp": "2025-06-22T14:37:08.003Z"
6}

This is useful for debugging and observability, especially in external environments where native debugging tools aren’t available.

UTF-8 Support and wchar_t Incompatibility

This project supports char, wide character wchar_t, and char16_t string inputs, with separate overloads in the API:

1extern "C" DLL_EXPORT
2const char* exec(const char* cmd, const char* s_args) 
3
4extern "C" DLL_EXPORT
5const wchar_t* exec_wchar_t(const wchar_t* cmd, const wchar_t* args)
6
7extern "C" DLL_EXPORT
8const char16_t* exec_char16_t(const char16_t* cmd, const char16_t* args);

The last two functions are wrappers to convert wchar_t and char16_t to char to avoid modifying the structure of our C++ internal functions, which handles only char and std::string types.

Supporting wchar_t is required because many Windows-centric APIs rely on it, however this type is not portable:

  • wchar_t is 16 bits on Windows but 32 bits on Linux/macOS.
  • It is not compatible with UTF-8 directly.
  • Internal functionality, like JSON libraries, expect UTF-8 std::string, not wide strings.

If we have to work with 16-bit chars on Linux or macOS we can use the char16_t version. For 32-bit chars (UTF-32), we can use the wchar_t version in these platforms.

Our tests round-trip correctly across all environments with input strings such as “é ñ ★ 文”, and emojis “😄” after adding these overloads.

Thread Safety with thread_local

To safely return a const char* response from the C API, we use a thread_local std::string buffer:

1thread_local std::string result;

thread_local works similar to the static modifier, but it’s scoped to the life of the calling thread.

This ensures:

  • No memory leaks (the buffer lives as long as the thread).
  • No race conditions between concurrent invocations.
  • No lifetime issues when passing results to clients.

Clients can call the API from multiple threads simultaneously with confidence.

Client Examples

This section shows examples for setting up and calling our library.

Calling from Swift

Calling the library from Swift involves some initial setup:

 1import Foundation
 2
 3// Define function type
 4typealias ExecFunction = @convention(c) (UnsafePointer<CChar>, UnsafePointer<CChar>) -> UnsafePointer<CChar>
 5
 6// Load the library
 7let libPath = "./liblibrary.dylib"
 8guard let handle = dlopen(libPath, RTLD_LAZY) else {
 9    fatalError("Failed to load \(libPath)")
10}
11
12// Get function pointer
13guard let execFunctionPointer = dlsym(handle, "exec") else {
14    fatalError("Could not find symbol 'exec'")
15}
16let execFunction = unsafeBitCast(execFunctionPointer, to: ExecFunction.self)
17
18// Helper to call the library function
19func exec(cmd: String, args: String) -> String {
20    let resultPtr = execFunction(cmd, args)
21    return String(cString: resultPtr)
22}

Once the setup is done, we may go ahead with calling our function. Swift strings are natively UTF-8, like we saw in our results:

1let args3 = #"{"names": ["Andrés", "Emojis and other chars: 😄 é ñ  "]}"#
2print("greet_people: ", exec(cmd: "greet_people", args: args3))

Output:

greet_people:  {"result":["hello Andrés!","hello Emojis and other chars: 😄 é ñ ★ 文!"]}

Calling from C#

In C# we import the method and use UTF8 marshaling. Notice that we’re using our exec_wchar_t version to properly handle wchar_t characters in Windows:

 1using System.Runtime.InteropServices;
 2
 3// Required to properly display UTF characters in the console
 4Console.OutputEncoding = System.Text.Encoding.UTF8;
 5
 6// Import the DLL
 7[DllImport("library.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
 8static extern IntPtr exec_wchar_t(string command, string args);
 9
10// Helper to call the library function
11static string Exec(string command, string args)
12{
13    IntPtr ptr = exec_wchar_t(command, args);
14    return Marshal.PtrToStringUni(ptr);
15}

Then:

1var args3 = @"{""names"": [""Andrés"", ""Emojis and other chars: 😄 é ñ ★ 文""]}";
2Console.WriteLine("greet people: " + Exec("greet_people", args3));

Output:

greet_people:  {"result":["hello Andrés!","hello Emojis and other chars: 😄 é ñ ★ 文!"]}

Calling from MQL (MetaTrader 5)

In MQL5, importing the function is just as simple as:

1#import "library.dll"
2string exec_wchar_t(const string cmd, const string args);
3#import

Notice that we’re using our exec_wchar_t version to properly handle wchar_t characters in Windows. Then, we call the function:

1string args3 = "{\"names\": [\"Andrés\", \"Emojis and other chars: 😄 é ñ ★ 文\"]}";
2Print("greet people: ", exec_wchar_t("greet_people", args3));

Output:

greet_people:  {"result":["hello Andrés!","hello Emojis and other chars: 😄 é ñ ★ 文!"]}

Summary

The Command Dispatcher pattern centralizes logic into a single, stable API surface. By combining a JSON-based argument model, UTF-8 support, and thread-local buffers for thread safety, it becomes a great foundation for building cross-platform C++ libraries. The pattern brings together several clean design principles:

  • Minimal API surface: A single dispatch() function handles all operations through command strings.
  • Easily extensible: New functionality is added by registering command handlers in one central location.
  • Thread-safe by design: Uses thread_local buffers to safely manage per-thread return values without leaks or conflicts.
  • Structured input and output: JSON enables complex arguments, rich responses, logs, and metadata.
  • Internationalization-friendly: Full UTF-8 support allows seamless use of emojis, accents, and other international characters with minimal overhead.

It works equally well whether we’re calling from high-level languages like Swift and C#, or from more constrained environments like MQL. With UTF-8 and JSON as first-class citizens, this pattern enables safe, powerful integration across language and platform boundaries.