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)
  11. Integration (pt. 2)
  12. Architecture (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).rawValue
}

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.

Keep reading: Cross-platform Swift: Integration (pt. 2)