Gopher Photograph by Nathan Youngman. Gopher by Renee French.
Portability isn’t the first thing that comes to mind when thinking about low-level, compiled programming languages. Languages which compile to machine code generate binaries that are specific to a CPU architecture and possibly an OS. Interpreted languages on the other hand (Python, Ruby, JavaScript), and also compiled languages which run on a virtual machine (Java, Scala), are usually praised for their portability. But is it indeed correct to claim that Java or Ruby apps are more portable than apps written in C or Go? Before answering this question, we need to define what portability means.
I define software portability as the ability to run the same application on multiple platforms easily. By “easily” I mean—without complicated setup or platform-specific configuration, and obviously without modifying the source code.
The slogan “Write Once, Run Anywhere” (WORA) was coined by Sun Microsystems with regards to Java. You write your code once, and from that point it should be able to run on any machine which can run the Java Virtual Machine, or JVM. This is made possible by the fact that Java code isn’t compiled to machine code that is executed directly by the CPU, but rather to bytecode which is executed by the JVM. This bytecode is the same on any JVM, which makes the code portable.
In terms of portability, Python, Ruby and JavaScript behave in a similar way to Java: you write code that is executed by a program (a VM or an interpreter) rather than a CPU, and this makes the code portable because the program which executes the code is supported on multiple platforms.
So, why don’t we use only interpreted languages or VM-based languages? What are the downsides of this approach? I would like to note two such downsides:
One downside is performance: the more layers of abstraction you have between the code and the hardware, the slower it tends to work. Interpreted and VM-based languages tend to consume more CPU cycles than languages which compile to machine code, and in addition there is less affinity between the software and the hardware underneath it, which makes it harder to write code that uses the hardware optimally. But as important as performance may be, it does not affect portability, so we’re not going to elaborate on this topic here. What does affect portability is the second downside we mentioned: dependencies. Dependencies affect portability. Big time. Let’s see how.
I define “dependencies” as anything your program expects to exist in its execution environment. This could include:
On first look, languages such as Python, Ruby or Java seem to be the most portable among the prominent programming languages nowadays: all you have to do is make sure the code works on one platform, and you should have it working everywhere, right? Well, that’s the theory at least. Your code will work as long as it doesn’t use OS-specific stuff and—assuming all of its dependencies are met. This is where the problem lies.
If you’ve ever pip install
ed or gem install
ed a package only to discover it relies on another package which doesn’t currently exist and cannot be installed—you know how frustrating dependency management can sometimes get. File permissions, installation paths and conflicting packages are all very common causes for problems when installing dependencies. This gets even worse when a package you’re trying to install doesn’t work well with another package that came with your operating system and which cannot be touched—a very common case with Python and Ruby installations which come as a part of a Linux or macOS installation. In addition, your code usually depends on the version of the interpreter or virtual machine which runs it.
All of the above applies not only to your code but to the packages on which your code depends. These packages are simply *code that someone else already wrote*—possibly you. This code could just as well depend on other packages and could require a specific runtime version in order to work properly, just like your application’s code. Realizing that, we can see that dependencies can have their own dependencies. This means that it’s enough to have a problem with one package somewhere in the dependency hierarchy to make your code completely unusable in a given environment.
Of course, code written in compiled languages such as C or Go has dependencies, too. However, these are usually handled at build time rather than at run time. Once the program is compiled, the resulting binary will simply work on the target platform. Interpreted languages, on the other hand, tend to put the responsibility for dependency management in the hands of the user, which greatly increases the chance of something going wrong, and complicates the deployment process on the target platform.
So, we can assert that in terms of portability it is better to solve all the dependency problems at build time and give our users ready-to-use binaries which they can simply run. However, this requires writing our application in a compiled language, and in addition—we need to compile our app for each CPU architecture and each operating system we want to support. So, we made our users’ life easier but made our own life harder. Fortunately, Go makes cross-compiling a breeze.
Go is a compiled language, which means we must compile our Go code for each platform we want to support. However, the Go tooling makes cross-compiling our code as easy as setting a variable or two before compiling. For example, here is how we create a Windows x86 binary on a Linux machine:
GOOS=windows GOARCH=386 go build -o hello.exe hello.go
That’s it. The resulting hello.exe
file will run happily on any Windows machine with an x86 CPU, and it will require zero setup steps.
How about a Mac with a 64-bit CPU? No problem:
GOOS=darwin GOARCH=amd64 go build hello.go
…and your app works on Mac.
The packages on which your app depends will be automatically included in the generated binary, so all you have to do is ship it to the users.
If you want to write highly-portable code and make the life of your users easier, I recommend writing your code in Go. You will end up with code which can be easily compiled for multiple platforms, and your releases may become as simple as a single file zipped in an archive which can be extracted and used as-is.
It is no coincidence that it becomes more and more common in the open-source community to release apps as single-file, hassle-free binaries. The guys at HashiCorp use this approach in most of their tools, and same goes for most CLI tools in the Kubernetes ecosystem: kubectl, helm, minikube and kops, for example, are all single-file binaries.
I wrote this post with mainly client-side CLI utilities in mind, however Go is great for portable server-side apps, too: The deployment of Go apps is much simpler than apps written in Java or Python, which makes it possible to write apps that don’t require configuration management. This is great for containers, too: just toss a binary in a 5-megabyte Alpine container, set an env var or two and run the app!
Combine the mentioned above with Go’s minimalist syntax, excellent maintainability and fast compile times, and you get a very good alternative for stuff you would normally write in Python or Ruby. Hell, you might even forget you are writing code in a low-level, compiled language. I personally even use Go for what you would normally call “scripts”: small-scale utilities which automate some task. With Go it can be a breeze to write a few lines of code, compile to all common platforms and ship a single file to your users.
Writing apps in Go will get both you and your users peace of mind since you won’t have to write lengthy installation guides which your users will have to follow, and you can trust that your app will work on the user’s machine without a list of conditions and special cases.
Have fun!
This blog post was published also on codeburst.io.