Review: Golang
I just started writing a real project in Go. Previously, I had done some simple tutorials, and sent a (very) small patch to an open-source project written in Go. I was really impressed with how practical, simple, and usable the language seemed, and I had told several friends that I was really interested in learning it.
Now that I'm a week into writing, the honeymoon phase is definitely over; Go has some incredibly weird footguns and strange behaviors that seem completely antithetical to it's policy of simplicity and usability.
The %x Debacle
Consider this example code:
package main
import "fmt"
type Enum int32
const (
ENUM0 Enum = iota
ENUM1
// ...
)
func (enum Enum) String() string {
switch enum {
case ENUM0: return "Enum(0)"
case ENUM1: return "Enum(1)"
default: return ""
}
}
func main() {
fmt.Printf("Enum: %s\n", ENUM0)
fmt.Printf("Enum: %d\n", ENUM0)
fmt.Printf("Enum: %x\n", ENUM0)
}
Looking at this I would immediately expect the following output:
$ go build main.go
$ ./main
Enum: Enum(0)
Enum: 0
Enum: 0x0
Instead, you get this:
Enum: Enum(0)
Enum: 0
Enum: 456e756d283029
I'm not 100% sure what's happening here but it appears that %x
causes fmt.Printf
to call Enum.String()
. You can confirm this by removing the definition of String()
and see that now everything works as expected. I don't know what shenanigans are happening behind the scenes but the behavior is unintuitive at best and dangerous at worse.
Function receivers
Go allows you to define functions on many types, which is pretty cool and useful:
func (en *Enum) foo() {
fmt.Println("Hello, world!")
}
func main() {
v := ENUM1
v.foo() // Prints "Hello, world!"
}
Even though v
is not a pointer, Go is smart enough to determine that we can call foo()
binding en
to the address of v
. (Or at least that's how I think it works.) However there are many unintuitive consequences of this:
For a shockingly long time I thought the there wasn't a real difference between pointer and object receivers. For someone new to the language, it doesn't seem like there is. THERE IS! If you define an object receiver it can copy the object being called. This is very bad if some other function
func (obj YourStruct) bar()
mutates the received object because the object will be essentially copied and thrown out. I had several bugs related to something along the lines of callingobj.setValue(10)
and then sometime later in the code,if obj.getValue() != 0 { ... }
blows up because it didn't actually change the object the function was called on. It was confusing and the only way I figured it out was spam-printing memory addresses to confirm that the two structs were not the same.It would seem intuitive that you could add (not override) functions to types defined externally. You can't! I guess I could see how this might be dangerous if you are writing a library, but it would be very useful.
Some library interfaces only define functions with object receivers. If you need to implement one of those interfaces, you have to copy the object, which sucks.
Slices
Similar to the receivers, slices caused a ton of memory-related bugs in my programs:
// ...
type MyStruct struct { a int }
func main() {
objs := make([]MyStruct, 3)
for ii := range 3 {
fmt.Printf("ii: %d\n", ii)
objs[ii] = MyStruct{ii}
fmt.Printf("Value of .a: %d\n", objs[ii].a)
}
// Some time later...
for _, st := range objs {
st.a *= 2
}
// And yet later ...
fmt.Printf("Value of obs[2].a: %d\n", objs[2].a)
}
$ go build main.go
$ ./main
Value of obs[2].a: 2
WHAT is THAT?! It's because range
copies the slice it is iterating over.
Worse yet, if you try to write a traditional for <init>; <condition>; <update>
loop, gopls will fuss at you and tell you to use a 'modernized' for-loop with the range
syntax. It's pushing people towards unintuitive behavior.
I even had trouble with iterating over slices of pointers, which in my mind should solve the problem but didn't. I am still not 100% sure what caused these bugs, but I ended up changing every for-loop in the code to look something like this:
for index := 0; index < len(objs); index++ {
someFuncThatTakesPointers(&objs[index])
}
Operating on slices is not very fun. If you search Google for 'how to delete from the middle of a slice in Go', you will get this code:
objs = append(objs[:index], objs[index + 1:]...)
Which is hideous and easy to mess up. The modern way is to use slices.Delete
which is, well essentially the same thing, and has an unintuitive second parameter, and since Go does not support optional arguments, you can't "hide" that extra complexity.
Unintuitive type casting syntax
Maybe I'm just too noob to understand, but this really got to me:
type MyType int32
type Iface interface {
// ...
}
type Iface2 interface {
// ...
}
func foo(st Iface) {
// OK
st2 := st.(Iface2)
// Not OK, which makes sense if you deep-dive but is unintuitive
tmp := MyStruct{1}
st3 := tmp.(Iface2)
// Also not OK, which is even less intuitive
st4 := (&tmp).(Iface2)
// Not OK, which makes no sense
var val int32 = 123
val2 := val.(MyType)
// OK, but why this syntax?...
val3 := MyType(val)
}
Other random drive-bys
- You cannot (should not?) have a pointer to an object typed as an interface. On second examination this makes sense, but since Go mixes pointers and values in so many other places it seems extremely unintuitive.
- Go does not have a native boolean XOR operator or function. (Again... what?)
- You cannot cast a
bool
to a numeric type. (I had a discussion with some friends and some people thought this was a good thing, I still dont' see how.) - There doesn't seem to be a native implementation of
map
,filter
, etc. over slices.
Conclusion
Go has a lot of things going for it, but the decisions here seem arbitrary. You could argue that Google wanted to reduce complexity and make the language simple and intuitive, but many of these choices result in more boilerplate code, and long comments explaining why it works differently here than in other languages. Maybe if I stick with the language this stuff will start to seem normal to me but right now it seems like a huge thorn in my side.