Rust as a Go Programmer

Filed under rust on June 14, 2021

So I decided to finally sit down and check out Rust, as it’s a language that’s gaining a large amount of traction and generating buzz for its various features.

The things that got me curious were:

  • Memory ownership, go garbage collection can be brutal
  • Functional features
  • Generics
  • Potentially never having to deal with C again

It took me a little bit to start getting my head around how it was all put together, but things eventually started to fall into place as I started to map over my existing knowledge.

Keep in mind I’m still only just getting into Rust, so this may not be the most accurate writeup, so you should still read the docs carefully rather than trusting some nerd on the internet.

How things map

Variables

var foo string
bar := "baz"

It’s simple and clear what’s going on here, we have an uninitialised string variable foo and another initialised one called bar.

let mut foo: String;
let bar: String = String::from("baz");

The main differences you’ll see here is the let keyword and a : to denote the variable type.

The biggest rub I’ve found in Rust is mutability. If a variable isn’t marked with mut, it and its child variables cannot be modified.

Functions

Functions are easy enough to grok.

func Foo() string {
  return "Bar"
}
fn foo() -> String {
  String::from("Bar")
}

Notice the lack of a return keyword in this function. While you can use one, it generally doesn’t seem to happen a lot in the code I’ve dug into online. The reason this works is because, by default, everything is an expression which returns a value in rust. We can specify a statement by using a semicolon character

fn foo() -> String {
  let x = String::from("Bar");
  x
}

Little annoying, but I feel this is most likely to support Rust’s functional features.

Traits

Traits in Rust are similar to interfaces in Go, allowing a more abstracted way of interacting with a variable.

For example, in Go we would write something like this

type Foo interface {
  Bar() string
}

type Baz struct {
  bar string
}

func (b *Baz) Bar() string {
  return b.bar
}

In Rust, we’d write it this way

trait Foo {
  fn bar(&self) -> String
}

struct Baz { bar: String }

impl Foo for Baz {
  fn bar(&self) -> String {
    self.bar.clone()
  }
}

Where Rust differs a little bit is that we can also add functions to the trait itself, similar to an abstract class in a language like Java

trait Foo {
    fn bar(&self) -> String;
    fn foo_bar(&self) -> String {
        String::from("foo_bar")
    }
}

Structs

Golang provides struct functionality in a single way.

type Foo struct {
  Bar string
}

In contrast, Rust provides 3 ways to define a struct, depending what you want to do.

struct FooUnit;

struct FooTuple(i32, i32);

struct Foo {
  Bar: String,
}

The unit struct will give you a more structural type, tuples are self explanatory, then there’s there the more recognisable struct.

Methods

Go allows for methods by way for receivers, like so

type Foo struct {
  bar string
}

func (f *Foo) AppendBar(s string) string {
  return fmt.Sprintf("%s%s", s, f.bar)
}

Rust uses impl blocks to achieve the same thing.

struct Foo {
    bar: String,
}

impl Foo {
    fn append_bar(self, s: &str) -> String {
        format!("{}{}", s, self.bar)
    }
}

fn main() {
    let f = Foo {bar: String::from("bar")};
    println!("{}", f.append_bar("foo"))
}

I like how it forces you to keep methods next to each other, as I’ve seen some go code where people haven’t kept it tidy.

Errors

Where Go has the error type, Rust has the Result<T,U> type. Let’s just look at the code

Go

import "fmt"

func DoSomething(foo, bar int) (string, error) {
  if foo == bar {
    return "foobar", nil
  }
  return "", fmt.Errorf("like all the others, this is a stupid error")
}

func main() {
  foobar, err := DoSomething(19,10)
  if err != nil {
    fmt.Printf("not foobar: %v\n", err)
  } else {
    fmt.Printf("everything is %s", foobar)
  }
}

Rust

struct StupidError {}

impl fmt::Display for StupidError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "like all the others, this is a stupid error")
    }
}

impl fmt::Debug for StupidError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "just another stupid error")
    }
}

fn do_something(foo: i32, bar: i32) -> Result<String, Error> {
    if foo == bar {
        Ok(String::from("foobar"))
    }else {
        Err(StupidError {})
    }
}

fn main() {
    let t = do_something(3, 3);
    match t {
        Ok(t) => println!("everything is {}", t),
        Err(e) => println!("not foobar: {}", e),
    }
}

When I first came to go, this kind of error handling really confused me. Having come from Java, Python and C# it was a bit strange to have to assign and check the error manually rather than maybe just letting it bubble up on its own. I’ve grown to quite like how explicit it is, though, and unlike Java I’m not forced to deal with it then and there if I just want to ignore it and keep going.

What differentiates Rust from Go here is the Result type, which we can pass to a match statement to check for it. Bit more verbose, but when you have a list of different errors I personally think Rust is a bit clearer than switching on the Go type:

switch recovered := err.(type) {
case BlahError:
  fmt.Println("blah error")
case FoobarError:
  fmt.Println("foobar error")
match res {
  Ok(b) => println!("{}", b),
  Err(e) => {
    match e {
      BlahError => println!("blah error"),
      FoobarError => println!("foobar error"),
    }
  }
}

The Extra Stuff

I’ve been pretty skeptical of the functional paradigm, believing that a lot of the people pushing it have never had to be pragmatic in their software development. I think Rust tries to take a lot of the dissent from my crowd and use it as feedback into the language design.

As a result, there’s some nice features we should probably touch on which can make code cleaner.

These things below have been covered a million times by people far more knowledgeable than myself. I’d fully recommend a thorough reading of the Rust book to properly acquaint yourself with them.

Match Expressions

The match keyword is similar to things I’ve seen in languages like Haskell or Scala, and one I actually quite like

let x = 10;
match x {
  // Rust will go down this list until it finds something that
  // matches on the left
  10 => format!("everything as expected"),
  20 => format!("wow, why's that there"),
  _ => format!("your guess is as good as mine"),
}

I think it’s fair to compare this to a switch statement in go, though it’s a little more powerful than that in that we’re free to match on anything to do with that variable.

Memory Ownership

Let me say this straight up: I love the idea, but the implementation is incredibly confusing.

I’ve had a few instances where go’s garbage collection will waste a large amount of CPU time on me cleaning up unused memory. Rust negates the need for a garbage collector by not having one, instead keeping track of when memory falls out of scope through ownership.

What this means in practice is that you not only have to worry about scope now, but also what’s using your variables.

For example, say we have a function

fn some_function(f: Foo) -> String {
  f.bar
}

fn main() {
  let f = Foo { bar: String::from("bar") };
  some_function(f);

  println!("{}", f.bar)
}

We’re going to see a compile error here

error[E0382]: borrow of moved value: `f`
  --> src/main.rs:66:20
   |
63 |     let f = Foostruct {bar: String::from("bar")};
   |         - move occurs because `f` has type `Foostruct`, which does not implement the `Copy` trait
64 |     let s = do_something_else(f);
   |                               - value moved here
65 |     // can't use f here anymore
66 |     println!("{}", f.bar);
   |                    ^^^^^ value borrowed here after move

From my (limited) understanding, this is occurring because ownership of that heap data has been moved into the function scope, and the object gets deallocated before returning. In order to remedy this, we must pass by reference and make sure the bar field in the struct isn’t moved either.

fn some_function(f: Foo) -> String {
  f.bar.clone()
}

fn main() {
  let f = Foo { bar: String::from("bar") };
  let s = some_function(f);

  println!("{}", f.bar)
  println!("{}", s)
}

I’m still getting my head around this, but this kind of tight control on memory would be incredibly helpful in low level programming. If I never need to spend days in valgrind again it’ll be too soon.

Final thoughts

I’m still trying to see why I should start using Rust more often. I like the idea of having something incredibly fast, but when it comes down to it Go is fast enough and less of a pain to use.

Threading is another situation where ownership just makes a mess of everything. The Arc provides shared object functionality, but it just cannot compare to Go’s simplicity with goroutines and WaitGroups.

I’ll continue on a little adventure, I think, but I’m still not sure whether Rust will become one of the mainstays of my language stable. For the most part Go has what I need in the standard library, and when I have a one off CSV to process I can just do it in a python list comprehension.