Table of contents (ongoing)
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:
Stringin thebankAccountFindsignatureBankAccountin thebankAccountFindand thebankAccountWithdrawsignaturesError?in thebankAccountLastErrorsignature
Our candidate C functions must only use C-compatible types, therefore:
- Replace
StringwithUnsafePointer<CChar>(i.e.const char *) - Replace
BankAccountwith a generic pointer (i.e.void *or some unique identifier) - Replace
Errorwith 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.