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.classfn 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 # NilClassfn 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) # => 255fn 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.inspectfn 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.sumuse 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 gradefn 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 categoryfn 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} " }
putsfn 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}"
enduse 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 maximumfn 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) # 25fn 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 validfn 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}"
endfn 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.maxfn 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 }
.inspectfn 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.namestruct 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").greettrait 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)
enduse 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
enduse 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}"
enduse 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 counteruse 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
enduse 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.valueuse 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 default — pub 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 countfn 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
endfn 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 / 7fn 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 bytesfn 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.