site.lyte.dev/content/blog/rust-vs-go.md
2024-06-12 17:08:53 -05:00

14 KiB

title date draft
Rust vs. Go 2024-06-06 true

A lot of folks have been pitting Go and Rust against each other. Normally, I would abstain from throwing my opinion out on the internet, but it's come up at work and since I heavily prefer one over the other (Rust subjectively!), I want to write out why that is. Hopefully it is useful to others out there.

Rust versus Golang

I'm aiming to keep this succinct so the people I share this with will actually read it. This will probably be dense and pointed, as I'm trying to construct an argument.

This article is opinion and not stating objective fact. These are my preferences. I have roughly-equivalent Rust experience, exposure, and knowledge than I do Golang, though I have run, operated, and maintained far more Golang services at my job than I have Rust.

Also by posting this I'm sure I will get free flame wars consultation on my Go code and become a better programmer! 😜

Stop Comparing! They're Different!

Of course they are! This doesn't mean they can't be used to solve the same problems. In my experience, one is simply better than the other for the same use-cases.

They're also the same category of thing: general purpose programming languages. Which means it's good to talk about the various strengths and weaknesses of the two.

Why Rust

Rust has all of Go's superpowers

Ok, except it's uber simplicity. But what you lose in simplicity, you gain in many other areas. I'll elaborate later.

Goroutines

Use tokio::spawn. Yes, they are different (mainly stackless versus stackful) but the goal and considerations are effectively the same.

If you don't like Tokio or can't use it, there are different implementations of this on top of Rust's async/await system that have the same benefits.

While it's arguably not as cohesive as Go's implementation, it's there if you want it.

Channels

Rust has these. See std::sync::mpsc.

Defer

Largely unnecessary thanks to the compiler, but you can implement your own via the Drop trait or reach for the scopeguard crate.

Tooling

Again, Rust has an incredible tools ecosystem just like Go. Better in many ways since many tools considered necessary for modern and idiomatic Go are simply not for Rust, such as code generation tools. Such needs are instead fulfilled by metaprogramming (macros) instead.

Explicit error handling

Haha! I guess I'm the four-millionth person to write about how awful if err ! = nil is, but seriously, Rust's ? operator and pattern matching abilities are so fantastic.

You're gonna love Rust in comparison.

Rust is expressive

Now we're getting to some of the things Rust is simply better than Go at.

Being expressive has downsides. A common question I'll find myself asking is "Which of these four ways is the best way to do this?"

But I would rather be able to have the expressions in the code mirror the intent more than be stuck with Go's simplicity.

Rust code has fewer bugs

Obviously this isn't entirely a fair thing to say, but it is true that Rust simply makes it incredibly difficult (effectively impossible?) to have certain kinds of bugs in your code as opposed to Go.

I simply write my code in Rust and when it compiles it works as I intended. Rust code and the compiler make it much more difficult to represent invalid states.

Meanwhile Go code has nil checks, implicit interfaces, and a few other things you will inevitably run into.

That said, Go's tooling has been improving a lot around this in my opinion; golangci-lint is awesome and shores up much of these issues.

Inline tests

I strongly dislike being required to put my unit tests in separate files. Call me crazy, but having the tests in the same file is something I find appealing.

Also doctests. The best kind of inline test. Gotta have 'em.

Modules and dependencies

Go's module system got bolted on a little late and suffers from the Google monorepo assumptions/expectations situation. In some ways, this is actually very cool. In many ways, though, it's pretty annoying. These days, if you follow the "modern" path, things can work great.

Rust has its own issues with Cargo and its ties to GitHub, but even those can be worked around if you like.

Overall, Rust has a more familiar and approachable module system.

Making Changes

When you change a hunk of Go code, you can't know for sure what else in the code may need to change as a result. If you add a field to a struct, you may need to find the places you are building that struct and update the code and hopefully you don't miss a spot.

Rust makes changing an existing codebase less scary. It will tell you pretty much everywhere you need to go and change other things as a result of your small change. It totally changes the way you modify software.

Documentation

Rust's standard library and popular crates' documentation is generally much better than Go's. Doctests are wonderful and examples are more common in Rust crates than in Go modules.

Macros

Having them is better than not. They are awesome. Yes, they make the compiler slower. Yes, they can do awful stuff.

So does Go codegen.

But being able to derive implementations and affect them with attributes is really phenomenal. Especially if you mess with something like bevy or axum.

Traits

Rust forces you the implement a trait explicitly while Go does not. It's a small thing, but this again lets the code communicate the intent of the programmer.

No surprises

Nothing that executes before main() while Go has freakin' init() and of course you can instantiate literally anything globally.

In Rust, you don't have this, which sucks and means you do OnceLock/ OnceCell/lazy_static! instead and it can be painful if you're so dead set on doing something weird like that.

But I'll take no surprises and explicitness any day.

Examples

I wanted to include a number of code examples I found particularly Rust-favoring. A picture is worth a thousand words!

Obvious: Error Handling

This is a simplified function in production right now:

func (t *Bot) FinishReceivingRequest(req RequestInProgress) (result *Request, err error) {
  result.Data = req.Data

	assignee, err := t.GetAssigneeSlackUser(req)
	if err != nil {
		return nil, fmt.Errorf("failed to retrieve assignee's Slack user info: %w", err)
	}
	result.SlackAssigneeID = assignee.ID

	manager, err := t.Slack.GetUserManager(assignee.ID)
	if err != nil {
		return nil, fmt.Errorf("failed to retrieve the assignee's manager Slack user info: %w", err)
	}
	result.SlackAssigneeMgrID = manager.ID

	issueSkeleton, err := req.Data.BuildJiraIssue(t, req)
	if err != nil {
		return nil, logrus.Errorf("Failed to create Jira issue object: %+v", err)
	}

	issue, res, err := t.Jira.CreateIssue(issueSkeleton)
	defer res.Body.Close()
	if err != nil {
		_, _, err := t.Slack.PostMessage(t.Conf.Slack.Channels.RequestThreads,
			slack.MsgOptionDisableLinkUnfurl(),
			slack.MsgOptionBlocks(
				slack.NewSectionBlock(
					&slack.TextBlockObject{
						Type: "mrkdwn",
						Text: "I failed to create a Jira ticket. See logs for details.\n\nError: " + err.Error(),
					}, nil, nil,
				),
			),
		)
		if err != nil {
			logrus.Errorf("failed to send Jira ticket creation failed message to slack: %v", err)
		}
	}
	result.JiraIssueKey = &issue.Key
	logrus.Infof("Jira issue created: %s", result.JiraIssueKey)

	channelId, ts, err := t.Slack.PostMessage(
		t.Conf.Slack.Channels.RequestThreads,
		slack.MsgOptionDisableLinkUnfurl(),
		req.MainMessageBlocks(),
	)
	if err != nil {
		return result, fmt.Errorf("failed to post Slack message for request -- this means there is data that has been created that nobody knows about: %w", err)
	}
	result.SlackTimeStamp = ts
	result.SlackChannelId = channelId

	// fetch permalink to the posted message to update jira ticket with
	url, err := t.Slack.GetPermalink(&slack.PermalinkParameters{
		Channel: channelId,
		Ts:      ts,
	})
	if err != nil {
		// TODO: retry?
		logrus.Errorf("Failed to retrieve permalink for Slack message for request -- this means we could not link the jira ticket to the slack thread: %s", err)
	}
	req.SlackThreadURL = url

	if issue != nil {
		rl := jira.RemoteLink{
			Object: &jira.RemoteLinkObject{
				URL:   url,
				Title: "Slack Thread",
			},
		}
		_, res, err := t.Jira.AddIssueRemoteLink(issue.ID, &rl)
		defer res.Body.Close()
		if err != nil {
			// TODO: retry?
			logrus.Errorf("Failed to add remote link to Slack message thread (ts: '%s', link: '%s') for jira issue (key: '%s'): %s", ts, url, issue.Key, err)
		}
	}

	logrus.Infof("Request data: %+v", req)
	logrus.Tracef("request's db data json: %+v", req.DataJSON)
	logrus.Tracef("request's db data: %+v", req.Data)

	dbResult := t.DB.Create(req)
	logrus.Tracef("request db create result: %+v", dbResult)

	// it's not the end of the world if we cannot update the main message, so we try it, log it, and keep going
	blocks, err = req.MainMessageBlocks()
	if err != nil {
		logrus.Errorf("Failed to build slack blocks for original message update: %v", err)
	}

	_, _, _, err = t.Slack.UpdateMessage(
		t.Conf.Slack.Channels.RequestThreads,
		req.SlackTimeStamp,
		blocks,
	)
	if err != nil {
		logrus.Errorf("Failed to update original message: %v", err)
	}


	_, _, err = t.Slack.PostMessage(
    channelId,
    slack.MsgOptionTS(ts),
    slack.MsgOptionBlocks(req.FirstReplyMessageBlocks()...),
  )
	if err != nil {
		logrus.Errorf("failed to post follow-up message in thread: %s", err)
	}

	if req.Data.NeedsApproval() {
		req, err = t.ApprovalSetup(req)
		if err != nil {
			return result, fmt.Errorf("failed to setup approval: %w", err)
		}
	}

	return
}

Here's how it looks in my Rust dream rewrite (thanks color_eyre::Result):

	// fetch permalink to the posted message to update jira ticket with
	url, err := t.Slack.GetPermalink(&slack.PermalinkParameters{
		Channel: channelId,
		Ts:      ts,
	})
	if err != nil {
		// TODO: retry?
		logrus.Errorf("Failed to retrieve permalink for Slack message for request -- this means we could not link the jira ticket to the slack thread: %s", err)
	}
	req.SlackThreadURL = url

	if issue != nil {
		rl := jira.RemoteLink{
			Object: &jira.RemoteLinkObject{
				URL:   url,
				Title: "Slack Thread",
			},
		}
		_, res, err := t.Jira.AddIssueRemoteLink(issue.ID, &rl)
		defer res.Body.Close()
		if err != nil {
			// TODO: retry?
			logrus.Errorf("Failed to add remote link to Slack message thread (ts: '%s', link: '%s') for jira issue (key: '%s'): %s", ts, url, issue.Key, err)
		}
	}

	logrus.Infof("Request data: %+v", req)
	logrus.Tracef("request's db data json: %+v", req.DataJSON)
	logrus.Tracef("request's db data: %+v", req.Data)

	dbResult := t.DB.Create(req)
	logrus.Tracef("request db create result: %+v", dbResult)

	// it's not the end of the world if we cannot update the main message, so we try it, log it, and keep going
	blocks, err = req.MainMessageBlocks()
	if err != nil {
		logrus.Errorf("Failed to build slack blocks for original message update: %v", err)
	}

	_, _, _, err = t.Slack.UpdateMessage(
		t.Conf.Slack.Channels.RequestThreads,
		req.SlackTimeStamp,
		blocks,
	)
	if err != nil {
		logrus.Errorf("Failed to update original message: %v", err)
	}


	_, _, err = t.Slack.PostMessage(
    channelId,
    slack.MsgOptionTS(ts),
    slack.MsgOptionBlocks(req.FirstReplyMessageBlocks()...),
  )
	if err != nil {
		logrus.Errorf("failed to post follow-up message in thread: %s", err)
	}

	if req.Data.NeedsApproval() {
		req, err = t.ApprovalSetup(req)
		if err != nil {
			return result, fmt.Errorf("failed to setup approval: %w", err)
		}
	}

	return
}

Why Not Rust

While I've made it clear that I prefer Rust, I also recognize that there are things that I consider Go to handle better.

Standard libraries

Go has a big standard library and just includes a ton of stuff. I wish Rust had something similar for "blessed" "core" crates. I understand the standard library for a systems-level language must be small, but that's how I feel.

Other Builtins

Go can kind of manage its own versions and everything and you pretty much just do go whatever and get on with your life.

With Rust, you need to learn a couple different tools (but mainly just cargo) such as rustup.

Go is Simple

It's easy to get caught up trying to do things in Rust the "right" way and work with the borrow checker and think about the optimal solution -- to revel in the computer science puzzle that the compiler presents to you.

In Go, that is simply less true. You will do the obvious thing at perhaps some slight performance cost and get back to implementing your feature and shipping code. It's almost always going to be perfectly sufficient and generally easy to come back to and understand.

Go Compilation Speed

The Go compiler is wonderfully quick. In my experience, though, there are usually codegen tools you end up adopting that offset this speed and slow things down again that require extra vigilance and tending to keep things fast.

With Rust, you have one compiler speed: not so fast. It's still sufficiently quick for me, especially on a Ryzen 9 5950X or an Apple M1 Max. For most projects, it does a good job being incremental, too.

But you will build Rust

Build Tinkering

With Go, you're just gonna build the binary and run it.

With Rust, you do have to think about --release performance -- such as when writing Leetcode solutions against your friends.

I'm getting nitpicky here, but... it's my article.

Versioning

Rust has issues with MSRV "promises" being largely unenforceable due to Cargo lockfiles not being --locked by default. This is pretty mind-boggling to me. Could also be a skill issue.