PONYλM2Modula-2

Ruby.CodeCompared.To/Rust

An interactive executable cheatsheet for Rubyists learning Rust

Ruby 4.0 Rust 1.97.1
Syntax Basics
Variables & Printing
Rust variables are immutable by default — reassignment requires let mut. Ruby has no equivalent: variables can always be reassigned, and most objects are mutable. (Ruby 4.0 freezes string literals by default, but that is object-level freezing of one class — arrays, hashes, and other objects stay mutable — not Rust's binding-level immutability.)
name = "Alice" age = 30 puts "Hello, #{name}! You are #{age} years old." message = "string literals are frozen in Ruby 4.0" puts message.frozen?
fn main() { let name = "Alice"; let age = 30; // Named format captures (Rust 1.58+) println!("Hello, {name}! You are {age} years old."); // Variables are immutable by default — `let mut` to allow reassignment let mut counter = 0; counter += 1; println!("counter: {counter}"); }
Rust's println! is a macro (note the !), not a function — it is checked at compile time. Named format captures like {name} work without extra arguments.
Variable Shadowing
Ruby reassignment reuses the same variable. Rust shadowing re-declares the name with a fresh let, creating a conceptually new binding.
value = 5 value = value.to_s # reassign — same name, new type puts value puts value.class
fn main() { let value = 5; // Shadowing: re-declare with `let` — new binding, can change type let value = value.to_string(); println!("{value}"); println!("{}", std::any::type_name::<String>()); }
Shadowing is not mutation — the previous binding still existed; it is simply no longer reachable by name. Unlike let mut, a shadowing let can change the variable's type.
Types & Data
Basic Types
Rust is statically typed with type inference — every type is known at compile time, but annotations are rarely required. There is no nil; absent values use Option<T>.
integer = 42 float = 3.14 boolean = true text = "hello" nothing = nil puts integer.class # Integer puts float.class # Float puts boolean.class # TrueClass puts text.class # String puts nothing.class # NilClass
fn main() { let integer: i32 = 42; let float: f64 = 3.14; let boolean: bool = true; let text: &str = "hello"; // No nil — use Option<T> instead (see Option & Result section) println!("{integer} {float} {boolean} {text}"); // Signed: i8 i16 i32 i64 i128 isize // Unsigned: u8 u16 u32 u64 u128 usize // Float: f32 f64 println!("i32 size: {} bytes", std::mem::size_of::<i32>()); println!("f64 size: {} bytes", std::mem::size_of::<f64>()); }
Integer overflow panics in debug builds and wraps in release builds — use the checked_* / saturating_* methods when overflow is a real possibility.
Type Casting
Unlike Ruby, Rust never silently coerces between numeric types — every conversion is written out, with as for numeric casts.
puts 42.to_f # => 42.0 puts 3.14.to_i # => 3 puts 42.to_s # => "42" puts "99".to_i # => 99 puts Integer("0xFF", 16) # => 255
fn main() { println!("{}", 42_i32 as f64); // 42.0 println!("{}", 3.14_f64 as i32); // 3 — truncates toward zero println!("{}", 42.to_string()); // "42" println!("{}", "99".parse::<i32>().unwrap()); // 99 println!("{}", i32::from_str_radix("FF", 16).unwrap()); // 255 }
An as cast is infallible but potentially lossy (3.9 as i32 is 3). Parsing from a string returns a Result and therefore requires error handling.
Strings
Two String Types
Ruby has one string class; Rust has two string types. &str is a borrowed reference to UTF-8 bytes; String is an owned, heap-allocated, growable buffer.
# Ruby has one string type frozen_str = "hello" # frozen in Ruby 4.0 mutable_str = String.new("hello") mutable_str << " world" puts frozen_str puts mutable_str puts frozen_str.frozen?
fn main() { // &str — immutable borrowed string slice (static or borrowed) let slice: &str = "hello"; // String — owned, heap-allocated, growable let mut owned = String::from("hello"); owned.push_str(" world"); println!("{slice}"); println!("{owned}"); println!("len: {}", slice.len()); }
Use &str for read-only access and function parameters; use String when building or mutating text. Convert with "hello".to_string() or String::from("hello"), and go back with &my_string.
String Operations
Watch out: the + operator on String consumes the left-hand side — after let message = greeting + ..., greeting is gone. format!() never takes ownership and is generally preferred.
greeting = "Hello" name = "World" puts greeting + ", " + name + "!" puts "#{greeting}, #{name}!" puts "hello world".upcase puts " hello ".strip puts "hello world".split(" ").inspect puts "ha" * 3 puts "hello".include?("ell") puts "hello world".gsub("world", "Rust")
fn main() { let greeting = String::from("Hello"); let name = "World"; // + consumes the left String let message = greeting + ", " + name + "!"; println!("{message}"); // format! never takes ownership — preferred let g2 = "Hello"; println!("{}", format!("{g2}, {name}!")); println!("{}", "hello world".to_uppercase()); println!("{}", " hello ".trim()); println!("{:?}", "hello world".split(' ').collect::<Vec<_>>()); println!("{}", "ha".repeat(3)); println!("{}", "hello".contains("ell")); println!("{}", "hello world".replace("world", "Rust")); }
String indexing by integer (e.g. text[0]) is not allowed in Rust because UTF-8 characters are variable-width; use .chars().nth(n) instead.
Collections
Vec (Array)
Rust's growable array is Vec<T>, built with the vec![] macro. (Fixed-size arrays [T; N] also exist, with a length known at compile time.)
numbers = [1, 2, 3, 4, 5] numbers.push(6) puts numbers.first puts numbers.last puts numbers.length puts numbers.include?(3) puts numbers[1..3].inspect numbers.sort! puts numbers.inspect
fn main() { let mut numbers = vec![1, 2, 3, 4, 5]; numbers.push(6); println!("{:?}", numbers.first()); // Some(1) println!("{:?}", numbers.last()); // Some(6) println!("{}", numbers.len()); println!("{}", numbers.contains(&3)); println!("{:?}", &numbers[1..=3]); // slice numbers.sort(); println!("{numbers:?}"); }
Index access panics if out of bounds; .get(i) returns Option<&T> for safe access. first() and last() likewise return Option where Ruby returns nil.
HashMap (Hash)
HashMap is not in Rust's prelude — it must be brought into scope with use std::collections::HashMap. Unlike a Ruby Hash, iteration order is not guaranteed.
scores = { "Alice" => 95, "Bob" => 87 } scores["Carol"] = 92 puts scores["Alice"] puts scores.key?("Bob") puts scores.keys.sort.inspect scores.each { |name, score| puts "#{name}: #{score}" } puts scores.values.sum
use std::collections::HashMap; fn main() { let mut scores: HashMap<&str, i32> = HashMap::new(); scores.insert("Alice", 95); scores.insert("Bob", 87); scores.insert("Carol", 92); println!("{:?}", scores.get("Alice")); // Some(95) println!("{}", scores.contains_key("Bob")); let mut keys: Vec<&&str> = scores.keys().collect(); keys.sort(); println!("{keys:?}"); for (name, score) in &scores { println!("{name}: {score}"); } let total: i32 = scores.values().sum(); println!("{total}"); }
scores["Alice"] would panic on a missing key; .get("Alice") returns Option<&V> instead. The .entry(key).or_insert(value) API is idiomatic for insert-if-absent.
Tuples
Ruby has no dedicated tuple type — small arrays play that role. Rust tuples have a fixed length, may mix types, and are accessed by position: .0, .1, and so on.
# Ruby uses arrays for tuples point = [3, 4] person = ["Alice", 30, true] puts point[0] puts person[1] x, y = point puts "#{x}, #{y}"
fn main() { let point: (i32, i32) = (3, 4); let person: (&str, u32, bool) = ("Alice", 30, true); println!("{}", point.0); println!("{}", person.1); // Destructuring — like Ruby parallel assignment let (x, y) = point; println!("{x}, {y}"); // Unit type () — zero-element tuple, implicit return of void functions let nothing: () = (); println!("{nothing:?}"); }
Destructuring works like Ruby's parallel assignment. The empty tuple () is the "unit type" — the implicit return value of functions that return nothing meaningful.
Const generics
Const generics let a definition be parameterized over a value — here the array length N — not just over types. Ruby never encodes length in a type at all (every Array is dynamic), so this has no Ruby analogue.
def sum(values) = values.sum # Ruby arrays are dynamic — length is never part of a "type". puts sum([1, 2, 3]) puts sum([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])); }
Because the length is known at compile time, [i32; 3] and [i32; 5] are distinct types served by one sum, stored inline on the stack with no heap allocation and no separate length field.
Control Flow
if / elsif / else
In both languages, if is an expression that returns a value — the whole chain can sit on the right-hand side of an assignment.
score = 85 grade = if score >= 90 then "A" elsif score >= 80 then "B" elsif score >= 70 then "C" else "F" end puts grade
fn main() { let score = 85; // if is an expression — returns a value let grade = if score >= 90 { "A" } else if score >= 80 { "B" } else if score >= 70 { "C" } else { "F" }; println!("{grade}"); }
Rust requires curly braces around every branch and has no then keyword. All branches must return the same type; if one returns "A", all must return &str.
match (case / when)
Rust's match is exhaustive — the compiler rejects a match that fails to cover every possible case, which is why a _ catch-all arm (like Ruby's bare else) appears below.
status = :pending message = case status when :pending then "Waiting..." when :active then "Running!" when :done then "Finished." else "Unknown" end puts message age = 25 category = case age when 0..12 then "child" when 13..17 then "teen" when 18..64 then "adult" else "senior" end puts category
fn main() { let status = "pending"; let message = match status { "pending" => "Waiting...", "active" => "Running!", "done" => "Finished.", _ => "Unknown", // _ is the catch-all }; println!("{message}"); let age: u32 = 25; let category = match age { 0..=12 => "child", 13..=17 => "teen", 18..=64 => "adult", _ => "senior", }; println!("{category}"); }
Ranges use ..= for an inclusive end (like Ruby's ..). Unlike Ruby's case, all arms must return the same type.
Loops
Rust has no times method — the idiom is for i in 0..n. Ranges are exclusive at the end by default (0..3 yields 0, 1, 2); ..= makes the end inclusive.
3.times { |i| puts i } count = 0 while count < 5 count += 1 end puts count result = loop do count += 1 break count * 10 if count > 7 end puts result (1..5).each { |n| print "#{n} " } puts
fn main() { for i in 0..3 { println!("{i}"); } // 0..3 = 0,1,2 (exclusive end) let mut count = 0; while count < 5 { count += 1; } println!("{count}"); // loop returns a value via break let result = loop { count += 1; if count > 7 { break count * 10; } }; println!("{result}"); // Inclusive range for n in 1..=5 { print!("{n} "); } println!(); }
loop is Rust's infinite loop — it can break with a value, the closest equivalent to Ruby's loop { break value if ... }.
if-let chains
Stabilized in the 2024 edition, let chains let a let pattern and ordinary boolean tests be joined with && in one if — much like Ruby's if (value = settings[:timeout]) && value > 10, except the Rust version also confirms the key exists (Some(..)) in the same breath.
settings = { timeout: 30 } # Ruby folds the lookup-and-bind and the comparison into one if: if (value = settings[:timeout]) && value > 10 puts "long timeout: #{value}" end
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}"); } }
Before the 2024 edition this needed a nested if let { if value > 10 { ... } }. A binding from an earlier link is visible to later links, so value can be compared right after it is bound.
Functions
Defining Functions
Rust annotates the return type with -> and has no keyword arguments, so the Ruby greeting: parameter below becomes a plain positional one.
def add(a, b) a + b # implicit return end # One-liner (Ruby 3+) def square(n) = n * n # Default / keyword arguments def greet(name, greeting: "Hello") "#{greeting}, #{name}!" end puts add(2, 3) puts square(5) puts greet("Alice") puts greet("Bob", greeting: "Hi")
fn add(a: i32, b: i32) -> i32 { a + b // no semicolon = expression = return value } fn square(n: i32) -> i32 { n * n } // No keyword args — use a struct or builder for many optional params fn greet(name: &str, greeting: &str) -> String { format!("{greeting}, {name}!") } fn main() { println!("{}", add(2, 3)); println!("{}", square(5)); println!("{}", greet("Alice", "Hello")); println!("{}", greet("Bob", "Hi")); }
The last expression in a block without a semicolon is the return value — explicit return is valid but mainly used for early exit. For many optional parameters, the idioms are a builder pattern or a struct with Default.
Multiple Return Values
Both languages return multiple values the same way: bundle them (a tuple in Rust, an array in Ruby) and destructure on the receiving side.
def min_max(numbers) [numbers.min, numbers.max] end minimum, maximum = min_max([3, 1, 4, 1, 5, 9]) puts minimum puts maximum
fn min_max(numbers: &[i32]) -> (i32, i32) { let min = *numbers.iter().min().unwrap(); let max = *numbers.iter().max().unwrap(); (min, max) } fn main() { let (minimum, maximum) = min_max(&[3, 1, 4, 1, 5, 9]); println!("{minimum}"); println!("{maximum}"); }
&[i32] is a slice — a borrowed view into any contiguous sequence of i32, whether from a Vec or a fixed array. It is the idiomatic parameter type for "read a sequence".
Closures / Blocks
Closures as Values
Rust closures use |params| syntax where Ruby lambdas use ->(params), and they capture their environment by reference by default.
double = ->(n) { n * 2 } square = ->(n) { n ** 2 } puts double.call(5) puts square.(4) numbers = [1, 2, 3, 4, 5] puts numbers.map(&double).inspect puts numbers.select { |n| n.odd? }.inspect puts numbers.reduce(0) { |sum, n| sum + n }
fn main() { let double = |n: i32| n * 2; let square = |n: i32| n * n; println!("{}", double(5)); println!("{}", square(4)); let numbers = vec![1, 2, 3, 4, 5]; let doubled: Vec<i32> = numbers.iter().map(|&n| double(n)).collect(); println!("{doubled:?}"); let odds: Vec<&i32> = numbers.iter().filter(|&&n| n % 2 != 0).collect(); println!("{odds:?}"); let sum: i32 = numbers.iter().sum(); println!("{sum}"); }
Add move before | to capture by value instead (required when the closure outlives its scope, e.g. in threads). Rust distinguishes Fn, FnMut, and FnOnce — the compiler infers which applies.
Higher-Order Functions
Where Ruby just takes a block or returns a lambda, Rust spells out the closure's interface: a generic bound (<F: Fn(...)>) to accept one, and impl Fn(...) — "some type implementing this closure trait" — to return one.
def apply_twice(value, &block) block.call(block.call(value)) end result = apply_twice(3) { |n| n * 2 } puts result # 12 def make_adder(n) ->(x) { x + n } end add5 = make_adder(5) puts add5.call(10) # 15 puts add5.call(20) # 25
fn apply_twice<F: Fn(i32) -> i32>(value: i32, function: F) -> i32 { function(function(value)) } fn make_adder(n: i32) -> impl Fn(i32) -> i32 { move |x| x + n } fn main() { let result = apply_twice(3, |n| n * 2); println!("{result}"); // 12 let add5 = make_adder(5); println!("{}", add5(10)); // 15 println!("{}", add5(20)); // 25 }
move is required in make_adder so the closure captures n by value and can outlive the function call. Trait objects (dyn Fn(...)) are the alternative when the closure type must be erased, e.g. stored in a collection.
Ownership & Borrowing
Ownership & Move
This is the biggest conceptual shift for Rubyists: Rust's ownership system replaces garbage collection. Assigning a value or passing it to a function moves it unless the type implements Copy.
# Ruby manages memory with garbage collection name = "Alice" greeting = name # both point to same object puts greeting puts name # both still valid original = String.new("hello") copy = original.dup copy.upcase! puts original # "hello" unchanged puts copy # "HELLO"
fn main() { // Each value has exactly one owner; assignment MOVES ownership let name = String::from("Alice"); let greeting = name; // `name` is moved into `greeting` // println!("{name}"); // compile error: value used after move println!("{greeting}"); // clone() makes a deep copy so both remain valid let original = String::from("hello"); let copy = original.clone(); println!("{original}"); // still valid println!("{}", copy.to_uppercase()); // Copy types (i32, bool, f64, char...) are always copied, not moved let x = 42; let y = x; println!("{x} {y}"); // both valid — i32 implements Copy }
After a move, the original binding is invalid — the compiler enforces this, as the commented-out println!("{name}") shows. clone() explicitly makes a deep copy when both bindings must stay valid.
Borrowing & References
Instead of taking ownership, a Rust function can borrow its argument: &T is an immutable reference, &mut T a mutable one.
def string_length(text) text.length # Ruby passes a reference automatically end greeting = "hello world" puts string_length(greeting) puts greeting # still valid
fn string_length(text: &str) -> usize { text.len() // borrows text — does not take ownership } fn append_exclamation(text: &mut String) { text.push('!'); } fn main() { let greeting = String::from("hello world"); println!("{}", string_length(&greeting)); println!("{greeting}"); // still valid — we only borrowed it let mut message = String::from("hello"); append_exclamation(&mut message); println!("{message}"); // "hello!" }
Any number of immutable borrows can exist simultaneously, but only one mutable borrow — and never alongside immutable ones. The borrow checker enforces these rules at compile time. Ruby achieves memory safety via runtime garbage collection; Rust achieves it at zero runtime cost.
Option & Result
Option (no nil)
Option<T> is Rust's explicit representation of a value that may be absent: Some(T) holds a value; None represents absence. Every Ruby nil idiom below has an Option counterpart.
users = { "alice" => 30, "bob" => 25 } age = users["alice"] # => 30 missing = users["carol"] # => nil puts missing.nil? # Safe navigation operator (chain &. through each call) puts missing&.to_s&.upcase # nil — no NoMethodError # Default name = nil puts name || "anonymous"
use std::collections::HashMap; fn main() { let mut users = HashMap::new(); users.insert("alice", 30_u32); users.insert("bob", 25_u32); let age: Option<&u32> = users.get("alice"); let missing: Option<&u32> = users.get("carol"); println!("{}", missing.is_none()); // true // map — like &. (safe navigation): transform Some, pass None through let upper = missing.map(|n| n.to_string()); println!("{upper:?}"); // None // unwrap_or — like || for nil let display = missing.copied().unwrap_or(0); println!("{display}"); // 0 // Pattern matching — exhaustive match age { Some(n) => println!("Age: {n}"), None => println!("Not found"), } // if let — when you only care about Some if let Some(n) = age { println!("alice is {n}"); } }
The type system forces you to handle the missing case — no more NoMethodError: undefined method for nil. Ruby's &. (safe navigation) becomes .map(), ||-style defaults become unwrap_or, and if let Some(...) handles the one-armed check.
Result (no exceptions)
Rust has no exceptions. A function that can fail says so in its signature by returning Result<T, E>Ok(value) on success, Err(error) on failure.
def divide(a, b) raise ArgumentError, "division by zero" if b == 0 a.to_f / b end begin puts divide(10, 2) puts divide(10, 0) rescue ArgumentError => err puts "Error: #{err.message}" end
fn divide(a: f64, b: f64) -> Result<f64, String> { if b == 0.0 { Err("division by zero".to_string()) } else { Ok(a / b) } } fn main() { match divide(10.0, 2.0) { Ok(result) => println!("{result}"), Err(err) => println!("Error: {err}"), } match divide(10.0, 0.0) { Ok(result) => println!("{result}"), Err(err) => println!("Error: {err}"), } // unwrap_or_else for concise default handling let result = divide(10.0, 2.0).unwrap_or(0.0); println!("{result}"); }
The ? operator (see the Error Handling section) propagates errors to the caller, similar to raise. unwrap() gives the value or panics (like an uncaught exception); expect("message") is the same but with a better panic message.
Iterators
map / filter / reduce
Rust iterators are lazy — an adaptor chain produces no values until a consumer such as .collect(), .sum(), or .count() drives it, which is why nearly every line below ends in one.
numbers = (1..10).to_a puts numbers.map { |n| n ** 2 }.inspect puts numbers.select(&:even?).inspect puts numbers.reject { |n| n > 5 }.inspect puts numbers.sum puts numbers.take(3).inspect puts numbers.count { |n| n > 5 } puts numbers.min puts numbers.max
fn main() { let numbers: Vec<i32> = (1..=10).collect(); let squares: Vec<i32> = numbers.iter().map(|&n| n * n).collect(); println!("{squares:?}"); let evens: Vec<&i32> = numbers.iter().filter(|&&n| n % 2 == 0).collect(); println!("{evens:?}"); let small: Vec<&i32> = numbers.iter().filter(|&&n| n <= 5).collect(); println!("{small:?}"); let sum: i32 = numbers.iter().sum(); println!("{sum}"); let first_three: Vec<&i32> = numbers.iter().take(3).collect(); println!("{first_three:?}"); println!("{}", numbers.iter().filter(|&&n| n > 5).count()); println!("{:?}", numbers.iter().min()); println!("{:?}", numbers.iter().max()); }
Chaining map().filter() is a single pass with no intermediate allocations. min() and max() return Option in case the collection is empty.
Chaining & flat_map
Method chaining looks almost identical in both languages, and flat_map means the same thing in both: map, then flatten one level.
words = ["hello world", "foo bar", "rust rocks"] puts words.flat_map { |phrase| phrase.split(" ") }.inspect puts words.flat_map { |phrase| phrase.split(" ") } .map(&:upcase) .select { |w| w.length > 3 } .inspect
fn main() { let words = vec!["hello world", "foo bar", "rust rocks"]; let flat: Vec<&str> = words.iter() .flat_map(|phrase| phrase.split(' ')) .collect(); println!("{flat:?}"); let result: Vec<String> = words.iter() .flat_map(|phrase| phrase.split(' ')) .map(|word| word.to_uppercase()) .filter(|word| word.len() > 3) .collect(); println!("{result:?}"); }
The key difference is under the hood: each step in a Ruby chain builds and returns a new intermediate Array, while a Rust chain describes a pipeline evaluated lazily in a single pass when .collect() runs.
Structs
Struct Basics
Rust has no classes: data lives in a struct, behavior in a separate impl block, and there are no inheritance hierarchies — traits (later section) provide shared behavior.
class Person attr_reader :name, :age def initialize(name, age) @name = name @age = age end def to_s = "#{@name} (#{@age})" def adult? = @age >= 18 end alice = Person.new("Alice", 30) puts alice puts alice.adult? puts alice.name
struct Person { name: String, age: u32, } impl Person { fn new(name: &str, age: u32) -> Self { Person { name: name.to_string(), age } } fn is_adult(&self) -> bool { self.age >= 18 } } impl std::fmt::Display for Person { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{} ({})", self.name, self.age) } } fn main() { let alice = Person::new("Alice", 30); println!("{alice}"); println!("{}", alice.is_adult()); println!("{}", alice.name); }
&self is an immutable reference to the instance; &mut self allows mutation. Implementing the Display trait is Rust's equivalent of defining to_s.
Default & Update Syntax
Rust's idiomatic answer to Ruby's default keyword arguments is the Default trait combined with struct update syntax: ..base fills every unspecified field from another instance.
class Config attr_reader :host, :port, :timeout def initialize(host: "localhost", port: 8080, timeout: 30) @host = host @port = port @timeout = timeout end end default_config = Config.new production_config = Config.new(host: "prod.example.com", timeout: 60) puts production_config.host puts production_config.port # inherited default puts production_config.timeout
#[derive(Debug)] struct Config { host: String, port: u16, timeout: u32, } impl Default for Config { fn default() -> Self { Config { host: "localhost".to_string(), port: 8080, timeout: 30 } } } fn main() { let production_config = Config { host: "prod.example.com".to_string(), timeout: 60, ..Config::default() // fill remaining fields from default }; println!("{}", production_config.host); println!("{}", production_config.port); // 8080 from default println!("{}", production_config.timeout); }
#[derive(Debug)] auto-generates the {:?} formatter. For types where every field has an obvious default, #[derive(Default)] can replace the hand-written impl Default.
Traits
Defining & Implementing Traits
Traits are Rust's equivalent of Ruby modules used as mixins: an interface that can also carry default implementations, as greet does below.
module Greetable def greet = "Hello, I'm #{name}" end class Person include Greetable attr_reader :name def initialize(name) = @name = name end class Robot include Greetable attr_reader :name def initialize(name) = @name = name def greet = "BEEP BOOP I AM #{name.upcase}" end puts Person.new("Alice").greet puts Robot.new("R2-D2").greet
trait Greetable { fn name(&self) -> &str; // Default implementation fn greet(&self) -> String { format!("Hello, I'm {}", self.name()) } } struct Person { name: String } struct Robot { name: String } impl Greetable for Person { fn name(&self) -> &str { &self.name } } impl Greetable for Robot { fn name(&self) -> &str { &self.name } fn greet(&self) -> String { format!("BEEP BOOP I AM {}", self.name().to_uppercase()) } } fn print_greeting(thing: &impl Greetable) { println!("{}", thing.greet()); } fn main() { print_greeting(&Person { name: "Alice".to_string() }); print_greeting(&Robot { name: "R2-D2".to_string() }); }
Unlike Ruby's include, the connection is explicit: impl TraitName for TypeName. &impl Greetable as a parameter type means "a reference to any type implementing Greetable" — compile-time duck typing.
Enums
Basic Enums
Where Ruby reaches for symbols, Rust defines an enum — a closed set of variants the compiler knows completely.
status = :pending message = case status when :pending then "Waiting..." when :active then "Running!" when :done then "Finished." end puts message
#[derive(Debug)] enum Status { Pending, Active, Done, } fn describe(status: &Status) -> &str { match status { Status::Pending => "Waiting...", Status::Active => "Running!", Status::Done => "Finished.", } } fn main() { let status = Status::Pending; println!("{}", describe(&status)); println!("{status:?}"); }
That closed set is what makes match exhaustive: omitting any variant is a compile error, which prevents bugs from unhandled cases. Rust enums are also far more powerful than symbols — each variant can carry data (next example). Derive Debug to get {:?} formatting for free.
Enums with Data
Each Rust enum variant can hold different data — Circle(f64) carries one float, Rectangle(f64, f64) carries two — and pattern matching destructures that data inline.
Shape = Struct.new(:type, :value) shapes = [ Shape.new(:circle, 5.0), Shape.new(:rectangle, [4.0, 6.0]), ] shapes.each do |shape| area = case shape.type when :circle Math::PI * shape.value ** 2 when :rectangle shape.value[0] * shape.value[1] end puts area.round(2) end
use std::f64::consts::PI; enum Shape { Circle(f64), Rectangle(f64, f64), } impl Shape { fn area(&self) -> f64 { match self { Shape::Circle(radius) => PI * radius * radius, Shape::Rectangle(width, height) => width * height, } } } fn main() { let shapes = vec![Shape::Circle(5.0), Shape::Rectangle(4.0, 6.0)]; for shape in &shapes { println!("{:.2}", shape.area()); } }
This is far more expressive than Ruby's symbol-based dispatch and eliminates the need for a separate lookup table or struct.
Error Handling
The ? Operator
The ? operator is shorthand for "return early with the error if this is Err, otherwise unwrap the Ok value" — Rust's idiomatic alternative to begin/rescue chains.
def parse_and_double(text) value = Integer(text) # raises ArgumentError if invalid value * 2 rescue ArgumentError => err raise "Parse error: #{err.message}" end begin puts parse_and_double("21") puts parse_and_double("abc") rescue => err puts err.message end
use std::num::ParseIntError; fn parse_and_double(text: &str) -> Result<i32, ParseIntError> { let value = text.trim().parse::<i32>()?; // ? returns Err early if parse fails Ok(value * 2) } fn main() { match parse_and_double("21") { Ok(n) => println!("{n}"), Err(err) => println!("Parse error: {err}"), } match parse_and_double("abc") { Ok(n) => println!("{n}"), Err(err) => println!("Parse error: {err}"), } }
? only works in functions returning Result or Option, and the function signature signals to callers that it can fail.
Custom Error Types
Where Ruby subclasses StandardError, a Rust custom error is a plain struct that implements Display for its user-facing message.
class AppError < StandardError attr_reader :code def initialize(message, code) super(message) @code = code end end begin raise AppError.new("not found", 404) rescue AppError => err puts "#{err.code}: #{err.message}" end
use std::fmt; #[derive(Debug)] struct AppError { message: String, code: u32 } impl fmt::Display for AppError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.code, self.message) } } fn might_fail(succeed: bool) -> Result<String, AppError> { if succeed { Ok("success".to_string()) } else { Err(AppError { message: "not found".to_string(), code: 404 }) } } fn main() { match might_fail(false) { Ok(value) => println!("{value}"), Err(error) => println!("{error}"), } }
In real codebases, the thiserror crate eliminates this Display boilerplate, and anyhow provides a simple catch-all error type similar to Ruby's StandardError. Both are external crates added via Cargo.toml.
let-else early return
A let ... else binding is Rust's version of Ruby's guard clause (return ... unless number): it matches the success pattern and binds the value into the surrounding scope, and if the match fails it runs the else block.
def parse_and_double(input) number = Integer(input, exception: false) return puts("not a number") unless number puts number * 2 end parse_and_double("42")
fn parse_and_double(input: &str) { let Ok(number) = input.parse::<i32>() else { println!("not a number"); return; }; println!("{}", number * 2); } fn main() { parse_and_double("42"); }
The else block must diverge (return, break, continue, or panic!). Unlike if let — whose binding lives only inside its block — the name bound by let-else stays in scope, so the happy path continues unindented just as a Ruby method does after its guards.
Concurrency
Threads & Mutex
Rust's ownership system makes data races a compile-time error — sharing mutable state across threads requires explicit synchronization, which is why the counter below is wrapped in Arc<Mutex<...>>.
mutex = Mutex.new counter = 0 threads = 4.times.map do Thread.new do 1000.times { mutex.synchronize { counter += 1 } } end end threads.each(&:join) puts counter
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0_i32)); let handles: Vec<_> = (0..4).map(|_| { let counter = Arc::clone(&counter); thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } }) }).collect(); for handle in handles { handle.join().unwrap(); } println!("{}", *counter.lock().unwrap()); }
Arc<T> is an atomically reference-counted pointer (thread-safe); Mutex<T> wraps a value and requires locking before access. Unlike Ruby's global VM lock, Rust threads achieve true parallelism.
Browser sandbox: Threads run synchronously in Ruby WASM. The counter reaches 4000 as expected but without true concurrency.
Channels
Rust channels play the role of Ruby's Queue; mpsc stands for "multiple producer, single consumer".
queue = Queue.new producer = Thread.new do 5.times do |i| queue << "message #{i}" end end producer.join 5.times do puts queue.pop end
use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); let producer = thread::spawn(move || { for i in 0..5 { sender.send(format!("message {i}")).unwrap(); } // sender dropped here — channel closes automatically }); // receiver acts as an iterator — stops when channel closes for message in receiver { println!("{message}"); } producer.join().unwrap(); }
The channel closes automatically when all senders are dropped — no need for a sentinel value like Ruby's :done. Iterating over receiver blocks until messages arrive and terminates when the channel closes. Use sync_channel(n) for a bounded channel.
Browser sandbox: Threads run synchronously in Ruby WASM — the producer block executes in full before the consumer loop.
async / await
Rust's std has async/await syntax but deliberately ships no executor — real programs use a runtime such as tokio. To stay self-contained, this example hand-rolls a tiny block_on that polls one future to completion on the current thread.
def double(value) = value * 2 # Ruby has no async/await keyword. A Thread runs the work and # #value blocks until the result is ready. worker = Thread.new { double(21) } puts worker.value
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 lazy value that does nothing until it is polled, which .await drives to completion. That is the reverse of a Ruby Thread, which starts running the moment you create it. Because awaiting compiles to a state machine, async tasks are far cheaper than OS threads.
Modules
Module Organization
Rust's mod organizes code, and everything inside is private by defaultpub opts each item in, which is why it appears on every struct, function, and method below.
module Geometry PI = Math::PI class Circle def initialize(radius) = @radius = radius def area = PI * @radius ** 2 end def self.distance(x1, y1, x2, y2) Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) end end circle = Geometry::Circle.new(5) puts circle.area.round(2) puts Geometry.distance(0, 0, 3, 4)
mod geometry { pub struct Circle { radius: f64 } impl Circle { pub fn new(radius: f64) -> Self { Circle { radius } } pub fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } } pub fn distance(x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { ((x2 - x1).powi(2) + (y2 - y1).powi(2)).sqrt() } } use geometry::Circle; fn main() { let circle = Circle::new(5.0); println!("{:.2}", circle.area()); println!("{}", geometry::distance(0.0, 0.0, 3.0, 4.0)); }
In a real project, modules live in separate files (geometry.rs) and are declared with mod geometry;. use brings paths into scope — like Ruby's require at the file level, but without adding methods to types.
⚠ Gotchas for Rubyists
Variables Are Immutable by Default
Watch out: a plain let binding cannot be reassigned — Rust requires let mut for any variable that will change, enforced at compile time.
count = 0 count = count + 1 # reassignment is always fine puts count
fn main() { let count = 0; // count = count + 1; // compile error: immutable variable // Option 1: shadow with a new `let` (changes type allowed) let count = count + 1; // Option 2: declare mutable from the start let mut mutable_count = 0; mutable_count += 1; println!("{count} {mutable_count}"); }
This eliminates whole classes of bugs from accidental mutation. Shadowing (let count = count + 1) is not mutation — it creates a new binding, and the type can change.
No nil — Use Option<T>
Watch out: Rust has no nil. Any value that might be absent must be explicitly wrapped in Option<T>.
def find_user(id) return nil if id == 0 "User##{id}" end user = find_user(0) if user.nil? puts "not found" else puts user.upcase end
fn find_user(id: u32) -> Option<String> { if id == 0 { None } else { Some(format!("User#{id}")) } } fn main() { match find_user(0) { None => println!("not found"), Some(user) => println!("{}", user.to_uppercase()), } // if let — compact form when you only care about Some if let Some(user) = find_user(1) { println!("{}", user.to_uppercase()); } }
The type system tells you exactly which values can be absent — no more NoMethodError: undefined method for nil:NilClass. The compiler forces you to handle None before using the value.
Integer Overflow & Type Mismatch
Watch out: Rust integers neither auto-promote to floats nor grow to arbitrary precision the way Ruby integers do. Mixing i32 and f64 in one expression is a compile error — you must cast with as.
puts 2 ** 100 # BigInteger — never overflows puts 1_000_000 * 1_000_000 # fine puts 1 + 1.0 # auto-promotion to Float puts 42.to_f / 7
fn main() { // Mixing i32 and f64 is a compile error — explicit cast required let integer: i32 = 42; let float: f64 = 7.0; // integer / float // compile error: mismatched types println!("{}", integer as f64 / float); // Overflow panics in debug mode, wraps in release mode let big: i32 = i32::MAX; println!("{big}"); // checked_add returns Option — None on overflow println!("{:?}", big.checked_add(1)); // None println!("{:?}", 100_i32.checked_add(1)); // Some(101) }
Overflow panics in debug builds (catching bugs) and wraps silently in release builds. Use checked_add, saturating_add, or wrapping_add for explicit overflow handling.
String Indexing Is Byte-Based
Watch out: Rust's str is UTF-8, and indexing it by integer position is a compile error — a Unicode character can occupy 1–4 bytes.
text = "héllo" puts text[0] # "h" puts text[1] # "é" (Unicode-aware) puts text.length # 5 characters puts text.bytesize # 6 bytes
fn main() { let text = "héllo"; // text[0] or text[1] — compile error! // Indexing &str by integer is not allowed; UTF-8 chars vary in width // Safe character access via iterator let first: Option<char> = text.chars().next(); let second: Option<char> = text.chars().nth(1); println!("{first:?}"); // Some('h') println!("{second:?}"); // Some('é') println!("{}", text.chars().count()); // 5 characters println!("{}", text.len()); // 6 bytes }
Use .chars() to iterate over characters, .chars().nth(n) for positional access, and .chars().count() for the character count — .len() is the byte length. This is stricter than Ruby but prevents subtle Unicode bugs.