PONY λ M2 Modula-2

Go.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Go and Rust

Go 1.26.2 Rust 1.95
Hello World & Running
Hello, World
package main import "fmt" func main() { fmt.Println("Hello, World!") }
fn main() { println!("Hello, World!"); }
Both languages center on a main function, but Rust needs no package clause or import for basic output. println! is a macro (the ! marks it), not a function — it expands at compile time and type-checks its format string, so a mismatched placeholder is a compile error rather than a runtime surprise.
Building & running
// Compile and run in one step: // go run main.go // Build a binary: // go build -o app main.go && ./app // Dependencies live in go.mod.
// Compile and run in one step: // cargo run // Build an optimised binary: // cargo build --release && ./target/release/app // Dependencies (crates) live in Cargo.toml.
Rust's cargo is the counterpart to the go tool: cargo run mirrors go run, and cargo build --release mirrors go build. Both compile to a single native binary with no runtime to ship. The big difference is dependencies — Cargo pulls libraries (crates) from crates.io, where Go modules are fetched directly from their version-control URLs.
Comments
package main import "fmt" func main() { // Single-line comment /* Block comment */ count := 0 fmt.Println(count) }
fn main() { // Single-line comment /* Block comment */ /// Doc comment (for the item below) let count = 0; println!("{}", count); }
Rust shares Go's // and /* */ comments and adds a documentation form: /// comments attach to the following item and are rendered by cargo doc into HTML, much like Go's convention of a comment immediately above a declaration, but with first-class tooling and Markdown support.
Imports & modules
package main import ( "fmt" "strings" ) func main() { fmt.Println(strings.ToUpper("rust")) }
use std::collections::HashMap; fn main() { let mut scores = HashMap::new(); scores.insert("rust", 10); println!("{:?}", scores); }
Rust's use brings a path into scope much like Go's import, but it pulls in a specific item (a type, function, or module) rather than a whole package, and you then refer to it by its short name. The standard library is the std crate; unlike Go, common collections like HashMap are not built-in keywords and must be brought in with use.
Variables & Types
Variables & mutability
package main import "fmt" func main() { count := 5 count = 10 // freely reassigned name := "Rust" fmt.Println(count, name) }
let mut count = 5; count = 10; // allowed because of `mut` let name = "Rust"; // immutable by default println!("{} {}", count, name);
This is a core mindset shift from Go. A Rust let binding is immutable by default — reassigning it is a compile error unless you opt in with let mut. Go variables are always mutable; Rust makes mutability visible and deliberate, which the compiler then uses to reason about aliasing and thread safety.
Shadowing
package main import ( "fmt" "strconv" ) func main() { value := "42" // Go: a new name is needed for the converted value. number, _ := strconv.Atoi(value) fmt.Println(number + 8) }
let value = "42"; let value: i32 = value.parse().unwrap(); println!("{}", value + 8);
Rust lets you shadow a name by declaring it again with let, even changing its type — here value goes from &str to i32 while reusing the name. Go forbids redeclaring a variable in the same scope, so a type change requires a fresh name. Shadowing keeps temporary transformations from cluttering the namespace.
Type inference
package main import "fmt" func main() { var ready bool = true count := 7 // inferred int ratio := 3.5 // inferred float64 fmt.Println(ready, count, ratio) }
let ready: bool = true; let count = 7; // inferred i32 let ratio = 3.5; // inferred f64 println!("{} {} {}", ready, count, ratio);
Both infer types from initializers, but Rust's inference is whole-function: it can deduce a variable's type from how it is used later, not just from its initializer. Where Go's := infers only from the right-hand side, Rust may leave a type open until a downstream call pins it down.
Constants
package main import "fmt" const MaxRetries = 3 const Greeting = "hello" func main() { fmt.Println(MaxRetries, Greeting) }
const MAX_RETRIES: u32 = 3; const GREETING: &str = "hello"; fn main() { println!("{} {}", MAX_RETRIES, GREETING); }
A Rust const must have an explicit type annotation and is inlined at every use, evaluated at compile time. Go constants can be untyped and take their type from context; Rust constants are always typed. The naming convention (SCREAMING_SNAKE_CASE) matches Go's exported-constant style.
Ownership & Borrowing
Move semantics
package main import "fmt" func main() { numbers := []int{1, 2, 3} other := numbers // both share the backing array other[0] = 99 fmt.Println(numbers[0]) // 99 — shared, freed by GC }
let numbers = vec![1, 2, 3]; let other = numbers; // ownership MOVES to `other` // println!("{:?}", numbers); // compile error: value moved println!("{:?}", other);
This is the defining difference. In Go, assigning a slice copies the header so both names share one GC-managed backing array. In Rust, assigning a Vec moves ownership: numbers is no longer usable, and the data is freed exactly once when other goes out of scope — no garbage collector, no double free, enforced at compile time.
Borrowing (shared references)
package main import "fmt" func sum(numbers []int) int { total := 0 for _, number := range numbers { total += number } return total } func main() { numbers := []int{1, 2, 3} fmt.Println(sum(numbers)) fmt.Println(numbers) // still usable }
fn sum(numbers: &Vec<i32>) -> i32 { let mut total = 0; for number in numbers { total += number; } total } let numbers = vec![1, 2, 3]; println!("{}", sum(&numbers)); println!("{:?}", numbers); // still usable: only borrowed
A &T is a borrow — temporary read access that does not take ownership. Passing &numbers lets sum read the vector while the caller keeps it. Go shares everything freely and lets the GC sort out lifetimes; Rust makes the borrow explicit and the compiler proves the data outlives every reference to it.
Mutable references
package main import "fmt" func double(numbers []int) { for index := range numbers { numbers[index] *= 2 } } func main() { numbers := []int{1, 2, 3} double(numbers) fmt.Println(numbers) // [2 4 6] }
fn double(numbers: &mut Vec<i32>) { for number in numbers.iter_mut() { *number *= 2; } } let mut numbers = vec![1, 2, 3]; double(&mut numbers); println!("{:?}", numbers); // [2, 4, 6]
A &mut T is an exclusive mutable borrow: Rust allows only one at a time and no shared &T while it is alive. That single rule eliminates data races at compile time. The *number dereference writes through the reference. Go lets any number of aliases mutate the same slice and trusts you to synchronise access yourself.
Slices as borrows
package main import "fmt" func main() { numbers := []int{10, 20, 30, 40} middle := numbers[1:3] fmt.Println(middle) // [20 30] }
let numbers = vec![10, 20, 30, 40]; let middle = &numbers[1..3]; println!("{:?}", middle); // [20, 30]
Both slice with a half-open range. A Go slice is a view that shares (and can grow back into) the backing array. A Rust &[T] is a borrow of a contiguous region, and the borrow checker forbids mutating numbers while middle is alive — so a slice can never outlive or dangle past the data it views.
Strings
String vs &str
package main import "fmt" func main() { greeting := "hello" // string (immutable) owned := greeting + " world" // a new string fmt.Println(owned) }
let greeting: &str = "hello"; // borrowed view let owned: String = greeting.to_string() + " world"; // heap-owned println!("{}", owned);
Go has one string type — an immutable byte view. Rust splits the idea in two: &str is a borrowed, fixed-size view (string literals are &'static str), while String is a growable, heap-owned buffer. You convert with to_string() or String::from, and a &String coerces to &str automatically when passed to functions.
Formatting
package main import "fmt" func main() { name := "Rust" year := 2010 fmt.Printf("%s since %d\n", name, year) line := fmt.Sprintf("%s-%d", name, year) fmt.Println(line) }
let name = "Rust"; let year = 2010; println!("{} since {}", name, year); let line = format!("{name}-{year}"); println!("{}", line);
Rust's println!/format! macros mirror Go's Printf/Sprintf, but the format string is checked at compile time and you can interpolate a variable inline as {name}. A missing argument or wrong type is a compile error, never Go's runtime %!s(int=...) output.
String methods
package main import ( "fmt" "strings" ) func main() { sentence := "the quick brown fox" fmt.Println(strings.ToUpper(sentence)) fmt.Println(strings.Split(sentence, " ")) fmt.Println(strings.Contains(sentence, "quick")) }
let sentence = "the quick brown fox"; println!("{}", sentence.to_uppercase()); let words: Vec<&str> = sentence.split(' ').collect(); println!("{:?}", words); println!("{}", sentence.contains("quick"));
Rust calls these as methods on the &str itself, like Go's strings package but member-style. split returns a lazy iterator, so you collect() it into a Vec — reflecting Rust's pervasive iterator model, where nothing allocates until you ask it to.
Characters vs bytes
package main import ( "fmt" "unicode/utf8" ) func main() { word := "héllo" fmt.Println(len(word)) // bytes fmt.Println(utf8.RuneCountInString(word)) // characters }
let word = "héllo"; println!("{}", word.len()); // bytes println!("{}", word.chars().count()); // characters for (index, character) in word.char_indices() { print!("{}:{} ", index, character); } println!();
Like Go, a Rust str is UTF-8 and len() returns bytes. Where Go reaches for the utf8 package or range, Rust offers chars() (an iterator of char) and char_indices(). Indexing a str by a single integer is deliberately a compile error, because a byte offset could land in the middle of a character.
Numbers
Sized integers & casts
package main import "fmt" func main() { var small int8 = 120 var big int64 = 9000000000 ratio := float64(small) / 7.0 fmt.Println(small, big, ratio) }
let small: i8 = 120; let big: i64 = 9_000_000_000; let ratio = small as f64 / 7.0; println!("{} {} {}", small, big, ratio);
Both have explicitly sized integers (i8/i64 mirror int8/int64) and require an explicit cast between numeric types — Rust spells it value as f64 rather than float64(value). Rust has no platform-width default like Go's int; the compiler infers i32 unless you annotate or the context demands otherwise. Underscores like 9_000_000_000 are digit separators.
Integer overflow
package main import "fmt" func main() { var value int8 = 127 value++ // silently wraps to -128 fmt.Println(value) }
let value: i8 = 127; let wrapped = value.wrapping_add(1); // explicit wrap -> -128 println!("{}", wrapped); // value + 1 would panic in a debug build (overflow check)
Go integer overflow silently wraps. Rust checks overflow in debug builds and panics, forcing you to opt into wrapping explicitly with wrapping_add (or checked_add/saturating_add). Release builds wrap like Go for speed, but the debug-time check catches accidental overflow during development rather than letting it corrupt a result silently.
Parsing numbers
package main import ( "fmt" "strconv" ) func main() { number, err := strconv.Atoi("42") if err == nil { fmt.Println(number + 8) } }
let number: i32 = "42".parse().unwrap(); println!("{}", number + 8); let maybe: Result<i32, _> = "oops".parse(); println!("{}", maybe.is_err());
Go's strconv.Atoi returns (int, error). Rust's str::parse returns a Result and infers the target type from context (the i32 annotation here), so the same method parses into any type that implements FromStr. unwrap() extracts the value or panics — fine for a demo, but real code matches on the Result.
Collections
Vec vs slice
package main import "fmt" func main() { numbers := []int{1, 2, 3} numbers = append(numbers, 4) fmt.Println(numbers, len(numbers)) }
let mut numbers = vec![1, 2, 3]; numbers.push(4); println!("{:?} {}", numbers, numbers.len());
Rust's Vec<T> is the counterpart to a growable Go slice: heap-allocated and homogeneous. push appends in place, which is why the binding must be mut, and the element type is part of the type, so a Vec<i32> can never accidentally hold a string. The vec! macro builds one from a literal list.
Fixed-size arrays
package main import "fmt" func main() { var fixed [3]int = [3]int{1, 2, 3} fmt.Println(fixed, len(fixed)) }
let fixed: [i32; 3] = [1, 2, 3]; println!("{:?} {}", fixed, fixed.len());
Both distinguish a fixed-size array from a growable sequence: Go's [3]int and Rust's [i32; 3] bake the length into the type. In Rust the array lives on the stack with a compile-time-known length, so indexing it past the end with a constant is caught at compile time rather than panicking at runtime.
HashMap vs map
package main import "fmt" func main() { ages := map[string]int{"Alice": 30} ages["Bob"] = 25 if age, ok := ages["Alice"]; ok { fmt.Println(age) } }
use std::collections::HashMap; let mut ages: HashMap<String, i32> = HashMap::new(); ages.insert(String::from("Alice"), 30); ages.insert(String::from("Bob"), 25); if let Some(age) = ages.get("Alice") { println!("{}", age); }
Rust's HashMap lives in std::collections rather than being a built-in keyword like Go's map. get returns an Option<&V> instead of Go's comma-ok pair, so you handle the missing-key case with if let or match — the type system makes "the key might be absent" impossible to forget.
Iterating with index
package main import "fmt" func main() { fruits := []string{"apple", "banana"} for index, fruit := range fruits { fmt.Printf("%d: %s\n", index, fruit) } }
let fruits = vec!["apple", "banana"]; for (index, fruit) in fruits.iter().enumerate() { println!("{}: {}", index, fruit); }
Where Go's range yields index and value directly, Rust composes iterator adapters: iter() borrows each element and enumerate() pairs it with its index. This adapter model — map, filter, zip, take, enumerate — is how Rust expresses the loops Go writes out by hand, and it compiles to the same tight code.
Control Flow
if as an expression
package main import "fmt" func main() { score := 75 grade := "fail" if score >= 60 { grade = "pass" } fmt.Println(grade) }
let score = 75; let grade = if score >= 60 { "pass" } else { "fail" }; println!("{}", grade);
In Rust if is an expression that produces a value, so you bind its result directly — there is no ternary operator because if already fills that role. Both branches must yield the same type, checked at compile time. Go's if is a statement, so it needs the separate declare-then-assign pattern.
match vs switch
package main import "fmt" func main() { day := 6 switch day { case 6, 7: fmt.Println("weekend") default: fmt.Println("weekday") } }
let day = 6; let kind = match day { 6 | 7 => "weekend", 1..=5 => "weekday", _ => "invalid", }; println!("{}", kind);
Rust's match is Go's switch taken further: it is an expression, it matches ranges (1..=5) and patterns, and it is exhaustive — the compiler rejects the code unless every possible value is handled, which is why the catch-all _ arm is required here. There is no implicit fall-through to guard against.
Loops
package main import "fmt" func main() { for index := 0; index < 3; index++ { fmt.Println(index) } }
for index in 0..3 { println!("{}", index); } let mut total = 0; loop { total += 1; if total == 3 { break; } } println!("total: {}", total);
Rust replaces the C-style for with for ... in over a range or iterator, and adds a dedicated loop keyword for an unconditional loop — which can even break with a value: let x = loop { break 5; };. Go folds all looping into for; Rust separates the bounded and unbounded cases into distinct, clearer constructs.
if let
package main import "fmt" func main() { inventory := map[string]int{"apples": 5} if count, ok := inventory["apples"]; ok { fmt.Println("have", count) } }
use std::collections::HashMap; let mut inventory = HashMap::new(); inventory.insert("apples", 5); if let Some(count) = inventory.get("apples") { println!("have {}", count); }
if let is Rust's shorthand for a match with a single interesting arm — it binds count only when get returns Some. It is the idiomatic replacement for Go's comma-ok pattern, and it pairs with else or with while let to handle the other shapes.
Functions & Closures
Defining functions
package main import "fmt" func greet(name string) string { return "Hello, " + name } func main() { fmt.Println(greet("Rust")) }
fn greet(name: &str) -> String { format!("Hello, {name}") } println!("{}", greet("Rust"));
A Rust fn annotates each parameter type and the return type after ->. The final expression without a semicolon is the return value (an explicit return also works). Accepting name: &str borrows the text instead of copying it — the idiomatic way to take read-only string input.
Closures
package main import "fmt" func main() { double := func(number int) int { return number * 2 } numbers := []int{1, 2, 3} doubled := []int{} for _, number := range numbers { doubled = append(doubled, double(number)) } fmt.Println(doubled) }
let double = |number: i32| number * 2; let numbers = vec![1, 2, 3]; let doubled: Vec<i32> = numbers.iter().map(|number| double(*number)).collect(); println!("{:?}", doubled);
Rust closures use |params| body and capture their environment automatically. Unlike Go's function literals, a closure's captured variables are borrowed or moved according to how they are used, and its type implements one of the Fn/FnMut/FnOnce traits — which is the mechanism by which functions accept closures generically.
Returning a closure
package main import "fmt" func makeAdder(amount int) func(int) int { return func(number int) int { return number + amount } } func main() { addTen := makeAdder(10) fmt.Println(addTen(5)) }
fn make_adder(amount: i32) -> impl Fn(i32) -> i32 { move |number| number + amount } let add_ten = make_adder(10); println!("{}", add_ten(5));
Returning a closure in Rust needs impl Fn(i32) -> i32 as the return type, and move forces the closure to take ownership of amount so it outlives the function call. Go infers all of this; Rust makes both the closure's trait and its capture mode (move vs borrow) explicit in the signature.
Iterator pipelines
package main import "fmt" func main() { numbers := []int{1, 2, 3, 4, 5} sum := 0 for _, number := range numbers { if number%2 == 0 { sum += number } } fmt.Println(sum) }
let numbers = vec![1, 2, 3, 4, 5]; let sum: i32 = numbers.iter().filter(|number| *number % 2 == 0).sum(); println!("{}", sum);
Rust's iterator adapters give it the map/filter/fold pipeline Go's standard library lacks. filter takes a closure and sum() consumes the iterator; the whole chain is lazy and compiles down to a single tight loop with no intermediate allocation — Rust's "zero-cost abstraction" in practice.
Structs & Enums
Structs
package main import "fmt" type Point struct { X, Y int } func main() { point := Point{X: 1, Y: 2} fmt.Printf("%+v\n", point) }
#[derive(Debug)] struct Point { x: i32, y: i32, } let point = Point { x: 1, y: 2 }; println!("{:?}", point);
Rust structs resemble Go's, but there are no zero values — every field must be given a value at construction — and fields are private to the module unless marked pub. The #[derive(Debug)] attribute auto-generates the {:?} formatting that Go provides through %+v.
Methods (impl)
package main import "fmt" type Rectangle struct { Width, Height int } func (rectangle Rectangle) Area() int { return rectangle.Width * rectangle.Height } func main() { fmt.Println(Rectangle{Width: 3, Height: 4}.Area()) }
struct Rectangle { width: i32, height: i32, } impl Rectangle { fn area(&self) -> i32 { self.width * self.height } } let rectangle = Rectangle { width: 3, height: 4 }; println!("{}", rectangle.area());
Rust groups a type's methods in a separate impl block instead of attaching each one with a receiver as Go does. The explicit &self is a borrow of the instance — &self to read, &mut self to mutate, self to consume — so each method's access to the object is visible in its signature.
Enums as sum types
package main import "fmt" // Go has no sum types; iota constants are the usual stand-in. type Direction int const ( North Direction = iota East South West ) func main() { fmt.Println(North, West) }
#[derive(Debug)] enum Direction { North, East, South, West, } let heading = Direction::West; println!("{:?}", heading);
This is one of Rust's biggest additions over Go. A Rust enum is a true sum type — a value is exactly one of its variants — where Go approximates the idea with iota integer constants that carry no guarantee of being valid. A match over an enum is checked for exhaustiveness, so adding a variant forces every match to be updated.
Enums carrying data
package main import "fmt" // Go models "a value or nothing" with a (value, ok) pair. func find(numbers []int, target int) (int, bool) { for index, number := range numbers { if number == target { return index, true } } return 0, false } func main() { fmt.Println(find([]int{10, 20, 30}, 20)) }
fn find(numbers: &[i32], target: i32) -> Option<usize> { for (index, number) in numbers.iter().enumerate() { if *number == target { return Some(index); } } None } println!("{:?}", find(&[10, 20, 30], 20));
Rust enum variants can carry data, which is how Option<usize> models "an index or nothing" without Go's (value, bool) pair or a nil pointer. The caller must unwrap the Option (via match, if let, or unwrap), so a missing value can never be silently mistaken for a real one.
Traits
Traits vs interfaces
package main import "fmt" type Speaker interface { Speak() string } type Dog struct{} func (dog Dog) Speak() string { return "woof" } func announce(speaker Speaker) { fmt.Println(speaker.Speak()) } func main() { announce(Dog{}) }
trait Speaker { fn speak(&self) -> String; } struct Dog; impl Speaker for Dog { fn speak(&self) -> String { String::from("woof") } } fn announce(speaker: &impl Speaker) { println!("{}", speaker.speak()); } announce(&Dog);
A Rust trait is like a Go interface, but a type must implement it explicitly with impl Speaker for Dog rather than satisfying it structurally just by having the right method. That makes the relationship intentional, and it lets traits carry default methods and associated types that Go interfaces cannot express.
Default methods
package main import "fmt" // Go interfaces cannot provide default methods; // you embed a struct or write a free helper instead. type Named interface { Name() string } func Greet(named Named) string { return "Hi, " + named.Name() } type Person struct{ name string } func (person Person) Name() string { return person.name } func main() { fmt.Println(Greet(Person{name: "Sam"})) }
trait Named { fn name(&self) -> String; fn greet(&self) -> String { format!("Hi, {}", self.name()) } } struct Person { name: String, } impl Named for Person { fn name(&self) -> String { self.name.clone() } } let person = Person { name: String::from("Sam") }; println!("{}", person.greet());
A Rust trait can supply default method bodies, so greet works for any implementer that provides name(). Go interfaces are pure contracts with no implementation, so shared behaviour there comes from struct embedding or free functions rather than from the interface itself.
Deriving traits
package main import "fmt" type Point struct{ X, Y int } func main() { first := Point{1, 2} second := Point{1, 2} fmt.Println(first == second) // comparable field-by-field }
#[derive(Debug, PartialEq, Clone)] struct Point { x: i32, y: i32, } let first = Point { x: 1, y: 2 }; let second = first.clone(); println!("{}", first == second);
The #[derive(...)] attribute auto-implements common traits — PartialEq for ==, Clone for explicit copies, Debug for {:?}. Go gives every comparable struct == and printing for free; Rust makes each capability opt-in, which is why copying needs an explicit .clone() and equality needs the derive.
Trait objects
package main import "fmt" type Shape interface{ Area() float64 } type Circle struct{ Radius float64 } func (circle Circle) Area() float64 { return 3.14159 * circle.Radius * circle.Radius } type Square struct{ Side float64 } func (square Square) Area() float64 { return square.Side * square.Side } func main() { shapes := []Shape{Circle{2}, Square{3}} for _, shape := range shapes { fmt.Printf("%.2f\n", shape.Area()) } }
trait Shape { fn area(&self) -> f64; } struct Circle { radius: f64 } impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius } } struct Square { side: f64 } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), ]; for shape in &shapes { println!("{:.2}", shape.area()); }
A Go interface value is implicitly a fat pointer that dispatches at runtime. Rust makes that explicit: a heterogeneous collection needs Box<dyn Shape>, where dyn marks dynamic dispatch and Box gives each differently-sized value a uniform heap pointer. The default in Rust is static dispatch via generics; dyn is the opt-in for Go-style runtime polymorphism.
Error Handling
Result vs (value, error)
package main import ( "errors" "fmt" ) func divide(numerator, denominator int) (int, error) { if denominator == 0 { return 0, errors.New("division by zero") } return numerator / denominator, nil } func main() { result, err := divide(10, 2) if err != nil { fmt.Println(err) } else { fmt.Println(result) } }
fn divide(numerator: i32, denominator: i32) -> Result<i32, String> { if denominator == 0 { return Err(String::from("division by zero")); } Ok(numerator / denominator) } match divide(10, 2) { Ok(result) => println!("{}", result), Err(message) => println!("{}", message), }
Rust folds Go's (value, error) pair into a single Result<T, E> enum — either Ok(value) or Err(error), never both and never neither. Because it is one value, the compiler can force you to handle the Err case (here via match), unlike Go where silently ignoring the returned err compiles cleanly.
The ? operator
package main import ( "fmt" "strconv" ) func parseAndDouble(text string) (int, error) { number, err := strconv.Atoi(text) if err != nil { return 0, err } return number * 2, nil } func main() { fmt.Println(parseAndDouble("21")) }
fn parse_and_double(text: &str) -> Result<i32, std::num::ParseIntError> { let number: i32 = text.parse()?; Ok(number * 2) } println!("{:?}", parse_and_double("21"));
The ? operator is Rust's answer to Go's repetitive if err != nil { return ..., err }. Applied to a Result, ? unwraps an Ok or returns the Err early from the enclosing function, collapsing the propagation boilerplate into one character while keeping it explicit at each call site.
Option vs nil
package main import "fmt" func first(numbers []int) (int, bool) { if len(numbers) == 0 { return 0, false } return numbers[0], true } func main() { if value, ok := first([]int{}); ok { fmt.Println(value) } else { fmt.Println("empty") } }
fn first(numbers: &[i32]) -> Option<i32> { numbers.first().copied() } match first(&[]) { Some(value) => println!("{}", value), None => println!("empty"), }
Rust has no nil. Absence is modeled by Option<T>Some(value) or None — so there is no null pointer to dereference by accident; the "billion-dollar mistake" is simply absent from the type system. A Go nil slice, map, or pointer can still be ranged or dereferenced, whereas a Rust None must be unwrapped before you can reach a value.
Panic
package main import "fmt" func mustBePositive(number int) int { if number < 0 { panic("negative value") } return number } func main() { defer func() { if recovered := recover(); recovered != nil { fmt.Println("recovered:", recovered) } }() fmt.Println(mustBePositive(-1)) }
fn must_be_positive(number: i32) -> i32 { if number < 0 { panic!("negative value"); } number } let outcome = std::panic::catch_unwind(|| must_be_positive(-1)); println!("did it panic? {}", outcome.is_err());
Both have panic for unrecoverable bugs, distinct from Result/error for expected failures. Rust's panic! unwinds the stack like Go's panic, and catch_unwind can intercept it — though doing so is rare and discouraged. The cultural rule matches Go's: reserve panics for "this should never happen," not for ordinary error flow.
let-else early return
package main import ( "fmt" "strconv" ) func main() { parsed, err := strconv.Atoi("42") if err != nil { fmt.Println("not a number") return } fmt.Println(parsed * 2) }
fn main() { let text = "42"; let Ok(number) = text.parse::<i32>() else { println!("not a number"); return; }; println!("{}", number * 2); }
A let ... else binding mirrors Go's if err != nil { return } guard: it pattern-matches on the happy path and binds the result in the surrounding scope, and if the pattern does not match it runs the else block, which must diverge (return, break, continue, or panic!). Unlike if let, whose binding is confined to its own block, the binding from let-else stays in scope afterward, so the rest of the function reads top-to-bottom with no extra indentation.
Pattern Matching
Destructuring
package main import "fmt" func main() { point := [2]int{0, 5} switch { case point[0] == 0 && point[1] == 0: fmt.Println("origin") case point[0] == 0: fmt.Printf("y-axis at %d\n", point[1]) default: fmt.Println("elsewhere") } }
let point = (0, 5); match point { (0, 0) => println!("origin"), (0, y) => println!("y-axis at {y}"), (x, 0) => println!("x-axis at {x}"), (x, y) => println!("at {x},{y}"), }
Rust's match destructures the tuple and binds variables in each arm — (0, y) matches any pair whose first element is 0 and captures the second as y. Go's switch has no destructuring, so the same logic becomes a series of manual index comparisons.
Match guards
package main import "fmt" func main() { number := 7 switch { case number < 0: fmt.Println("negative") case number%2 == 0: fmt.Println("even") default: fmt.Println("odd") } }
let number = 7; let description = match number { value if value < 0 => "negative", value if value % 2 == 0 => "even", _ => "odd", }; println!("{}", description);
A match arm can carry an if guard — value if value % 2 == 0 — that adds a boolean condition on top of the pattern. Combined with bindings and ranges, guards let a single match express what Go writes as a conditionless switch with boolean cases.
Matching enum data
package main import "fmt" // A tagged union in Go: a struct with a kind field. type Shape struct { Kind string Radius float64 Side float64 } func main() { shape := Shape{Kind: "circle", Radius: 2} if shape.Kind == "circle" { fmt.Printf("%.2f\n", 3.14159*shape.Radius*shape.Radius) } }
enum Shape { Circle { radius: f64 }, Square { side: f64 }, } let shape = Shape::Circle { radius: 2.0 }; let area = match shape { Shape::Circle { radius } => 3.14159 * radius * radius, Shape::Square { side } => side * side, }; println!("{:.2}", area);
Matching a data-carrying enum extracts its fields in the same step that selects the variant — Shape::Circle { radius } both confirms the variant and binds radius. This replaces Go's tag-field-plus-conditional idiom, and exhaustiveness checking guarantees every shape is handled, so a new variant cannot be silently forgotten.
if-let chains
package main import "fmt" func main() { settings := map[string]int{"timeout": 30} // Go folds the comma-ok test and a comparison into one if: if value, ok := settings["timeout"]; ok && value > 10 { fmt.Println("long timeout:", value) } }
use std::collections::HashMap; fn main() { let settings: HashMap<&str, i32> = [("timeout", 30)].into_iter().collect(); // Edition 2024: chain a let pattern and a bool test with && if let Some(&value) = settings.get("timeout") && value > 10 { println!("long timeout: {value}"); } }
Stabilised in the 2024 edition, let chains let a let pattern and ordinary boolean conditions be joined with && inside one if — the direct counterpart to Go's if value, ok := m[k]; ok && value > 10. Before 2024 this required a nested if let { if value > 10 { ... } }. Bindings introduced by an earlier link are in scope for later links, so value is usable in the comparison that follows it.
Generics
Generic functions
package main import "fmt" func Max[T int | float64](first, second T) T { if first > second { return first } return second } func main() { fmt.Println(Max(3, 7)) fmt.Println(Max(2.5, 1.5)) }
fn max<T: PartialOrd>(first: T, second: T) -> T { if first > second { first } else { second } } println!("{}", max(3, 7)); println!("{}", max(2.5, 1.5));
Both languages now have generics with constraints. Rust's trait bound T: PartialOrd (which supplies the > comparison) is the analogue of Go's type-set constraint int | float64, but it composes from ordinary traits and works for any ordered type without listing them — and it is monomorphised, so there is no runtime dispatch cost.
Generic structs
package main import "fmt" type Pair[T any] struct { First, Second T } func main() { pair := Pair[string]{First: "a", Second: "b"} fmt.Println(pair.First, pair.Second) }
struct Pair<T> { first: T, second: T, } let pair = Pair { first: "a", second: "b" }; println!("{} {}", pair.first, pair.second);
Generic structs look nearly identical: Pair<T> versus Pair[T any]. Rust infers the type argument from the values at construction (no Pair::<&str> needed here), and because generics are monomorphised, Pair<&str> and Pair<i32> compile to distinct, fully-optimised types.
Bounds require capabilities
package main import "fmt" func PrintAll[T any](items []T) { for _, item := range items { fmt.Println(item) } } func main() { PrintAll([]int{1, 2, 3}) }
use std::fmt::Display; fn print_all<T: Display>(items: &[T]) { for item in items { println!("{}", item); } } print_all(&[1, 2, 3]);
To print T generically, Rust requires the bound T: Display (the trait behind {}), because nothing is printable by default — every capability is opted into through a trait. Go's any needs no such bound because fmt.Println uses runtime reflection, trading Rust's compile-time guarantees for flexibility.
Const generics
package main import "fmt" // Go generics parameterize over types, not values, so a slice's // length is a runtime property, not part of its type. func sum(values []int) int { total := 0 for _, value := range values { total += value } return total } func main() { fmt.Println(sum([]int{1, 2, 3})) fmt.Println(sum([]int{1, 2, 3, 4, 5})) }
// N is a compile-time constant: each array length is its own type, // yet one definition covers them all. fn sum<const N: usize>(values: [i32; N]) -> i32 { values.iter().sum() } fn main() { println!("{}", sum([1, 2, 3])); println!("{}", sum([1, 2, 3, 4, 5])); }
Const generics let a definition be parameterized over a value — here the array length N — not just over types, something Go's generics cannot express. Because the length is part of the type and known at compile time, [i32; 3] and [i32; 5] are distinct types served by one sum, with the data living inline on the stack and no heap allocation or runtime length field.
Concurrency
Threads vs goroutines
package main import "fmt" func main() { done := make(chan bool) go func() { fmt.Println("hello from goroutine") done <- true }() <-done fmt.Println("main done") }
use std::thread; let handle = thread::spawn(|| { println!("hello from thread"); }); handle.join().unwrap(); println!("main done");
Go's go schedules a lightweight goroutine onto its runtime; Rust's thread::spawn starts a real OS thread and returns a JoinHandle you join() to wait on. Rust threads are heavier than goroutines and need no runtime, and crucially the spawned closure's captures are checked for thread-safety at compile time.
Channels
package main import "fmt" func main() { messages := make(chan int) go func() { for index := 0; index < 3; index++ { messages <- index * index } close(messages) }() for value := range messages { fmt.Println(value) } }
use std::sync::mpsc; use std::thread; let (sender, receiver) = mpsc::channel(); thread::spawn(move || { for index in 0..3 { sender.send(index * index).unwrap(); } }); for value in receiver { println!("{}", value); }
Rust's std::sync::mpsc gives Go-style channels: send() pushes, and iterating the receiver yields values until every sender is dropped (the move closure takes the sender, so the loop ends when the thread finishes). The name means "multiple producer, single consumer," but the share-by-communicating model is the same as Go's.
Shared state
package main import ( "fmt" "sync" ) func main() { var mutex sync.Mutex counter := 0 var waitGroup sync.WaitGroup for index := 0; index < 5; index++ { waitGroup.Add(1) go func() { defer waitGroup.Done() mutex.Lock() counter++ mutex.Unlock() }() } waitGroup.Wait() fmt.Println(counter) }
use std::sync::{Arc, Mutex}; use std::thread; let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..5 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { let mut value = counter.lock().unwrap(); *value += 1; })); } for handle in handles { handle.join().unwrap(); } println!("{}", *counter.lock().unwrap());
To share state across Rust threads you wrap it in Arc<Mutex<T>>Arc is an atomically reference-counted pointer (each thread owns a handle) and Mutex guards the data. The compiler refuses to share anything not proven thread-safe, so the lock is not optional discipline as in Go but a requirement to even reach the value.
Collecting parallel results
package main import ( "fmt" "sync" ) func main() { numbers := []int{1, 2, 3, 4} results := make([]int, len(numbers)) var waitGroup sync.WaitGroup for index, number := range numbers { waitGroup.Add(1) go func(position, value int) { defer waitGroup.Done() results[position] = value * value }(index, number) } waitGroup.Wait() fmt.Println(results) }
use std::thread; let numbers = vec![1, 2, 3, 4]; let mut handles = vec![]; for number in numbers { handles.push(thread::spawn(move || number * number)); } let results: Vec<i32> = handles .into_iter() .map(|handle| handle.join().unwrap()) .collect(); println!("{:?}", results);
A Rust JoinHandle<T> carries the thread's return value, so collecting each handle.join() gathers the results with no shared mutable slice and no WaitGroup. The move closure takes ownership of number, and the auto-derived Send trait on i32 is what lets the compiler prove the value may safely cross the thread boundary.
async / await
package main import "fmt" func double(value int) int { return value * 2 } func main() { // Go's concurrency is goroutines and channels — there is no // async/await keyword; the runtime schedules goroutines for you. done := make(chan int) go func() { done <- double(21) }() fmt.Println(<-done) }
use std::future::Future; use std::pin::pin; use std::task::{Context, Poll, Waker}; // std has async/await syntax but ships no executor, so here is a tiny one. // Real programs reach for tokio or async-std instead of hand-rolling this. fn block_on<F: Future>(future: F) -> F::Output { let mut future = pin!(future); let mut context = Context::from_waker(Waker::noop()); loop { if let Poll::Ready(value) = future.as_mut().poll(&mut context) { return value; } } } async fn double(value: i32) -> i32 { value * 2 } fn main() { let result = block_on(async { let first = double(21).await; first }); println!("{result}"); }
An async fn returns a Future — a value that does nothing until it is polled, which .await drives to completion. This is the opposite of a Go goroutine, which the runtime starts and schedules eagerly. Rust deliberately ships no built-in executor in std, so a future needs a runtime such as tokio to actually run; the few lines of block_on here are a minimal stand-in that polls one future on the current thread. The payoff is that async tasks are cheap state machines with no per-task stack.