Swift and C++ interoperability with gRPC part 1

Prolog

This is going to be fun. I will show you how you can embed a gRPC server written in C++ in a Swift programm that you can run on a Mac or an iPhone. I'll use another gRPC server written in Swift so that not only the Swift part can call into C++ via gRPC but the C++ part can call into Swift via gRPC as well – all in the same process, of course :-D

But why?

Well first it's fun, second you can learn a few things along the way and third: Given the right situation and constraints this can be a great idea!

Warning

There're definitely a few other – more straight forward ways – of doing Swift/C++ interoperability; not all great though and with different trade offs. If you read through the end you know enough to decide wether or not this is for you.

I do want to mention that Swift is – by far – the coolest programming language that I've came across and that it runs on Darwin, Linux and Windows and that you should really revistit your life choices if you manouvered yourself in the corner googleing for an article like this ;)

Start easy - create a demo library

Let's first create a little library in C++ that uses some random dependencies and cross-compile that for iPhone, iPhone simulators and the Mac running on Intel or Apple-Silicon. To enable multi-architectures we'll use fat-libs and to enable multi-platform support we'll stuff these fat-libs into a xcframework. All will be done via CMake, the ios-cmake toolchain and a small shell script.

chukle library

Start by creating a directory and a few files:

mkdir chuckle && touch chuckle/{chuckle.cpp,chuckle.h,Cli.cpp,CMakeLists.txt}

And add the following content:

chuckle.h

#ifndef CHUCKLE_CHUCKLE_H #define CHUCKLE_CHUCKLE_H #include <string> std::string joke(); #endif //CHUCKLE_CHUCKLE_H

chuckle.cpp

#include "chuckle.h" #include <cpr/cpr.h> #include <nlohmann/json.hpp> using namespace cpr; using json = nlohmann::json; std::string joke() { Response r = Get(Url("https://api.chucknorris.io/jokes/random")); json j = json::parse(r.text); return j["value"]; }

Cli.cpp

#include "chuckle.h" #include <iostream> using namespace std; int main() { cout << joke() << endl; return 0; }

and finally

CMakeLists.txt

cmake_minimum_required(VERSION 3.19) project(chuckle) set(CMAKE_CXX_STANDARD 17) set(CPR_BUILD_TESTS OFF) set(CPR_BUILD_TESTS_SSL OFF) include(FetchContent) FetchContent_Declare( cpr GIT_REPOSITORY https://github.com/whoshuu/cpr.git GIT_TAG 1.6.2) FetchContent_MakeAvailable(cpr) FetchContent_Declare(json GIT_REPOSITORY https://github.com/nlohmann/json.git GIT_TAG v3.7.3) FetchContent_GetProperties(json) if(NOT json_POPULATED) FetchContent_Populate(json) add_subdirectory(${json_SOURCE_DIR} ${json_BINARY_DIR} EXCLUDE_FROM_ALL) endif() add_library(chuckle chuckle.cpp chuckle.h) add_executable(joke Cli.cpp) target_link_libraries(chuckle PRIVATE cpr::cpr nlohmann_json::nlohmann_json) target_link_libraries(joke chuckle)

I am not a C++ programmer but I think the C++ code is quite readable. There's a free function joke() that returns a std::string representing a Joke – how usefull is that!

I used two dependencies for this great programm:

If you never used CMake before – I didn't – let me point out a few things. add_library and add_executable add a target to the CMake project. target_link_libraries configures the linker to link build artefacts into products. In the above example you will end with the library libchuckle that we will use and a command line programm joke that you can run to test the library.

Both FetchContent-blocks are taken from the documentation from the two libraries that we use. Note that I configured the build of cpr by setting the two variables CPR_BUILD_TESTS and CPR_BUILD_TESTS_SSL to OFF.

We should be able to print a joke to the terminal, now. From inside the chuckle folder do the following:

mkdir out && cd out && cmake ..

This will download the dependencies and configure the build system. Once that's done you can build everything with:

make

and run the cli:

./joke

Chuck Norris can mix water and oil.

Let's inspect what we have got so far:

  • the joke programm and
  • libchuckle.dylib

Check the binary with otool -L joke you'll see something like this:

joke: @rpath/libchuckle.dylib (compatibility version 0.0.0, current version 0.0.0) @rpath/libcpr.1.dylib (compatibility version 1.0.0, current version 1.6.0) @rpath/libcurl-d.dylib (compatibility version 0.0.0, current version 0.0.0) /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1775.118.101) /System/Library/Frameworks/Security.framework/Versions/A/Security (compatibility version 1.0.0, current version 59754.100.106) /usr/lib/libz.1.dylib (compatibility version 1.0.0, current version 1.2.11) /usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 905.6.0) /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1292.100.5)

That tells you that joke needs libchuckle.dylib to operate and that it doesn't really carry the meat from libchuckle in the binary. Check the size of the binary:

ls -lahs joke

It's 64k.

If you have Hopper – which I highly recommend – I want to show you something. Open the binary in Hopper and search for the label joke(). Click on the first occurence and then enable pseudo-code in Hopper:

void _Z4jokev() { pointer to joke(); return; }

It's just a pointer. Not the real deal. libchuckle.dylib has it. Check if you want to :-D

build a static library

Building libchuckle as a static library is easy with CMake. Just add the STATIC keyword to the chuckle target:

add_library(chuckle STATIC chuckle.cpp chuckle.h)

This time you might want to generate the build system in another folder:

mkdir static && cd static && cmake .. && make

Now joke is much larger (854 kb) and instead of libchuckle.dylib we have libchuckle.a a static library. If you open joke in Hopper again you'll see the following as pseudo-code for joke():

int __Z4jokev() { r31 = r31 - 0x1d0; var_10 = r28; stack[-24] = r27; saved_fp = r29; stack[-8] = r30; r29 = &saved_fp; var_1A8 = r8; cpr::Url::Url(&stack[-384]); cpr::Response cpr::Get<cpr::Url>(&stack[-384]); cpr::Url::~Url(); nlohmann::detail::input_adapter::input_adapter<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, 0>(&stack[-432]); std::__1::function<bool (r29 - 0x38); nlohmann::basic_json<std::__1::map, std::__1::vector, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, bool, long long, unsigned long long, double, std::__1::allocator, nlohmann::adl_serializer>::parse(&stack[-432], r29 - 0x38, 0x1); std::__1::function<bool (); nlohmann::detail::input_adapter::~input_adapter(); r0 = nlohmann::basic_json<std::__1::map, std::__1::vector, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, bool, long long, unsigned long long, double, std::__1::allocator, nlohmann::adl_serializer>& nlohmann::basic_json<std::__1::map, std::__1::vector, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, bool, long long, unsigned long long, double, std::__1::allocator, nlohmann::adl_serializer>::operator[]<char const>(&stack[-416]); var_1C0 = r0; nlohmann::basic_json<std::__1::map, std::__1::vector, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, bool, long long, unsigned long long, double, std::__1::allocator, nlohmann::adl_serializer>::operator std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> ><std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, 0>(); var_18 = **___stack_chk_guard; nlohmann::basic_json<std::__1::map, std::__1::vector, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, bool, long long, unsigned long long, double, std::__1::allocator, nlohmann::adl_serializer>::~basic_json(); r0 = cpr::Response::~Response(); r8 = *___stack_chk_guard; r8 = *r8; r8 = r8 - var_18; if (r8 != 0x0) { r0 = __stack_chk_fail(); } return r0; }

You can tell by the size of libchuckle.a (1,9Mb) that it should contain everything we need to proceed :-D

make it cross platform

To make this cross platform you need to change a few things. First you need to link alle the required object files into the libchuckle.a this can be done with CMake:

add_library( chuckle STATIC chuckle.cpp $<TARGET_OBJECTS:cpr> $<TARGET_OBJECTS:libcurl> $<TARGET_OBJECTS:zlib> )

This links the object files into libchuckle.

To build this for multiple architectures and platforms we need the ios-cmake toolchain. Just copy it into the chuckle folder and while you're at it delete static and out.

You can now setup the build system for iOS devices with the following command:

cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=OS64 \ -DDEPLOYMENT_TARGET=14.0 \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/os64

If that step fails, please run it again. For reasons that I haven't understand yet this fails on the first run but works on the second run for me and others.

and run the build process with:

cmake --build ./out/os64 --config RelWithDebInfo

My complete build script looks like this:

#!/bin/sh # iOS & simulator running on arm64 & x86_64 cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=OS64 \ -DDEPLOYMENT_TARGET=14.0 \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/os64 cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=SIMULATORARM64 \ -DDEPLOYMENT_TARGET=14.0 \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/simulator_arm64 cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=SIMULATOR64 \ -DDEPLOYMENT_TARGET=14.0 \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/simulator_x86_64 # macOS on arm64 cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=MAC_ARM64 \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/mac_arm64 # macOS on x86_64 cmake -S ./ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DPLATFORM=MAC \ -DCMAKE_TOOLCHAIN_FILE=ios.toolchain.cmake \ -DHAVE_SOCKET_LIBSOCKET=FALSE \ -DHAVE_LIBSOCKET=FALSE \ -B out/mac_x86_64 cmake --build ./out/os64 --config RelWithDebInfo --parallel 8 cmake --build ./out/simulator_arm64 --config RelWithDebInfo --parallel 8 cmake --build ./out/simulator_x86_64 --config RelWithDebInfo --parallel 8 cmake --build ./out/mac_arm64 --config RelWithDebInfo --parallel 8 cmake --build ./out/mac_x86_64 --config RelWithDebInfo --parallel 8 rm -rf libchuckle.xcframework mkdir -p "out/mac/chuckle/" mkdir -p "out/simulator/chuckle/" lipo -create out/mac_arm64/chuckle/libchuckle.a \ out/mac_x86_64/chuckle/libchuckle.a \ -output out/mac/chuckle/libchuckle.a lipo -create out/simulator_arm64/chuckle/libchuckle.a \ out/simulator_x86_64/chuckle/libchuckle.a \ -output out/simulator/chuckle/libchuckle.a xcodebuild -create-xcframework \ -library "out/os64/chuckle/libchuckle.a" \ -library "out/simulator/chuckle/libchuckle.a" \ -library "out/mac/chuckle/libchuckle.a" \ -output libchuckle.xcframework # copy Header mkdir -p libchuckle.xcframework/Headers cp include/chuckle/chuckle.h libchuckle.xcframework/Headers # copy xcframework into Swift package mkdir -p ChuckleWrapper/lib cp -a libchuckle.xcframework ChuckleWrapper/lib

CAUTION: I changed a few locations. You can find the project here: chuckle

Now we got a xframework that we can depend on in a Swift package that can carry an ObjC++-Wrapper to call into out code.

Build the Swift package

If you checked out the repository you already saw how to setup the swift package. Create a subdirectory ChuckleWrapper and run this from within:

swift package init

Basically we need this Swift file:

@_exported import ObjC

That will depend on the ObjC target that has the following files:

ObjC/include/ChuckleWrapper.h

#ifndef ChuckleWrapper_h #define ChuckleWrapper_h #import <Foundation/Foundation.h> @interface ChuckleWrapper : NSObject + (NSString *)joke; @end #endif /* ChuckleWrapper_h */

and it's implementation

ObjC/ChuckleWrapper.mm

#import "ChuckleWrapper.h" #include "chuckle.h" @implementation ChuckleWrapper + (NSString *)joke { return [NSString stringWithCString:joke().c_str() encoding:[NSString defaultCStringEncoding]]; } @end

Here we can include chuckle.h because we copied the header file into our xcframework and let the ObjC target depend on that via

Package.swift

// swift-tools-version:5.3 import PackageDescription let package = Package( name: "ChuckleWrapper", platforms: [ .iOS(.v14), .macOS(.v11) ], products: [ .library( name: "ChuckleWrapper", targets: [ "libchuckle", "ObjC", "ChuckleWrapper" ]), ], dependencies: [ ], targets: [ // lib .binaryTarget( name: "libchuckle", path: "lib/libchuckle.xcframework" ), // ObjC++ Wrapper .target( name: "ObjC", dependencies: [ "libchuckle" ], path: "Sources/ObjC", cxxSettings: [ .headerSearchPath("../../lib/libchuckle.xcframework/Headers") ] ), .target( name: "ChuckleWrapper", dependencies: [ "ObjC" ], path: "Sources/Swift" ), .testTarget( name: "ChuckleWrapperTests", dependencies: ["ChuckleWrapper"]), ] )

The path to the header files is configured via cxxSettings in the ObjC target.

Let's test the package

ChuckleWrapperTests.swift

import XCTest @testable import ChuckleWrapper final class ChuckleWrapperTests: XCTestCase { func testJoke() { guard let joke = ChuckleWrapper.joke() else { XCTFail() return } print(""" --- \(joke) --- """) XCTAssertFalse(joke.isEmpty) } }

CAUTION: Xcode has a really hard time with such a package. If Xcode refuses to compile the package you can either try to compile & run tests form the terminal:

swift build && swift test

Or it can help to delete .swiftpm