Rust vs Go: A Hands-On Comparison
shuttle.rs
shuttle.rs
I learned both of these simultaneously in two quirky ways.
I learned Go using the "Learn Go with Tests" book https://quii.gitbook.io/learn-go-with-tests/
I learned Rust through a book called "From JavaScript to Rust" https://github.com/vinodotdev/node-to-rust/releases/download...
The thing that stood out the most to me while learning these was their focus on composition over inheritance. Even though I really like C-based languages, I have never liked the inheritance push from the 90s-2000s.
I really like some of the things in Go, but overall I felt I just didn't need it. .NET, Java, etc are more mature with comparable or better capabilities across the board. It's just hard for me to justify Go outside of stylistic preference.
I really, really like Rust but ever since learning it almost 2 years ago, I still have not found a single use case for it. It's partially the maturity of the competition, but it's also that even in HPC, ML/DL, etc I've done just fine with the "legacy" stack.
I would love to hear from people what their thoughts are on this. What are some scenarios where you see these languages thriving? What makes you want to use them? What's the best use case? Would love to evolve my thinking.
And with go embed[1], that single binary can be literally _everything_ needed by the application. Even non-go stuff can be embedded, like for example, a compiled React application.
Long term, I think static binaries win, probably due to advances in WASI/WASM; where the whole execution stack (as we think of it today) is thrown away and replaced.
Not just CLIs, think about prometheus node_exporter or any kind of agent you need to install on a server or deploy to people's workstations or to external customers where you don't have any kind of control.
Single binary, it's definitively one of the killer feature of go.
I think this is very outdated claim: https://www.graalvm.org/
I think JVM specifically has many design issues which allows rust to squeeze good extra mile compared to JVM hpc solution.
First, Go has other deployment options other than Docker or providers that support docker containers. For example, Google App Engine (GAE) has a command line interface very similar to Shuttle that enables deployment and hosting of your Go application. This is probably the best 'apples' to 'apples' comparison.
Go and Rust are very similar in this way because both can be compiled to a statically linked binary with zero runtime dependencies[1]. This is important because both Javascript and Python are interpreted, frequently with run-time dependencies in addition to the interpreter, that can make hosting and deployment more difficult.
Since both languages compile to a statically linked binary, they can be hosted virtually anywhere. Any old linux box or VCS with a modern os should work just fine. Optionally, one can serve from behind Nginx or Caddy for extra functionality.
I've chosen this approach on a side project. It deploys with a simple `make deploy` command that runs tests, creates the static binary, and deploys the build asset over SSH and cycles the systemd service. It can be rolled back with `make rollback` which reverts by simply changing the symlink and restarting the service.
[1] Assumes a modern OS.
When I have a dumb idea (they’re all dumb), depending on what its core features are, I’ll prototype it in Go, TypeScript, or Python. Inevitably I’ll want to or I will actually rebuild it with rust once it’s relatively stable.
It’s like Go, TypeScript, and Python are pencil and rust is ink.
Go is my favourite for strictly back end prototyping, but I don’t love it for processing data. That’s where Python shines. Then sometimes I’ll use a full stack framework like Next or Remix and build a backend there, but transfer it (or important parts of it) to rust eventually.
My favourite deployment is a totally static site with a rust backend. But it’s equally easy with Go, and the initial development with Go is always way faster for me. It’s just the long term maintenance story that I don’t love with Go.
Also, I prefer Rust’s type system quite a bit. It could just be intuitive to me. I do find several teams I’ve worked with on Go projects have not actually understood the Go type system, and they’ve run into pain as a result very regularly.
It's not weird. Weird is "I have a language I use for everything. Quick scripts! Network servers! My shell! Games! Batch processing CSV records from random FTP servers! Everything! Any implication that my language is not the best choice for all these things must be met with maximum hostility!" is the weird thing. Popular, but weird.
A professional programmer should have at a bare minimum a static language, a dynamic scripting language, basic shell familiarity (be it Unix or Powershell, I'm OS agnostic here, but you should be able to use whatever it is), and probably at least some basic SQL of some sort. And as your career advances, it'll only grow from there.
Time to rewrite my shell scripts in rust.
I'm mostly joking but there might be a couple cases where that makes sense for me...
But you need to be doing something seriously funky for it to be worth it and cross into the positive in the cost/benefits analysis. I actually have a service that does this, doing some fairly sophisticated stuff with git repos and what is in them. But it's certainly not something I reach for because Go am best languages for all task.
not needing to learn and master another lang for scripting looks like significant pros to me. I am actually using java for my scripting because of that, which is stranger choice than golang.
why this is necessary?
In my own experience, people who only know one or the other often end up with gaping holes in their skillset. One I see a lot even yea verily here on HN is people who only work with dynamic scripting languages do not realize just how slow their languages are, and pour vast amounts of ultimately wasted effort into speeding their scripting language up when the answer is to stop using them. On the flip side, people who know only a static language, and it's often only one static language, tend to have serious tunnel vision in general about software engineering; I would almost say it's like they don't so much know how to "program" as to drive their IDE in that one language. These people are often the worst about whatever the dogma is in their particular language and will end up driving themselves mindlessly off a cliff, even in their "native" language, when solving problems their dogma doesn't work well for. Example: The thing we've almost all seen with Java codebases that have patterns freaking everywhere, but where they aren't actually doing anything; the factoryfactory that only ever constructs one type of thing in the end, the template code pattern that has only one thing in the code that uses it, etc.
Having at least that much diversity in your language toolkit helps you escape the sort of thing Dijkstra was talking about with how BASIC was ruining people. The effect he was really observing is that in general, if you only have one language, and especially if you spend too much time in that one language before branching out, you tend to start mistaking the details of your language for the foundational reality of computing, and that causes all sorts of "difficult to characterize well in an HN post" problems for you. In Dijkstra's day, that happened to be BASIC, but today there are many languages that can cause that problem. Perhaps Javascript is the leader, not because of any specific characteristic of the language itself but because it is relatively easy to be a "web programmer" and never end up branched out into anything else. I see the negative effects of this from a bunch of people who think that the JS solution is the only solution, and can't even process the idea that if another language does it some other way, there may in fact be reasons for that and it may be a better solution in some ways. I wish that were sarcasm, but it really isn't, it's experience from helping people online.
I love this analogy
The thing I feel like misses from the article is the more "second thought but vital" stuff, like observability. The tracing ecosystem for Rust is a blessing, and so boilerplate-free that you sprinkle it all over and never forget about it. From experience, having to manually access the span, put data in it, and defer close, that people don't bother doing them. Yay macros!
With Go it's really easy to throw stuff at the wall, and it'll stick. Rust encourages you to put glue on it before throwing stuff.
Also once you get used to axum and the various extractors, be warned, you'll hardly go back. It's so natural and powerful.
How so? While the Go seems very high quality, they are a Rust shop so I assumed the Rust is even better (I don't know Rust).
What would actually be done for such an app would at least be one variant for each error type you can encounter (e.g. Db(sqlx::Error), Api(reqwest::Error), ...) with something like thiserror. Then in the conversion to Response, there'd be a (admittedly) large match that maps out every error kind to a response code. In this example, they conflated both the external api request and database error to a 500, while before they properly made the api request a 404 (which is not always the case), and the db error to a 500.
For real apps you gain a lot. By doing this legwork, you get terrific developer experience as virtually all errors that can happen get mapped to a proper status code and handled by a single `?`, and terrific user experience as all errors ... (you get the idea).
Don't get me wrong, that's still an improvement! They do show it is easy to have the infrastructure to do so. In the go version, there is literally no thought given to this. Sprinkle
return c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
everywhere and done!So congrats! Relevant part FTA:
Which language is right for you?
Go:
- easy to learn, fast, good for web services
- batteries included. We did a lot with just the standard library.
- Our only dependency was Gin, which is a very popular web framework.
Rust:
- fast, safe, evolving ecosystem for web services
- no batteries included. We had to add a lot of dependencies to get the same functionality as in Go and write our own small middleware.
- the final handler code was free from distracting error handling, because we used our own error type and the ? operator. This makes for very readable code, at the cost of having to write additional adapter logic.
Um. No. Go was originally created at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson.
Some key reasons for its creation include:
Improving productivity of Google engineers - The creators felt existing languages like C++ took too long to compile and had other productivity issues. Go was designed to be simpler and faster to compile.
Supporting concurrency and multi-core machines - Go has built-in concurrency features like goroutines and channels to allow easy parallelism and concurrency on multi-core machines. This was becoming increasingly important with multi-core CPUs.
Improved code readability and maintainability - Go was designed with a clean, minimal syntax that avoided complex features like inheritance in favor of simplicity. This improved code readability and made programs easier to maintain. Better performance than interpreted languages - While Go is a compiled language like C++, it was designed to provide performance closer to those languages while maintaining some productivity benefits of interpreted languages like Python.
Supporting networked systems and servers - Go has good built-in support for networking and servers, making it well-suited for building networked systems, web servers, and other server tools.
References: https://go.dev/talks/2012/splash.article
that's possibly Google marketing hyperbole : check out what the guys were doing just before changing from Bell Labs to Google.
https://golang.design/history/#acknowledgements
Separately, check out Russ Cox famous in the Go world...
How is this not a web service?
As is evident from the code, Go is simpler and cleaner. In 3 months it would be much easier to reason about than the Rust version.
I am happy with our decision to migrate to Go from Common Lisp. I also wonder what is the long term memory usage of the Rust system. With Go I have found very little memory use which remains constant for months before machine swap.
Go should be compared to other high-level garbage collected languages: Java, Python, C# -- that kind of thing.
Rust should be compared to C++ and C, but I'd also love to see it compared to other languages with advanced type systems, or contrasted with languages with more traditional or simple type systems.
- static types
- small, static binaries for deployments
- lower memory requirements and value semantics where appropriate
- a reasonably large ecosystem
- cross-platform with strong support for linux/cloud
These two languages usually end up at the top of my list when given these requirements.Why group by "high-level" (whatever that means) and gc?
Why not say "why compare rust and go - go belongs in the minimal type system category with zig, C, python, lisp. rust belongs in the inferred thpe system category with haskell, ocaml, f# and so on"?
Or (as is the case with the article), why not group them by popularity for a domain/task and do a comparison in that light?
I think they also get compared because they attract programmers that value similar things, just coming at it from two different directions.
Btw. I really liked an introduction article about Rust in some Linux magazine (don't remember which one). Write hello world, print a number to stdout, handle option and result, no mention of pointers or algebraic data types theory. I am sure will get a lot of people into using it without scaring anyone. You really need very little to get started.
Some people say the learning curve for Rust is steep. But, in a way you're learning how to write safer code using the latest in language techniques.
There are other ways to write safe code than just reference counting systems. And arguably, a software engineer would learn more best practices by managing memory more directly.
Do you need them? Will you enjoy using it?
what was the compilation time of both projects, esp. after using crates like 'anyhow'? How long did it take to have a full fix-build-test cycle in both languages? How many refactorings per time unit could a smart coder do in both ecosystems?
I've found that refactors are more involved in Rust (because of some "viral" features that require changing code in a lot of places, adding lifetimes to a type being the most obvious example) requiring more effort from the developer, but that after handling the complaints from the compiler the resulting code is usually properly ported to the new design. This has not been my experience in other languages, particularly dynamically typed ones. It is partially helped by some design patterns that are prevalent in Rust libraries, like typed builders or more complex trait bounds that ensure correct usage of the exposed APIs.
The error handling in Go is SO verbose. When reading my code (or even reviewing other people's code) in order to understand at a high level what is going on, I feel like I'm squinting through a mesh wire window.
Compare this example in Go:
city := c.Query("city")
latlong, err := getLatLong(city)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
weather, err := getWeather(*latlong)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
weatherDisplay, err := extractWeatherData(city, weather)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.HTML(http.StatusOK, "weather.html", weatherDisplay)
To this code in Rust: let lat_long = fetch_lat_long(¶ms.city).await?;
let weather = fetch_weather(lat_long).await?;
let display = WeatherDisplay::new(params.city, weather);
Maybe on first glance the Rust code can seem alien (what is a `?` doing there, what is actually going on with `.await`, etc) but when you are writing a 100k line application in Rust, you learn the patterns and want to be able to see the domain application logic clearly. And yet, there's no hidden errors or exceptions. When this code fails, you will be able to clearly identify what happened and which line the error occurred on.Prototyping even small applications in Go is verbose. And worse still, error prone. It's easy to be lazy and not check for errors and oops 3 months in your code fails catastrophically.
I know a lot of people like Go on here but in my mind Go only makes sense as a replacement for Python (static compilation, better error handling than Python, faster etc). If you don't know exactly what you want to build, maybe it is faster to prototype it in Go? I still would reach for Rust in those instances but that's just me. For large applications there's no question in my mind that Rust is a better choice.
Edit:
people have pointed out that I'm not comparing the same thing, which is true, I apologize for the confusing. But even code where Go propagates the errors, it is much more verbose (and my point still stands)
err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
if err == nil {
return latLong, nil
}
latLong, err = fetchLatLong(name)
if err != nil {
return nil, err
}
err = insertCity(db, name, *latLong)
if err != nil {
return nil, err
}
And this is extremely common. More discussion in the thread below.I think Rust is a fantastic systems language that is misapplied to the web. I think Python was a fantastic scripting language that is misapplied to the web, too, so you can put that in context.
I counted the number of lines in my work projects, and I have $WORK projects that are 100k lines of code. Maintaining that in Go would seem like a nightmare to me, but in Rust that is so much nicer. My personal projects range from 10k - 35k and in all of those I much prefer the ones where I'm writing and maintaining Rust vs Go when it comes to similar complexity.
Even 100k LOC is pretty small for a software project, and likely doesn't need more than a few engineers. The advantage of the simplicity of Go shows up when you have to coordinate across >100 people, many of which are kind of mediocre, and you need all of them to ship features. If everyone in the world were a genius who is obsessed with writing clean code, Rust would be a fantastic language to work in at that scale, but they are not.
err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
if err == nil {
return latLong, nil
}
latLong, err = fetchLatLong(name)
if err != nil {
return nil, err
}
err = insertCity(db, name, *latLong)
if err != nil {
return nil, err
}
In Rust propagating errors is a lot more succinct and easy to do. It is usually what you want to do as well (you can think of Python and C++ exceptions as essentially propagating errors). The special case can be handled explicitly. In Go, you have to handle everything explicitly, and if you don't you can fail catastrophically.I guess it comes down to what features the language provides that makes it easy to do "the right thing" (where "the right thing" may depend on where your values lie; for example, I value correctness, readability of domain logic, easy debugging etc). And in my opinion, it's easy to do what I consider bad software engineering in languages like Go.
val, err := someOperation(arg)
if err != nil {
return nil, fmt.Errorf("some operation with arg %v: %v", arg, err)
}
It really sucks when you're debugging an application, and the only information you have is "read failed" because you just propagated errors. Where did the read happen? In what context?Go errors usually contain enough context that they're good enough to print to console in CLI applications. Docker does exactly that - you've seen one if you've ever tried executing it as a user that isn't in "docker" group:
docker: Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post http://%2Fvar%2Frun%2Fdocker.sock/v1.35/containers/create: dial unix /var/run/docker.sock: connect: permission denied. See 'docker run --help'.Not to pick on any of these projects, but this pattern is way too common to not have a some sugar:
https://github.com/goodlang/good/blob/3780b8d17edf14988777d3...
https://github.com/kubernetes/kubernetes/blob/1020678366f741...
https://github.com/golang/go/blob/6cf6067d4eb20dfb3d31c0a8cc...
Yes, because each level is (hopefully) there for a reason, and provides a deeper context about where and why the error occurred.
If your contextual information is too repetitive and redundant, it may be the time to refactor your code and remove the unnecessary layers.
It reminds me of A Philosophy of Software Design:
The utility of a module (or any form of encapsulation such as a function) is greater, the smaller the interface relative to the implementation. Shallow ve deep modules.
Error handling might be a proxy to determine the semantic depth of a module.
Another perspective: (real) errors typically occur when a program interacts with the outside world AKA side effects etc.
Perhaps it’s more useful to lift effects up instead of burying them. That will automatically put errors up the stack, where you actually want to handle them.
This perspective also made me think of another advantage of Go error handling over traditional try/catch logging:
In other programming languages, when doing some asynchronous work in some background worker or whatever, exceptions become useless as error reporting mechanisms, because stack traces don't correspond to the logical flow of the program. I remember Java's Spring to be exceptionally painful in this regard.
In Go, since errors are values and you're expected to add context information whenever appropriate, you can shift and shuffle those errors between stacks, and still keep useful context information when you eventually read those errors.
some_operation(&arg)
.with_context(|| format!("Some operation failed: {arg}"))?;
or, in most cases, the shorter some_operation(&arg)
.context("Some operation failed")?;
If you forgot to call `with_context` and simply write some_operation(&arg)?;
the error is still propagated, it's just missing whatever context you wanted to add.Whether that's better or worse than the Go snippet is something I'll let the reader decide.
The only issue there is if you want to return the error with code 200 (which you shouldn't, but it's been known to happen). In that case the Go code and the Rust code will look a bit closer to each other because then you can't use `?` this way (without writing some more boilerplate elsewhere).
Handle(err)
which may take a line, but isn't the 3 lines everyone refers to, and often has a lot of extra logic.
(I like Rust, C, C++ etc. as well.)
Like take a look at this pattern:
err := db.Get(&latLong, "SELECT lat, long FROM cities WHERE name = $1", name)
if err == nil {
return latLong, nil
}
latLong, err = fetchLatLong(name)
if err != nil {
return nil, err
}
err = insertCity(db, name, *latLong)
if err != nil {
return nil, err
}
Is it really necessary to have the error handling explicit in this case? Go through any of your go code. I find this kind of error handling is 90% of the error handling I write.If you just append if err != nil blocks everywhere without reasoning about the context you're probably doing it wrong.
Handle(err)
Control flow wise, Go is much easier to understand than chains of ? and await.
`match result { Ok(v) => v, Err(e) => return e.into() }`
This means that the code related to error handling is all in one place, and your business logic can just focus on the happy path.
can you build some utility function check_r, which will panic if found error code, so your Go code will look like:
latlong := check_r(getLatLong(city))
Kubernetes is over 1.5M loc and I've not seen problem with error handling.
You don't need a database, just an in-memory cache. All you're storing is recent weather info, which you get from an external server. Caching is just to prevent hitting the external server too hard.
Then the program becomes a single-file executable, which means you can get rid of Docker.
If you set this up to run under FCGI, it can run on some $5/month shared hosting account.
Or, at least, they would be after applying optimizations to them.
In short, both of these would serve more rps than you will likely ever need on even the lowest end virtual machines. The underlying API provider will probably cut you off from querying them before you run out of RPS.