Written by Jon Bodner, Learning Go: An Idiomatic Approach to Real-World Go Programming is, in my opinion, one of the best resources there is to learn Go, particularly if you are somewhere around the intermediate stage with a couple of years under your belt. From the basic topics covered at the start all the way to cgo
, reflection, and unsafe
, this book covers it all, complete with tips on writing idiomatic code. I can guarantee this book will leave you with strongly reinforced learnings and new knowledge.
The book came out right before Go 1.18 was released (mayor feature being generics), but everything contained in this book still holds beautifully.
I highly recommend this book for those that value Go as a professional tool and are serious about mastering it.
Check out some other books I’ve read on the bookshelf.
Summary
Learning Go is a complete treasure trove of learnings about everything related to Go: its syntax, standard tools, standard APIs, common idioms, common sources of bugs, great insight into the reasons for the design of some of Go’s APIs and operators, and ending with advanced topics such as cgo
and unsafe
. No prior knowledge of Go is required from the reader; given prior exposure to other similar programming languages, this book will effortlessly take the reader from zero to hero.
Some of the things I liked
Learning Go exposes the early adopter to common sensible idioms used throughout the ecosystem.
The comma-OK idiom
(page 54)
Simple way to differentiate between a type’s zero value and its absence, usually as return values from some sort of API (typically a map).
1
2
3
4
5
6
v, ok := myMap["key"]
if !ok {
// handle key not found
}
// handle value
Note that if your API may return an error for other reasons, then it’s better to use a sentinel error:
1
2
3
4
5
6
7
8
9
10
11
12
var ErrNotFound = errors.New("my sentinel error")
v, err := myAPI.Get("key")
if errors.Is(ErrNotFound) {
// handle key not found
}
if err != nil {
// handle other error
}
// handle value
Lastly, the comma-OK idiom is implemented by channels (to differentiate between the zero-value and a closed channel) and type assertions (to know whether the assertion is true).
Left-aligned, short if
statement bodies
(page 70)
Avoid deeply nested structures:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// BAD
if i%3 == 0 {
if i%5 == 0 {
return "FizzBuzz"
} else {
return "Fizz"
}
} else if i%5 == 0 {
return "Buzz"
} else {
return fmt.Sprint(i)
}
// GOOD
if i%3 ==0 && i%5 == 0 {
return "FizzBuzz"
}
if i%3 == 0 {
return "Fizz"
}
if i%5 == 0 {
return "Buzz"
}
return fmt.Sprint(i)
In addition, I endorse “The Happy path is left-aligned” even when using the comma-OK idiom:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// not great
if v, ok := myMap["key"]; ok {
// handle value
} else {
// handle key not found
}
// better
v, ok := myMap["key"]
if !ok {
// handle key not found
}
// handle value
The improvement in readability due to increased line-of-sight (see linked article) is worth the slight increase in number of lines in my opinion.
Types are executable documentation
(page 136)
User-defined types add clarity by exposing the concept represented by a given value. Imagine functional options without a custom type:
1
2
3
func DoSomething(ctx context.Context, key string, opts ...func(*Config)) error {
// do something
}
This API’s clarity can be enhanced as follows:
1
2
3
4
5
type Option func(*Config)
func DoSomething(ctx context.Context, key string, opts ...Option) error {
// do something
}
But user-defined types can do much more than this. Consider http.HandlerFunc:
1
2
3
4
5
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
In this case, the type serves as an adapter for user-provided functions that meet the signature requirements. This frees the user from having to define a whole struct
type just to implement the one ServeHTTP
method:
1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
mux := http.NewServeMux()
mux.Handle("/foo", http.HandlerFunc(handleFoo)) // just cast the function to http.HandlerFunc
err := http.ListenAndServe(":8080", mux)
if err != nil {
log.Fatal(err)
}
}
func handleFoo(w http.ResponseWriter, r *http.Request) {
// handle foo
}
One other advantage of user-defined types is that they have the potential to stop being simple data structures or primitives and start having useful behaviour. Consider the following example:
1
2
3
4
5
6
7
8
9
10
11
12
func main() {
percentage := 0.2
subtotal := 29.5
ApplyDiscount(subtotal, percentage)
fmt.Printf("applied %.0f%%\n", percentage*100)
}
func ApplyDiscount(subtotal, percentage float64) {
// do something
}
Let’s make “percentage” more useful as a first-class concept:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Percentage int
func (p Percentage) String() string {
return fmt.Sprintf("%d%%", p)
}
func (p Percentage) Float() float64 {
return float64(p / 100)
}
func main() {
var percentage Percentage = 20
subtotal := 29.5
ApplyDiscount(subtotal, percentage)
fmt.Printf("applied %s\n", percentage)
}
func ApplyDiscount(subtotal float64, p Percentage) {
// do something
}
Another example - an in-memory datastore useful for tests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Store[K comparable, V any] interface {
Get(k K) (V, error)
Put(k K, v V) error
}
type mockStore[K comparable, V any] map[K]V
func (m mockStore[K, V]) Get(k K) (V, error) {
return m[k], nil
}
func (m mockStore[K, V]) Put(k K, v V) error {
m[k] = v
return nil
}
sync.Map Is Not The Map You Are Looking For
(page 240)
I generally find Go’s community and (in some cases) its documentation rather dogmatic and prescriptive when compared to others. The practical effect of this in the real world is it tends to lead the novice/mid-level engineer to the incorrect conclusion that a certain API or pattern is the best fit for their use case. sync.Map is one of those cases where the documentation generally leads engineers to suboptimal solutions in terms of performance1:
Map is like a Go map[interface{}]interface{} but is safe for concurrent use by multiple goroutines without additional locking or coordination. Loads, stores, and deletes run in amortized constant time.
The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content.
The Map type is optimized for two common use cases: (1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys. In these two cases, use of a Map may significantly reduce lock contention compared to a Go map paired with a separate Mutex or RWMutex.
The first paragraph informs us that this map is safe for concurrent reads and writes. This map has had type parameters ever since generics were introduced in Go 1.18, so this line is outdated.
The second paragraph recommends use of the plain map with more common synchronization primitives (locks, channels). One of the reasons for this recommendation - better type safety - is now outdated. It’s a rather short paragraph.
The third paragraph is longer and is the one most likely to mislead the novice/mid-level engineer: as an authoritative source, it gives the impression that if your use case matches the two enumerated there then you should use sync.Map
without further consideration. The second paragraph’s recommendation is usually brushed away after reading this one.
A good engineer should have further considerations before deciding on whether to use sync.Map
:
- Are stampedes a concern?
- Are external services impacted when populating the cache?
- How large is the cache expected to grow?
- How frequently are cache entries added?
- Are cache entries ever updated after being added?
- How expensive is it to create entries for the cache and how does it stack against the 40ns it takes to transfer L2 caches between CPUs as per the original author of
sync.Map
? How many cores do your nodes have?
And I’m sure there are more.
In my experience, caches are usually held in memory somewhere and implemented because the cached data is “expensive” to create. Given this, scenario (1) is always served more optimally with judicious use of sync.RWMutex
and a plain map to protect against stampedes (a frequent concern), or with a plain map and atomic.Pointer
to implement a read-copy-update scheme if the cache is updated infrequently.
I have yet to come across scenario (2), but some of those questions would still apply.
In conclusion, I think sync.Map
is overused and I also think Go’s API documentation should limit its prescriptive language and just state the facts of how its APIs operate.
Avoid APIs that depend on exposed package-level state
(page 251)
“Avoid APIs that depend on exposed package-level state” is my key takeaway from the book:
There are package-level functions, http.Handle, http.HandleFunc, […] Don’t use them outisde of trivial test programs. […] Furthermore, third-party libraries could have registered their own handlers with the
http.DefaultServeMux
and there’s no way to know without scanning through all of your dependencies (both direct and indirect). Keep your application under control by avoiding shared state.
The following example illustrates the point.
Your code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import (
"net/http"
"scratchpad/global/helper"
)
func main() {
http.Handle("/myAPI", http.HandlerFunc(myHandler))
}
func myHandler(w http.ResponseWriter, r *http.Request) {
// read request
// do awesome stuff
result := "success " + helper.DoSomethingHelpful()
_, _ = w.Write([]byte(result))
}
What your “awesome” helper is doing:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package helper
import "net/http"
func init() {
http.DefaultServeMux.Handle("/secrets", http.HandlerFunc(exposeSecrets))
}
func DoSomethingHelpful() string {
return "great work done here"
}
func exposeSecrets(w http.ResponseWriter, r *http.Request) {
// expose all your secrets from env vars, local filesystem, etc.
}
Some of the things I learned
A selection of some of the most interesting or surprising things I learned about Go.
Complex numbers
(page 24)
Go supports complex numbers. You can perform arithmetic operations on them, and they are comparable (eg. can be used as keys in a map). The following example prints (4+6i)
and a
:
1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
a := complex(1, 2)
b := complex(3, 4)
c := a + b
fmt.Println(c)
m := map[complex128]string{
a: "a",
b: "b",
}
fmt.Println(m[a])
}
Struct conversion
(page 59)
Anonymous structs are interchangeable if their fields align perfectly and are comparable:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func main() {
alice := struct {
Name string
Age int
}{
Name: "Alice",
Age: 30,
}
garfield := struct {
Name string
Age int
}{
Name: "Garfield",
Age: 2,
}
garfield = alice
fmt.Printf("%+v\n", garfield)
}
// Output:
// {Name:Alice Age:30}
This is particularly useful when populating third-party structs with fields that are anonymous structs. Instead of having to do this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type Config struct {
Simple1 string
Simple2 string
Composite1 struct {
Name string
Date time.Time
Value int
}
Composite2 struct {
Name string
Date time.Time
Value int
}
}
func main() {
conf := Config{
Simple1: "one",
Simple2: "two",
Composite1: struct {
Name string
Date time.Time
Value int
}{
Name: "alice",
Date: time.Now(),
Value: 1,
},
Composite2: struct {
Name string
Date time.Time
Value int
}{
Name: "bob",
Date: time.Now(),
Value: 4,
},
}
fmt.Printf("%+v\n", conf)
}
You can save a few lines by declaring an anonymous struct that matches the structure of the composite fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
type Config struct {
Simple1 string
Simple2 string
Composite1 struct {
Name string
Date time.Time
Value int
}
Composite2 struct {
Name string
Date time.Time
Value int
}
}
func main() {
type composite struct {
Name string
Date time.Time
Value int
}
conf := Config{
Simple1: "one",
Simple2: "two",
Composite1: composite{
Name: "alice",
Date: time.Now(),
Value: 1,
},
Composite2: composite{
Name: "bob",
Date: time.Now(),
Value: 4,
},
}
fmt.Printf("%+v\n", conf)
}
The Universe Block
(page 65)
I was surprised to learn that what I thought were special keywords that could not be used anywhere in code are actually predeclared identifiers in the universe block: the block in which all code is in scope. Because they are mere (predeclared) identifiers and not keywords, they can be shadowed just like any other identifier:
1
2
3
4
5
6
7
8
9
10
func main() {
nil := 1
fmt.Println(nil)
append := "append"
fmt.Println(append)
}
// Output:
// 1
// append
Pointer Receivers vs Value Receivers
(page 132)
Go considers both pointer and value receiver methods to be in the method set for a pointer. For a value instance, only the value receiver methods are in the method set.
The practical effect of this is that one cannot assign a value type to a variable of an interface type if the former does not have methods with value-type receivers that implement the interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Greeter interface {
Greet()
}
type Runner interface {
Run()
}
type Athlete struct{}
func (a *Athlete) Greet() {
fmt.Println("Hello there!")
}
func (a Athlete) Run() {
fmt.Println("I am running!")
}
func main() {
var a Greeter = &Athlete{}
var b Greeter = Athlete{} // compile error!
var c Runner = &Athlete{}
var d Runner = Athlete{}
}
I had never given much though to this distinction and now got curious - why? The Golang FAQ has an answer:
This distinction arises because if an interface value contains a pointer *T, a method call can obtain a value by dereferencing the pointer, but if an interface value contains a value T, there is no safe way for a method call to obtain a pointer. (Doing so would allow a method to modify the contents of the value inside the interface, which is not permitted by the language specification.)
Even in cases where the compiler could take the address of a value to pass to the method, if the method modifies the value the changes will be lost in the caller. As an example, if the Write method of bytes.Buffer used a value receiver rather than a pointer, this code:
1 2 var buf bytes.Buffer io.Copy(buf, os.Stdin)would copy standard input into a copy of buf, not into buf itself. This is almost never the desired behavior.
The second reason is fairly easy to understand: if bytes.Buffer had a value receiver for its Write method, then io.Copy
would write the contents of os.Stdin
to a copy of buf
, not the caller’s copy.
The first reason is a bit more esoteric: if value types were allowed to implement interfaces then any method call that modifies the value itself would also modify the interface object itself. Or should they modify a copy made on the fly? What would be the ramifications of that? Does the caller’s reference to the interface object magically update to the new value? Or should the changes be effected on a different copy than the caller’s? Rather than opening up this can of worms, the Go team decided to simplify the mental model by this rule prohibiting this edge case.
Invoking a function with args of type interface will result in a heap allocation
(page 147)
This was not really a new concept to me, but I still had my “duh!” moment as I read this because I never thought to consider this consequence of “Accept Interfaces, Return Structs” (see below):
[…] reducing heap allocations improves performance by reducing the amount of work for the garbage collector. Returning a struct avoids a heap allocation, which is good. However, when invoking a function with parameters of interface types, a heap allocation occurs for each of the interface parameters.
The allocations occur not at the function’s invocation, but before it, wherever the implementations of the interface types were instantiated2.
Of course, the usual caveat applies: write readable, maintainable code first, then optimize if needed.
Alias declarations
(page 189)
I was aware of aliases but never bothered to look closely at them since I haven’t had the need to declare them myself, although I have used some from the standard library plenty of times. Some of these may surprise you:
byte
,rune
, andany
are aliases (see code)os.PathError
,os.FileInfo
,os.FileMode
, andos.DirEntry
are also aliases
The difference between alias declarations and type definitions is the former does not create a new type; it merely creates a new name that can also be used to refer to the type definition.
The following example defines type Person
and an alias to it, Alice
. Outwardly the code seems to define a new method on Alice
, but really the method is attached to Person
and also invokable from a reference to Alice
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Person struct {
name string
}
func (p *Person) Name() string {
return p.name
}
type Alice = Person
func (a *Alice) Greet() string {
return fmt.Sprintf("Hello, my name is %s!", a.name)
}
func main() {
a := &Alice{name: "Alice"}
fmt.Println(a.Name())
p := &Person{name: "Alice"}
fmt.Println(p.Name())
fmt.Println(p.Greet())
}
Before you get any ideas though - there is no way to get around the hard restriction on modifying the structure of types in different packages:
1
2
3
4
5
6
7
import "os"
type ErrSneaky = os.PathError
func (e ErrSneaky) DoEvil() { // error: Cannot define new methods on the non-local type 'fs.PathError'
// do something evil
}
Writing to channels in select statements
(page 211)
Not 100% surprising but then again - I have never come across a use case for this:
Cases in a select statement can include “send statements” (ie. writing to a channel) as well as receiving operations (ie. reading from a channel); a single instance of select
can have both.
Monotonic time
(page 240)
When available, Go internally uses monotonic clocks to calculate durations between two points in Time.
This is a fascinated topic that really deserves an article of its own, so I won’t discuss it here. Dropping a couple of links for those interested:
JSON Decoder
(page 245)
I have been using json.Decoder in my APIs since forever because of the way it collapses two actions into one.
Compare this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func myHandler(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
// handle error
}
request := &MyRequest{}
err = json.Unmarshal(payload, request)
if err != nil {
// handle error
}
// process request
}
to this:
1
2
3
4
5
6
7
8
9
10
func myHandler(w http.ResponseWriter, r *http.Request) {
request := &MyRequest{}
err := json.NewDecoder(r.Body).Decode(request)
if err != nil {
// handle error
}
// process request
}
The benefits and features of json.Decoder
do not stop there though:
It can decode multiple values
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
type USD struct {
Dollars int `json:"dollars"`
Cents int `json:"cents"`
}
func main() {
r := strings.NewReader(`
{"name": "Alice", "age": 32}
{"dollars": 10, "cents": 2}
`)
decoder := json.NewDecoder(r)
person := Person{}
usd := USD{}
err := decoder.Decode(&person)
if err != nil {
panic(err)
}
err = decoder.Decode(&usd)
if err != nil {
panic(err)
}
fmt.Printf("%v\n", person)
fmt.Printf("%v\n", usd)
}
Note that this feature is typically used to decode sequences of objects of the same type in a loop using Decoder.More() as exit condition.
It can be more performant
Followup from above, json.Decoder
only reads the next object or array from the stream and no more3.
Empty struct uses no memory
(page 263)
It’s the reason why the “use-map-as-set” idiom is written like this:
1
set := map[string]struct{}
instead of:
1
set := map[string]*struct{}
One would think that adding an entry to the second case (set["a"] = nil
) would result in less memory usage than the first case (set["a"] = struct{}
). In fact, the opposite is true:
1
2
3
4
5
6
func main() {
var o struct{}
var p *struct{}
fmt.Println(unsafe.Sizeof(o)) // prints 0
fmt.Println(unsafe.Sizeof(p)) // prints 8
}
The reason that a pointer uses 8 bytes of memory (on a 64-bit system) is because that is the space required to store the pointer value itself (not the thing to which it points). Aside from the extra heap allocation used up by the thing the pointer points to, this is another reason why invoking a function with args of type interface may result in more memory use.
The reason that an empty struct uses no memory is because it holds no data and, it being a value-type, it can be stored on the stack without taking up any space (again, because it does not hold any data).
I point you to Dave Cheney’s excellent article on the topic, The empty struct, where he goes the extra mile and covers the implications across several scenarios.
Make functions and structs with reflection
(pages 312-313)
I have hardly ever peeked at the reflect package because reflection is a bad idea.
That being said - I had no idea Go’s reflect
package was capable of instantiating types (I thought it was only good for introspection):
Huh, would you look at that?
Anyway - it’s a fun curiosity, but I’m 99.9% sure I’ll never use any of this4.
Use unsafe.Pointer to convert external binary data
(page 316)
This is one slick trick.
Say we want to read the following bytes off the network: [0 132 95 237 80 104 111 110 101 0 0 0 0 0 1 0]
. Say these bytes correspond to a message with the following structure (in order):
- Value: 4 bytes, an unsigned, big-endian 32-bit int
- Label: 10 bytes, ASCII
- Active: 1 byte, boolean flag
- Padding: 1 byte, because we want everything to fit into 16 bytes
The straightforward implementation of the parser would slice the incoming bytes and assign those to their respective fields:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type Message struct {
Value uint32
Label [10]byte
Active bool
}
func main() {
data := [16]byte{0, 132, 95, 237, 80, 104, 111, 110, 101, 0, 0, 0, 0, 0, 1, 0}
msg := Message{
Value: binary.BigEndian.Uint32(data[:4]), // allocating memory for a pointer to the new slice (same underlying array tho)
Label: *(*[10]byte)(data[4:16]), // ditto. Worst if you use copy().
Active: data[14] == 1,
}
fmt.Printf("Value: %d\n", msg.Value) // 8675309
fmt.Printf("Label: %s\n", msg.Label) // Phone
fmt.Printf("Active: %t\n", msg.Active) // true
}
Those allocations may add up.
What you can do instead is the following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
var isLE bool
func init() {
var x uint16 = 0xFF00
xb := *(*[2]byte)(unsafe.Pointer(&x))
// on a little-endian platform, the bytes will be stored as [00 FF]
// on a big-endian platform, the bytes will be stored as [FF 00]
isLE = xb[0] == 0x00
}
type Message struct {
Value uint32
Label [10]byte
Active bool
}
func main() {
data := [16]byte{0, 132, 95, 237, 80, 104, 111, 110, 101, 0, 0, 0, 0, 0, 1, 0}
msg := *(*Message)(unsafe.Pointer(&data))
if isLE {
msg.Value = bits.ReverseBytes32(msg.Value)
}
fmt.Printf("Value: %d\n", msg.Value) // 8675309
fmt.Printf("Label: %s\n", msg.Label) // Phone
fmt.Printf("Active: %t\n", msg.Active) // true
}
Jon claims the “unsafe” version is twice as performant (benchmarks here), which I don’t doubt.
There are a few things to unpack here:
We use a variable type with a width of 2 bytes (uint16
) to store complementary values in each of the bytes, FF
and 00
, in that order. If the order changes then the platform this code is running on is little-endian.
unsafe.Pointer
unsafe.Pointer can be converted to a pointer value of any other type. This is not possible with other types or pointer values.
BEWARE
unsafe.Pointer
is “unsafe” for a reason. Imagine the following scenario:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func main() { data := [0]byte{} // you received a payload of an unexpected size msg := *(*Message)(unsafe.Pointer(&data)) if isLE { msg.Value = bits.ReverseBytes32(msg.Value) } // The following prints whatever garbage happens to be at the memory addresses // where Value, Label, and Active should be. // This is now a potential vulnerability in the implementation. fmt.Printf("Value: %d\n", msg.Value) // 1884229632 fmt.Printf("Label: %s\n", msg.Label) // ��U@ fmt.Printf("Active: %t\n", msg.Active) // false }
Memory layout
A continuous area of memory is allocated (whether on the heap or on the stack) to hold a struct’s datacitation needed. The fields may or may not themselves reside contiguously due to their certain alignment guarantees that need to be met. Each field naturally has a size (aka. “width”) that corresponds to its type.
Bringing it all together
Because our Message
struct above was designed to occupy exactly 16 bytes of memory (including alignment), an array of 16 bytes can be reinterpreted as an instance of Message
. This reinterpretation is possible by casting the array to an unsafe.Pointer, and then casting the unsafe.Pointer to Message
. This last step is only possible with unsafe.Pointer
.
Go’s memory layout is a great topic for a future article - stay tuned.
Things I am on the fence about
Accept Interfaces, Return Structs
(page 146)
“Accept Interfaces, Return Structs” is a structural pattern first popularized by Jack Lindamood way back in 2016, so it’s been around a while.
I think this pattern is at its strongest when working in big, fast-paced teams with engineers who may yet have not developed their design skills to its full potential. However, it’s just altogether easier to keep diluting a type’s “purpose” by tacking on more and more methods to its API. Not many people are capable of thinking in higher-level, more abstract terms like “Input”, “Output”, “Scalar”, “Func”, etc.. So when you stop to think about it, “accept interfaces, return structs” is at its strongest when another universal best practice is thrown by the wayside: “the bigger the interface, the weaker the abstraction”. And from where I am standing, a “best practice” that only stands strong by weakening another best practice doesn’t net you much at all from a philosophical standpoint.
After years of programming in Go, I still actually do recommend this pattern to other team members, but I admit I am not fully convinced. And it seems no one has time for a nuanced conversation about this.
The other one I can think of is database/sql where the docs for Conn say “Prefer running queries from DB unless there is a specific need for a continuous single database connection”. This leads some engineers to write service logic that receives a *sql.DB including its administrative methods (
SetConnMaxIdleTime
,SetConnMaxLifetime
, etc). ↩Recall that interfaces are composed of the interface’s type and an instance of a type that implements the interface. It’s the latter that usually requires an allocation in the heap because it is usually implemented by a pointer value. ↩
May read a little more than required because the minimum bytes to be read is 512: https://github.com/golang/go/blob/21ff6704bc8efa72abe191263aae938f3c867480/src/encoding/json/stream.go#L146-L169 ↩
Among many others, one downside is it mitigates the compiler’s ability to eliminate dead code ↩