On building a small cross-platform CLI tool in C, Go & Swift
A great thing about being a programmer is that if you need specific, customised, tooling, you can just write it yourself. Often there are existing options, but of course it’s a lot more fun to write your own tools, especially when you have room for some experimentation.
This is how I came to write a simple tool.. 3 times, in different languages.
My goal was to write a very simple command line tool that can generate one-time passwords compatible with Google Authenticator. Google authenticator uses the Time-based One-Time Password algorithm (TOTP) to generate codes. Instead of writing my own implementation, I wanted to use an existing TOTP library, since there already are many good ones.
Essentially, all I want my tool to do, is accept a secret as single input, then call an exiting TOTP library to generate a code, and print the generated access code to the standard output.
The question I was asking myself was: suppose I would like to use the tool on several platforms (Mac, Windows, Ubuntu), and would like to
distribute the tool amongst a small group of — not necessarily technical — people (e.g. colleagues), what programming language would be the most pragmatic/viable/fun option?
Of course you can look at this question from many angles. Let’s focus on building and distributing the tool. Then, these were my “should have” requirements:
- It should be possible to distribute the tool as single executable that works “out of the box”, meaning the user does not have to install dependencies like runtimes, frameworks, libraries, etc.
- With the same code base (but possibly different toolchains) it should be possible to produce builds for multiple platforms.
I wanted to create a binary for this specific experiment, that’s why I did not consider interpreted languages like Node.js, Ruby, and Python for this specific tool. Although, of course in general these languages would all make perfectly viable options to use for writing a cross-platform command line tool.
There is also a disadvantage to those languages, being that the end user needs to have a runtime (e.g. Node.js) installed. Although many platforms come with common runtimes pre-installed, the user might need to install a different version. That is not always a trivial task for non-technical users.
(I’m aware that there are tools to compile interpreted languages to stand-alone executables, but that feels a bit like cheating here).
In the end, my choice was to experiment with C, Go and Swift.
I decided to stay in my “programming language comfort zone”, because learning a new language was not part of my experiment. Therefore I did not experiment with (in opinion) very interesting other languages, such as Rust, which I will try out in the future (feel free to leave a comment with your Rust experiences). Also good to note maybe: for this experiment I considered C++ overkill (or actually maybe, my C++ knowledge is just lacking).
What I learned
- Typically, executables build with C are linked dynamically. That means that end users need to install dependencies (linked libraries) in order to run the tool. That's definitely not ideal.
- There are ways around this, but these all come with some disadvantages:
- Static linking: create single binary that will hold all required binary code. But, that requires that all libraries that you use (for example a TOTP library) support static linking. This is definitely not always the case. Furthermore, Apple does not support statically linked binaries on Mac OS X.
- Distribute linked dynamic libraries with your application. This means that for every target OS you’ll have to pre-build all linked libraries, make sure these libraries can be found by the executable (e.g. changing rpath on macOS), and bundle them with the app. In other words you need compile and bundle
.so(Linux) files with your app.
- C does not have a runtime that needs to be bundled with the application. Therefore the resulting executable is quite small. The only dependency (dynamic library) is the C standard library
libc, which is by default available on the OS'es I would like to target.
- Building a single C code base on different platforms can be a pain. I generally prefer to use the “default”, or most widely supported, build chain for a platform. In my opinion that is Visual Studio on Windows, Xcode on Mac (or GCC on Mac command line) and GCC on Linux. But that means that for every platform you need to install and setup a completely different build environment (project file, build scripts, etc).
- Compiling dependencies from source for multiple platforms is hard. Like I mentioned above, setting up build chain for your own code on different platforms can be difficult already. It is even more difficult to compile third party libraries from source for multiple platforms. Some are relatively easy to work with cross-platform, but others are a real pain because they lack support for, or documentation on, cross-platform building.
- Executables build by Golang are statically linked by default. This means users don’t have to install any dependencies, and you don’t need to distribute dynamic libraries with your application. For a small command line application, the only thing you need to distribute is the executable.
- Unfortunately, because of the static linking, the resulting executable is relatively large. That is because a Go binary includes the Go runtime, so that the end user does not need to have Go installed. (but, as Dotan Nahum, points out, there are ways to trim down some fat)
- Go is available as a binary distribution on all target platforms I was interested in. That makes setting up a build environment and building on these platforms painless.
- A great thing about Go, is that you can easily compile for multiple platforms on one machine.
- It is recommended to statically link to the Swift standard library, so that the resulting executable is not bound to the specific version of Swift that it was built with. This results in a large binary (more than 10 MB for a simple tool). The need for static linking is due to Swift lacking ABI stability. That is on the roadmap to be solved in a future Swift version though. (In comparison, Objective-C does have ABI stability by the way).
- Cross-platform support has not yet matured. You can compile a Swift program on both on Mac and Linux (there is no official Windows release yet), but the cross-platform build system — Swift Package Manager (SPM) — is not nearly as mature as Xcode on MacOS. Furthermore, many libraries that are available on CocoaPods or Carthage (MacOS only) don’t support SPM (cross-platform) yet.
When it comes to building cross-platform and distributing the tool, Go gave me the best developer experience.
Thanks to the default static linking, it is easy to create a single executable for distribution.
Building a Go program on different platforms is also really easy. There is no need for writing platform specific build scripts, or using platform dependent tool chains.
Downside is that the resulting executable is relatively large (several megabytes), but in my situation that was not a real issue.
Next up is C. Writing in C always gives me a pleasant sense of control and a feeling of freedom due to the lack of constraints from a runtime. Of course the downside to this is that you can easily shoot yourself in the foot. But the biggest problem here was that there is no single toolchain for building that works just as flawlessly cross-platform as Go.
And finally, Swift. While I really like Swift as a language, and I would only consider Swift as obvious choice when writing command line tools specifically for macOS. Swift is too much of a “moving target” for me. That has several implications, an important one is that it is not straightforward to use Swift on other platforms. Another issue for me is that Windows is not yet officially supported.
As a final note: I wanted to share my experiences, but in the end, what language suits you best comes down to personal preference and current state of languages. Next year might be different.