So as many of you may know, I really like Rust and have been programming in it for nearly a year now.
Recently, for a course I had to use Go. This was an interesting opportunity; Rust and Go have been compared a lot as the "hot new languages", and finally I'd get to see the other side of the argument.
Before I get into the experience, let me preface this by mentioning that Rust and Go don't exactly target the same audiences. Go is garbage collected and is okay with losing out on some performance for ergonomics; whereas Rust tries to keep everything as a compile time check as much as possible. This makes Rust much more useful for lower level applications.
In my specific situation, however, I was playing around with distributed systems via threads (or goroutines), so this fit perfectly into the area of applicability of both languages.
This post isn't exactly intended to be a comparison between the two. I understand that as a newbie at Go, I'll be trying to do things the wrong way and make bad conclusions off of this. My way of coding may not be the "Go way" (I'm mostly carrying over my Rust style to my Go code since I don't know better); so everything may seem like a hack to me. Please keep this in mind whilst reading the post, and feel free to let me know the "Go way" of doing the things I was stumbling with.
This is more of a sketch of my experiences with the language, specifically from the point of view of someone coming from Rust; used to the Rusty way of doing things. It might be useful as an advisory to Rustaceans thinking about trying the language out, and what to expect. Please don't take this as an attack on the language.
What I liked
Despite the performance costs, having a GC at your disposal after using Rust for very long is quite liberating. For a while my internalized borrow checker would throw red flags on me tossing around data indiscriminately, but I learned to
ignore it as far as Go code goes. I was able to quickly share state via pointers without worrying about safety, which was quite useful.
Having channels as part of the language itself was also quite ergonomic.
data <- chan and chan <- data syntax is fun to use, and whilst it's not very different from
.send() and
.recv() in Rust, I found it surprisingly easy to read. Initially I got confused often by which side the channel was, but after a while I got used to it. It also has an in built
select block for selecting over channels (Rust has a macro).
gofmt. The Go style of coding is different from the Rust one (tabs vs spaces, how declarations look), but I continued to use the Rust style because of the muscle memory (also too lazy to change the settings in my editor).
gofmt made life easy since I could just run it in a directory and it would fix everything. Eventually I was able to learn the proper style by watching my code get corrected. I'd love to see a
rustfmt, in fact, this is one of the
proposed Summer of Code projects under Rust!
Go is great for debugging programs with multiple threads, too. It can detect deadlocks and post traces for the threads (with metadata including what code the thread was spawned from, as well as its current state). It also posts such traces when the program crashes. These are great and saved me tons of time whilst debugging my code (which at times had all sorts of cross interactions between more than ten goroutines in the tests). Without a green threading framework, I'm not sure how easy it will be to integrate this into Rust (for debug builds, obviously), but I'd certainly like it to be.
Go has really great
green threads goroutines. They're rather efficient (I can spawn a thousand and it schedules them nicely), and easy to use.
Edit: Andrew Gallant reminded me about Go's testing support, which I'd intended to write about but forgot.
Go has really good in built support and tooling for tests (Rust does too). I enjoyed writing tests in Go quite a bit due to this.
What I didn't like
Sadly, there are a lot of things here, but bear in mind what I mentioned above about me being new to Go and not yet familiar with the "Go way" of doing things.
No enums
Rust has enums, which are basically tagged unions. Different variants can contain different types of data, so we can have, for example:
enum Shape {
Rectangle(Point, Point),
Circle(Point, u8),
Triangle(Point, Point, Point)
}
and when matching/destructuring, you get type-safe access to the contents of the variant.
This is extremely useful for sending typed messages across channels. In this model. For example, in Servo we use such an enum for sending
details about the progress of a fetch to the corresponding XHR object.
Another such enum is used for communication between the constellation and the compositor/script.
This gives us a great degree of type safety; I can send messages with different data within them, however I can only send messages that the other end will know how to handle since they must all be of the type of the message enum.
In Go there's no obvious way to get this. On the other hand, Go has the type called
interface {} which is similar to
Box<Any> in Rust or
Object in Java. This is a pointer to any type, with the ability to match on its type. As a Rustacean I felt incredibly dirty using this, since I expected that there would be an additional vtable overhead. Besides, this works for any type, so I can always accidentally send a message of the wrong type through a channel and it'll end up crashing the other end at runtime since it hit a
default: case.
Of course, I could implement a custom interface
MyMessage on the various types, but this will behave exactly like
interface{} (implemented on all types) unless I add a dummy method to it, which seems hackish. This brings me to my next point:
Smart interfaces
This is something many would consider a feature in Go, but from the point of view of a Rustacean, I'm rather annoyed by this.
In Go, interfaces get implemented automatically if a type has methods of a matching signature. So an interface with no methods is equivalent to
interface{}; and will be implemented on all types automatically. This means that we can't define "marker traits" like in Rust that add a simple layer of type safety over methods. It also means that interfaces can only be used to talk of code level behavior, not higher level abstractions. For example, in Rust we have the
Eq trait, which uses the same method as
PartialEq for equality (
eq(&self, &other)), and the behavior of that method is exactly the same, however the two traits mean fundamentally different things: A type implementing
PartialEq has a normal equivalence relation, whilst one that also implements
Eq has a full equivalence relation. From the point of view of the code, there's no difference between their behavior. But as a programmer, I can now write code that only accepts types with a full equivalence relation, and exploit that guarantee to optimize my code.
Again, having interfaces be autoimplemented on the basis of the method signature is a rather ergonomic feature in my opinion and it reduces boilerplate. It's just not what I'm used to and it restricts me from writing certain types of code.
Packages and imports
Go puts severe restrictions on where I can put my files. All files in a folder are namespaced into the same package (if you define multiple packages in one folder it errors out). There's no way to specify portable relative paths for importing packages either. To use a package defined in an adjacent folder, I had to do
this, whereas in Rust (well, Cargo), it is easy to specify relative paths to packages (crates)
like so. The import also only worked if I was developing from within my
$GOPATH, so my code now resides within
$GOPATH/src/github.com/Manishearth/cs733/; and I can't easily work on it elsewhere without pushing and running
go get everytime
.
Documentation
Rust's libraries aren't yet well documented, agreed. But this is mostly because the libraries are still in flux and will be documented once they settle. We even have the awesome
Steve Klabnik working on improving our documentation everywhere. And in general caveats are mentioned where important, even in unstable libraries.
Go, on the other hand, has stable libraries, yet the documentation seems skimpy at places. For example, for the methods which read till a delimiter in
bufio, it was rather confusing if they only return what has been buffered till the call, or block until the delimiter is found. Similarly, when it comes to I/O, the blocking/non-blocking behavior really should be explicit; similar to what
Sender and
Receiver do in their documentation.
Generics
This is a rather common gripe -- Go doesn't have any generics aside from its builtins (chans, arrays, slices, and maps can be strongly typed). Like my other points about enums and interfaces, we lose out on the ability for advanced type safety here.
Overall it seems like Go doesn't really aim for type safe abstractions, preferring runtime matching of types. That's a valid choice to make, though from a Rust background I'm not so fond of it.
Visibility
Visibility (public/private) is done via the capitalization of the field, method, or type name. This sort of restriction doesn't hinder usability, but it's quite annoying.
On the other hand, Rust has a keyword for exporting things, and whilst it has style recommendations for the capitalization of variable names (for good reason -- you don't want to accidentally replace an enum variant with a wildcard-esque binding in a match, for example), it doesn't error on them or change the semantics in any way, just emits a warning. On the other hand, in Go the item suddenly becomes private.
Conclusion
A major recurring point is that Go seems to advocate runtime over compile time checking, something which is totally opposite to what Rust does. This is not just visible in library/language features (like the GC), but also in the tools that are provided — as mentioned above, Go does not give good tools for creating type safe abstractions, and the programmer must add dynamic matching over types to overcome this. This is similar (though not the same) as what languages like Python and Javascript advocate, however these aren't generally interpreted, not compiled (and come with the benefits of being interpreted), so there's a good tradeoff.
Go isn't a language I intend to use for personal projects in the near future. I liked it, but there is an overhead (time) to learning the "Go way" of doing things, and I'd prefer to use languages I already am familiar with for this. This isn't a fault of the language, it's just that I'm coming from a different paradigm and would rather not spend the time adjusting, especially since I already know languages which work equally well (or better) to its various fields of application.
I highly suggest you at least try it once, though!