The Way to Go(lang)
When I started exploring new programming languages and tooling, Go caught my attention for several compelling reasons. First and foremost, its ability to produce standalone binaries, similar to C, is HUGE! Unlike Python, I don’t need to worry about where the final code would execute, everything can be just packaged together. YES, I know we can ultimately get Python to do something similar, but it’s just soooo easy with Go. The ability to easily generate binaries for any operating system and architecture makes it a powerful choice for building cross-platform tools.
Go offers a more modern and readable syntax like Python while maintaining static typing like C - offering the best of both worlds. Go is also very close to C in performance. What really sets Go apart is its extensive standard library combined with an incredible package sharing ecosystem. One of Go’s standout features is goroutines, a straightforward yet robust and efficient concurrent programming approach. This, combined with compilation checks, makes it an excellent choice for building CLI tooling and backends.
Python is still invaluable for many tasks I undertake on a day to day basis, especially quickly scripting things and manipulating dictionaries with ease. However, Go excels in creating sophisticated CLI utilities with high consideration for consistency, ease of execution, and performance. Through this blog post, I offer a guide based off my quick ramp up before getting into writing Go code.
Fundamentals
Before getting anywhere, Go should be installed using the instructions specified in Go Docs. It’s very straightforward.
It’s an amazing idea to use containers to run Go stuff. In my project containerized security toolkit, the default Go BIN path is at
/root/go/bin/.
Getting Started
Go uses go.mod files for dependency tracking, which can be initialized using the go mod init command. For example →
1
go mod init github.com/tanq16/go-learn
In Go, code is organized into packages, which also groups functions together from files in the same directory. A typical Go program starts with a main package and a main function →
1
2
3
4
5
package main
import "fmt"
func main() {
fmt.Println("Etherios!")
}
This code can be run with go run . or compiled into a binary with go build .. To install the program as an executable in the default Go bin path, use go install. The default path can be changed to an arbitrary one with the following →
1
go env -w GOBIN=/home/user/bin
A new package can be added to the code with the import construct. So, if a module is added (for example) with import github.com/cristalhq/jwt/v5, the dependencies can be updated with the command go mod tidy. Alternatively, the module can be retrieved on the CLI with go get github.com/cristalhq/jwt/v5 and it auto-updates the go.mod file.
A go.sum file is automatically generated and represents a dependency lock with checksums of different modules being used in the go.mod file.
Learning to Crawl
Go provides the fundamental features like most programming languages. See examples of if conditions, return statements, and slices below →
1
2
3
4
if errorCondition == "" { // define error condition
return "", errors.New("Bad Condition")
}
return "No-Error", nil
A slice is like an array but dynamic, so it allows adding and removing elements. An example of randomly printing (selected with the math/rand package) a string from a slice is as follows →
1
2
3
4
5
6
7
8
9
10
11
12
import "fmt"
import "math/rand"
func main() {
//
stringList := []string{
"Hola",
"Hi",
"Namaste",
}
fmt.Println(stringList[rand.Intn(len(stringList))])
}
Slices have the following properties →
- An array is defined as
var x [n]typewherexis an array ofnvalues of typetype. The size of the array is part of the definition so the number values must be defined and cannot be resized. - For dynamic sizes, we use slices defined as
var x []type. A slice can also be generated for an array by specifying a low and high index, likevar x []int = fibbo[1:4]. - A slice only represents the particular section of an array, so if a slice is modified, other slices of the same array will also observe that change.
- Defining a slice without the number of elements and initializing it with certain elements basically creates an underlying array and then creates and returns the slice referencing that array.
- A slice has a length and a capacity obtained by
len(s)andcap(s). - A slice can also be initialized with
makelike so -var s = make([]int, 2, 5) // type, len, cap. - A slice can be re-sliced to a greater size as long as there is sufficient capacity (set at initialization). The capacity can be increased by using the
appendfunction, which has the following definition -func append(s []T, vs ...T) []T.
The next example snippet shows how to use map in a function, defining return types and arguments for a function, variable declarations, and looping through a slice →
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import "fmt"
import "math/rand"
func randomizeStrings(people []string, stringList []string) (map[string]string) {
printableStrings := make(map[string]string)
for _, name := range people {
printableStrings[name] = stringList[rand.Intn(len(stringList))]
}
return printableStrings
}
func main() {
stringList := []string{"Hola","Hi","Namaste","Bello",}
var people []string
people = append(people, "Alice")
people = append(people, "Alicia")
printableStrings := randomizeStrings(people, stringList)
fmt.Println(printableStrings)
}
A quick reference of all CLI commands used so far →
1 2 3 4 5 6 7 8 go mod init $MODULE_PATH go get $PUBLIC_MODULE go mod tidy go mod edit -replace $PUBLIC_MODULE=$LOCAL_PATH go run . go build . go env -w GOBIN=/home/user/bin/ go install
Learning to Walk
Exported variablesstart with a capital letter in Go. This means thatmath.piis incorrect as it should bemath.Pi, becausePiis exported from themathpackage.- When the return variables are mentioned in the function declaration statement, then even if the function only has a lone
returnstatement without any variables mentioned after it, the variables mentioned in the function declaration will be returned. This is called anaked return. - Variables declared without an initial value are automatically assigned their default zero value, like
falsefor bool,0for int and""for string. - Constants are declared with the
constkeyword and they cannot be initialized with the:=shorthand. - In for loops, the init and post statements can be optional. So instead of
sum := 0; for i := 0; i < 100; i++ {sum += i}, it can besum := 1; for ; sum < 100; {sum += sum}and alsosum := 1; for sum < 100 {sum += sum}. for {}is an infinite loop.- If statements can contain an initialization like
if v := math.Pow(x, n); v < lim {return v}, however, the variablevwill only be valid in the scope of the if statement and its else blocks. - Switch statements are basically an if-else shorthand and do not need a break statement. If a switch is defined without any variable it defaults to
switch true. - A
deferstatement defers the execution of a function until the surrounding function has returned. All deferred functions are pushed onto a stack, which are then executed by being LIFO’ed out of the stack when the enclosing function returns. - Pointers work similar to C, where
*TYPEis a pointer to a variable of typeTYPEand&VARis the pointer to the variableVAR. The zero value of a pointer isnil. There is no pointer arithmetic in Go, unlike C.
A struct is a collection of fields and the fields are accessed using a . like so →
1
2
3
4
5
6
7
8
9
10
11
12
13
import "fmt"
type Vertex struct {
X int
Y int
}
func main() {
v := Vertex{1, 2}
u := Vertex{X: 1} // Y is given value 0 implicitly
v.X = 4
fmt.Println(v.X, u.Y)
}
If p is a pointer to v and v is a variable of type struct, then a field x can be accessed as v.x and (*p).x. But Go also allows accessing it through p.x.
A for loop can iterate over elements of a slice or a map using the range form as follows - for i, v := range pow {...}, where i is the index and v is the copy of the element in that index. The v can be omitted to get only the index, or either of i or v can be replaced with _ when they are not actively being used. The example from “A Tour of Go” on slices demonstrates a 2-D slice very well →
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import "golang.org/x/tour/pic"
func Pic(dx, dy int) [][]uint8 {
pic := make([][]uint8, dy)
for i := range pic {
pic[i] = make([]uint8, dx)
for j := range pic[i] {
pic[i][j] = uint8(i ^ j)
}
}
return pic
}
func main() {
pic.Show(Pic)
}
A map is used for mapping keys to values and is defined as var m = map[string]CustomStruct such that keys of type string are mapped to values of type CustomStruct struct. A variable can be provided a value like in Python dictionaries, m["first"] = ...(customstructval)....
An element can be deleted using delete(m, key). To test if a key is present in a map, use e, present = m[key], such that present is a bool representing whether the element e is in the map m; e gets the zero value of the element type of m. This example of using a map from “A Tour of Go” shows how to use a map →
1
2
3
4
5
6
7
8
9
10
import "golang.org/x/tour/wc"
import "strings"
func WordCount(s string) map[string]int {
counts := make(map[string]int)
for _,val := range strings.Fields(s) {counts[val]++}
return counts
}
func main() {wc.Test(WordCount)}
Learning to Run
There is no concept of classes in Go, instead custom functions can be defined for specific types. These are called methods and they have a receiver argument that appears between the func keyword and the name of the function. The following example from “A Tour of Go” shows how a method can be defined for a struct →
1
2
3
4
5
6
7
8
9
type Vertex struct {X, Y float64}
// the Abs() method has a receiver of type Vertex
func (v Vertex) Abs() float64 {return math.Sqrt(v.X*v.X + v.Y*v.Y)}
func main() {
v := Vertex{3, 4}
fmt.Println(v.Abs())
}
A method can only be declared for a receiver of type that is defined in the same package as the method, which means built-in types like int cannot have a method. If such a method is needed, a custom type should be defined as a copy of the built-in type and then the method should be defined for that custom type. A method can also accept a receiver of type *T, i.e., a pointer. If the receiver is a value, then like a normal function argument, the method will work on a copy of the actual object so it won’t make changes to it. If changes are needed, the receiver should be a pointer literal.
If a function has a pointer argument, it expects a pointer (i.e., &x), but a method with a pointer receiver accepts both the value and pointer (i.e., x, &x). The same goes the other way around too, i.e., for value argument and value receivers. Using pointer receivers helps avoid copying the value at every method call.
An interface is a set of method signatures. An interface can hold any value that implements those methods. A very common interface is the Stringer interface defined under fmt as follows →
1
2
3
type Stringer interface {
String() string
}
Stringer is a type that can describe itself as a string. The fmt package and several others use this to print values. Like Stringer, error is also an interface defined as →
1
2
3
type error interface {
Error() string
}
Learning to Fly
Project Structure
Realistically, it’s all subjective. Anyone can have any number of directories and packages in their projects, and structure them in whatever manner that seems relevant. Although, Go tries to move away from typical project structures from C and Java. Personally, something like this makes sense to me →
1
2
3
4
5
6
7
8
9
10
11
12
project-structure
├── cmd # top level instructions
│ ├── command-one.go
│ ├── command-two.go
├── globals # package name - Globals
│ └── vars.go
├── go.mod
├── go.sum
├── main.go # package name - main
└── resource-one
├── resource-one-function-a # package name - FunctionA
├── resource-one-function-b # package name - FunctionB
Obviously, this can be tweaked as necessary, but the structure is kind of readable. It may not appear readable to anyone but myself, which is why a project structure realistically depends on the programmer. But usually, pkg will have stuff they want to export so their code can be used as a package elsewhere, and internal would have dependencies specific to the project at hand.
Goroutines and Concurrency
Go’s concurrent programming model is one of its strongest features. Goroutines are lightweight threads that can be created simply by prefixing a function call with the go keyword →
1
go f(x, y, z)
To synchronize goroutines, Go provides channels. In general, one can send data to a channel or receive from it →
1
2
3
ch := make(chan int) // channel that processes integers
ch <- v1 // Send value of variable v1 to channel
v2 := <-ch // Receive from ch and store into v2
Goroutines can synchronize by default when sending or receiving data because channels ipmlicitly block themselves on either side for data to be ready. Channels can also be created with an initial size, just like a slice. If a sized channel is full, it will block sending data to it until there is a read operation.
Values can be sent to a channel after it is created but not when it has closed. For receiving values, it can be done until all values are retrieved. To test if a channel is closed or to close it manually, use this →
1
2
val, ok := <-ch // ok is a bool
close(ch) // close channel ch
To receive all values from a channel until it is empty, use a for loop like so →
1
for i := range ch
Channels will close by default when all values are retrieved, so they don’t need an explicit close operation.
Inside complex interactions, closing it after sending all data is recommended.
The sync package provides Muetx type for locking and unlocking data and WaitGroups to coordinate multiple goroutines →
1
2
3
4
5
6
var v map[string]string
mu := sync.Mutex
mu.Lock()
v['update'] = 'value'
mu.Unlock()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func worker(id int) {
fmt.Println("Start Worker:", id)
time.Sleep(time.Second * 10) // simulate a 10 second task
fmt.Println("Complete Worker:", id)
}
func main() {
var wg sync.WaitGroup // define wait group
for i := 1; i <= 5; i++ { // launch 5 goroutines
wg.Add(1) // add to goroutine count before launching
go func() {
defer wg.Done() // pops a count from wg after go routine is done
worker(i)
}()
}
wg.Wait() // wait for count to go to 0, i.e., all goroutines to complete
}
A semaphore pattern can be used to limit the number of concurrent goroutines launched by a piece of code &rrar;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func worker(id int) {
fmt.Println("Start Worker:", id)
time.Sleep(time.Second * 10) // simulate a 10 second task
fmt.Println("Complete Worker:", id)
}
func main() {
var wg sync.WaitGroup // define wait group
semaphore := make(struct{}, 5) // limit to 5 threads at a time
for i := 1; i <= 20; i++ { // launch 20 goroutines
wg.Add(1) // add to goroutine count before launching
semaphore <- struct{}{} // send struct to fill buffer (allocate gofunc)
go func() {
defer wg.Done() // pops a count from wg after go routine is done
defer func() {<-sempahore}() // deallocate gofunc
worker(i)
}()
}
wg.Wait() // wait for count to go to 0, i.e., all goroutines to complete
}
There is also a concept of advanced Data Pipelines in Go. A Pipeline is a series of Stages connected by channels. Each stage is one or more goroutines such that the stage receives values from upstream via inbound channels, does processing, and sends the processed values downstream via outbound channels. This can help create large-scale projects with a comprehensive and robust structure.
Building CLI Applications
For building command-line applications, the Cobra framework is the go-to choice. Go also has a built-in flag package but Cobra is quite famous and widely accepted to be one of the best frameworks for CLI applications. To install the package, use this command, followed by initializing the Cobra CLI project →
1
2
go install github.com/spf13/cobra-cli@latest
cobra-cli init # requires the go mod init call to be done already
This will create a cmd folder and a main.go file. Then, subcommands can be added with the cobra-cli as follows →
1
cobra-cli add <subcommand> [-p <parentcmd>]
With -p, Cobra adds a sub-subcommand to the parent in concern. Cobra generates help and completion commands by default. The completion subcommand can be disabled by adding this line in the func Execute() function of the root.go file in the cmd directory →
1
rootCmd.CompletionOptions.DisableDefaultCmd = true
A Version can be added to the app by mentioning it in the rootCmd variable in the root.go file. Similarly, the help default subcommand can be hidden and disabled with the following placed in the same function →
1
rootCmd.SetHelpCommand(&cobra.Command{Hidden: true})
If the help function is to be called at any point in the code, use the following →
1
cmd.Help()
While Cobra is excellent for traditional CLI applications, it’s also worth mentioning
Bubbleteafor creating modern terminal user interfaces.
Fin
Go has proven to be an excellent choice for highly-performant applications, CLI tools, and day to day utilities. Goroutines, strong standard library support, and excellent package management makes it a very powerful programming language. It doesn’t intend to replace Python or another language for specific use cases; instead Go’s strengths in producing consistent, cross-platform binaries open up a large number of avenues when building applications.
