Skip to content

Instantly share code, notes, and snippets.

@Integralist
Last active October 7, 2024 21:48
Show Gist options
  • Save Integralist/ac32f8f42244171183c5142a624c741d to your computer and use it in GitHub Desktop.
Save Integralist/ac32f8f42244171183c5142a624c741d to your computer and use it in GitHub Desktop.
[Go vs Rust: syntax differences] #go #golang #rust #rustlang #syntax

The following examples are written from the perspective of an engineer who writes code using the Go programming language, and so you'll find that I've written notes about how Rust is different and I don't really cover the why or how of the example Go code. Additionally, the Go examples are far from exhaustive because I'm using this as a 'scratch pad' for my Rust learnings.

Error Handling

Go example

package main

import (
	"fmt"
	"log"
	"os"
)

func main() {
	f, err := os.Open("hello.txt")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(f)
}

Rust example

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

Notes

Rust uses a std::result::Result Enumerator to encapsulate the returned value, which could be either a Ok(T) or Err(E) variant.

NOTE: The more correct way to describe an enum is enumerated type.

Enumerators are a powerful feature in Rust, unlike in Go where you don't have enumerators but iota (that and half-baked custom implementations using custom types with constants).

The use of constants to vaguely mimic a form of enum would be:

type TokenScope string

const (
	GlobalScope TokenScope = "global"
	PurgeSelectScope TokenScope = "purge_select"
	PurgeAllScope TokenScope = "purge_all"
)

While using iota is better, it doesn't have the same flexibility and expressiveness as rust:

package main

import (
	"fmt"
)

// InputType is a base for the different input variants.
type InputType int64

// Variants of an InputType
//
// NOTE: These can be passed anywhere the InputType is specified.
const (
	InputTypeUndefined InputType = iota // 0
	InputTypeEmail                      // 1
	InputTypeNumber                     // 2
	InputTypeURL                        // 3
)

// Notice the return type acts a bit like an interface
// in that we're able to return any of the defined input types
// as they all are a subset of InputType.
func (it InputType) Convert(s string) InputType {
	switch s {
	case "email":
		return InputTypeEmail
	case "number":
		return InputTypeNumber
	case "url":
		return InputTypeURL
	}
	return InputTypeUndefined
}

// It represents a new instance of InputType
//
// NOTE: 
// You can't call a method on the type without first creating a new instance. 
// So to make things easier for a consumer I make it a package level public variable.
// This means you'll likely need to think more about how you name this variable as you'll have to also remeber this is likely to be in a separate package.
var It = new(InputType)

func main() {
	fmt.Println(It.Convert("email") == InputTypeEmail)   // true
	fmt.Println(It.Convert("number") == InputTypeNumber) // true
	fmt.Println(It.Convert("url") == InputTypeURL)       // true
	fmt.Println(It.Convert("nope") == InputTypeURL)      // false
}

There's many ways to get to the value from within the Result Enum (i.e. you'll want to get either the value inside of the Ok or the Err variants).

The most verbose variation, which is to use a match statement, is demonstrated in the above example where we take the value extracted from the Result Enum and overwrite the f variable to now contain the extracted value.

Other ways to get at the value contained in a Result are:

  • ?: append this operator and it'll return either the value inside of Ok or return the error inside Err.
  • unwrap: returns the Ok value otherwise the program will panic.
  • expect: same as unwrap except you can customise the panic message.

NOTE: These methods are implemented on both std::result::Result and std::option::Option.

The above are the most common ways to get at the value contained in a Result or Option enum, but there are also:

  • unwrap_or: returns the Ok or Some value (in the case of an Option enum variant), otherwise returns your given 'default' value.
  • unwrap_or_else: returns the Ok or Some value (in the case of an Option enum variant), otherwise computes it from a closure.

One last thing to mention about error handling in Rust is the following if let pattern, which is used when using match is overly explicit/verbose due to its exhaustive nature:

let number = Some(7); // pretend this was generated by a function returning a std::option::Option

if let Some(i) = number {
    println!("Matched {:?}!", i);
}

Refer: https://doc.rust-lang.org/rust-by-example/flow_control/if_let.html

Structs

  • Define a struct.
  • Define hello function with struct as the receiver.
  • Define a constructor function.

Go example

package main

import "fmt"

type Foo struct {
	bar string
	baz int
}

func (f Foo) hello() {
	fmt.Printf("%s, %d\n", f.bar, f.baz)
}

func NewFoo() *Foo {
	return &Foo{
		bar: "bar",
		baz: 123,
	}
}

func main() {
	f := Foo{
		bar: "bar",
		baz: 123,
	}
	fmt.Printf("f: %+v\n\n", f)

	f.hello()

	fp := NewFoo()
	fmt.Printf("\nfp: %+v\n\n", fp)
	
	fp.hello()
}

Rust example

#[derive(Debug)]
struct Foo<'a> {
	bar: &'a str,
    baz: u8,
}

impl<'a> Foo<'a> {
    fn hello(&self) {
        println!("{}, {}", self.bar, self.baz);
    }
    
    fn new(bar: &str, baz: u8) -> Foo {
        Foo {
            bar,
            baz,
        }
    }
}

fn main() {
	let f = Foo {
  		bar: "bar",
        baz: 123,
  	};
  	
  	println!("{:#?}", f);
  	
  	f.hello();
  	
  	let foo = Foo::new("new bar", 255);
  	
  	foo.hello();
}

Rust requirements

To print the struct we need to implement Debug (so we 'derive' it using existing implementation rather than implement it ourselves).

We have to add a lifetime 'a to the struct so that Rust will compile the code (for safety reasons Rust needs to ensure the referenced string assigned to the field lives long enough for the code to be considered safe).

The compiler also complains when defining a method on Foo using impl: "implicit elided lifetime not allowed here". The resolution is: "indicate the anonymous lifetime", which is done using <'_>:

impl Foo<'_>

If we read the Rust documentation on elision rules we'll see that the 'anonymous lifetime' is just a new rule that enables syntax sugar that makes the more explicit lifetime code (i.e. impl<'a> Foo<'a>) simpler.

For the sake of the example, I used the more explicit version because it makes understanding the Rust code and the relationship of the 'a lifetime clearer.

Notes

The impl block actually creates a 'namespace', meaning we have to call new using the namespace: Foo::new().

When a function's arguments match the field names of a struct, you can omit the typical key: value format. So instead of Foo{bar: bar} we just write Foo{bar}.

You can't omit a struct field in Rust. Unlike in Go which will automatically assign a default value (the type's zero value). To do this in Rust you need to derive Default on the struct and splat the remaining fields using Default::default()...

#[derive(Debug, Default)]
struct Foo {
    bar: u8,
    baz: bool,
}

fn main() {
    let f = Foo {
        bar: 123,
        ..Default::default()
    };
    println!("{:?}", f);
    println!("{} {}", f.bar, f.baz); // There would be a WARNING if we didn't read the fields.
}

Alternatively assign the field type to an Option type, that way it will default to None.

Interface

In traditional programming languages an interface defines a contract. It states that a certain object follows the behaviours expected by implementing the assigned interface.

Go example

package main

import "fmt"

type foo interface {
	bar()
}

func callBar(value foo) {
	value.bar()
}

type x int
type y string

func (t x) bar() {
	fmt.Printf("Int: %d\n", t)
}
func (t y) bar() {
	fmt.Printf("String: %s\n", t)
}

func main() {
	callBar(x(1))
	callBar(y("foo"))
}

Rust example

trait Foo { 
    fn bar(&self); 
}

impl Foo for i32 { 
    fn bar(&self) {}
}

impl Foo for String { 
    fn bar(&self) {}
}

// impl Trait (i.e. Generics) short-hand:
// fn call_bar(value: impl T)
fn call_bar<T: Foo>(value: T) {
    value.bar()
}

fn main() {
    call_bar(1i32);
    call_bar("foo".to_string());
}

Notes

In Go the concept of an interface is more flexible than the traditional definition because your objects can implement an interface without explicitly being assigned it. This means a single object could in theory implement lots of different interfaces (hence why it's more flexible than traditional programming languages, because in those languages you'd need to explicitly assign multiple interfaces to an object).

From an implementation stand point, Go uses 'dynamic dispatch' when dealing with interfaces, while methods on a struct or any other concrete type are always resolved statically (reference). This means it is faster to compile a Go program but isn't as fast to run because when dealing with interface method resolution the value of the 'receiver' that a method is implemented on can only be determined at runtime (this can also, in extreme cases, be less memory safe).

In Rust the concept of an interface is referred to as a 'trait'. Traits are just as flexible as Go, and more so in the sense that they provide both 'dynamic dispatch' and 'static dispatch' (the latter meaning the code doesn't have a single function, like with dynamic dispatch, but has multiple functions compiled that reflect each receiver -- this is possible due to the use of generics in the rust language).

NOTE: Refer to this article for the difference between a 'type' and a 'trait' in Rust.

In the above Rust example the two call_bar calls will actually compile to two distinct functions, like:

fn call_bar_int(value: int) { value.bar() }
fn call_bar_string(value: String) { value.bar() }

This is because it uses trait bounds (i.e. <T: Foo>). If the example used a 'trait object' (see docs on trait objects), then it would cause 'dynamic dispatch' to be utilised. Dynamic dispatch means the two call_bar calls will always call the single call_bar function, with the address of bar loaded from the interface's vtable.

Refer: riptutorial.com for more details on static vs dynamic dispatch in Rust and also this Rust by Example page on dyn Trait.

Marker Traits

Imagine we have a trait for an Animal that defines a make_noise method. We want to call function and pass any object that defines make_noise but we only want to do this for 'pets' not all 'animals'.

So how can we make it so that we only accept pets? This is where marker traits are useful:

pub trait Pet {}

impl Pet for Dog {}
impl Pet for Cat {}

Okay so far so good so now we know that these are pets not just animals. We call these marker traits because they have no functions for you to implement, but they allow you to “mark” the type with the trait. How do we tell our function to utilize this functionality then?

fn record_pet_noise<P: Animal + Pet>(pet: &P) -> Result<Sound, Mp4EncodeError> {
  let noise = pet.make_noise();
  mp4_encode(noise)
}

We added another trait boundary to P which says “We accept a type only if it implements the traits Animal and Pet.” Pet is a marker trait. It doesn’t do anything, but it restricts what types are acceptable.

Trait Bounds

We can compose behaviours using a 'trait bound', which determines what something should be able to do...

trait Bar: PartialEq + Debug {
	fn something_specific_for_bar(&self);
}

In the above example, the Bar trait requires the implementator to also implement the PartialEq and Debug traits.

NOTE: Go does something similar with embedding interfaces within interfaces.

We've already seen trait bounds in the earlier example, but it can take multiple forms...

fn call_bar<T: Foo>(value: T) {
    value.bar()
}

fn call_bar<T>(value: T) 
where 
    T: Foo,
{
    value.bar()
}

fn call_bar(value: impl Foo) {
    value.bar()
}

The last example is preferred, but often the trait bounds can be complex enough that the second variation with a where clause is better.

The first example is the most traditional relative to other languages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment