Go is a wonderful language. It's easy to learn and a pleasure to use; it has a great balance of flexibility and rigidity that allows me to be productive without feeling like I'm building on quicksand.
But, like any other language, it has its share of edge cases, idiosyncrasies and even inconsistencies that can leave you scratching your head. The following is an attempt to catalog such bits of Go, along with an attempt to explain them, so that I can understand them better.
- Slices
- Arrays
- Strings
- Interfaces
- Assignability Rules
- Defer and Recover
- Package sync
- Channels
- Tips and Tricks
package main
import "fmt"
func main() {
s1 := []int{0, 1, 2}
// Prints 3 (the size of the underlying array)
fmt.Println(cap(s1))
s2 := s1[0:2]
// Prints 3 (s2 has the same underlying array as s1)
fmt.Println(cap(s2))
s2[0] = -1
// Prints -1 (s2 has the same underlying array as s1)
fmt.Println(s1[0])
s2 = append(s2, 3)
// Prints 3 (s2 still has the same underlying array as s2)
fmt.Println(s1[2])
s2 = append(s2, 4)
s2[0] = 0
// Prints -1 (s1 and s2 don't share underlying array anymore)
fmt.Println(s1[0])
}The last append (s2 = append(s2, 4)) takes s2 over its capacity,
so Go creates a new underlying array for it.
package main
import (
"fmt"
)
func main() {
s1 := []int{0, 1, 2, 4}
// Prints 4
fmt.Println(cap(s1))
s2 := s1[:2]
// Prints 4
fmt.Println(cap(s2))
s2 = s2[:4]
// Prints 4 (re-slicing upwards within capacity works)
fmt.Println(s2[3])
s2 = s1[1:]
// Prints 3 (slicing off the bottom changes capacity)
fmt.Println(cap(s2))
s2 = s2[1:]
// Prints 2
fmt.Println(cap(s2))
}Slicing off the top of a slice doesn't change its capacity and we can re-slice back up to get the original length (and elements). Slicing off the bottom, however, changes the capacity (i.e. there's no way to get the sliced off elements back).
Arrays are rarely used explicitly in Go. Usually you just create a slice where other languages may use arrays. Go implicitly creates an underlying array and takes care of deallocating it once the slice stops using it.
package main
func main() {
var a1 [2]int
var a2 [2]int
var a3 [3]int
// Works
a1 = a2
// Causes a compile-time error
a1 = a3
_ = a1
}a1 = a3 does not fall within any of the
assignability rules: a1 and
a3 have different underlying types, since an array's length is part
of its type.
package main
import "fmt"
func main() {
a1 := [...]int{0, 1, 2, 3}
a2 := a1
a2[0] = 1
// Prints 0
fmt.Println(a1[0])
}Unlike slices, arrays are value types, which means that passing an array actually copies all of it, including the memory that holds the data.
package main
import (
"fmt"
"strings"
)
func main() {
var s string
pp := strings.Split(s, ",")
// Prints 1 true
fmt.Println(len(pp), pp[0] == "")
}This one follows from the strings.Split() documentation:
If s does not contain sep and sep is not empty, Split returns a slice of length 1 whose only element is s.
package main
import "fmt"
type MyError struct{}
// Implements the error interface
func (me MyError) Error() string {
return "my error"
}
func main() {
var myErr *MyError
var err error = myErr
// Prints true
fmt.Println(myErr == nil)
// Prints false
fmt.Println(err == nil)
// Prints true
fmt.Println(err.(*MyError) == nil)
}Explained in the Go FAQ.
Also, this post by a Go developer provides more context:
... we, probably incorrectly, chose to use the same identifier,
nil, to represent the zero value both for pointers and for interfaces. ... If we do change something in this area for Go 2, which we probably won't, my preference would definitely be to introducenilinterfacerather than to further confuse the meaning ofr != nil.
package main
func main() {
type MyMap1 map[int]int
type MyMap2 map[int]int
var m1 MyMap1
var m2 MyMap2
var m map[int]int
// Works
m1 = m
// Causes compile-time error
m1 = m2
type MyInt int
var d1 MyInt
var d2 int
// Causes compile-time error
d1 = d2
// Avoid "declared but not used" errors
_, _ = m1, d1
}m1 = m works because it falls within the
assignability rules. In this
case, they both have identical underlying types and at least one of
them (m) does not have a named type.
m1 = m2 causes a compilation error despite the identical underlying
types, because they both have defined types.
Finally, d1 = d2 causes an error because int is also a named
type—predeclared types are considered named types according to
the spec, which also mentions this:
To avoid portability issues all numeric types are defined types...
package main
import (
"fmt"
)
func main() {
// Prints foo
fmt.Println(testFunc())
}
func testFunc() string {
result := "foo"
defer func() {
result = "bar"
}()
return result
}According to the spec:
... if the surrounding function returns through an explicit return statement, deferred functions are executed after any result parameters are set by that return statement but before the function returns to its caller
However:
... if the deferred function is a function literal and the surrounding function has named result parameters that are in scope within the literal, the deferred function may access and modify the result parameters before they are returned
Which means that the following code works as expected:
package main
import (
"fmt"
)
func main() {
// Prints bar
fmt.Println(testFunc())
}
func testFunc() (result string) {
result = "foo"
defer func() {
result = "bar"
}()
return result
}package main
import (
"fmt"
"sync"
)
func main() {
defer func() {
if err := recover(); err != nil {
fmt.Println("Caught:", err)
}
}()
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Won't be caught
panic("spawned goroutine panic")
}()
wg.Wait()
}recover() has to be called on the panicking goroutine in order to
stop the panicking. This one can be easy to miss, but it follows
directly from the spec.
With reader/writer mutexes, there are two opposing
priority
policies. Which one does sync.RWMutex implement?
According to the doc:
If any goroutine calls Lock while the lock is already held by one or more readers, concurrent calls to RLock will block until the writer has acquired (and released) the lock, to ensure that the lock eventually becomes available to the writer.
However, at the time of this writing (go1.22), it doesn't appear to be the whole story. Consider the following example:
package main
import (
"fmt"
"sync"
"time"
)
const (
write = "write"
read = "read"
)
var mtx sync.RWMutex
var wg sync.WaitGroup
func runWorker(num int, mode string) {
go func() {
// Order lock requests by their consecutive number.
time.Sleep(time.Duration(num*10) * time.Millisecond)
fmt.Printf("worker %d requesting lock (for %s)\n", num, mode)
if mode == write {
mtx.Lock()
} else {
mtx.RLock()
}
fmt.Printf("worker %d acquired lock\n", num)
time.Sleep(100 * time.Millisecond)
if mode == write {
mtx.Unlock()
} else {
mtx.RUnlock()
}
fmt.Printf("worker %d released lock\n", num)
wg.Done()
}()
}
func main() {
wg.Add(4)
runWorker(1, read)
runWorker(2, write)
runWorker(3, write)
runWorker(4, read)
wg.Wait()
}The output is:
worker 1 requesting lock (for read)
worker 1 acquired lock
worker 2 requesting lock (for write)
worker 3 requesting lock (for write)
worker 4 requesting lock (for read)
worker 1 released lock
worker 2 acquired lock
worker 2 released lock
worker 4 acquired lock
worker 4 released lock
worker 3 acquired lock
worker 3 released lock
Indeed, the first blocked write lock request (in worker 2) results in
the following read request getting blocked too, despite the fact that
the mutex is locked for reading. And once the lock is released, the
writer acquires it before the reader. This would appear to confirm
that sync.RWMutex is write-preferring.
However, when the writer is done with the mutex, the remaining blocked writer takes the back seat to the blocked reader (i.e. the reader gets to acquire the lock first).
This suggests that sync.RWMutex is write-preferring when locked for
reading and read-preferring when locked for writing.
Closed channels can be read from even when empty. Reading from such a channel will succeed immediately, returning the zero value of the underlying type:
package main
import (
"fmt"
"time"
)
func main() {
// Generate a series of ints
cInts := make(chan int)
go func() {
for i := 0; i < 3; i++ {
cInts <- i
time.Sleep(time.Second)
}
// Will cause the reader in the select to be spammed with zeroes
close(cInts)
}()
// Generate a series of floats
cFloats := make(chan float32)
go func() {
for i := 0; i < 5; i++ {
cFloats <- float32(i)
time.Sleep(time.Second)
}
// Same problem as `close(cInts)`
close(cFloats)
}()
for {
select {
case d := <-cInts:
fmt.Println(d)
case f := <-cFloats:
fmt.Printf("%f\n", f)
}
}
}Detecting this involves checking the additional boolean result which
is set to false if the channel is closed and empty. We can also
prevent unnecessary work by setting the channel to nil as soon as we
detect that it's closed (and empty). Since communication on nil
channels can never proceed, the select will skip the channel in the
future iterations of the loop.
package main
import (
"fmt"
"time"
)
func main() {
// Generate a series of ints
cInts := make(chan int)
go func() {
for i := 0; i < 3; i++ {
cInts <- i
time.Sleep(time.Second)
}
close(cInts)
}()
// Generate a series of floats
cFloats := make(chan float32)
go func() {
for i := 0; i < 5; i++ {
cFloats <- float32(i)
time.Sleep(time.Second)
}
close(cFloats)
}()
for {
select {
case d, ok := <-cInts:
if ok {
fmt.Println(d)
} else {
cInts = nil
}
case f, ok := <-cFloats:
if ok {
fmt.Printf("%f\n", f)
} else {
cFloats = nil
}
default:
if cInts == nil && cFloats == nil {
return
}
}
}
}Empty struct values (struct{}{}) don't take any memory space and are
useful when presence of a value is needed but the actual value doesn't
matter. For example, when implementing sets using maps:
package main
import (
"fmt"
"unsafe"
)
func main() {
set := make(map[string]struct{})
set["foo"] = struct{}{}
if _, ok := set["foo"]; ok {
fmt.Println("foo exists")
}
if _, ok := set["bar"]; !ok {
fmt.Println("bar doesn't exist")
}
// Prints 0
fmt.Println(unsafe.Sizeof(set["foo"]))
}Dave Cheney's article gives more examples of empty struct usage.