7 Go Features You Really Need to Know
Most dev surveys have one thing in common - they all show Go as one of the fastest growing languages. Why is that? What makes Go so special in an environment filled with strong, well established alternatives?
The answer is simpler than you would expect, and it’ll be obvious once we’ll explore 7 of Go’s top features and main selling points. Don’t worry - these concepts are straightforward, and, once you’ll learn them, you’ll probably be able to understand 80% of any Go codebase.
A Basic Go Program
The most efficient way to learn a new programming language is to dive right into it, so let’s take a look at a Go program. There are a couple of details and conventions you should be aware of from the get-go.
Let’s start by initializing a module which will create a go.mod file in our directory. This is where we’ll do our version control and dependency management.
go mod init awesome
Next, I’m creating a main.go
file with a main package and a main function as the default entry point into our program.
Here, I’m defining a scanner instance which allows me to read user data, and a slice of 5 string elements. Slices are a powerful concept we’ll discuss in detail when we’ll look at data structures. For now just remember that this line creates an empty structure that is ready to hold up to 5 strings.
Then we’ll use a for loop to read 5 values from the terminal, and append them to our slice. These values are then sorted, iterated over, and printed back to the screen.
func main() {
scanner := bufio.NewScanner(os.Stdin)
slice := make([]string, 0, 5)
for i := 0; i < 5; i++ {
fmt.Printf("Enter " + strconv.Itoa(i+1) + ": ")
scanner.Scan()
slice = append(slice, scanner.Text())
}
sort.Strings(slice)
for _, str := range slice {
fmt.Println(str)
}
}
A couple of things to note here. First, we are relying on the standard library quite a lot. This is common when working with Go, and the language is renowned for all the functionalities it comes packed with out of the box. Second, the Go compiler is strict, and will throw an error if you declare unused variables. To bypass this issue, we can use the blank identifier.
Back in the terminal, we’ll run go build to compile our code and get an executable file as the result. Quick side note - the Go compiler is really really fast. This performance is achieved thanks to a concise type system, an efficient dependency analysis process and other language features we’ll discuss during this article.
go build main.go
Besides the main function, we can define one or more init functions to be executed only once, before the main function, when the package is imported. This is a good place to load up property files, establish database connections or perform any other setup tasks your program might need. Remember that init functions cannot be called or referenced directly.
func init() {
fmt.Println("Runs first")
}
func main() {
fmt.Println("Runs second")
}
And, since we are talking about functions, note that a unique and powerful Go feature is the ability to return multiple values from a function.
func divide(a int, b int) (int, error) {
if b == 0 {
return 0, errors.New("woops")
}
return a / b, nil
}
Before moving to variables and pointers, let’s also briefly discuss packages. All go programs are organized into groups of files called packages. These allow code to be shared in smaller, reusable pieces.
package handler
import(
"awesome/view/home"
"awesome/view/write"
"github.com/labstack/echo/v4"
)
type BasicHandler struct{}
func(h BasicHandler) ShowHome(ctx: echo.Context) error {
return render(ctx, home.ShowHome())
}
func(h BasicHandler) ShowWrite(ctx: echo.Context) error {
return render(ctx, write.ShowWrite())
}
By convention, the name of your package should be the same as the directory containing it. Another convention states that identifiers starting with an uppercase letter will be exported and visible outside the package, while lowercase identifiers will remain private to the package.
Variables and Pointers
Next, let’s see how we work with data in Go. If your background is in high level programming languages, this is where you’ll need to pay a bit more attention. One of Go’s selling points is its support for pointers and working with the memory directly. This might seem scary at first, but you’ll see that Go manages to find a balance between the power of direct memory manipulation and safety against common programming errors.
Using the var keyword we can define variables both at package and function levels. Remember that Go is strongly typed. You can use explicit type declarations but, most of the time, the compiler is smart enough to do the type inference for you when initializers are present.
package main
import "fmt"
var first = "John"
func main() {
var last string = "Doe"
fmt.Println(first + " " + last)
}
Inside functions, you can use short variable declarations
var last := "Doe"
for a more concise way of writing your code, and you can define values that can’t be changed after initialization via the const keyword.
const last = "Doe"
Pointers, which are variables that store the memory address of other variables, can be created via the “address-of” operator.
x := 42
p := &x
Then, you can still access the actual value behind the pointer with the help of the dereferencing operator.
func main() {
x := 42
p := &x
fmt.Println(p) //prints:0xc0000a6018
fmt.Println(*p) //prints: 42
}
Unlike C or C++, Go doesn’t allow pointer arithmetic, which is a common source of errors and vulnerabilities, and it is garbage collected which means that memory management is largely handled by the Go runtime.
Control Flow
Pointers are a lot more useful when combined with user defined types, but, before looking at that, let’s briefly review Go’s main control flow statements.
When it comes to looping things are really really easy. The for construct can be used with an init statement, a condition expression and a post statement.
for i := 0; i < 5; i++ {
fmt.Println(i)
}
Both the init and post statements are optional, so you can drop the semicolons and replicate the C’s whale behavior.
j := 0
for j < 5 {
fmt.Println(j)
j++
}
You can also drop the expression to end up with a block of code that loops forever.
for {
fmt.Println("forever!")
}
If’s are straight forward as well, but note that you can start with a short statement to execute before the condition.
if greeting, err := getGreeting(9); err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println(greeting)
}
The defer construct might be a bit more novel, and it provides an easy way to postpone the execution of a function until the surrounding function returns. The deferred call’s arguments are evaluated immediately, but the function will be executed later.
func main() {
file, err := os.Open("awesome.txt")
if err != nil {
fmt.Println(err)
return
}
defer file.Close()
writer := bufio.NewWriter(file)
_, err = writer.WriteString("Like & Subscribe")
if err != nil {
fmt.Println()
return
}
}
Types
Moving to something a bit more involved, let’s look at Go’s type system. Remember that we are talking about a statically typed programming language, so the compiler has to know the type of every value in your program. Types provide two pieces of information: how much memory to allocate (or, the size of the value) and what that memory represents.
Go offers both a handful of built-in types, and an easy way to create user-defined composite types via the struct keyword. We can easily initialize structs via literals either by passing in both the field name and the values, or just the values.
type User struct {
FirstName string
LastName string
}
func main() {
u := User {
FirstName: "John",
LastName: "Doe",
}
u.hi()
}
We can also add behavior to our types via methods, but you might find this developer experience a bit weird at first.
func (u User) hi() {
fmt.Println("Hi" + u.FirstName)
}
Methods are just functions that contain a receiver, which is an extra parameter declared before the function name. Note that this can be either a value, or a pointer receiver.
Understanding the distinction between values and pointers is key to be successful with Go. Of course, basic types like int, float or boolean are copied on assignment and are passed around by value. Structs however are far more interesting - so let’s look at a quick example.
I’m defining a basic User structure, with an Age field and two methods - one with a value and one with a pointer receiver.
type User struct {
Age int
}
func (u User) birthdate() {
u.Age += 1
}
func (u *User) birthdateCorrect() {
u.Age += 1
}
func main() {
u1 := User {100}
u1.birthdate()
fmt.Println(u1) // Prints 100
u1.birthdateCorrect()
fmt.Println(u1) // Prints 101
}
When this code is executed you’ll see that only the second user will output the expected result. This is because the value receiver performs a copy of the struct inside the method, performs the mutation on the copy, but when the function finishes its execution and is removed from the stack, the old struct value will be back in scope.
You might have also noticed that the struct is a value, but the receiver expects a pointer. In such cases, Go adjusts the value to comply with the method’s receiver, so you don’t have to worry about it.
Before moving to data structures, we should briefly review Go from an Object Oriented perspective. The main thing you need to know is that while Go does not follow the classical OOP model, it implements many of its key principles in a simpler, more flexible way.
Remember that you can control the visibility of variables in Go through capitalized and uncapitalized identifiers.
type User struct {
// Exported: accessible outside the package
Email string
// Unexported: accessible only within the package
password string
}
func (u User) GetPassword() string {
return u.password
}
Polymorphism is achieved through interfaces, which are types that just declare behavior. When a user-defined type implements the set of methods declared by an interface type, the compiler will be able to automatically pair these types up when needed.
type User struct {
Email string
password string
}
type Auth interface {
LogIn()
}
func (u User) LogIn() {
fmt.Println("login")
}
func main() {
var u Auth = User{
Email: "hi@awesome",
password: "***",
}
u.LogIn()
}
When it comes to inheritance, Go doesn’t support the traditional approach, and relies on composition via type embeddings instead. Any existing type can be declared within another type, and then, through inner type promotion, identifiers from the inner type are promoted up to the outer type.
type Shape struct {
Name string
}
type Square struct {
Shape
Size int
}
func (s Shape) Print() {
fmt.Println(s.Name)
}
func main() {
s := Square{Shape{"Circle"}, 20}
s.Print()
}
Go also offers generics support, but this is a rather recent development. Because they aim for simplicity and readability, the language designers were reluctant to add in complexity that could potentially compromise these principles. As a result, despite the fact that Go was first released in 2009, it only got generics support in 2022 after a lot of pressure from the community, and enough time and effort put into making sure that the Go’s principles are still followed.
func Swap[T any](slice []T, i, j int) {
slice[i], slice[j] = slice[j], slice[i]
}
func main() {
// Slice of int
intSlice := []int{1, 2, 3, 4}
Swap(intSlice, 0, 2)
fmt.Println(intSlice)
// Slice of string
stringSlice := []string{"a", "b", "c", "d"}
Swap(stringSlice, 1, 3)
fmt.Println(stringSlice)
}
Data structures
Next, let’s discuss arrays, slices and maps.
In Go, arrays are a fixed-length data type that contains a contiguous block of elements of the same type. Once an array is declared, neither the data being stored nor its length can be modified. If you need a larger capacity, you have to create a new array and then copy in the existing values.
func main() {
// Explicit size
a1 := [3]int{1, 2, 3}
fmt.Println("Length:", len(a1))
// Implicit size
a2 := [...]string{"a", "b", "c", "d"}
for i := 0; i < len(a2); i++ {
fmt.Println(a2[i])
}
}
Slices can fix the capacity limitation, since they are built around the concept of dynamic arrays, and can grow or shrink based on your needs. Slices are three field data structures that contain the metadata Go needs to manipulate the underlying array.
func main() {
s := []int{1, 2, 3}
s = append(s, 4, 5)
fmt.Println("Length:", len(s)) //Prints 5
// Remove the element at index 2
s = append(s[:2], s[2+1:]...)
for -, v := range s {
fmt.Print(v) // Prints 1 2 4 5
}
}
Note that these are created through literals very similar to arrays, with one small difference - no length is passed in the square brackets.
Finally, Maps are data structures containing unordered collections of key / value pairs. Go maps use hash tables under the hood, and there are various implementation details here worth exploring in a dedicated article.
func main() {
langs := map[string]int{
"go": 10,
"js": 4,
}
langs["java"] = 8
delete(langs, "js")
for lang, score := range langs {
fmt.Println(lang + " => " + strconv.Itoa(score))
}
}
Please let me know in the comments if you are interested in more technical deep dives on such topics.
Concurrency
In short, concurrency in Go is the ability for functions to run independent of each other, and it is one of the many things Go excels at. When a function is defined as a goroutine, it is treated as an independent unit of work, and it gets scheduled and then executed on an available logical processor. This work is coordinated by the Go runtime scheduler, which sits on top of the operating system and binds operating system threads to logical processors that can execute goroutines.
func numbers(label string, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 5; i++ {
time.Sleep(time.Second / 2)
fmt.Printf("%s - %d\n", label, i)
}
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go numbers("A", &wg)
go numbers("B", &wg)
wg.Wait()
}
Of course, concurrent work may lead to race conditions, which happen when two or more goroutines have unsynchronized access to shared resources. To avoid this, read and write operations against a shared resource must be atomic.
var count int64
var wg sync.WaitGroup
func increase() {
defer wg.Done()
for i := 0; i < 3; i++ {
atomic.AddInt64(&count, 1)
}
}
func main() {
wg.Add(2)
go increase()
go increase()
wg.Wait()
fmt.Println(count)
}
As an alternative you can rely on channels which allow goroutines to safely communicate and share data with each other.
func main() {
c := make(chan string)
defer close(c)
go func() {
c <- "Hello from goroutine!"
}()
m := <-c
fmt.Println(m)
}
Standard Library
Finally, let’s briefly talk about the Go standard library. This is a comprehensive set of core packages providing a wide range of features, ranging from basic programming necessities like logging to more complex topics like building HTTP servers or interacting with the operating system. Since these packages are tied to the language, they also come with a lot of promises and guarantees.