Table of contents (ongoing)
While writing C in a Swift context makes no difference at all, it takes a few tricks to reduce the friction in Swift code that embeds C routines. You will find tangible examples of what I describe here on my GitHub repository. Let’s go through them together.
The mystique of Swift pointers
Swift has often changed the way it interacts with C entities, especially around version 3 and 4, IIRC. It has changed so much that upgrading Xcode to a new minor version would likely break some code with obscure error messages. To this day, the ambiguity around Swift pointer types still beats me.
Have you ever noticed how many variants exist?
Each of them with its mutable counterpart, totalling EIGHT pointer types. Don’t be offended, Swift, but this is ridiculously complex, and I hope that someday that part of the language will be properly simplified. It’s the single thing, hands down, that makes Swift/C interop unappealing.
Personally, I have a hard time getting them right, and find myself bruteforcing the Swift code until I get it to compile. Don’t even get me started on the withUnsafeBytes()
variants and the horrendously deceptive compiler/LSP errors that they trigger. They are very similar to the ones you stumble upon in complex SwiftUI closures with generics, to get the idea.
Let me show you some lovely examples:
Unsafe with
closures
When using withUnsafeBytes()
, withCString()
, and other similar closures, make sure to follow these ultimate 3 rules:
- Never ever let the closure arguments outlive the closure.
- Never.
- Ever.
Thank me later. You’re welcome.
This is another clunky syntax of Swift. Especially if your C function requires multiple variables to be mapped to their “unsafe representation”, you may quickly end up with this beautiful accordion:
var a = ...
var b = ...
var c = ...
a.withUnsafeBytes { pa in
b.withUnsafeBytes { pb in
c.withUnsafeBytes { pc in
my_demanding_c_function(pa, pb, pc)
}
}
}
Then, disgusted by the indentation overflow, you might look for ways to make the code more linear, and naturally think of returning the pointers that the closures provide:
let pa = a.withUnsafeBytes { $0 }
let pb = b.withUnsafeBytes { $0 }
let pc = c.withUnsafeBytes { $0 }
my_demanding_c_function(pa, pb, pc)
Much, much cleaner, only to find out that this code will crash, sooner or later, at some point of your life. They call it unpredictable behavior, and I guess it’s for the same reasons why you shouldn’t return a reference to a local variable. So, remember the rule, and kindly accept the nested closures. Or, keep reading.
Bridging to Swift
Ironically, the above constructs are enough to minimize or even discourage the use of C in Swift. The time I wasted on Swift pointers made me constantly reconsider the balance between Swift and C code %, in that exposing C pointers to Swift was so frustrating that I’d rather write the whole logic in C, and let Swift be a thin wrapper. After all, it’s the best approach because going back and forth from and to Swift/C types is very inconvenient. Why would you ever use a bare memcpy()
or strlen()
in Swift? There are better, native alternatives.
The point of C interop is to perform the low-level logic in C files, and only expose in/out types that are easier to funnel to Swift for use at higher levels of abstraction, especially if you make good use of the _Nullable
and _Nonnull
clang qualifiers.
All in all, Swift bridges C struct
as if it were a Swift value type, and pointers to struct
are naturally mapped to UnsafePointer<T>
or UnsafeMutablePointer<T>
according to their const
modifier. enum
and union
also behave pretty much the same way.
So, what’s the matter? Why would one need more than this? Well, because everything is cool until you hit pointers, strings, and memory management.
OpaquePointer
Sure, a C struct
is nicely bridged to Swift, until it has less linear (yet super common) fields like:
typedef struct {
char str[32];
uint16_t *buf;
const void (*)(char **ptr);
int **matrix;
} my_cool_struct;
Good luck with the bridged Swift version of this structure, and good luck to manage its memory layout properly.
But this is when I casually discovered the magic wand, the ultimate structure for C objects in Swift: OpaquePointer.
Opaque pointers are a popular way to attain OOP-like encapsulation in C. You write a forward declaration in a .h header, then the full definition in a .c file. By doing so, the type internals are only exposed to the .c file that needs them to implement its logic. Externally, the pointers are treated as generic I/O handles, and Swift can’t see through them because it can only bridge what it sees in the C headers, i.e. a shallow type name. For the record, Objective-C can do this with @class MyType
.
The benefits of OpaquePointer
For example, if we have this structure in a C header:
// .h
typedef struct {
const tls_channel_options *_Nonnull opt;
SSL_CTX *_Nonnull ssl_ctx;
size_t buf_len;
uint8_t *_Nonnull buf_cipher;
uint8_t *_Nonnull buf_plain;
SSL *_Nonnull ssl;
BIO *_Nonnull bio_plain;
BIO *_Nonnull bio_cipher_in;
BIO *_Nonnull bio_cipher_out;
bool is_connected;
} tls_channel;
and return e.g. tls_channel *
from a function, its type will map to UnsafeMutablePointer<tls_channel>
in Swift, and we’ll be able to access its fields with .pointee
. We don’t need or want that level of detail, those are C concerns.
Therefore, we split the definitions across two files. The header with a forward pointer declaration:
// .h
typedef struct tls_channel *tls_channel_ctx;
and the .c file, with the full definition, mind the lack of typedef
here:
// .c
struct tls_channel {
const tls_channel_options *_Nonnull opt;
SSL_CTX *_Nonnull ssl_ctx;
// ...
};
At this point, we replace tls_channel *
with the tls_channel_ctx
alias:
// before
tls_channel *tls_create_func();
// after
tls_channel_ctx tls_create_func();
and Swift will consider the returned object to be of OpaquePointer
type.
Benefits:
- No unsafe Swift pointers involved ever again
- No unsafe
with
closures, you pass the object as is to C functions from Swift - The C layer is the only responsible of the object, and Swift cannot mess with it
Granted, it’s not a one-size-fits-it-all, but for complex C objects that need to cross the Swift boundary often, opaque pointers may be a good bet.
Debugging
Talking about the Xcode debugger, it is generally able to debug C code called from Swift without issues. Not that I pushed this to the limit, but so far I’ve only noticed two places where the debugger is unhelpful.
For example, due to their very nature, you don’t get to see what opaque pointers point to in Swift code. However, opaque pointers encapsulate private data, so a logical deduction would be that you’d rather put your breakpoint in C files, and let Swift treat them as a black box.
The other situation is inline
functions, where the Xcode debugger clearly struggles to step in (with reason?).
Bottom line
Interacting with Swift pointers is painful, but we can make our lives easier with some workarounds. The friction is directly proportional to the surface we expose to Swift, so we need to hide the C internals as much as possible, and minimize the roundtrips across languages. Use opaque pointers when types become complex.
Now that we learned a few tricks about C interop, we can go back to the SwiftPM manifest to manage multiple, alternative implementations. See you in the next article.