Golang Project Structure

Tutorials, tips and tricks for writing and structuring Go code

Rob Pike’s Go Proverbs (Part Two)

Language

  • unknown

by

This is the second part of a three-part series discussing the Go proverbs that were devised by Rob Pike.

(The original post is available to read, if you haven’t already. UPDATE: And the third and final post is now available to read too.)

gofmt’s Style Is No One’s Favourite, Yet gofmt Is Everyone’s Favourite

The gofmt command-line tool automatically rewrites our code in a strict, standardized format that is widely accepted by the Go community.

This ensures that our code is easily readable for other developers, without any idiosyncratic uses of whitespace or inconsistencies in line-spacing.

Yes, you may personally prefer to use four spaces instead of a single tab to indent a code block, but the fact is that it really doesn’t matter how indentation is formatted, so long as it’s done consistently across a codebase.

So by outsourcing concerns about style to the gofmt tool, we can spend our time focusing on the accuracy and efficiency of our code, rather than fighting petty wars of religion over the style of code.

In addition, because gofmt applies the same rules across all Go projects, code written by different developers in different teams or organizations ends up looking the same. This uniformity enhances readability, especially when working with large or unfamiliar codebases. Developers can focus on understanding the logic rather than adjusting to different formatting styles, making the overall development experience smoother.

One of the biggest advantages of using gofmt is that it enforces an ostensibly objective standard, ensuring that formatting is not subject to human foible or personal whims. This is particularly useful in collaborative environments, where different contributors may have conflicting style preferences. By adhering to gofmt, all contributors follow the same formatting rules, reducing friction and promoting smoother collaboration.

A Little Copying Is Better Than a Little Dependency

While reusing code via libraries or other dependencies can be convenient, it does introduce external complexity and risks that can sometimes outweigh the benefits.

A small amount of code duplication within a project — even though not advocated in traditional programming practices (e.g. the principle of DRY) — may actually be preferable to adding an external dependency, which brings potential challenges in maintenance, compatibility and security.

Dependencies often create a form of tight coupling between your project and external code. When a project relies on an external library, it becomes vulnerable to changes, bugs or even abandonment of that library.

The version of the dependency could be updated in a way that breaks your application, requiring additional effort to fix it.

This can lead to “dependency hell,” where managing these external pieces becomes as challenging, if not more so, than writing your own code.

On the other hand, copying a small piece of functionality into your own codebase gives you full control over it, reducing these risks while making the system more self-contained and simpler to manage.

Syscall Must Always Be Guarded With Build Tags

A syscall in Go is a low-level function that interacts directly with the operating system’s kernel, bypassing the Go runtime. Since different operating systems (e.g., Linux, Windows, macOS) have different system calls and behaviours, using them requires a platform-specific approach.

What Are Build Tags?

Build tags are special comments in Go that are used to conditionally include or exclude files during compilation based on certain conditions, like the target operating system or architecture.

Why Use Build Tags?

Guarding syscalls with build tags ensures that platform-specific syscalls are only compiled for the appropriate system.

For instance, a syscall for reading a file in Linux may not work on Windows, and vice versa. By using build tags, developers can ensure that the correct system call is included for each platform without introducing errors or incompatibilities.

An Example Of Build Tags Guarding Syscalls in Go

For instance, imagine you have a file called linux_syscall.go that contains a Linux-specific syscall. You could guard this file with a build tag like this:

// +build linux

package main

import "syscall"

// place Linux-specific syscall code here

This ensures that the code in linux_syscall.go will only be compiled when targeting Linux. If you attempt to compile the same codebase on Windows or macOS, this file will be excluded, preventing compilation errors.

Why Does Guarding Syscalls Matter?

A syscall specific to one platform may not even exist on another platform, causing the compiler to fail.

Even if the code compiles, running platform-specific syscalls on the wrong system can lead to undefined behaviour or crashes, since the operating system may not know how to handle those calls.

Ultimately, build tags help to keep the codebase clean and modular by isolating platform-specific logic. This avoids scattering conditional logic throughout the code, making it easier to maintain and reason about.

Cgo Must Always Be Guarded With Build Tags

Cgo is a feature in Go that allows Go code to call C functions or include C libraries.

While Cgo provides powerful interoperability between Go and C, it introduces platform dependencies and complexities that require careful handling — hence the need for build tags.

Why Should Cgo Be Guarded With Build Tags?

C libraries are often platform-specific, meaning that code using Cgo might only work on certain operating systems or architectures. By using build tags, developers can ensure that Cgo code is only compiled and executed on platforms where the necessary C libraries are available, preventing cross-platform compatibility issues.

Moreover, not all Go environments need or want to include Cgo due to its potential drawbacks, such as reduced portability and slower build times. Some Go programs are designed to be purely Go-based for easier distribution and cross-compilation. Using build tags, you can make Cgo-dependent code optional, so it is only included when explicitly required or supported by the build environment.

Cgo also introduces challenges in cross-compilation, as building a Go program that uses Cgo requires a working C toolchain for the target platform. Guarding Cgo with build tags ensures that when cross-compiling for different platforms, the Go toolchain can exclude the Cgo parts and avoid compilation issues that arise from missing C dependencies.

An Example of Guarding Cgo with Build Tags

Suppose you have a file called cgo_code.go that uses Cgo to interface with a C library, but only for systems where Cgo is supported, like Linux:

// +build linux,cgo

package main

/*
#cgo LDFLAGS: -lmylib
#include 
*/
import "C"

func useMyLib() {
    C.mylib_function()
}

In this example, the build tag // +build linux,cgo ensures that the Cgo code is only compiled on Linux systems with Cgo enabled.

This prevents the code from being compiled on systems where Cgo is either disabled or unavailable, such as when cross-compiling or building on systems without the necessary C toolchain.

What Are The Benefits of Using Build Tags With Cgo?

By guarding Cgo with build tags, you avoid issues where the Go toolchain tries to compile Cgo code on platforms that don’t support it or where required C libraries are missing.

Build tags allow Go developers to provide a fallback or pure-Go alternative on platforms that don’t support Cgo, making the application more portable across a wider range of systems.

Finally, by using build tags, Cgo code is isolated from the rest of the Go codebase. This makes it easier to maintain and modify platform-specific logic without impacting the rest of the application.

Cgo Is Not Go

This proverb reflects an important distinction between pure Go code and Go code that uses Cgo — which, as we saw in the previous proverb, is a tool that allows Go programs to interact with C libraries.

It serves as a reminder that even though Cgo allows access to powerful C libraries and system-level functionality, it introduces complexities and trade-offs that go against the core principles of Go, which is a programming language that emphasizes simplicity, efficiency and portability.

What Are Some Key Reasons Why Cgo Should Not Be Considered Equivalent to Go?

One of the fundamental principles of Go is its efficient concurrency model and garbage collection, which is tightly integrated into the Go runtime. When Go code calls C code through Cgo, it has to cross the boundary between the Go runtime and the C environment. This introduces a performance overhead, since the Go runtime has to manage different calling conventions and memory models. This overhead can negate Go’s performance advantages, especially in high-performance or concurrent applications.

Moreover, Go is designed to be highly portable and easy to compile across different platforms. Pure Go code can be cross-compiled to different operating systems and architectures with minimal effort. However, Cgo introduces a dependency on external C libraries, making cross-compilation much more difficult. To compile Go code that uses Cgo for a different platform, you need to ensure that the corresponding C toolchain and libraries are available for the target environment. This makes Go programs less portable and can unnecessarily complicate the build process.

Go is well known for its simplicity, both in language design and tooling. When you introduce Cgo, you introduce the complexities of C, such as manual memory management, pointer arithmetic and the potential for buffer overflows or memory leaks. Additionally, debugging issues in Cgo code can be harder, as you have to deal with two different runtimes (Go and C) and two sets of debugging tools. This introduces more potential for bugs and can increase the cognitive load for developers.

Go’s concurrency model, based on goroutines and channels, provides a high-level abstraction for safe concurrent programming. However, when you call C code through Cgo, you lose some of the benefits of Go’s concurrency model. For example, C code may block the Go scheduler or be unsafe in concurrent scenarios. Additionally, Go’s garbage collector and memory management don’t extend into the C code, so you need to be careful with memory management, synchronization, and thread safety.

Comparing the Philosophy of Go and C

As mentioned above, Go was designed to be a modern systems language that has a focus on simplicity, clarity and ease of maintenance.

Cgo, while useful for accessing existing C libraries or system-level functionality, introduces many of the pitfalls that Go was specifically designed to avoid.

By saying “Cgo is not Go”, we can reinforce the idea that writing pure Go code is the preferred approach for achieving the benefits that Go was designed to provide: clean, maintainable, accessible and portable code.

With the Unsafe Package There Are No Guarantees

The "unsafe" package allows Go developers to perform operations that bypass Go’s type safety and memory management guarantees, enabling direct manipulation of memory, type conversions and interactions with pointers in ways that are not typically allowed in Go.

While "unsafe" can be powerful and provide low-level control similar to languages like C, it also opens the door to dangerous behaviour, leading to unpredictable results and potential program crashes.

The Go runtime cannot provide its usual guarantees of memory safety, type correctness or program behaviour when "unsafe" is used.

What Are Some of the Key Risks Involved in Using the "unsafe" Package?

One of Go’s core features is strong, static type safety, which ensures that variables of different types cannot be mistakenly interchanged. However, the "unsafe" package allows for casting between arbitrary types using unsafe.Pointer. This can lead to unintended consequences if memory is interpreted incorrectly, resulting in invalid memory access, undefined behaviour or crashes.

Look at the example code below:

package main

import "unsafe"

func main() {
	var i int = 42
	var p unsafe.Pointer = unsafe.Pointer(&i)
	var f *float64 = (*float64)(p)
	// now f points to memory holding an int but is interpreted as a float64
}

In this example, converting an integer pointer to a float pointer through unsafe.Pointer leads to nonsensical behaviour because Go can no longer guarantee that the memory is being used correctly according to its type.

Go’s garbage collector manages memory automatically, preventing common issues like dangling pointers or memory leaks. However, using the "unsafe" package allows you to manually manipulate pointers, potentially bypassing Go’s memory management. If you manipulate memory incorrectly, such as accessing memory that has been freed, or modifying read-only memory, it can lead to crashes or corrupted data.

Furthermore, Go’s runtime and compiler make certain optimizations based on the assumption that memory and types are managed safely. When using "unsafe", those assumptions no longer hold, and the runtime might behave in ways that are unpredictable or platform-dependent. Operations that work in one environment may fail in another, making code that uses "unsafe" fragile and hard to maintain.

Finally, one of Go’s strengths is its ability to compile programs for multiple platforms in a straightforward way. However, code that uses "unsafe" could behave differently on different architectures or operating systems because it makes assumptions about memory layout, word size and pointer representation that are not guaranteed to be consistent across platforms.

So What Is the Intended Purpose of the "unsafe" Package?

Despite its evident dangers, the "unsafe" package exists to provide flexibility in very specific cases where performance or low-level access is absolutely necessary, such as:

  • interfacing with system-level APIs or hardware;
  • working with memory-mapped I/O or specialized optimizations;
  • and implementing specialized libraries (e.g., serialization, reflection).

However, using the "unsafe" package should be done with extreme caution.

The phrase “there are no guarantees” serves as a warning that Go’s usual safety nets — type checking, memory management, and predictable behaviour — are bypassed when "unsafe" is involved. It also implies that code relying on "unsafe" can be fragile, prone to subtle bugs, and difficult to debug or maintain.

The "unsafe" package provides a backdoor to powerful but dangerous operations in Go. By using it, you must remember that you are choosing to step outside Go’s safe and managed environment, forfeiting the ordinary guarantees of memory safety, type safety and predictable behaviour.

Leave a Reply

Your email address will not be published. Required fields are marked *