Rust:Go comparison

Nodir Turakulov
8 min readAug 30, 2023

--

This blogpost used to be a Twitter thread in 2021 when I had a Twitter account. I am posting this only to share what used to be there.

Things that caught my attention as a Go programmer while reading the Rust book. This post is focusing on differences. Definitely not an exhaustive description of the lang.

No nil in Rust lang per-se, so by default you don’t have to check for nil in your code. You can’t trick the compiler into passing a nil either. Instead, “nullability” is opt-in, using explicit Option<T> container type.

fn foo(u: Option<&User>) {
// If u has some user, then shadow u variable
// with a new variable u of type &User.
if let Some(u) = u {
printin! ("Hello {}", u. name)
}
// ..
}

fn bar(u: &User) {
// don't have to check u for nil
// ...
}

fn main() {
let u = User{ /*..*/ };
foo(Some(&u)) ;
foo(None); // like nil

let u2: &User;
bar (u2); // <- COMPILATION ERROR: u2 is uninitialized
}

Rust lang design decisions are heavily influenced by “Zero cost abstraction” principle from C++, which is defined as 1. What you don’t use, you don’t pay for 2. What you do use, you couldn’t hand-code any better. This precludes GC, and coroutines at lang level.

An object is dropped/destructed when its (last) owner goes out of scope. Ownership can be transferred from var to var, from func to func, etc. Objects can also be borrowed temporarily. At the lang level, ownership is exclusive. Multi-ownership is opt-in, and provided via stdlib.

fn main() {
let bob = User{name: String::from("bob")};

// Transfer ownership to bob2.
let bob2 = bob;
// Variable bob is no longer valid.

// Transfer ownership to foo().
foo (bob2);
// Var bob2 is no longer valid.
}

fn foo(u: User) {
printin!("Hello {}", u.name);
// u's memory is released here.
}

struct User {
name: String,
}

Ownership/borrowing rules prevent mutating a vector while iterating over it. This is an error at compile-time!

fn main( ) {
let mut ints = vec![1, 2, 3];
for x in &ints {
// COMPILTION ERROR:
// An immutable borrow begins at the loop declaration and ends at loop end.
// A mutable borrow happens in push() call.
// Rule: a mutable borrow is mutually exclusive with any other borrows
ints.push(1) ;
}

There are also less trivial cases where compiler cannot figure out lifetime of a value. Then you have to annotate func input/output params or struct fields with “lifetimes”. It is hard to summarize this other than “it can get complicated”. Read more: https://doc.rust-lang.org/stable/book/ch10-03-lifetime-syntax.html

No defer() in Rust, but it is not a huge deal because Rust drops objects automatically. A type can implement custom drop() func which releases system resources. The following code does NOT explicitly close the file anywhere, and yet the file isn’t leaked.

fn main() {
let mut file = File::open("foo. txt").unwrap() ;
let mut contents = String::new();
file.readToString(&mut contents).unwrap();
println!("Contents: {}", contents);
}

Unlike Go, Rust forces you to think heap-vs-stack, and encourages storing data on the stack. If an object is stored in the heap, it stands out in the code, by being wrapped in Box<T> or another smart pointer.

Can be verbose though. Here is LinkedList in Rust and Go:

// Rust
struct LinkedList {
value: String,
next: Box<Option<LinkedList>>,
}

fn main() {
let head = LinkedList {
value: String::from("a"),
next: Box::new(Some(LinkedList {
value: String::from("b"),
next: Box::new(Some(LinkedList {
value: String::from("c"),
next: Box::new(None),
})),
})),
};

let a = Shead;
let b = a.next.as_ref().as_ref().unwrap();
let c = b.next.as_ref().as_ref().unwrap();
println!("() -> {} -> {})", a.value, b.value, c.value);
}
// Go
type LinkedList struct {
value string
next *LinkedList
}

func main() {
var head = &LinkedList{
value: "a",
next: &LinkedList{
value: "b",
next: &LinkedList{ value: "c" },
},
}

fmt.Printf(
"%s -> %s -> %s",
head.value,
head.next.value,
head.next.next.value,
)
}

There are a few pointer types. You use the cheapest one sufficient for the job. Need multiple owners? Use Rc<T>. Wait, need access from different threads? Then Arc<T>. Need to bend ownership/borrow rules? Use RefCell<T>, or maybe Rc<RefCell<T>>, or Arc<Mutex<T>>.

Immutability is a part of the lang, and the lang encourages it. Vars/params are immutable by default: mutable ones stand out in code because they must be annotated with `mut`. Can convert to immutable without copying. Not only var/params are immutable, but the referenced object itself too (deep immutability).

let immutable = String::from("immutable");
let mut mutable = String::from("mutable");

mutable.push_str(" foo"); // WORKS
immutable.push_str(" foo"); // COMPILATION ERROR

Rust supports macros, which are basically Rust code that generates Rust code. Rust doesn’t support reflection, but macros have access to the AST. Rust funcs don’t support varags, but macros effectively do:
println!(“Hello {}”, user.name)

Rust trait is like Go interface. Unlike Go, a type must declare that it implements a trait; but it isn’t a problem because you can implement your trait for third-party types! A trait method can have a default impl. Effectively, you can add new methods to third-party types.

trait MyLen {
fn len(&self) -> usize;

// double_len has a default impl.
// A type implementing MyLen my override it.
fn double_len(&self) -> usize {
self.len() * 2
}
}

// Implement MyLen in String type from stdlib.
impl MyLen for std::string::String {
fn len(&self) -> usize {
return self.len()
}
}

fn main() {
let s = String::from("");

// Calling double_len on a String!
println!("{}", s.double_len());
}

You can even implement a trait for ANY type that satisfies a constraint, AKA blanket implementations. Also, a generic type can have methods defined *conditionally* on the type arguments, e.g. only if a type arg implements a trait. This kinda blows my mind.

trait MyLen {
fn len(&self) -> usize;
}

trait Dimensions {
fn print_dims(&self);
}

// Implement Dimensions for
// anything that implements MyLen.
impl<T: MyLen> Dimensions for T {
fn print_dims(&self) {
println!("length: {}", self.len())
}
}
struct Proxy<T> {
real: T,
}

trait Closer {
fn close(&mut self);
}

impl<T> Proxy<T> {
// These methods are available for any Proxy<T>
}

// Implement Closer for any Proxy<T> where T implements Closer.
impl<T: Closer> Proxy<T> {
fn close(&mut self) {
self.real.close();
}
}

Both langs follow “errors are values” philosophy. Rust wraps values in Result<T, E> where E is the error type.

Rust’s “?” operator is syntax sugar for Go’s checking err != nil and returning err as-is.

use srd::error::Error;

fn foo() -> Result<i32, Box<dyn Error>> {
// If bar returns an error, return it.
// Otherwise, if baz returns an error, return it.
// Otherwise, return their product.
Ok(bar()? * baz()?)
}

fn bar() -> Result<i32, &'static std> {
Ok(1)
}

fn baz() -> Result<i32, std::io::Error> {
Ok(2)
}

The “?” operator does NOT add contextual info to the returned error, AND an error in Rust doesn’t necessarily capture the stacktrace (support exists, but it has been experimental for 2y). I wonder if this combination makes it challenging to tell where an error came from.

Rust encourages all types to be known at compile time. Otherwise a type stands out in the code, annotated with `dyn`. For traits used in a func signature, Rust has syntax sugar to use generics instead, or replace returned trait with concrete type behind the scenes if it can.

trait IntReader {
fn read(&mut self) -> i32;
}

trait Printer {
fn print(&mut self);
}

// Accepts a trait and returns a trait,
// but both are annotated with "impl" syntax sugar.
fn int_printer(r : impl IntReader) -> impl Printer {
IntPrinter{ints: r}
}

// The equivalent
fn print_int_2<T: IntReader>(r: T) -> IntPrinter<T> {
IntPrinter{ints: r}
}

//-----------
struct IntPrinter<R: IntReader> {
ints: R,
}

impl<T: IntReader> Printer for IntPrinter<T> {
fn print(&mut self) {
println!("value: {}", self.ints.read());
}
}

Rust has enums, which are actually discriminated unions. Each member can store different types of data. The byte size of the enum type is the byte size of its largest member. Rust has pattern matching and it works particularly nicely for enums.

enum Message {
Quit,
Move {x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

fn act(m: Message) {
use Message::*;
match m {
Quit => println!("I am quitting"),
Move{x, y} => println!("Moving to ({}, {})", x, y),
Write(s) => println!("Writing {:?}", s),
ChangeColor(r, g, b) => println!("Changing color to ({}, {}, {}), r, g, b),
}
}

panic crashes the current thread, not necessarily entire process… unless it was the main thread. In Go, panic in any goroutine can crash the process. There is no recover() in Rust.

Rust’s type inference is more advanced. The following code creates a hashtable without explicitly specifying its key/value type params anywhere. They are inferred from return types of foo() and bar().

fn main() {
let mut ht = HashMap::new();
ht.insert(foo(), bar());
}

Rust has a short syntax for anonymous funcs: if Rust can infer types of in/out params, then they can be omitted

foo(|x| x * 2);

Only anonymous functions can be closures. You can define fn inside an fn, but it is not allowed to capture the environment, so a function call never allocates memory.

Rust implements iterators (with map, filter, etc) that adhere to “zero cost abstraction” principle. For the following non-trivial code, Rust compiler is smart enough not to generate any loops (anywhere) for calculation of one prediction. read more: https://doc.rust-lang.org/stable/book/ch13-04-performance.html

let buffer: &mut [i32];
let coefficients: [i64, 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
let prediction = coefficients.iter()
.zip(&buffer[i - 12..i])
.map(|(&c, &s)| c * s as i64)
.sum::<i64>() >> qlp_shift;

let delta = buffer[i];
buffer[i] = prediction as i32 + delta;
}

// Rust compiler is smart enough not to generate any loops (anywhere)
// corresponding to the iterator code.

// Source: https://doc.rust-lang.org/book/ch13-04-performance.html

Rust seems to like exhaustiveness. Struct literals must specify ALL fields. In particular, this prevents un-initialized reference fields (so.. no nil). When pattern matching, the set of patterns must be exhaustive.

Unit tests can be in the same file as the code-under-test.

fn factorial(x: i32) -> i32 {
if x == 1 {
1
} else {
x * factorial(x-1
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn factorial_5() {
assert_eq!(factorial(5), 120)
}
}

At last, concurrency. Today, Rust has full support for OS-thread-based concurrency, whereas async/await model is very much pre-1.0. The Rust async book is WIP, and my brain melted while I was reading it due to the amount of complexity imposed on me as a user of Rust. Gotta wait.

Mutexes are brilliant in Rust. Impossible to forget to lock/unlock.

  • The value is wrapped in a mutex, and can be retrieved only by locking
  • The lock is released automatically when it leaves the scope
fn main() {
// Wrap an int in a mutex.
// Wrap the mutex in Arc so that we can share across threads.
let counter = Arc::new(Mutex::new(0));

let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Retrieve by calling lock();
let mut num = counter.lock().unwrap();
*num += 1;
// The lock is released here automatically.
});
handles.push(handle);
}

for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}

Go-like channels are available in stdlib and third-party libs. Stdlib defines common types, like Future, while third-party libs implement runtimes. tokio.rs is a popular third-party async framework. It comes with a plug-in async runtime and purpose-built channels.

// tx: t for transmission
// rx: r for receiving
// Note: they ARE generic.
let (tx, rx) = mpsc::channel();
for i in 0..10 {
let tx = tx.clone();
thread::spawn(move|| {
tx.send(i).unwrap();
});
}

for _ in 0..10 {
let x = rx.recv().unwrap();
println!("{}", x)
}

--

--