Go, I love you, but you're bringing me down.
Charles Francoise
Posted on July 24, 2017
I've said it before, and I'll say it again: I really like Go. It's the most fun I've had learning a language since Python. It's statically typed and compiled – and as a systems-oriented programmer, I never found anything that could really move me away from C because of this – it has a great standard library, it handles code reuse without inheritance beautifully, and those concurrency paradigms have changed my life for ever.
That being said, I quickly had a few issues with some of the language's specifics. And while I've been trying to learn its patterns and conventions as best I can, I'm pretty certain that some of them could be fixed inside the language, and not by using a specific pattern or abstraction. For the sake of simplicity.
As I was reading Russ Cox's Gophercon 2017 talk about Go 2, I decided to take him up on this:
We need your help.
Today, what we need most is experience reports. Please tell us how Go is working for you, and more importantly not working for you. Write a blog post, include real examples, concrete detail, and real experience. That's how we'll start talking about what we, the Go community, might want to change about Go.
So here are, as a new Go programmer, my 2 cents about what's missing.
Operator overloading
Got your attention? Good. Because I hate operator overloading. I've been through too many C++ code bases where developers thought it was better to be clever than to write a clean interface. I've had my share of over-enthusiastic coders re-defining fundamental semantics such as the dot(.
), arrow(->
) or call(()
) operators to do very unexpected things. The cost in documentation and on-boarding new developers was huge, and the mental work of switching paradigms when coding made the whole thing quite error-prone. The fact that the stdlib does weird things pretty freely (std::cout << "Hello, World!"
, anyone?) doesn't really help.
But operators are also a great shorthand for some clearly defined semantics. In other words, if semantics precede the operator, there's a far lesser chance to abuse them. An example would be the []
operator for integer-based indexing (such as arrays or slices), or for key-value operations (such as maps). Or the usage of the range
keyword to iterate over arrays, slices, maps and channels.
One way to make sure these semantics are well respected would be to make the operators as syntactic sugar to well-defined interfaces.
Key-value operator:
struct KeyValueMap interface {
Value(interface{}) interface{}
SetValue(interface{}, interface{})
}
We could then use the []
operator as a shorthand to these functions.
foo := keyVal["bar"]
would be equivalent to
foo := keyVal.Value("bar")
The objects used as key would need to be hashable or comparable in some way. Perhaps the compiler could enforce the same rules it does with maps.
Range iteration could be done similarly:
type Iterator interface {
Next() (interface{}, bool)
Reset()
}
Next
would return the next value in the iteration, and a bool
that would be true
when the iterator is finished. Reset
would be used to initialize the iterator at the beginning of the for
loop.
for v := range it {
would be equivalent to
it.Reset()
for !done {
i, done := it.Next()
if done {
break
}
I think this would allow Go developers to implement much more go-ish structures and I can see many cases of memory-efficient iterators that would make use of this clean syntax.
Of course, this kind of breaks the strong typing in Go. Maybe a feature like this would go hand-in-hand with generics?
Initialization of embedded interfaces
We recently had a case of an issue that took us a while to debug. We were using type embedding to compose different services in one structure. At first we had just one handler.
type FooHandler interface {
// some `FooHandler` methods
}
type Handler struct {
FooHandler
}
func main() {
// Foo is a concrete type implementing FooHandler
h := Handler{FooHandler: &Foo{}}
RegisterHandler(h)
We embedded a second handler, but forgot to add it in the Handler
initialization. The error message was pretty cryptic.
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x20 pc=0x10871d6]
goroutine 1 [running]:
main.RegisterHandler(0x1108140, 0x1126ac8, 0x0, 0x0)
This was fixed by adding the initialization.
h := Handler{FooHandler: &Foo{}, BarHandler: &Bar{}}
I wish the error message would have been clearer. Such a simple omission shouldn't require 30 minutes and two programmers. Maybe we're just new at Go, but I wish the error would have been more explicit. That or the compiler could warn against using zero values for embedded types. I don't know if there's a case where you would want to embed a nil
value. This would probably be a breaking change for a few code bases, but the benefit in memory safety could be worth it.
Closed channels
EDIT: The issue I detailed here was only because of a feature of the language I didn't know of. It was pointed out in the comments and on Twitter (and here). I'll be showing Go way to avoid the problem I had.
In the following program, the final loop loops forever while receiving zero values from both channels, ca
and cb
.
func main() {
// Create a first channel
ca := make(chan int)
go func() {
// Send one integer over the channel every millisecond up to 9
for i := 0; i < 10; i++ {
ca <- i
time.Sleep(1 * time.Millisecond)
}
close(ca)
}()
// Create a second channel
cb := make(chan int)
go func() {
// Send one integer over the channel every millisecond up to 99
for i := 0; i < 100; i++ {
cb <- i
time.Sleep(1 * time.Millisecond)
}
close(cb)
}()
// Receive from the first available channel and loop
for {
select {
case n := <-ca:
fmt.Printf("a%d ", n)
case n := <-cb:
fmt.Printf("b%d ", n)
}
}
}
This problem can be fixed by reading two values from the channel. By using n, ok := <-ca
, ok
will be false
if the channel is closed. Once the channel has been closed, we set it to nil
as receiving from a nil
channel blocks.
// Read from first available channel and loop
// until both channels are nil
for ca != nil || cb != nil {
select {
case n, ok := <-ca:
if ok {
fmt.Printf("a%d ", n)
} else {
// If the channel has been closed, set it to nil
// Receiving from a nil channel blocks, so we know
// this select branch will be unreachable after this
ca = nil
}
case n, ok := <-cb:
if ok {
fmt.Printf("b%d ", n)
} else {
cb = nil
}
}
}
I'm still pretty new to Go, so I'm fairly certain these are either misconceptions on my part, or things that have been tried and abandoned because they didn't work. Feel free to give me any documentation or pointers in the comments.
Posted on July 24, 2017
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.