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
wherex
is an array ofn
values 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
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 thatmath.pi
is incorrect as it should bemath.Pi
, becausePi
is exported from themath
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 anaked 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 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 variablev
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 typeTYPE
and&VAR
is 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
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.