Ruby.CodeCompared.To/Rust

An interactive executable cheatsheet for Rubyists learning Rust

Ruby 4.0 Rust 1.95
Syntax Basics
Variables & Printing
name = "Alice" age = 30 puts "Hello, #{name}! You are #{age} years old." message = "immutable by default 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}"); }
Both languages make values immutable by default β€” Ruby freezes strings in 4.0; Rust requires let mut for any reassignable binding. 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
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>()); }
Rust shadowing re-declares a binding with let, creating a new variable (potentially with a different type). Ruby reassignment reuses the same slot; Rust shadowing is conceptually a new binding. Unlike let mut, shadowing can change the type.
Types & Data
Basic Types
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>()); }
Rust is statically typed with type inference β€” types are known at compile time but rarely need to be written explicitly. There is no nil; absent values use Option<T>. Integer overflow panics in debug mode and wraps in release mode.
Type Casting
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 }
Rust uses as for numeric casts β€” infallible but potentially lossy (3.9 as i32 is 3). Parsing from strings returns Result and requires error handling. Unlike Ruby, Rust will not silently coerce between numeric types.
Strings
Two String Types
# 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()); }
Rust has two string types. &str is a borrowed reference to UTF-8 bytes β€” use it for read-only access and function parameters. String owns its data β€” use it when building or mutating. Convert: "hello".to_string() or String::from("hello"); back: &my_string.
String Operations
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")); }
The + operator on String consumes the left-hand side β€” after let r = greeting + ..., greeting is gone. Use format!() to avoid this. String indexing by integer (e.g. s[0]) is not allowed in Rust because UTF-8 characters are variable-width; use .chars().nth(n).
Collections
Vec (Array)
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:?}"); }
vec![] creates a Vec<T>. Index access panics if out of bounds; use .get(i) which returns Option<&T> for safe access. first() and last() return Option rather than Ruby's nil. Fixed-size arrays [T; N] have a compile-time size; Vec<T> is the growable equivalent.
HashMap (Hash)
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}"); }
HashMap must be brought into scope with use. Unlike Ruby Hash, iteration order is not guaranteed. scores["Alice"] would panic on a missing key; use .get("Alice") which returns Option<&V>. The .entry(key).or_insert(val) API is idiomatic for insert-if-absent.
Tuples
# 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:?}"); }
Rust tuples have a fixed length and may contain mixed types. Elements are accessed by index .0, .1, etc. Destructuring works like Ruby's parallel assignment. The empty tuple () is the "unit type" β€” the implicit return value of functions that return nothing meaningful.
Control Flow
if / elsif / else
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}"); }
In both languages, if is an expression that returns a value. Rust requires curly braces around every branch β€” no then keyword. All branches must return the same type; if one returns "A", all must return &str.
match (case / when)
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}"); }
Rust's match is exhaustive β€” the compiler rejects non-exhaustive matches. Ranges use ..= for inclusive end (like Ruby's ..). The _ wildcard is like Ruby's bare else. Unlike Ruby case, all arms must return the same type.
Loops
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!(); }
Rust has no times method β€” use for i in 0..n. Ranges are exclusive at the end by default (0..3 = 0,1,2); use ..= for inclusive. loop is Rust's infinite loop β€” it can break with a value, the closest equivalent to Ruby's loop { break value if ... }.
Functions
Defining Functions
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")); }
Return type is annotated with ->. The last expression in a block without a semicolon is the return value β€” explicit return is valid but mainly used for early exit. Rust has no keyword arguments; use a builder pattern or a struct with Default for optional parameters.
Multiple Return Values
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}"); }
Both languages return multiple values via a tuple/array with destructuring on the receiving side. &[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
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}"); }
Rust closures use |params| syntax and capture their environment by reference by default. Add move before | to capture by value (required when the closure outlives its scope, e.g. in threads). Rust distinguishes Fn, FnMut, FnOnce β€” the compiler infers which applies.
Higher-Order Functions
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 }
Rust uses generics (<F: Fn(...)>) or trait objects (dyn Fn(...)) to accept closures. impl Fn(...) as a return type means "some type that implements this closure trait". move is required in make_adder so the closure captures n by value and can outlive the function call.
Ownership & Borrowing
Ownership & Move
# 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 }
Rust's ownership system replaces garbage collection. When a value is assigned or passed to a function, it is moved unless the type implements Copy. After a move, the original binding is invalid β€” the compiler enforces this. clone() explicitly makes a deep copy. This is the biggest conceptual shift for Rubyists.
Borrowing & References
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!" }
&T is an immutable reference (borrow) β€” any number can exist simultaneously. &mut T is a mutable reference β€” only one can exist at a time, and not alongside any immutable references. These rules are enforced at compile time by the borrow checker. Ruby achieves safety via runtime GC; Rust achieves it at zero runtime cost.
Option & Result
Option (no nil)
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}"); } }
Option<T> is Rust's explicit representation of an optional value: Some(T) holds a value; None represents absence. The type system forces you to handle the missing case β€” no more NoMethodError: undefined method for nil. &. (safe navigation) becomes .map() or if let Some(...).
Result (no exceptions)
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}"); }
Rust has no exceptions. Functions that can fail return Result<T, E>. The ? operator (see Error Handling section) propagates errors to the caller, similar to raise. unwrap() gives the value or panics (like an uncaught exception). expect("msg") is the same but with a better panic message.
Iterators
map / filter / reduce
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()); }
Rust iterators are lazy β€” they produce no values until consumed by .collect(), .sum(), .count(), etc. 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
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:?}"); }
Method chaining looks almost identical in both languages. The key difference is that Rust chains describe a pipeline evaluated in a single pass; Ruby's each method returns a new Array. flat_map works the same way in both: map then flatten one level.
Structs
Struct Basics
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); }
Rust separates data (struct) from behavior (impl). &self is an immutable reference to the instance; &mut self allows mutation. There are no class hierarchies β€” Rust uses traits for shared behavior. The Display trait is Rust's equivalent of to_s.
Default & Update Syntax
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); }
The ..base struct update syntax fills unspecified fields from another instance. #[derive(Debug)] auto-generates the {:?} formatter. The Default trait provides default values β€” Rust's idiomatic equivalent of Ruby's default keyword arguments.
Traits
Defining & Implementing Traits
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() }); }
Traits are Rust's equivalent of Ruby modules used as mixins. A trait defines an interface and can provide default implementations. Unlike Ruby's include, you must explicitly impl TraitName for TypeName. &impl Greetable as a parameter type means "any type implementing Greetable" β€” compile-time duck typing.
Enums
Basic Enums
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:?}"); }
Rust enums are far more powerful than Ruby symbols β€” each variant can carry data. match on an enum is exhaustive: omitting any variant is a compile error, which prevents bugs from unhandled cases. Derive Debug to get {:?} formatting for free.
Enums with Data
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()); } }
Each Rust enum variant can hold different data β€” Circle(f64) holds one float, Rectangle(f64, f64) holds two. Pattern matching destructures the data inline. 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
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}"), } }
The ? operator is shorthand for "return early with the error if this is Err, otherwise unwrap the Ok value". It is Rust's idiomatic alternative to begin/rescue chains. ? only works in functions returning Result or Option. The function signature signals to callers that it can fail.
Custom Error Types
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}"), } }
Custom error types implement Display for user-facing messages. In real codebases, the thiserror crate eliminates this boilerplate; anyhow provides a simple catch-all error type similar to Ruby's StandardError. Both require adding to Cargo.toml.
Concurrency
Threads & 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()); }
Rust's ownership system makes data races a compile-time error β€” you cannot share mutable data across threads without explicit synchronization. Arc<T> is an atomically reference-counted pointer (thread-safe). Mutex<T> wraps a value and requires locking before access. Unlike Ruby's GIL, Rust threads achieve true parallelism.
Browser sandbox: Threads run synchronously in Ruby WASM. The counter reaches 4000 as expected but without true concurrency.
Channels
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(); }
mpsc = "multiple producer, single consumer". 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.
Modules
Module Organization
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)); }
Rust's mod organizes code; pub marks items as public (like Ruby's public visibility). Everything is private by default. 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
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}"); }
Rust requires let mut for any variable that will be reassigned. This is enforced at compile time and eliminates whole classes of bugs from accidental mutation. Shadowing (let x = x + 1) is not mutation β€” it creates a new binding and the type can change.
No nil β€” Use 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()); } }
Rust has no nil. Any value that might be absent must be explicitly wrapped in Option<T>. 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
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) }
Rust integers do not auto-promote or extend to arbitrary precision. Mixing i32 and f64 in an expression is a compile error β€” you must cast with as. 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
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 }
Rust's str is UTF-8. Indexing by integer position is a compile error because a Unicode character can take 1–4 bytes. Use .chars() to iterate over characters, .chars().nth(n) for positional access, and .len() for byte length. This is stricter than Ruby but prevents subtle Unicode bugs.