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.