Post

The Way to Go(lang)

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]type where x is an array of n values of type type. 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, like var 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) and cap(s).
  • A slice can also be initialized with make like 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 append function, 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 variables start with a capital letter in Go. This means that math.pi is incorrect as it should be math.Pi, because Pi is exported from the math package.
  • When the return variables are mentioned in the function declaration statement, then even if the function only has a lone return statement without any variables mentioned after it, the variables mentioned in the function declaration will be returned. This is called a naked return.
  • Variables declared without an initial value are automatically assigned their default zero value, like false for bool, 0 for int and "" for string.
  • Constants are declared with the const keyword 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 be sum := 1; for ; sum < 100; {sum += sum} and also sum := 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 variable v will 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 defer statement 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 *TYPE is a pointer to a variable of type TYPE and &VAR is the pointer to the variable VAR. The zero value of a pointer is nil. 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 Bubbletea for 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.

Resources

This post is licensed under CC BY 4.0 by the author.