Golang minimalist design overview
Let's review the Golang syntax in a cheatsheet form, we'll discover one of the main focus on the language design, simplicity. Does Go achieve being a simple language by having a reduced grammar and keywords? And what are the tradeoffs for that simplicity
Declaring variables
As we can see below there is several ways of defining variables in Golang, probably a bit too many as recognized by the languages creators, especially for a language that strives for simplicity as its main design feature. Something to keep an eye on is the possibility of shadowing variables, which I think add more pain than benefit.
var helloWorld string = "Hello World"
var helloInference = "Hello World" // Using type inference
var helloZeroValue string
helloAssignment := "hello assignment"
helloPointer := new(string) // Pointer to zero value string
otherPointer := &helloWorld
// Make a slice of with initial length of 5 int and
// total capacity of 10 to have room to grow without reallocating
intSlice := make([]int, 5, 10)
// Variable shadowing
n := 0
if true {
n := 1 // n is redeclared for this scope
n++
}
// n is still 0
Immutability
Unfortunately, there is no support for immutable variables in Golang. There is also no concept of enums in Golang, so constants is the closest and the workaround that we see being abused to replace this.
const myConst = "Some value" // Compile-time constant
const (
statusPending = "PENDING"
statusActive = "ACTIVE"
)
Zero values
One thing to keep in mind in Golang is the introduction of zero values and nillability, which tries to reduce the "billion dollar mistake" about null references by providing a default (zero) value. Although on doing that introduces some behaviors that the developer need to have clear to avoid introducing more issues than the ones it tries to solve.
Some of the zero values provided by the language could be quite unexpected while starting with go, even seasoned developers can fall into these errors, rather than breaking the application with null references exceptions, go might return a default value which could lead to other kind of bugs more complicated to debug fix. For example, obtaining the value by its index from a nil map will return its zero value, even though there is nothing for the index, the map is not initialized. I don't think this solved the problem of null references, but rather transform it into something different that in my view could make things worse. There are better solutions nowadays like, kotlin null safety, or option types, like in Rust.
Let's take a look on some of those zero values, pay attention specially to slices, maps and channels.
// Numbers
// byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64
var anInt int // Initialized to 0
// Floating point numbers
// float32, float64
var aFloat float32 // Initialized to 0.0
var aBoolean bool // Initialized to false
var aString string // Initialized to ""
var anInterface interface{} // Initialized to nil (nil type, nil value)
// Slices
var aSlice []int // Initialized to []
// You can call len(), cap() which return 0, or even append to it
// But trying to access an item will panic, unlike in maps below
// Maps
// Accessing an element returns its zero value,
// in this case "", assigning a value panics
var aMap map[int]string
// Nil channel, which blocks forever on read or write
// panics if you try to close it
var aChan chan int
var aPointer *int // Initialized to nil, dereferencing will panic
var aFunc func(string) // Init to nil, invoking the func will panic
// Structs
var myStruct aStruct // Inits the struct with zeroed values fields, {aField: 0}
More details about nil and zero values can be seen here https://nilsmagnus.github.io/post/nillability-in-go/
Types & Generics
Golang is a strongly typed language, but unlike other strongly typed languages, it took a while for golang to obtain generics. Generics become part of the language in version 1.18. 10 years after the initial release. This has lead to some abuse of the any or empty interface which forces the user to cast objects in runtime, this can be seen not only in 3rd party libraries but also the standard library, although sometimes there were not many alternatives, apart from generators to generate that boilerplate code.
Generics in Golang is also different than other languages, as you can only specify type parameters at the struct or function level, but not at the method level.
i := int(32.987) // casting to integer
// Methods for parsing and serialization to and from strings are defined on the strconv module
someInt, _ := strconv.Atoi("1") // Parses an string to integer
// Formatting a date or converting it to string. It's, let's say odd
// There is a magical date composed by
// {month}/{day} {hour}:{minute}:{second} {year} {timezone} - 01/02 03:04:05PM '06 -0700
// which makes sense until you consider the order of the elements for most of the population
theTime := time.Date(2021, 8, 15, 14, 30, 45, 100, time.Local)
println(theTime) // 2021-08-15 14:30:45.0000001 -0500 CDT
println(theTime.Format("2006-1-2 15:4:5")) // 2021-8-15 14:30:45
// Casting an interface to its implementation
func castInterface(value interface{}){
myValue, ok = value.(MyStruct) // Cast an interface to an explicit type
}
// Generics
type MyGenericStruct[T any] struct {
items []T
}
func (m *MyGenericStruct[T]) AddItem(item T) {
m.items = append(m.items, item)
}
// Methods can't define its own generic types only the ones defined for the type
// A workaround on this is to define a function (not a method, there is no receiver here)
// with its first parameter being the type for the method as in not object oriented languages, like C
func MyFunc[T any](c *MyStruct, t T) {
// Do something with T
}
Collections
Golang makes a distinction between arrays, which have a predefined size on the declaration, and slices, unbounded arrays which contain an initial size which can grow as more items are added to it, having to reallocate if their total capacity is exceeded. Slices have length, as in arrays, but they also have capacity, so that you can preallocate some extra room to grow without the need to reallocate.
array := [2]int{1, 2} // An array of 2 items with values 1 and 2
// A slice with length of 2 with zeroed values i.e: [0,0]
// and a total capacity of 10 before it needs reallocation
slice := make([]int, 2, 10)
// Adding one item to the slice i.e: [0,0,1]
// increasing the length to 3, but not reallocating as it has a capacity of 10
slice = append(slice, 1)
sliceA := []int{1, 2} // Alternative way to declare the slice with initial values
fooMap := make(map[int]string) // A map with int keys and string as values
fooMap[1] = "Value for 1"
Control flow
If-else syntax is similar to other languages, but without the need to add parenthesis if not required by the condition. The ternary operator is missing although something similar can be achieved inlining the if else execution in one line.
if condition {
fmt.Println("Condition was true")
} else {
fmt.Println("Condition was false")
}
`;
export const loops = `
// Standard for loop
for i := 0; i < 10; i++ {
println(i)
}
// Simulating a while loop
i := 0
for {
if i == 10 {
break
}
println(i)
i++
}
// For each
for i, v := range os.Args { // os.Args returns parameters received when invoking the cmd
println("index: %v, value: %v", i, v)
}
Loops
In term of loops, only for exists, following with the design approach for a minimalism, although the other options can be executed with different combinations for the for loop.
Not having a standard iterator seems to be a big missing piece in the design, asked for many developers. Right now the only way to use the range operator is to use slices, arrays, maps and channels. However, this doesn't cover any custom collections, although some work is being done on this front.
// Standard for loop
for i := 0; i < 10; i++ {
println(i)
}
// Simulating a while loop
i := 0
for {
if i == 10 {
break
}
println(i)
i++
}
// For each
for i, v := range os.Args { // os.Args returns parameters received when invoking the cmd
println("index: %v, value: %v", i, v)
}
Switch statement
On the switch statements, we find two flavours: one where only one expression is being evaluated and a second one where expressions are being evaluated until one satisfies the condition, although it can be forced to keep processing the following branches.
// Switch where only one option is evaluated, the exception if fallthrough is defined
switch anInt {
case 0:
println(0)
case 1:
println(1)
fallthrough // Next condition is also evaluated for execution
case 2:
println(2)
default:
println("Something different")
}
// Switch where all options are evaluated
switch {
case anInt >= 0:
println("Positive")
case anInt < 0:
println("Negative")
}
Select
Select statement allows you to wait for different blocking operations, yielding control back, until the first one resolved to resume the execution. This is used in Golang for different patterns like reading from channels, avoid executing IO operation if the context has been cancelled...
// Select statement blocks until one of the channels receives
// a value or to control context cancellations/timeouts...
ch1 := make(chan int)
ch2 := make(chan int)
select {
case v1 := <-ch1:
fmt.Println("Got: ", v1)
case v2 := <-ch2:
fmt.Println("Got: ", v2)
default: // To avoid blocking if the other cases are not ready
fmt.Println("The default case!")
}
Functions
This is where go probably distance itself more from a GC and OOP languages and gets closer to a systems programming language. Golang focus on performance and on reducing heap allocations with a pass by value by default. You should keep this in mind if coming from other languages, like Java, .Net, Python, JS... This allows golang to reduce heap allocations to a minimum and leave less work for the Garbage collector.
Again, with go minimalism on language design, there a few feature that you might miss from other languages, like optional parameters or function overloads, although you still get variadic parameters.
aStruct := aStruct{aField: 1}
func funcByVal(someStruct aStruct) {}
// A copy of the struct is sent to the func
// stack allocated in this case
funcByVal(aStruct)
func funcByRef(someStruct *aStruct) {}
// A reference to the struct is sent to the func,
// in this case still stack allocated
funcByRef(&aStruct)
// Call the function with as many params as needed,
// which will be received as a slice of int []int
func funcWithVariadicArgs(params ...int) {}
funcWithVariadicArgs(1, 2, 3)
// Lambdas (anonymous functions)
var fn = func(param string) string {
// Func always needs to be declared and return parameter , if needed, unlike in other languages.
fmt.Println(param)
return "done"
}
fn("Hello Lambda")
Object Oriented Programming
Despite golang not being an OOP language, it still provides some of the most important characteristics from these. Even though the syntax might seem a bit different it achieves almost the same effects.
Encapsulation and abstraction can be achieved by defining structs which contain public or private methods or fields, hiding its internal implementation from the consumers.
Some form of inheritance could also be achieved by using embedded structs, which copy the fields and methods from the embedded struct, as we will see in the example below.
Finally, polymorphism could also be achieved by interfaces, which are implicitly implemented if all the methods defined in the interface are defined.
// Structs: inheritance, encapsulation and polymorphism
type fooStruct struct {
APublicField int // This will be a public field as it starts with a capital letter, same applies to structs, methods...
aPrivateField int // This however, will be only accessible to the module where is defined
}
// Embedded structs
type aStruct struct {
aField int
}
type bStruct struct {
aStruct // bStruct "inherits" all fields and methods from aStruct
bField int
}
bStruct := bStruct{}
println(bStruct.aField) // Use a field inherited from aStruct
// Interfaces
type MyWriter interface {
// aField int // Interfaces can only declare methods
write() // Any struct that implements someMethod, implicitly implemented the interface
}
type SomeWriter struct {
}
// You can force the compiler to implement an interface for a struct
// and get help from the IDE to implement the methods required
var someWriter MyWriter = (*SomeWriter)(nil)
// Method of the SomeWriter struct as it declares a receiver for SomeWriter
// could be a pointer or o a copy
func (w *SomeWriter) write() {
}
// SomeWriter implicitly implements the MyWriter interface
var someWriter MyWriter = &SomeWriter{}
someWriter.write()
Error handling
Lastly, one of the most polemic features in golang, which make for a repetitive code, but treat errors as any other value without special behavior. There has been some improvements in this front with errors.Is and errors.As, but somehow still feels simple and prone to errors without some help from additional tools like linters.
The use of linters is recommended, if not mandatory, to avoid dealing with errors, tools like golangci errcheck improve some of the shortcomings of the language.
err := mightReturnError() // This function might return an error when called
if err != nil {
//handle error
}
val, err := returnValueOrError() // Returns either val or error
if err != nil { // Similar to try catch in other languages
//handle error
}
// Operation was ok, do something with val
println(val)
// Examples of functions that return errors
func mightReturnError() error {
// Completes successfully or returns an error
errors.New("Some basic error")
}
func returnValueOrError() (int, error) {
// Returns an int value and optionally an error if something went wrong
return 0, errors.New("Some basic error")
}
file, err := os.Open("somefile.txt") // Returns either a file pointer or error
if err != nil {
//handle error
}
defer func() {
file.Close() // You can use defer to avoid forgetting freeing the OS resource
if err := recover(); err != nil {
// You can also recover from a panic somewhere down the stack inside a defer function
// to do some cleaning and freeing any required resources or bubble up the panic to parent coroutines
println("Ooops something panicked: %v", err)
os.Exit(1)
}
}()
//panic("Something is wrong") // Panic so that it can be processed by the previous defer-recover function, similar to try-finally in other languages
// Custom errors
var mySimpleError = errors.New("Some basic error")
// Something more advanced
type MyError struct { // Create a custom error type
someImportantInfo int // Custom errors can contain additional useful details about the error
}
var _ error = MyError{} // Force implementation of error by MyError
func (e MyError) Error() string {
return fmt.Sprint("MyError failed due to: %v", e.someImportantInfo) // Return the details of the error
}
myCustomError := MyError{someImportantInfo: 1}
var myError *MyError
if errors.As(myCustomError, &myError) {
println("Opps filed due to: %v", myError.someImportantInfo)
}
More details for errors.Is as errors.As https://gosamples.dev/check-error-type
In Summary
Golang has opted for a minimalist language design, to make it simple to learn, but sometimes it feels like it fails to achieve that, leaving the developer with more things to be concerned about rather than helping them on producing better code with less exceptions, sorry, errors. The omissions of generics until version 1.18 has lead to some design choices which make the language inconsistent and sometimes feels a bit random on its design, like the many ways to initialize a variable or the lack of a standard iterator, date formatting...
However, besides the focus on simplicity, you shouldn't be fooled by some of the areas where golang excels, making it the first choice on cloud native software, native compilation, pass by value first, low memory footprint. We will cover that in a separate post. I will write another to cover also some of the ways where golang can lead to errors and how to avoid them. As this has already got longer than I wanted.