Table of contents (ongoing)
Since the last article, I’ve made significant progress about improving the build system of Passepartout/Partout and the layout of the outputs. That’s why I felt compelled to explore this part of the series a bit further before climbing up the software layers. Here I go through a few tricks that made the process both simpler and more efficient.
Trick 1: CMake toolchains
As mentioned before, SwiftPM still has its fair amount of quirks and limitations when it comes to building complex projects. The Android side was my main concern in that Swift SDKs are meant to work hand in hand with SwiftPM, thus making the CMake integration convoluted. Nevertheless, the good fellows on the Swift forums got me in the right direction: CMake toolchains.
A toolchain is a way to stuff in a single file everything you need to compile or cross-compile a project: the compiler, the compiler options, where to find the standard libraries, what to link by default and so on. At the end of the day, Swift SDKs tailor these settings in a way that is suitable for SwiftPM, but it didn’t take big additional efforts to adapt them to be a toolchain for use with CMake.
With some help from the environment variables, I could design toolchains that made my life dramatically easier to build Swift projects for Android and Linux. I truly encourage you to have a look at them because they were a game-changer for me. They also forced me to understand at a deeper level how swiftc works and produces outputs under the hood. SwiftPM may be a bit too high-level for that matter.
Trick 2: Static linking
On Linux and Android, the Swift toolchain offers static variants of the standard libraries, and once again, I can’t deny that a major itch I have is the inconvenience of the Swift runtime. After encapsulating the annoyances of CMake into toolchains, however, I realized how quick it was at this point to link the Swift runtime statically. Basically, as simple as changing the -resource-dir in the CMAKE_Swift_FLAGS of the toolchain file. Check out the build script to see what those variables represent.
Nevertheless, in case you don’t know how linking works in general, a static library is not something that lives on its own, whereas a dynamic library is much closer to a standalone executable. A static library is a set of “unanimated” binary code that a project can fetch and look through to assemble the final output, whereas a dynamic library is an output per se. This means that static libraries –and Swift is no exception–, cannot be part of another binary unless it’s a final output, be it an executable or a dynamic library.
I’ll make this subject clearer later on, when I’ll elaborate on the best strategies to make your Swift library:
- A single output, with no Swift dependencies.
- 100% agnostic of Swift to its consumers.
- Fully usable cross-platform and from any other programming language.
Trick 3: Understanding Swift libraries
Passepartout doesn’t use Partout directly, it rather does through another Swift layer that sits in the middle to provide decoupling and app-specific behavior. At this point, it was time to properly understand how to depend on a Swift library (Partout) from another Swift library (the Passepartout logic). Again, SwiftPM makes this seamless, but CMake leaves you with no other option than digging down the rabbit hole. Let me give you a concrete example of what I’m talking about.
A Swift library, be it static or dynamic, is no different from a library written in any other language. Bear with my simplification, but a library is a binary file that exports resuable logic through a set of symbols, typically functions and variables/constants. See them as a big C file compiled together with your code, of which the header is the list of the exported symbols. The moment you link the library to your code, the symbols that your code uses are resolved within the library and merged together into the final outputs.
By this definition, it’s not straightforward how to tell a Swift library from a non-Swift library, because seen from the outside, libraries only expose a C-like interface. The way we enhance the integration of a Swift library is by making it export its modules. In CMake, you do it with the following flags:
set_target_properties(partout PROPERTIES
LIBRARY_OUTPUT_DIRECTORY ${OUTPUT_DIR}
ARCHIVE_OUTPUT_DIRECTORY ${OUTPUT_DIR}
RUNTIME_OUTPUT_DIRECTORY ${OUTPUT_DIR}
# Swift modules are emitted with these properties
Swift_MODULE_NAME "Partout"
Swift_MODULE_DIRECTORY "${OUTPUT_DIR}/modules"
)
In this case:
partoutis a Swift target producing a static library.- The library is generated with the
.a(Mac/*nix) or.lib(Windows) suffix in${OUTPUT_DIR}. - Stemming from
Swift_MODULE_NAME, thePartout.swiftmodulefile is generated in${OUTPUT_DIR}/modules.
Such file describes the metadata we need to use Partout with all the power of Swift, undoubtly more convenient than a dry C ABI. Somewhere else, like I do in Passepartout, the consumer of the Swift library will include the /modules directory in the headers search path:
target_include_directories(passepartout_shared PRIVATE
${ABI_INCLUDE}
${OUTPUT_DIR}/partout
${OUTPUT_DIR}/partout/modules
)
Which is what will make the import Partout directive eventually available to Swift code! After a decade writing Swift, I wouldn’t be surprised if most Swift developers out there never had to do this manually.
Trick 4: Non-default imports
By the time you figure out the Swift linking model, you realize how important it is for interoperability that your library has the smallest public surface. The more internals you expose, the more you are prone to fragility and useless complexity. The habit of importing packages has to be taken cautiously when writing a library, because any import is literally a liability. Since the very beginning, the library developer should be aware of what to make public and act accordingly.
Why is that crucial? Because public symbols may end up in the Swift module output, the one we’ve just talked about in the previous chapter. Some examples:
- If we use, say, a
CoreLocationentity in a public function of our library, we’ll be tied to Apple forever. - If we use an entity from an internal module in a public function, we’ll need to also export the internal module, and the internals are likely to change over time.
- If we use a C entity in a public function, we’ll need to also export the proper headers and modulemap.
The way to avoid these in the first place had been around for a while, and it was @_implementationOnly import. The notation made sure that the imported symbols would never make it to the public interface of the library. Swift 6 eventually made this official and polished with the formal proposal of access level on imports.
Internal and private imports are absolutely the best way to ensure that the public interface of a library is as small as possible, cross-platform, and easier to integrate for not dragging unnecessary dependencies.
Bottom line
As long as CMake remains the superior tool, letting SwiftPM go was deeply beneficial in my fight against the complexity of the build system. There’s still minor work to do, but Partout has finally reached a point where, let alone the footprint, you can integrate it like any other C library. In the next article, I’ll show you how to use @_cdecl for that purpose.