Davide De Rosa

Cross-platform Swift: Integration (pt. 1)

Cross-platform Swift: Integration (pt. 1)

part of a series: < prev | next >

Table of contents (ongoing)

  1. Introduction
  2. Combine
  3. Core libraries
  4. Platform specifics
  5. C interop (pt. 1)
  6. C interop (pt. 2)
  7. Build system (pt. 1)
  8. Build system (pt. 2)
  9. Build system (pt. 3)
  10. Integration (pt. 1)

We are at a stage where Partout, our cross-platform Swift library:

  • Builds as a static library
  • Emits a Swift module interface for use in other Swift code
  • Comes with a few vendored dependencies as dynamic libraries (OpenSSL, WireGuard)
  • Uses C for low-level routines, including OS frameworks or the NDK on Android
  • Doesn’t use Apple frameworks, unless behind availability conditionals
  • Doesn’t use Objective-C
  • Depends on the Swift runtime

It’s time to actually use the library, be it in an app or another library. I’ll skip the obvious case of using a Swift library in a Swift project, for which SwiftPM remains the recommended way to do it.

Cross-language integration

Earlier in the series, I think I mentioned how the C language is the typical denominator of multi-language interactions between binary programs. In other words, C is sort of the English language of programming languages, the midway where foreign languages meet, and in fact the preferred way to deal with FFI. As a consequence, in order to use a Swift library in a non-Swift project, it must learn to speak C.

There are important design considerations to make before picking what to expose, but we could summarize them in a clear principle: your library must be able to behave like an API. C is a powerful yet very basic imperative language, so you’d better rethink your business logic in terms of simple types and function calls.

For example, a REST API translates quite well to a C interface:

  • Function name -> HTTP Method + Path
  • Function input -> URL Components or HTTP Request
  • Function output -> HTTP Response
  • Data types -> JSON (untyped)

More specifically, this Swift code:

final class BankAccount {
    init(customerId: String) { ... }
    func withdraw(amount: Int) throws { ... }
}

Could be exposed from a REST API like:

GET /banks/<customer_id>
PUT /banks/<customer_id>/withdraw {"amount":...}

And linearized in a C API like:

// Returns an opaque handle for subsequent calls
void *bank_account_find(const char *customer_id);
bool bank_account_withdraw(void *account, int amount);
int bank_account_last_error();

That is, with global, “static” vanilla functions. Swift abstractions must be left aside in this process.

Introducing @_cdecl

Let’s follow up on the previous example:

private var lastError: Error?

func bankAccountFind(customerId: String) -> BankAccount? {
    // Imagine a central database
    BankDatabase.shared.find(customerId)
}

func bankAccountWithdraw(account: BankAccount, amount: Int) -> Bool {
    do {
        try account.withdraw(amount: amount)
        return true
    } catch {
        lastError = error
        return false
    }
}

func bankAccountLastError() -> Error? {
    lastError
}

See how we expose Swift logic through global functions, with a minor notion of statefulness in lastError. State can be retained in different ways, e.g. with a context object, but the global variable is enough here for the sake of the example. The programming pattern is now close to the C imperative, but something is still off.

We must get rid of any Swift in the public interfaces, and there are several occurrences to fix in this code:

  • String in the bankAccountFind signature
  • BankAccount in the bankAccountFind and the bankAccountWithdraw signatures
  • Error? in the bankAccountLastError signature

Our candidate C functions must only use C-compatible types, therefore:

  • Replace String with UnsafePointer<CChar> (i.e. const char *)
  • Replace BankAccount with a generic pointer (i.e. void * or some unique identifier)
  • Replace Error with an integer error code, for example

Once we sort this out, we’re ready to make the functions publicly available, and we do that with the @_cdecl keyword:

private enum ErrorCode: Int {
    case success
    case notFound
    case noMoney
}

private var lastErrorCode: ErrorCode?

@_cdecl("bank_account_find")
func bankAccountFind(customerId rawCustomerId: UnsafePointer<CChar>?) -> UnsafeMutableRawPointer? {
    guard let rawCustomerId else { preconditionFailure() }
    let customerId = String(cString: rawCustomerId)
    guard let existing = BankDatabase.shared.find(customerId) else {
        return nil
    }
    let rawAccount = Unmanaged.passUnretained(existing).toOpaque()
    return rawAccount
}

@_cdecl("bank_account_withdraw")
func bankAccountWithdraw(account rawAccount: UnsafeMutableRawPointer?, amount: Int) -> Bool {
    guard let rawAccount else { preconditionFailure() }
    let account = Unmanaged.fromOpaque(rawAccount).takeUnretainedValue()
    do {
        try account.withdraw(amount: amount)
        lastErrorCode = nil
        return true
    } catch {
        lastErrorCode = .noMoney
        return false
    }
}

@_cdecl("bank_account_last_error")
func bankAccountLastError() -> Int {
    lastErrorCode ?? .success
}

The optional arguments are there for maximum interop with C compilers. Standard C doesn’t provide nullability information, it’s something you must assert yourself. If you only use clang, though, you can leverage the _Nonnull and _Nullable qualifiers to avoid the unwraps and make your Swift code safer at compile-time.

Ultimately, you would bundle the library with a C header like the one we originally envisioned:

void *bank_account_find(const char *customer_id);
bool bank_account_withdraw(void *account, int amount);
int bank_account_last_error();

And the external C consumer will call into Swift code without even knowing that it’s Swift. If one day you decide to switch from Swift to another language, be it Rust, Go, or C itself, the library consumers will not be affected. This is a nice-to-have for durable software.

Bottom line

The way Swift supports FFI, i.e. communication with other programming languages, is with @_cdecl and C-compatible types. The ability for a Swift library to also exist as a pure C library allows it to be reusable across a vast number of environments without modifications. Swift consumers will certainly get more benefits and better ergonomics, but a well-designed C interface will cover any other non-Swift scenario that you can think of. More on this in the next article.