Photo by JOSHUA COLEMAN on Unsplash
Best Practices for Writing Efficient and Idiomatic Go Code in 2023
In the article, “we will discuss the best practices to write efficient and idiomatic Go code that is easy to read, maintain, and scale.”
- Use the “go vet” command to check for common mistakes in your code. Here’s an example of how
go vet
can help catch an error in the Go code:
package main
import "fmt"
func main() {
var myMap map[string]string
myMap["hello"] = "world"
fmt.Println(myMap["hello"])
}
At first glance, this code may look correct. However, if you try to run it, you’ll get a runtime error: panic: assignment to entry in nil map
. This is because the myMap
variable has been declared but not initialized, so it is nil
. To catch this error before running the code, you can use go, vet
. Running go vet
on this code will give you the following output:
.\main.go:7:9: assignment to entry in nil map
2. Avoid using global variables whenever possible. Instead, use function parameters or package-level variables. In Go, global variables can cause concurrency issues and make it harder to reason about your code.
Here’s a short example to illustrate why avoiding global variables is important in Go:
package main
import (
"fmt"
"time"
)
var counter int = 0 // global variable
func increment() {
counter++
}
func main() {
for i := 0; i < 10; i++ {
go increment() // invoke increment function concurrently
}
time.Sleep(time.Second) // sleep for 1 second to allow all goroutines to complete
fmt.Println(counter) // expected output: 10
}
In this example, we have a global variable counter
which is used by the increment
function to increment its value by one. In the main
function, we launch 10 goroutines that call the increment
function concurrently.
However, since the the counter
variable is a global variable, all 10 goroutines will be accessing and modifying the same variable simultaneously. This can lead to race conditions and other unexpected behaviour, which can be difficult to debug.
To avoid these issues, it would be better to pass the counter
variable as a parameter to the increment
function or use a closure to capture the variable. This way, each goroutine will have its own copy of the variable and the behaviour of the program will be more predictable and easier to reason about.
- Avoid using unnecessary pointers. In Go, pointers should only be used when necessary, as they can introduce complexity and potential issues related to memory management. Pointers should be used in cases where direct memory access is required, or when a function needs to modify the value of a variable that is outside its scope.
Here’s an example to illustrate the use of pointers in Go:
package main
import "fmt"
func swap(a *int, b *int) {
temp := *a
*a = *b
*b = temp
}
func main() {
var x int = 10
var y int = 20
fmt.Println("Before swap:", x, y)
swap(&x, &y)
fmt.Println("After swap:", x, y)
}
In this example, we have defined a function swap
that takes two pointers to integers as arguments. The function swaps the values of the integers pointed to by the pointers using a temporary variable. We then call the swap
function with the addresses of the x
and y
variables, which causes the values of x
and y
to be swapped.
Using pointers, in this case, allows us to modify the values of the x
and y
variables outside the scope of the swap
function. Without pointers, we would need to return the swapped values and assign them to new variables in the main
function, which would be less efficient and more cumbersome.
However, it’s worth noting that using pointers can introduce potential issues related to memory management, such as dereferencing null pointers or causing memory leaks. Therefore, it’s important to use pointers judiciously and carefully manage their lifetimes and values to avoid these issues.
- Use the “defer” statement to ensure that resources are released when they are no longer needed.
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.Create("example.txt")
if err != nil {
fmt.Println("Error creating file:", err)
return
}
defer file.Close() // close the file when the function returns
_, err = file.WriteString("Hello, world!")
if err != nil {
fmt.Println("Error writing to file:", err)
return
}
}
In this example, we create a file named example.txt
using the os.Create
a function, which returns a file object and an error. If there is an error creating the file, we print an error message and return it from the function.
However, we also use the defer
statement to schedule the file.Close()
function call immediately before the function returns. This ensures that the file is closed even if an error occurs while writing to the file.
After creating the file, we use the file.WriteString
method to write the string "Hello, world!" to the file. If there is an error writing to the file, we print an error message and return it from the function. However, because we have used defer
to schedule the file.Close()
function call, the file will be closed even if an error occurs.
By using defer
to ensure that resources are cleaned up when they are no longer needed, we can avoid memory leaks and other issues related to resource management. The defer
statement is a powerful tool for managing resources in Go, and it's important to use it appropriately in your code.
- Use the built-in concurrency features, such as Goroutines and channels, to write scalable and efficient code.
Here’s an example of how to use Goroutines and channels to write a program that concurrently computes the sum of a slice of integers:
package main
import (
"fmt"
)
func sum(numbers []int, result chan int) {
sum := 0
for _, num := range numbers {
sum += num
}
result <- sum
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
// Create a channel to receive the result
result := make(chan int)
// Launch a Goroutine to compute the sum of the first half of the slice
go sum(numbers[:len(numbers)/2], result)
// Launch another Goroutine to compute the sum of the second half of the slice
go sum(numbers[len(numbers)/2:], result)
// Wait for the Goroutines to finish and receive their results
sum1 := <-result
sum2 := <-result
// Compute the final sum
finalSum := sum1 + sum2
fmt.Printf("The sum of %v is %d\n", numbers, finalSum)
}
In this example, we define a sum
function that takes a slice of integers and a channel as arguments. The function computes the sum of the numbers in the slice and sends the result through the channel.
In the the main
function, we create a channel to receive the results and launch two Goroutines to concurrently compute the sum of the first half and the second half of the numbers
slice. We then wait for the Goroutines to finish and receive their results from the channel. Finally, we compute the final sum by adding the two partial sums together.
By using Goroutines and channels, we can take advantage of the parallelism provided by modern processors to efficiently compute the sum of a large slice of integers.
6. Use interfaces to define behaviour, rather than implementation. This makes your code more flexible and easier to change.
Here is an example in Go:
package main
import "fmt"
// Define an interface for a notifier that can send a message.
type Notifier interface {
Notify(message string) error
}
// Define a struct for an email notifier that implements the Notifier interface.
type EmailNotifier struct {
// Implementation details for sending email messages.
// ...
}
// Implement the Notify method for the EmailNotifier.
func (en *EmailNotifier) Notify(message string) error {
// Code to send an email message.
fmt.Printf("Email sent: %s\n", message)
return nil
}
// Define a struct for a text message notifier that implements the Notifier interface.
type TextMessageNotifier struct {
// Implementation details for sending text messages.
// ...
}
// Implement the Notify method for the TextMessageNotifier.
func (tmn *TextMessageNotifier) Notify(message string) error {
// Code to send a text message.
fmt.Printf("Text message sent: %s\n", message)
return nil
}
func main() {
// Create an instance of the EmailNotifier.
emailNotifier := &EmailNotifier{}
// Create an instance of the TextMessageNotifier.
textMessageNotifier := &TextMessageNotifier{}
// Define a slice of notifiers.
notifiers := []Notifier{emailNotifier, textMessageNotifier}
// Loop through the notifiers and send a message with each one.
for _, notifier := range notifiers {
notifier.Notify("Hello, world!")
}
}
In this example, we define an interface called Notifier
which defines the behaviour of an object that can send a message. We then define two structs, EmailNotifier
and TextMessageNotifier
, that both implement the Notifier
interface. Each struct provides its own implementation details for sending messages, but the behaviour of sending a message is defined by the Notifier
interface.
In the main
function, we create instances of both notifiers and store them in a slice of Notifier
interfaces. We can then loop through the notifiers and call the Notify
method on each one, without needing to know the implementation details of how the message is sent. This makes the code more flexible and easier to change since we can add or remove notifiers without affecting the rest of the code that uses them.
7. Use the built-in testing framework to write unit tests for your code.
In Go, the built-in testing framework is part of the standard library, and it uses the “testing” package.
Here’s an example of how to write unit tests for a simple Go function:
package mymath
import "testing"
func Add(a, b int) int {
return a + b
}
func TestAdd(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) returned %d, expected %d", result, expected)
}
}
In this example, we have a package named mymath
that contains a function named Add
, which takes two integers as arguments and returns their sum. We also have a test function named TestAdd
that tests the “Add” function. The test function takes a *testing.T argument, which is used to report test failures and errors.
Inside the TestAdd
function, we call the Add
function with arguments 2 and 3 and store the result in a variable named result
. We also define an expected result of 5, since 2 + 3 = 5. Finally, we use the “if” statement to compare the result
variable to the expected
variable, and if they are not equal, we use the t.Errorf
function to report a test failure.
To run the test, we can simply run the “go test” command in the terminal from the directory containing our code. Go will automatically detect and run any test functions in the package.
- Write clear and concise code. Use meaningful variable and function names, and avoid unnecessary comments.
9. Follow the “zero value” convention when declaring variables. This means that variables should be initialized to their zero value, such as 0 for integers or “ ” for strings.
10. Use gofmt to format your code. Go has a standard code formatting tool called gofmt, which automatically formats your code to follow the Go style guide. Using gofmt helps ensure that your code looks consistent and readable, making it easier for other developers to understand.
11. Use the standard library whenever possible. Go’s standard library provides a rich set of functionality, including networking, cryptography, and file I/O. When writing code, try to use the standard library as much as possible, as it is well-tested and efficient.
12. Keep functions short and focused: Functions should be short and focused on a specific task. This makes the code more modular and easier to read and understand.
13. Avoid unnecessary allocations: Go’s garbage collector is efficient, but it’s still best to avoid unnecessary memory allocations whenever possible. For example, you can reuse slices and arrays instead of creating new ones for each operation.
14. Use Go’s error handling system: Go’s error handling system uses explicit return values to indicate success or failure. This can be a powerful way to write robust and reliable code, but it requires careful attention to detail.
Conclusion
Writing efficient and idiomatic Go code requires following best practices such as keeping the code simple, using interfaces, avoiding global variables, writing tests, using channels for concurrency, and using defer for resource management. By following these practices, developers can write code that is easy to read, maintain, and scale. These practices also help to ensure that the code is efficient, performs well, and is in line with Go’s philosophy of simplicity and readability.