PONYλM2Modula-2

Lua.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Lua and Rust

Lua 5.3 Rust 1.97.1
Running It & Output
Hello, World
The exclamation mark is the only thing here that is not obvious, and it is worth knowing on line one: println! is a macro, not a function. Rust has no variadic functions at all, so the standard library gets a printf-shaped interface by generating code instead.
print("Hello, World!")
println!("Hello, World!");
Because it is a macro, the format string is checked at compile time: too few arguments for the placeholders is a build error rather than something you discover in production. Lua's string.format checks nothing until it runs.
print and io.write
Lua's print takes any number of values and tabs between them. Rust splits the newline decision into two macros and joins nothing for you.
print("a", "b") -- several values, tab-separated io.write("no newline") print() print(true)
println!("a\tb"); // one format string, so the tab is yours print!("no newline"); println!(); println!("{}", true);
So Lua's print is closest to println! and io.write is closest to print!. The one to remember for debugging is {:?}, the Debug format, which prints a structure the way you would type it — the counterpart of writing an inspect function by hand in Lua.
Comments
-- A single-line comment. --[[ A long comment spanning several lines. ]] print("commented")
// A single-line comment. /* A block comment spanning several lines. */ /// A documentation comment, read by rustdoc. fn main() { println!("commented"); }
The third form has no Lua counterpart. A /// comment is Markdown that cargo doc renders into HTML — and any code block inside it is compiled and run by cargo test, so documentation examples cannot rot without the build noticing.
The last expression is the return value
Two things arrive at once. The signature has to say what goes in and what comes out, and the body has no return — in Rust an if is an expression, so the block's final expression is what the function yields.
local function grade(score) if score >= 40 then return "pass" else return "fail" end end print(grade(42))
fn grade(score: i32) -> &'static str { if score >= 40 { "pass" // no 'return': the last expression IS the value } else { "fail" } } fn main() { println!("{}", grade(42)); }
The semicolon is what distinguishes the two: "pass" is the block's value, and "pass"; would be a statement that discards it, leaving the function returning nothing. return does exist and is used for early exits. The &'static str return type says "a borrowed string that lives for the whole program", which is what a literal is.
Ownership: The One Big Idea
Passing a value can give it away
Start here, because everything else follows from it. In Lua, passing a table passes a reference: the callee can change it and the caller keeps using it afterwards. In Rust, passing a Vec by value moves it, and the caller no longer has it.
local function consume(words) words[#words + 1] = "added" return #words end local list = { "a", "b" } print(consume(list)) print(#list) -- 3: the SAME table was modified
fn consume(mut words: Vec<&str>) -> usize { words.push("added"); words.len() } fn main() { let list = vec!["a", "b"]; println!("{}", consume(list)); // println!("{}", list.len()); // error[E0382]: borrow of moved value }
This is not a rule about function calls — it is a rule about assignment, and calls are one case of it. Each value has exactly one owner; when ownership moves, the old name becomes unusable, which is how Rust knows there is exactly one place responsible for freeing it. The Lua column's answer, that both names see the change, is the thing the next two rows show how to ask for.
Borrowing: passing without giving away
The ampersand is the fix for the previous row, and it is the shape most Rust function signatures take. A borrow lets the callee look without taking ownership, which is exactly what a Lua call has always done.
local function count(words) return #words end local list = { "a", "b" } print(count(list)) print(#list) -- still usable, as always
fn count(words: &Vec<&str>) -> usize { words.len() } fn main() { let list = vec!["a", "b"]; println!("{}", count(&list)); println!("{}", list.len()); // still usable: it was only borrowed }
So the everyday Rust equivalent of a Lua function taking a table is one taking &Vec<T> — or better, &[T], a slice, which also accepts arrays. The compiler guarantees the borrow cannot outlive the value, so unlike a C pointer there is nothing to check at run time and nothing to get wrong.
One mutable borrow at a time
Lua lets any number of names point at one table and all of them write to it. Rust allows either many readers or exactly one writer, never both at once — which is the rule that eliminates data races and, on the way, a good deal of single-threaded aliasing confusion.
local list = { "a" } local first = list local second = list -- as many aliases as you like first[#first + 1] = "b" second[#second + 1] = "c" print(#list)
let mut list = vec!["a"]; let first = &mut list; first.push("b"); // let second = &mut list; // error[E0499]: cannot borrow twice list.push("c"); // fine, now that 'first' is finished with println!("{}", list.len());
Both columns print 3; the difference is that Rust made you sequence the writes. The borrow ends at its last use rather than at the end of the block, which is why list.push("c") is accepted on the following line — that refinement is called non-lexical lifetimes and it is why modern Rust complains far less than its reputation suggests.
Copying a collection is explicit in both
A convergence hiding behind a difference in vocabulary. Lua programmers already know that copy = original does not copy — they write the loop, or reach for a deepcopy helper.
-- Copying a table is manual in Lua too: assignment only aliases. local original = { 1, 2 } local copy = {} for index, value in ipairs(original) do copy[index] = value end copy[#copy + 1] = 3 print(#original, #copy)
let original = vec![1, 2]; let mut copy = original.clone(); // one call, and it is explicit copy.push(3); println!("{}\t{}", original.len(), copy.len());
Rust names the operation and puts it in the standard library, and the fact that you must write .clone() is deliberate: an allocation is never implied by an assignment. The convention worth knowing is that a visible .clone() in a hot loop is a code-review conversation, in a way that Lua's hand-rolled copy loop never is.
Numbers are copied, not moved
Relief after the previous rows: the move rule does not apply to small scalar values. Both columns behave identically, and for the same underlying reason.
local count = 7 local also = count -- numbers are values, not references also = also + 1 print(count, also)
let count = 7; let mut also = count; // i32 is Copy, so this copies rather than moves also += 1; println!("{}\t{}", count, also);
A type that implements Copy is duplicated on assignment instead of moved — every integer, float, bool, char, and any tuple or struct made only of those. Lua draws the same line in the same place: numbers, booleans and strings are values, while tables and functions are references.
A value can outlive the function; a reference cannot
In Lua the question never comes up, because the garbage collector decides when something dies and nothing can point at a freed value. In Rust the compiler decides at build time, so the shape of what you return matters.
-- The collector keeps a table alive as long as anything can reach it, -- so returning something built inside a function needs no thought. local function makeName() local inner = "kept" return function() return inner end end print(makeName()())
fn make_name() -> String { let inner = String::from("kept"); inner // ownership moves OUT, so it survives the call // &inner would be error[E0106]: returns a reference to local data } fn main() { println!("{}", make_name()); }
Returning the value itself is fine — ownership moves to the caller. Returning a reference to a local is rejected, because the local is gone by then; that is the whole content of the phrase "the borrow checker". The Lua column is doing the same thing in effect: the closure keeps inner reachable, so the collector leaves it alone.
Spelling a Lua table: Rc plus RefCell
This is the row to sit with. Everything the Lua column does for free — one thing, several names, all of them able to write — is available in Rust, and writing it out is the plainest statement of what that convenience actually costs.
-- One table, two names, both able to mutate it. This is Lua's default -- and costs nothing to ask for. local shared = { count = 0 } local left, right = shared, shared left.count = left.count + 1 right.count = right.count + 1 print(shared.count)
use std::cell::RefCell; use std::rc::Rc; fn main() { // Shared ownership (Rc) plus interior mutability (RefCell) is what it // takes to spell a Lua table in Rust. let shared = Rc::new(RefCell::new(0)); let left = Rc::clone(&shared); let right = Rc::clone(&shared); *left.borrow_mut() += 1; *right.borrow_mut() += 1; println!("{}", shared.borrow()); }
Rc is a reference count, so several owners share one value and it is freed when the last is dropped; RefCell moves the one-writer rule from compile time to run time, panicking if two borrows overlap. Together they are a Lua table: shared, mutable, reference-counted. Note what you gave up — the aliasing bug the compiler was catching for you is now a run-time panic — and note what Lua gives up in exchange: its collector, running everywhere, all the time. For sharing across threads the pair is Arc<Mutex<T>>.
Variables, Mutability & Shadowing
local becomes let
The closest one-to-one mapping on the page. Rust infers the type from the initializer, so ordinary code carries no more annotation than Lua does.
local count = 3 local label = "items" print(count) print(label)
let count = 3; // inferred as i32 let label: &str = "items"; // or say the type out loud println!("{}", count); println!("{}", label);
Inference is local but thorough: it works backwards from how a variable is later used, so let numbers = Vec::new(); followed by numbers.push(1) works out that this is a Vec<i32>. What it will not do is cross a function boundary — every signature must be spelled out, which is deliberate, so a change inside one function cannot silently retype another.
Immutable unless you say mut
Lua has no way to say a variable will not change. Rust makes that the default and asks you to opt out, which flips which case is the noisy one.
local total = 10 total = total + 5 -- every local is mutable print(total)
let mut total = 10; // without 'mut' the next line does not compile total += 5; println!("{}", total);
The practical effect is that mut becomes a signal you read: a binding without it is a promise, both to the compiler and to the next person. It also composes with borrowing — &mut is only obtainable from a mut binding — so the two rules reinforce each other rather than being separate things to remember.
Shadowing, which you already do
A genuine convergence, and one most Rust tutorials present as exotic. Declaring local twice with the same name is legal Lua and reasonably common; Rust does the same thing and considers it idiomatic.
local value = "42" local value = tonumber(value) -- a second local of the same name print(value + 1)
let value = "42"; let value: i32 = value.parse().unwrap(); // a second let of the same name println!("{}", value + 1);
Both print 43. The reason it matters more in Rust is that the second binding may have a different type, which is how the parse-then-use pattern avoids inventing a name like value_as_number. This is not mutation: the first value still exists and is simply no longer reachable by that name.
A forgotten local cannot leak
Lua's most-cited footgun is that local is opt-in, so a typo creates a global. Rust removes the footgun by removing the category.
local function configure() threshold = 5 -- no 'local', so this is a GLOBAL end configure() print(threshold) -- 5, and visible everywhere
fn configure() -> i32 { let threshold = 5; // there is no way to leak this out threshold } fn main() { println!("{}", configure()); // println!("{}", threshold); // error[E0425]: not found in this scope }
There is no implicit global scope: a name must be declared, and a misspelling is a compile error rather than a fresh global holding nil. Mutable global state is possible but deliberately awkward — a static mut requires unsafe, and the ordinary answer is to pass the value in or use a OnceLock.
There Is No Truthiness
A condition must BE a bool
Lua's rule is famously narrow — only nil and false are falsy, so 0 and "" are true — and it is the thing you most often have to hold in mind when moving between languages. Here it stops applying rather than changing.
local count = 0 if count then print("0 is TRUE in Lua") end local text = "" if text then print("so is the empty string") end
let count = 0; // if count { } // error[E0308]: expected bool, found integer if count == 0 { println!("a condition must BE a bool"); } let text = ""; // if text { } // error[E0308] as well if text.is_empty() { println!("so you say what you mean"); }
Rust has no conversion from any type to bool, so there is no falsy set and no accidental truthiness. The cost is that each check has to name what it means: is_empty(), is_none(), == 0. The gain is that the most common Lua-to-anything porting bug — a 0 that was true in one language and false in the next — cannot be written.
x = x or default becomes unwrap_or
The most common line in all of Lua, and the Rust version fixes the bug hiding in it.
local function greet(name) name = name or "stranger" return "Hello, " .. name end print(greet("Ada")) print(greet(nil))
fn greet(name: Option<&str>) -> String { format!("Hello, {}", name.unwrap_or("stranger")) } fn main() { println!("{}", greet(Some("Ada"))); println!("{}", greet(None)); }
Lua's or replaces anything falsy, so enabled = enabled or true can never be false. unwrap_or replaces absence and nothing else. The family is worth learning together: unwrap_or_else takes a closure when the default is expensive, and unwrap_or_default uses the type's zero value.
a and b or c becomes a real if
Lua has no ternary operator and borrows one out of and and or. Rust does not need a special operator, because if is already an expression.
local score = 42 local grade = score >= 40 and "pass" or "fail" print(grade)
let score = 42; let grade = if score >= 40 { "pass" } else { "fail" }; println!("{}", grade);
The Lua idiom silently returns c whenever b is false or nil, which is why enabled and false or true yields true. Rust's if cannot fall through, and the compiler additionally requires both arms to have the same type — so the version that would have surprised you does not build.
nil Becomes Option
nil becomes Option
Rust has no nil, no null and no empty value that any type can hold. Absence is instead a wrapper around the type — which sounds like extra ceremony and turns out to be the thing that removes a whole class of bug.
local person = nil print(person == nil) person = "Ada" print(person)
let mut person: Option<&str> = None; println!("{}", person.is_none()); person = Some("Ada"); println!("{}", person.unwrap());
Because Option<&str> and &str are different types, a function that cannot accept absence simply does not have to check for it — the compiler already refused every caller who might pass one. In Lua, any parameter can be nil, which is why so many Lua functions open with a guard.
A missing key returns Option, not nil
Lua's nil-means-absent rule is one of its best conveniences and the source of its most annoying bugs, because the nil travels: it gets stored, passed on, and blows up somewhere else entirely.
local ages = { alice = 30 } print(ages.alice) print(ages.bob) -- nil, not an error
use std::collections::HashMap; fn main() { let mut ages = HashMap::new(); ages.insert("alice", 30); println!("{:?}", ages.get("alice")); // Some(30) println!("{:?}", ages.get("bob")); // None, and not a panic }
get hands back an Option, so the absence is visible in the type and cannot be mistaken for a value further down. The indexing form ages["bob"] also exists and panics — that is the deliberate split throughout Rust: an Option-returning method when absence is expected, and a panicking shortcut when it is a bug.
Testing for nil becomes if let
The Lua idiom tests the value and then uses the same variable. Rust's version tests and unwraps in one step, binding a new name that is the inner value rather than the wrapper.
local ages = { alice = 30 } local age = ages.alice if age then print("alice is " .. age) else print("no alice") end
use std::collections::HashMap; fn main() { let mut ages = HashMap::new(); ages.insert("alice", 30); if let Some(age) = ages.get("alice") { println!("alice is {}", age); } else { println!("no alice"); } }
Inside the block, age is a number and needs no further checking, which is the point: the check and the use cannot drift apart. Where the else branch exits, let Some(age) = … else { return; }; is the flatter spelling and reads much like an early-return guard in Lua.
Transforming a maybe-value without unwrapping it
Once absence is a value rather than a hole, it gets methods. map applies a function only when something is there, which replaces the guard-and-branch shape entirely.
local ages = { alice = 30 } local function describe(name) local age = ages[name] if age then return name .. " is " .. age end return name .. " is unknown" end print(describe("alice")) print(describe("bob"))
use std::collections::HashMap; fn main() { let ages = HashMap::from([("alice", 30)]); let describe = |name: &str| { ages.get(name) .map(|age| format!("{} is {}", name, age)) .unwrap_or_else(|| format!("{} is unknown", name)) }; println!("{}", describe("alice")); println!("{}", describe("bob")); }
The family is large and worth browsing once: map, and_then for a function that itself returns an Option, filter, ok_or to turn absence into an error, and ? to return early from a function that returns Option. Nothing in Lua corresponds — the and/or chain is as far as it goes.
Two String Types
One string type becomes two
Lua has exactly one string type and it is immutable, so there is never a decision to make. Rust's split is the first place ownership shows up in everyday code, before you have met a single Vec.
local literal = "hello" -- one type, immutable, interned local built = literal .. " there" print(literal) print(built)
let literal: &str = "hello"; // a borrowed view, fixed at compile time let built: String = format!("{} there", literal); // owned and growable println!("{}", literal); println!("{}", built);
&str is a borrowed slice of text somebody else owns — a literal, or part of a String. String owns a heap buffer and can grow. The rule of thumb: take &str in function parameters so callers can pass either, and return String when you built something new. Unlike Lua's strings, a String is mutable, which the Gotchas section puts to use.
The .. operator becomes format!
Lua gives concatenation its own operator so + can stay arithmetic, and coerces the number for you. Rust has no coercion at all, so the number is formatted rather than added.
local parts = {} for index = 1, 3 do parts[#parts + 1] = "line " .. index end print(table.concat(parts, "; "))
let mut parts = Vec::new(); for index in 1..=3 { parts.push(format!("line {}", index)); } println!("{}", parts.join("; "));
format! is the workhorse: same syntax as println! but returning a String. + does concatenate strings, awkwardly — it takes an owned String on the left and a &str on the right, so "a" + "b" does not compile. Reach for format! and stop thinking about it. Note also 1..=3, an inclusive range, which is Lua's for index = 1, 3 exactly.
Both count bytes, not characters
A convergence worth stating out loud, because both languages are quietly byte-oriented and both surprise people the first time non-ASCII text arrives. Five characters, six bytes, in each of them.
local word = "naïve" print(#word) -- 6: # counts BYTES print(utf8.len(word)) -- 5: the utf8 library counts characters
let word = "naïve"; println!("{}", word.len()); // 6: len() counts BYTES too println!("{}", word.chars().count()); // 5: and this counts characters
Neither # nor .len() counts characters, so any string with an accent in it reports more than you can see. Both languages then give you a way to ask properly — Lua 5.3's utf8 library and Rust's chars() iterator, both of which walk the encoding. The deeper difference is that Rust guarantees a String holds valid UTF-8, while a Lua string is an arbitrary byte sequence that may not be text at all — which is why utf8.len can return nil and Rust has a separate Vec<u8> for bytes that are not text.
You cannot index a string by number
Another convergence, arrived at from opposite directions. Lua strings are not tables, so word[1] is simply nil; Rust refuses to compile it, and for a better reason.
local word = "code" print(word:sub(1, 1)) -- there is no word[1]; sub is how you index print(word:byte(1))
let word = "code"; // println!("{}", word[0]); // error[E0277]: cannot be indexed by usize println!("{}", &word[0..1]); // a byte SLICE, which must land on a boundary println!("{}", word.as_bytes()[0]);
Rust rejects word[0] because there is no honest answer: a byte is not a character and a character is not always one byte. So you choose — &word[0..1] for a text slice, as_bytes() for raw bytes, chars().nth(0) for a character. Both columns print c then 99; Lua's byte and Rust's as_bytes are the same operation. A slice that splits a multi-byte character panics.
Slicing: 1-based inclusive becomes 0-based exclusive
Three changes at once: the base, whether the end is included, and the loss of negative indexing.
local word = "codecompared" print(word:sub(1, 4)) -- 1-based, END index, inclusive print(word:sub(-8)) -- a negative index counts back from the end
let word = "codecompared"; println!("{}", &word[0..4]); // 0-based, END index, exclusive println!("{}", &word[word.len() - 8..]); // no negative indices at all
0..4 is a range that stops before 4, which is why the two spellings pick out the same four characters from different-looking numbers. Rust has no counterpart to Lua's -8, so the arithmetic is yours — and it must be right, since an out-of-range slice panics where Lua's sub quietly clamps.
Lua patterns become methods, and regex is a crate
Lua ships a small pattern language of its own to keep the interpreter tiny. Rust's standard library goes further and ships no pattern matching on strings at all — only methods.
local line = "user=ada id=42" print(line:match("id=(%d+)")) print((line:gsub("%s+", ",")))
let line = "user=ada id=42"; let id = line.split_whitespace() .find_map(|field| field.strip_prefix("id=")) .unwrap(); println!("{}", id); println!("{}", line.replace(' ', ","));
For the common cases the methods are clearer than a pattern: split, splitn, strip_prefix, trim, find, replace, starts_with. When you genuinely need a regular expression, the regex crate is the universal answer and is faster than most languages' built-in engine — it is simply not in std, which is a deliberate choice about what the standard library should carry. The extra parentheses in the Lua column discard gsub's second return value, the replacement count.
One Table Becomes Many Types
The table splits in two
A Lua table is an array and a hash map simultaneously, which is why the constructor above needs no explanation. Rust makes you choose, because the choice is part of the type — and it also makes you choose the element type.
local everything = { 10, 20, 30, name = "mixed" } print(#everything) print(everything.name)
use std::collections::HashMap; fn main() { let numbers = vec![10, 20, 30]; let mut fields = HashMap::new(); fields.insert("name", "mixed"); println!("{}", numbers.len()); println!("{}", fields["name"]); }
Being homogeneous is the bigger change, not being split. A Lua table happily holds a number, a string and a function; a Vec<i32> holds integers. When you genuinely need a mixed collection the answer is an enum listing the possibilities, which the Matching section covers — and which turns "what is in here?" from a run-time question into a compile-time one.
Indexing starts at zero
The change you will feel most often, and the one that no amount of understanding stops you getting wrong occasionally.
local colors = { "red", "green", "blue" } print(colors[1]) -- the FIRST element print(colors[#colors]) -- the last
let colors = vec!["red", "green", "blue"]; println!("{}", colors[0]); // the FIRST element println!("{}", colors[colors.len() - 1]); // the last
Prefer colors.last().unwrap() to the length arithmetic: it returns an Option, so an empty collection gives None instead of panicking on 0 - 1 — and on an unsigned usize that subtraction is itself an overflow. Note also that an out-of-range index panics in Rust, where Lua hands back nil and lets the mistake travel.
table.insert and table.remove become methods
The operations line up one to one with the index base shifted. Note that Lua overloads table.insert on its argument count, while Rust gives the two behaviors separate names.
local queue = { "a", "b" } table.insert(queue, "c") table.insert(queue, 1, "start") table.remove(queue, 2) print(table.concat(queue, ","))
let mut queue = vec!["a", "b"]; queue.push("c"); queue.insert(0, "start"); queue.remove(1); println!("{}", queue.join(","));
A Vec<T> is a growable heap array — the direct counterpart of a Lua table used as a list. Rust also has fixed-size arrays ([i32; 3], on the stack, length in the type) and slices (&[i32], a borrowed window onto either). Lua has one structure where Rust has three, and the distinction is about where the memory lives.
The counting idiom: or becomes entry
Counting occurrences is the same three-line loop in both languages, and comparing the middle line is the fastest way to understand what entry is for.
local counts = {} for _, word in ipairs({ "a", "b", "a" }) do counts[word] = (counts[word] or 0) + 1 end print(counts.a, counts.b)
use std::collections::HashMap; fn main() { let mut counts: HashMap<&str, i32> = HashMap::new(); for word in ["a", "b", "a"] { *counts.entry(word).or_insert(0) += 1; } println!("{}\t{}", counts["a"], counts["b"]); }
Lua's (counts[word] or 0) + 1 reads the key and then writes it, hashing twice. entry looks the slot up once and hands back a handle you can fill in — so or_insert supplies the default and the * dereferences the resulting &mut i32 to add to it. Same idea, one lookup, and no way to typo the key on the second line.
Neither hash map promises an order
A convergence with a sharp edge, and Rust's version of it is stricter than Lua's in a way that is genuinely helpful.
local ages = { alice = 30, bob = 25, carol = 41 } local names = {} for name in pairs(ages) do names[#names + 1] = name end table.sort(names) -- pairs order is UNDEFINED, so sort for _, name in ipairs(names) do print(name, ages[name]) end
use std::collections::HashMap; fn main() { let ages = HashMap::from([("alice", 30), ("bob", 25), ("carol", 41)]); let mut names: Vec<&str> = ages.keys().copied().collect(); names.sort(); // HashMap order is RANDOMIZED, so sort for name in names { println!("{}\t{}", name, ages[name]); } }
Lua's pairs order is undefined, which in practice means stable for a given build and then different on somebody else's machine. Rust randomizes the hash seed per process, so an accidental dependence on order fails on the second run rather than in production. Where you want order, use BTreeMap, which keeps keys sorted and iterates in that order — the Iterators section uses it.
A table of named fields becomes a struct or a tuple
A Lua table with named keys is doing the job of a struct, and one with numbered keys the job of a tuple. Rust separates them, and only one of the two needs declaring up front.
local point = { x = 1, y = 2 } print(point.x, point.y) local pair = { 1, 2 } print(pair[1], pair[2])
struct Point { x: i32, y: i32 } fn main() { let point = Point { x: 1, y: 2 }; println!("{}\t{}", point.x, point.y); let pair = (1, 2); // an anonymous tuple: no declaration needed println!("{}\t{}", pair.0, pair.1); }
A tuple is the lightweight option — no name, fields reached by position, good for returning two things. A struct costs a declaration and buys names, methods and a type the compiler can check. The middle ground is a tuple struct (struct Meters(f64);), which wraps one value in a distinct type so meters and feet cannot be mixed up — a kind of safety Lua has no way to express.
table.sort becomes sort_by_key
Lua takes a comparator answering "does the left one come first?". Rust offers three spellings, and the shortest asks only what to sort by.
local people = { { name = "Ada", age = 36 }, { name = "Bob", age = 25 }, { name = "Cy", age = 41 }, } table.sort(people, function(left, right) return left.age < right.age end) for _, person in ipairs(people) do print(person.name, person.age) end
fn main() { let mut people = vec![("Ada", 36), ("Bob", 25), ("Cy", 41)]; people.sort_by_key(|person| person.1); for person in &people { println!("{}\t{}", person.0, person.1); } }
sort_by_key covers most cases; sort_by takes a full comparator returning an Ordering, and bare sort works when the elements are already comparable. Two behaviors differ from Lua: Rust's sort is stable, so equal elements keep their relative order, and it cannot be given an inconsistent comparator by accident — where a bad Lua comparator raises "invalid order function" at run time.
Iterators, Lazy and Free
The loop you always write becomes a chain
Lua has no filter and no map, so this loop — accumulate into a fresh table, conditionally — is the most-written shape in the language. Rust's iterators replace it, and the replacement costs nothing at run time.
local numbers = { 1, 2, 3, 4, 5, 6 } local doubledEvens = {} for _, number in ipairs(numbers) do if number % 2 == 0 then doubledEvens[#doubledEvens + 1] = number * 2 end end print(table.concat(doubledEvens, ","))
let numbers = [1, 2, 3, 4, 5, 6]; let doubled_evens: Vec<String> = numbers.iter() .filter(|number| *number % 2 == 0) .map(|number| (number * 2).to_string()) .collect(); println!("{}", doubled_evens.join(","));
The chain compiles to roughly the same machine code as the hand-written loop, which is what "zero-cost abstraction" means in practice. Two details for a newcomer: iter() yields references, hence the *number to compare against a number, and collect() is what decides the result type — annotating Vec<String> is how it knows what to build.
Sum, maximum and fold
Each of these is a loop in Lua, and the loop computing two at once is the one that goes wrong — because the running maximum has to be seeded from the first element rather than from zero.
local prices = { 4.50, 12.00, 3.25 } local total, highest = 0, prices[1] for _, price in ipairs(prices) do total = total + price if price > highest then highest = price end end print(string.format("%.2f\t%d", total, #prices)) print(string.format("%.1f", highest))
let prices = [4.50, 12.00, 3.25]; let total: f64 = prices.iter().sum(); let highest = prices.iter().cloned().fold(f64::MIN, f64::max); println!("{:.2}\t{}", total, prices.len()); println!("{:.1}", highest);
The odd-looking fold is worth the detour: there is a max() on iterators, but it requires Ord, and floats are not totally ordered because NaN compares false against everything. So Rust makes you say what you want with NaN present rather than guessing — a small example of a large habit. Both columns format their floats explicitly, since Lua prints a float as 12.0 and Rust prints an f64 as 12.
An iterator is a recipe, not a result
Rust's iterators are lazy: map builds a description and runs nothing. In most languages that is a trap, because the source can change before the work happens. Watch what stops it being one here.
local numbers = { 1, 2, 3 } local squares = {} for _, number in ipairs(numbers) do squares[#squares + 1] = number * number end numbers[4] = 4 -- too late: squares was already built print(table.concat(squares, ","))
let mut numbers = vec![1, 2, 3]; let squares = numbers.iter().map(|number| number * number); // numbers.push(4); // error[E0502]: numbers is borrowed by the iterator let collected: Vec<String> = squares.map(|square| square.to_string()).collect(); numbers.push(4); // fine here: the iterator is finished with println!("{}", collected.join(","));
The iterator borrows numbers, so the compiler refuses any modification while it is alive — the laziness cannot surprise you, because the aliasing that would make it surprising is rejected. This is worth noticing as a pattern: the borrow checker is not only about memory safety; it rules out logic bugs that GC'd languages leave to your discipline. The Lua column, being eager, was never at risk in the first place.
Grouping, which neither standard library does for you
Fifteen lines against eight, and the saving is not where you would expect: Rust has no group_by in its standard library either, so both columns write the loop. What disappears is the bookkeeping around it.
local words = { "apple", "avocado", "banana", "blueberry", "cherry" } local byLetter = {} for _, word in ipairs(words) do local letter = word:sub(1, 1) byLetter[letter] = byLetter[letter] or {} table.insert(byLetter[letter], word) end local letters = {} for letter in pairs(byLetter) do letters[#letters + 1] = letter end table.sort(letters) for _, letter in ipairs(letters) do print(letter, #byLetter[letter]) end
use std::collections::BTreeMap; fn main() { let words = ["apple", "avocado", "banana", "blueberry", "cherry"]; let mut by_letter: BTreeMap<char, Vec<&str>> = BTreeMap::new(); for word in words { by_letter.entry(word.chars().next().unwrap()).or_default().push(word); } for (letter, group) in &by_letter { println!("{}\t{}", letter, group.len()); } }
Two things do the work. or_default() creates the empty Vec when the key is new, replacing Lua's byLetter[letter] or {}; and BTreeMap keeps its keys sorted, so the whole collect-and-sort-the-keys dance at the bottom of the Lua column is unnecessary. The itertools crate does have chunk_by, and reaching for a crate for this is normal Rust rather than a failure.
Three ways to loop, and they differ in ownership
The one place where ownership reaches into something as ordinary as a for loop. Lua has a single answer; Rust has three, and picking the wrong one is a common early frustration.
-- One loop form, and the table is always shared with the loop. local words = { "a", "b" } for _, word in ipairs(words) do print(word) end print(#words) -- still there
let words = vec!["a", "b"]; for word in words.iter() { println!("{}", word); } // borrows each item println!("{}", words.len()); // still there for word in words { println!("{}", word); } // CONSUMES the vector // println!("{}", words.len()); // error[E0382]: use of moved value
iter() borrows each element, iter_mut() borrows each mutably so the loop can change them in place, and into_iter() — which a bare for word in words calls — takes ownership and leaves the collection consumed. When a loop unexpectedly refuses to compile, the usual fix is &collection or .iter(). That is why the Rust column here prints its items twice: the two loops are two different operations.
Functions & Closures
Declaring a function
Both languages have a statement form and a value form, and Rust's value form uses pipes rather than a keyword. The signature is where the real difference lives.
local function double(value) return value * 2 end local triple = function(value) return value * 3 end print(double(21)) print(triple(14))
fn double(value: i32) -> i32 { value * 2 } fn main() { let triple = |value: i32| value * 3; println!("{}", double(21)); println!("{}", triple(14)); }
An fn must annotate every parameter and its return type — inference stops at the boundary on purpose, so changing one function's body can never silently retype another. A closure written with |…| may leave them out, because it is used nearby and the compiler can see how. Note the naming convention too: Rust is snake_case throughout, which happens to be Lua's common style as well.
Multiple return values become one tuple
The call sites look nearly identical, and what happens underneath does not. Lua returns a list of values that the caller may take some or all of; Rust returns one tuple value that the caller may destructure.
local function divide(numerator, denominator) return numerator // denominator, numerator % denominator end local quotient, remainder = divide(17, 5) print(quotient, remainder)
fn divide(numerator: i32, denominator: i32) -> (i32, i32) { (numerator / denominator, numerator % denominator) } fn main() { let (quotient, remainder) = divide(17, 5); println!("{}\t{}", quotient, remainder); }
The difference shows up when a call sits inside a larger expression. Lua adjusts a multi-value call to a single value in most positions, so print(divide(17, 5), "x") prints only the quotient — a real source of confusion. A tuple is one value everywhere, so nothing silently disappears. Also note Lua's //: since 5.3, / always produces a float, so integer division needs its own operator, where Rust's / on two integers is already integer division.
... has no counterpart at all
Rust has no variadic functions. Not "an awkward form of them" — none, and the language is not planning any. So the argument list becomes a slice and the caller writes the brackets.
local function sum(...) local total = 0 for _, value in ipairs({ ... }) do total = total + value end return total, select("#", ...) end local total, count = sum(1, 2, 3, 4) print(total, count)
fn sum(values: &[i32]) -> (i32, usize) { (values.iter().sum(), values.len()) } fn main() { let (total, count) = sum(&[1, 2, 3, 4]); println!("{}\t{}", total, count); }
This is why println! is a macro: macros run at compile time and can take any number of arguments, which is how the standard library offers a printf-shaped interface without the language having varargs. Writing your own macro_rules! for this is possible and rarely worth it — a slice parameter is idiomatic and reads fine.
No default arguments either
Lua has no default arguments and fakes them with or. Rust has none either, and the idiomatic replacement is a different shape entirely: put the options in a struct and give the struct a default.
local function connect(host, port, timeout) port = port or 80 timeout = timeout or 30 return string.format("%s:%d timeout=%d", host, port, timeout) end print(connect("example.com")) print(connect("example.com", 8080))
struct Connection { host: String, port: u16, timeout: u32 } impl Default for Connection { fn default() -> Connection { Connection { host: String::from("localhost"), port: 80, timeout: 30 } } } fn describe(connection: &Connection) -> String { format!("{}:{} timeout={}", connection.host, connection.port, connection.timeout) } fn main() { let plain = Connection { host: String::from("example.com"), ..Default::default() }; println!("{}", describe(&plain)); let custom = Connection { host: String::from("example.com"), port: 8080, ..Default::default() }; println!("{}", describe(&custom)); }
..Default::default() is the struct-update syntax — take these fields, fill the rest from the default — and it is the closest thing Rust has to named optional arguments. Note the gain over the Lua column: the defaults are stated once, in one place, and every field is named at the call site rather than counted. The heavier version of this pattern is a builder, which is what libraries expose when there are many options.
Closures, and the three ways they capture
The classic Lua closure, and it works — but Rust needs three extra words, and each one is telling you something true about what the code does.
local function makeCounter() local count = 0 return function() count = count + 1 return count end end local counter = makeCounter() print(counter()) print(counter()) print(counter())
fn make_counter() -> impl FnMut() -> i32 { let mut count = 0; move || { count += 1; count } } fn main() { let mut counter = make_counter(); println!("{}", counter()); println!("{}", counter()); println!("{}", counter()); }
move takes ownership of count, which is required because the closure outlives the function. FnMut says the closure mutates what it captured, which is why counter itself must be let mut — calling it changes it. The three traits are the whole taxonomy: Fn only reads, FnMut mutates, FnOnce consumes and can be called once. Lua's closures are all quietly FnMut, and the upvalue is reference-counted for you.
Metatables Become Traits
The setmetatable idiom becomes struct plus impl
Lua has no classes — it has a convention, repeated in every codebase in slightly different forms: a table of methods, an __index pointing at itself, and a constructor calling setmetatable. Rust splits that convention into two declarations that say what each half is for.
local Account = {} Account.__index = Account function Account.new(owner, balance) return setmetatable({ owner = owner, balance = balance }, Account) end function Account:deposit(amount) self.balance = self.balance + amount return self.balance end local account = Account.new("Ada", 100) print(account:deposit(50)) print(account.owner)
struct Account { owner: String, balance: i32 } impl Account { fn new(owner: &str, balance: i32) -> Account { Account { owner: String::from(owner), balance } } fn deposit(&mut self, amount: i32) -> i32 { self.balance += amount; self.balance } } fn main() { let mut account = Account::new("Ada", 100); println!("{}", account.deposit(50)); println!("{}", account.owner); }
The struct is the data and the impl is the behavior, and separating them is what lets you write several impl blocks for one type. new is a convention rather than a keyword — there is no special constructor, just an associated function returning Self. Note &mut self on deposit: the method says it modifies the account, which is why account had to be declared mut.
self is a real parameter in both
A Lua programmer has an advantage here that programmers from most other languages do not: you already know self is an ordinary first parameter, because function T:m() is sugar for function T.m(self). Rust agrees, and goes further.
local Greeter = {} Greeter.__index = Greeter function Greeter.new(name) return setmetatable({ name = name }, Greeter) end function Greeter:hello() -- the colon adds an implicit self return "Hello, " .. self.name end function Greeter.goodbye(self) -- exactly the same thing, spelled out return "Goodbye, " .. self.name end local greeter = Greeter.new("Ada") print(greeter:hello()) print(greeter.goodbye(greeter))
struct Greeter { name: String } impl Greeter { fn hello(&self) -> String { format!("Hello, {}", self.name) } fn into_goodbye(self) -> String { // takes self BY VALUE: consumed format!("Goodbye, {}", self.name) } } fn main() { let greeter = Greeter { name: String::from("Ada") }; println!("{}", greeter.hello()); println!("{}", greeter.into_goodbye()); }
Rust's self comes in three forms, and each is a claim: &self borrows to read, &mut self borrows to modify, and bare self takes ownership so the value is consumed by the call. That last one has no Lua counterpart at all and is worth recognizing — a method named into_something conventionally takes self, and the original is gone afterwards. What Rust does not have is Lua's dot-versus-colon hazard: there is one call syntax, so forgetting the receiver is impossible.
There is no inheritance to translate to
Lua's inheritance is a metatable on a metatable: a lookup that misses in Dog is forwarded to Animal. Rust has no struct inheritance whatsoever — not a restricted version, none — so this is a genuine redesign rather than a translation.
local Animal = {} Animal.__index = Animal function Animal.new(name) return setmetatable({ name = name }, Animal) end function Animal:speak() return self.name .. " makes a sound" end local Dog = setmetatable({}, { __index = Animal }) -- Dog falls back to Animal Dog.__index = Dog function Dog.new(name) return setmetatable(Animal.new(name), Dog) end function Dog:speak() return self.name .. " barks" end print(Animal.new("Generic"):speak()) print(Dog.new("Rex"):speak())
trait Speak { fn name(&self) -> &str; fn speak(&self) -> String { format!("{} makes a sound", self.name()) // a DEFAULT method } } struct Animal { name: String } struct Dog { name: String } impl Speak for Animal { fn name(&self) -> &str { &self.name } } impl Speak for Dog { fn name(&self) -> &str { &self.name } fn speak(&self) -> String { format!("{} barks", self.name) } } fn main() { println!("{}", Animal { name: String::from("Generic") }.speak()); println!("{}", Dog { name: String::from("Rex") }.speak()); }
A default trait method is the piece that recovers what you actually wanted from inheritance: shared behavior, overridable per type. What it does not give you is inherited fields, which is why both structs declare name and both implement the accessor. When that duplication grows, the answer is composition — hold the shared struct as a field — and Rust's position is that this was always the better design and the language simply declines to offer the other one.
Duck typing becomes a trait bound
In Lua, describe accepts anything that happens to have those two methods, and finds out at the moment of the call. In Rust the requirement is written down and checked when the program is built.
local function describe(shape) return shape:name() .. " area " .. shape:area() end local Square = {} Square.__index = Square function Square:name() return "square" end function Square:area() return self.side * self.side end print(describe(setmetatable({ side = 3 }, Square)))
trait Shape { fn name(&self) -> &str; fn area(&self) -> i32; } struct Square { side: i32 } impl Shape for Square { fn name(&self) -> &str { "square" } fn area(&self) -> i32 { self.side * self.side } } fn describe(shape: &impl Shape) -> String { format!("{} area {}", shape.name(), shape.area()) } fn main() { println!("{}", describe(&Square { side: 3 })); }
&impl Shape is a generic parameter, so the compiler generates a specialized describe for each type and the calls are direct — no lookup at run time, unlike Lua's metatable walk. When you need a collection of mixed types instead, &dyn Shape uses a vtable and behaves more like what Lua does. Rust's traits are nominal: a type with matching methods that does not say impl Shape will not be accepted.
__tostring becomes Display, and Debug comes free
Lua has one hook for turning a value into text. Rust has two, and the split — one for users, one for you — is more useful than it first appears.
local Vector = {} Vector.__index = Vector Vector.__tostring = function(self) return "(" .. self.x .. ", " .. self.y .. ")" end local point = setmetatable({ x = 1, y = 2 }, Vector) print(tostring(point))
use std::fmt; #[derive(Debug)] struct Vector { x: i32, y: i32 } impl fmt::Display for Vector { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "({}, {})", self.x, self.y) } } fn main() { let point = Vector { x: 1, y: 2 }; println!("{}", point); // Display: the __tostring counterpart println!("{:?}", point); // Debug: derived, no Lua counterpart }
Display is the human-facing form, written by hand because only you know how the type should read. Debug is the programmer-facing form and is derived: one line generates a representation showing the field names, which is what {:?} prints, and {:#?} pretty-prints it over several lines. Getting a structure dump for free is the thing Lua programmers most often write an inspect helper for.
__add becomes impl Add
The metatable feature that transfers most directly. Lua names its hooks __add, __sub, __mul, __eq, __lt, __len, __call, __concat; Rust puts each behind a trait in std::ops.
local Vector = {} Vector.__index = Vector Vector.__add = function(left, right) return setmetatable({ x = left.x + right.x, y = left.y + right.y }, Vector) end local sum = setmetatable({ x = 1, y = 2 }, Vector) + setmetatable({ x = 10, y = 20 }, Vector) print("(" .. sum.x .. ", " .. sum.y .. ")")
use std::ops::Add; struct Vector { x: i32, y: i32 } impl Add for Vector { type Output = Vector; fn add(self, other: Vector) -> Vector { Vector { x: self.x + other.x, y: self.y + other.y } } } fn main() { let sum = Vector { x: 1, y: 2 } + Vector { x: 10, y: 20 }; println!("({}, {})", sum.x, sum.y); }
Two differences. Operator overloading in Rust is just trait implementation, so it uses the same mechanism as everything else and gets the same coherence guarantees — where Lua picks whichever operand happens to carry the metamethod, which can be asymmetric and surprising. And note fn add(self, …): addition consumes both vectors here, so a type meant for arithmetic normally derives Copy or implements Add for references too.
derive: equality and copying for one line
Two Lua tables holding the same contents are never equal, because == on tables compares identity unless you write __eq. One line of Rust removes that chore, and several others with it.
local origin = { x = 1, y = 2 } local sameValues = { x = 1, y = 2 } print(origin == sameValues) -- false: tables compare by identity local copy = { x = origin.x, y = origin.y } print(copy.x, copy.y)
#[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } fn main() { let origin = Point { x: 1, y: 2 }; let same_values = Point { x: 1, y: 2 }; println!("{}", origin == same_values); // true: PartialEq was derived let copy = origin.clone(); println!("{}\t{}", copy.x, copy.y); println!("{:?}", copy); }
#[derive(…)] generates trait implementations from the shape of the struct: PartialEq compares field by field, Clone copies field by field, Debug prints the fields, and Hash, Default, PartialOrd and Copy are all available the same way. This is the habit to pick up — a data-carrying struct in real Rust almost always opens with a derive list, and hand-writing these is reserved for the cases where the default is wrong.
Enums and Pattern Matching
The tagged table becomes an enum
The tag-field table is a pattern every Lua codebase grows, and every one of them gets it slightly wrong somewhere: a typo'd tag string, a branch that forgets a case, a payload read as the wrong type. Rust's enum is that pattern turned into a language feature.
-- Lua's nearest thing is a table with a tag field, checked by hand. local function makeValue(kind, payload) return { kind = kind, payload = payload } end local value = makeValue("text", "hi") if value.kind == "number" then print("number", value.payload) else print("text", value.payload) end
enum Value { Number(f64), Text(String), } fn main() { let value = Value::Text(String::from("hi")); match value { Value::Number(number) => println!("number\t{}", number), Value::Text(text) => println!("text\t{}", text), } }
A Rust enum is a tagged union: exactly one variant at a time, each carrying its own data of its own type. That is what makes the match below it safe — inside the Text arm, text is a String, and no branch can misread the payload. This is the single most-loved feature of the language, and it is also what Option and Result are built from.
The elseif ladder becomes match
Lua has no switch at all; the replacements are an elseif ladder or a table of functions keyed by value. Rust's match is an expression, so it produces a value rather than jumping around.
local function describe(value) if value == 0 then return "zero" elseif value == 1 then return "one" elseif value < 0 then return "negative" else return "many" end end print(describe(0)) print(describe(-4)) print(describe(7))
fn describe(value: i32) -> &'static str { match value { 0 => "zero", 1 => "one", number if number < 0 => "negative", _ => "many", } } fn main() { println!("{}", describe(0)); println!("{}", describe(-4)); println!("{}", describe(7)); }
The word doing the most work is exhaustive: match must cover every possibility or the code does not compile, so _ is the catch-all. Over an enum that is transformative — add a variant, and every match that has not been updated becomes a build error rather than a silent fall-through to the else. number if number < 0 is a match guard, and patterns can also be ranges (2..=9) or alternatives (1 | 2).
Matching on the shape of a value
A pattern can reach inside a value and test its fields, so a decision table stays a table instead of turning into nested conditions.
local function shipping(order) if order.weight > 20 and order.express then return "freight express" end if order.weight > 20 then return "freight" end if order.express then return "parcel express" end return "parcel" end print(shipping({ weight = 30, express = true })) print(shipping({ weight = 2, express = false }))
struct Order { weight: i32, express: bool } fn shipping(order: &Order) -> &'static str { match order { Order { weight: 21.., express: true } => "freight express", Order { weight: 21.., .. } => "freight", Order { express: true, .. } => "parcel express", _ => "parcel", } } fn main() { println!("{}", shipping(&Order { weight: 30, express: true })); println!("{}", shipping(&Order { weight: 2, express: false })); }
The arms are tried in order, so the specific ones must come first — as with the Lua ladder, and as easy to get wrong. What you gain is that the shape being tested is legible at a glance: 21.. is a half-open range pattern and .. means "and the other fields, whatever they are". Patterns nest, so Order { customer: Customer { country: "GB", .. }, .. } is a valid arm.
Two return values become one Result
The return nil, message convention is how Lua libraries report a failure that is not exceptional, and it is a convention rather than a rule — nothing makes the caller look at the second value. Rust makes the same idea a type.
local function parse(text) local number = tonumber(text) if number then return number end return nil, "not a number: " .. text end local function show(text) local number, problem = parse(text) print(number or problem) end show("42") show("nope")
fn parse(text: &str) -> Result<i32, String> { text.parse::<i32>().map_err(|_| format!("not a number: {}", text)) } fn show(text: &str) { match parse(text) { Ok(number) => println!("{}", number), Err(problem) => println!("{}", problem), } } fn main() { show("42"); show("nope"); }
A Result is an enum with two variants, so the caller cannot reach the number without acknowledging the other case — and the compiler warns about a Result that is discarded entirely. Compare the failure modes: a Lua caller who writes local number = parse(text) gets nil and carries on, which is exactly the bug this type exists to prevent.
Draining a collection with while let
The Lua loop tests the length and then removes, which means the condition and the operation each assume something about the other. Rust's version asks once.
local stack = { "a", "b", "c" } while #stack > 0 do print(table.remove(stack)) end
let mut stack = vec!["a", "b", "c"]; while let Some(top) = stack.pop() { println!("{}", top); }
pop returns Option<T>, and while let keeps looping as long as the pattern matches — so the emptiness check and the removal are the same operation and cannot disagree. Both print c, b, a. This shape recurs constantly in Rust: any function returning an Option can drive a while let.
pcall Becomes Result and panic!
pcall becomes Result, and ? propagates it
Lua propagates an error by not catching it: error unwinds until some pcall up the stack stops it, so intermediate functions say nothing. Rust makes propagation explicit and gives it one character.
local function parse(text) local number = tonumber(text) if not number then error("not a number: " .. text, 0) end return number end local function total(left, right) return parse(left) + parse(right) end print(pcall(total, "2", "3")) print(pcall(total, "2", "x"))
fn parse(text: &str) -> Result<i32, String> { text.parse::<i32>().map_err(|_| format!("not a number: {}", text)) } fn total(left: &str, right: &str) -> Result<i32, String> { Ok(parse(left)? + parse(right)?) // ? returns early on Err } fn main() { println!("{:?}", total("2", "3")); println!("{:?}", total("2", "x")); }
? is the whole ergonomic story: on Ok it unwraps the value and carries on, and on Err it returns that error from the enclosing function immediately. So total reads almost like the Lua version while still being honest about failing. The trade-off is visible in the signature: every function on the failure path must return a Result, which is more typing and also a map of exactly where things can go wrong.
Failure is in the type, not the documentation
This is the difference that changes how code is read rather than how it is written. In Lua, any function may raise, and there is no way to tell which do.
-- Nothing in the signature says this can fail. You find out by reading it, -- or by having it fail. local function risky(value) if value < 0 then error("negative", 0) end return value * 2 end print(risky(21)) print(pcall(risky, -1))
// The return type says it can fail, so callers cannot forget. fn risky(value: i32) -> Result<i32, String> { if value < 0 { return Err(String::from("negative")); } Ok(value * 2) } fn main() { println!("{:?}", risky(21)); println!("{:?}", risky(-1)); }
Rust has no exceptions and no throws clause to ignore — the failure is part of the value the function hands back, so the type system carries the information that Lua leaves to a docstring. The practical effect on a codebase is that you can see the failure surface by reading signatures, and adding a new failure mode to a function is a change its callers are forced to notice.
One error channel becomes two
Rust splits what Lua puts down one pipe. Knowing which channel a failure belongs in is most of knowing how to write idiomatic Rust error handling.
-- Lua has ONE channel, and pcall catches all of it: a deliberate error -- and a programming mistake arrive the same way. local ok, message = pcall(function() error("deliberate", 0) end) print(ok, message) local alsoOk = pcall(function() local nothing = nil return nothing.field -- a bug, same channel end) print(alsoOk)
fn deliberate() -> Result<i32, String> { Err(String::from("deliberate")) // an expected failure: a value } fn main() { println!("{:?}", deliberate()); let numbers: Vec<i32> = Vec::new(); println!("{:?}", numbers.first()); // no panic: an Option, not an index // numbers[0]; // THIS panics: a programming bug }
A Result is for failures the caller should handle: a missing file, bad input, a refused connection. A panic! is for a broken invariant — an index out of range, a failed assertion, a None you promised would be Some — and it is not meant to be caught. The library convention follows the split, which is why first() returns an Option and [0] panics: same question, two answers, depending on whether absence is expected.
The closest thing to pcall, and why you should not reach for it
There is a function that catches a panic, and a Lua programmer's instinct will be to use it as pcall. It is worth seeing precisely so that instinct can be put down.
local ok, message = pcall(function() error("boom", 0) end) print(ok) print(message)
use std::panic; fn main() { panic::set_hook(Box::new(|_| {})); // silence the default panic report let outcome = panic::catch_unwind(|| panic!("boom")); let _ = panic::take_hook(); println!("{}", outcome.is_err()); println!("caught"); }
catch_unwind exists for two narrow jobs: stopping a panic at an FFI boundary, where unwinding into C is undefined behavior, and supervising a worker thread. It is deliberately awkward — the closure must be unwind-safe, the default handler prints to stderr unless you replace it, and a build configured with panic = "abort" makes it useless. Everyday recoverable failure is Result, and reaching for this instead is the clearest sign of a Lua reflex.
unwrap and expect: asserting that it worked
The everyday cost of Lua's nil-means-absent rule is distance: the missing value is produced in one place and blows up in another, often after being stored. Rust's escape hatches fail at the point of the wrong assumption instead.
local ages = { alice = 30 } print(ages.alice) -- Reading a missing key gives nil, and the mistake surfaces later, elsewhere. local bob = ages.bob print(bob == nil)
use std::collections::HashMap; fn main() { let ages = HashMap::from([("alice", 30)]); println!("{}", ages["alice"]); // ages.get("bob").unwrap() would panic HERE, naming this file and line. println!("{}", ages.get("bob").copied().unwrap_or(-1)); }
unwrap() turns None or Err into an immediate panic; expect("…") does the same with a message, and is preferred precisely because the message states the assumption that turned out to be false. Neither is shameful in a place where the invariant really does hold — but each one is a claim, and the reviewable question is whether the claim is true.
Coroutines, Iterators and Real Threads
coroutine.wrap becomes an iterator
For the producer case — a function that hands back values one at a time and suspends in between — Rust's answer is an iterator, and when the sequence is expressible in terms of existing iterators the code is shorter than the Lua original.
local function counter(limit) return coroutine.wrap(function() for value = 1, limit do coroutine.yield(value) end end) end for value in counter(3) do print(value) end
fn counter(limit: u32) -> impl Iterator<Item = u32> { 1..=limit } fn main() { for value in counter(3) { println!("{}", value); } }
Both are lazy: nothing runs until the loop asks. What Rust does not have is a way to write that suspension yourself on stable — there is no yield keyword available, so a generator is not an option and you either compose existing iterators, as here, or implement the Iterator trait, as the next row does. Generators have been in the works for years and remain nightly-only.
Writing the suspension by hand
Here is the cost of having no generators, stated plainly. The Lua column suspends in the middle of a loop and the local variables simply survive; the Rust column has to name the state, put it in a struct, and turn the loop inside out into a next method.
local function fibonacci(count) return coroutine.wrap(function() local previous, current = 0, 1 for _ = 1, count do coroutine.yield(previous) previous, current = current, previous + current end end) end for value in fibonacci(6) do io.write(value .. " ") end print()
struct Fibonacci { previous: u32, current: u32 } impl Iterator for Fibonacci { type Item = u32; fn next(&mut self) -> Option<u32> { let value = self.previous; self.previous = self.current; self.current = value + self.current; Some(value) } } fn main() { let fibonacci = Fibonacci { previous: 0, current: 1 }; for value in fibonacci.take(6) { print!("{} ", value); } println!(); }
What a coroutine gives you for free is exactly this: the compiler working out what state to keep across a suspension. Writing it by hand is mechanical rather than hard, and it does buy something back — Fibonacci is an ordinary value you can store, clone or pass around, and because it implements Iterator it inherits take, map, filter, zip and the rest at no extra cost. Note that next returning Some forever makes this infinite, and take(6) is what bounds it.
Sending values IN, which has no counterpart
This is where Lua is strictly more capable, and it is worth being direct about it. coroutine.yield is an expression: it hands a value out, and the value passed to the next resume becomes its result — so a coroutine is a two-way conversation.
local machine = coroutine.create(function(first) print("got " .. first) local second = coroutine.yield("ready") print("got " .. second) return "done" end) local _, reply = coroutine.resume(machine, "one") print("yielded " .. reply) print(select(2, coroutine.resume(machine, "two")))
struct Machine; impl Machine { fn start(&self, first: &str) -> &'static str { println!("got {}", first); "ready" } fn resume(&self, second: &str) -> &'static str { println!("got {}", second); "done" } } fn main() { let machine = Machine; println!("yielded {}", machine.start("one")); println!("{}", machine.resume("two")); }
Nothing in Rust does this. An iterator's next takes no argument, so the flow is one-way; the replacements are an object whose methods carry the conversation, as above, or a pair of channels between two threads. Both columns print the same four lines, and the Rust version needed a hand-built state machine to do it. This is the honest cost of no generators, not a spelling you have not found yet.
Coroutines interleave; threads actually run at once
Lua's concurrency story is cooperative and single-threaded: a coroutine yields, something resumes it, and only one thing ever runs. Rust's threads are operating-system threads running on separate cores.
-- A Lua state is single-threaded. Coroutines take turns; nothing runs -- in parallel, and there is no shared-memory race to worry about. local tasks = {} for id = 1, 3 do tasks[id] = coroutine.wrap(function() return "worker " .. id end) end for _, task in ipairs(tasks) do print(task()) end
use std::thread; fn main() { let mut handles = Vec::new(); for id in 1..=3 { handles.push(thread::spawn(move || format!("worker {}", id))); } for handle in handles { println!("{}", handle.join().unwrap()); } }
join() waits for a thread and hands back what its closure returned, which is why the output is in spawn order even though the work was not. The interesting part is what makes this safe: the ownership rules from the second section are the same rules that prevent data races, because a value moved into a thread cannot still be reachable outside it. That is what "fearless concurrency" refers to — not a library, but the borrow checker applied to threads.
Passing messages between threads
Once work really is parallel, handing results back needs a mechanism. A channel is the one Rust reaches for first, and it is the shape a Lua programmer already knows from a task queue — with one difference that shows up in this very example.
-- With one thread, "sending" is just appending to a table. local queue = {} for id = 1, 3 do queue[#queue + 1] = "message " .. id end for _, message in ipairs(queue) do print(message) end
use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); for id in 1..=3 { let sender = sender.clone(); thread::spawn(move || sender.send(format!("message {}", id)).unwrap()); } drop(sender); let mut messages: Vec<String> = receiver.iter().collect(); messages.sort(); // arrival order is genuinely not deterministic for message in messages { println!("{}", message); } }
The drop(sender) is load-bearing: the receiver's iterator ends when every sender is gone, and the original would otherwise stay alive and hang the program. The sort is honest rather than cosmetic — with three real threads the arrival order varies between runs, which is exactly the class of nondeterminism a single-threaded Lua state never exposes you to. For shared mutable state instead of messages, the pair is Arc<Mutex<T>>: the thread-safe sibling of the Rc<RefCell<T>> from the Ownership section.
require Becomes mod, use and Cargo
require loads a file; mod declares a namespace
The mental model changes rather than the syntax. Lua's require is a file operation: it searches package.path, runs the file once, caches the result in package.loaded, and returns it — so a module is a value you assign to a variable.
-- require runs a FILE once and gives you whatever it returned. -- In a real project: local mathHelpers = require("math_helpers") local mathHelpers = { double = function(value) return value * 2 end } print(mathHelpers.double(21))
// A module is a NAMESPACE declared in code. One file can hold several. mod math_helpers { pub fn double(value: i32) -> i32 { value * 2 } } fn main() { println!("{}", math_helpers::double(21)); }
A Rust mod declares a namespace and returns nothing; use only shortens paths, so use math_helpers::double; would let you write double(21). Files do come into it — mod math_helpers; with no body tells the compiler to look in math_helpers.rs — but the module tree is built by those declarations rather than inferred from the directory, which is why a new file that nothing declares is silently not compiled.
local becomes private-by-default, and pub is the opt-in
Lua has one privacy mechanism — a local is visible in its file and nowhere else — and anything exported goes on a table you return. Rust's defaults point the same way, with finer control available.
local module = {} local function helper(value) -- 'local', so invisible outside this file return value + 1 end function module.increment(value) return helper(value) end print(module.increment(41))
mod counter { fn helper(value: i32) -> i32 { value + 1 // private by default: no keyword needed } pub fn increment(value: i32) -> i32 { helper(value) } } fn main() { println!("{}", counter::increment(41)); }
Private is the default and pub is the opt-in, which is the inverse of most languages and matches Lua's habit of returning a small table from a big file. The graduations are worth knowing when a project grows: pub(crate) is visible throughout your own crate but not to anyone depending on it, and pub(super) only to the parent module. A crate is the unit of compilation and of publication, and Lua has no counterpart to it at all.
LuaRocks becomes Cargo
Both ecosystems have a registry and a command-line installer. The differences are where the dependency is recorded and how much of the toolchain comes with it.
-- $ luarocks install penlight -- LuaRocks installs into a tree that package.path already searches. local stringx = require("pl.stringx") print(stringx.strip(" padded "))
// $ cargo add regex // The dependency lands in Cargo.toml and the exact version in Cargo.lock. use regex::Regex; fn main() { let pattern = Regex::new(r"id=(\d+)").unwrap(); println!("{}", &pattern.captures("id=42").unwrap()[1]); }
LuaRocks installs into a shared tree that the interpreter finds through package.path, so which version you get depends on the machine. Cargo writes the requirement into Cargo.toml and the resolved version into Cargo.lock, both checked in, so cargo build reproduces the same dependency graph anywhere — and the same tool also runs your tests, builds your docs, formats your code and publishes your crate. Neither column runs here: neither package is available to the browser runner, and the Rust one is the reason this page stays inside the standard library.
Patching the string metatable becomes an extension trait
Adding a method to a type you do not own is routine in Lua: every string shares one metatable, so a single assignment reaches all of them. Rust allows it too, and the restriction it places on doing so is the most interesting thing in this section.
-- Lua lets you add to the string metatable, for the whole program. local stringMethods = getmetatable("").__index stringMethods.shout = function(self) return self:upper() .. "!" end print(("hello"):shout())
trait Shout { fn shout(&self) -> String; } impl Shout for str { fn shout(&self) -> String { self.to_uppercase() + "!" } } fn main() { println!("{}", "hello".shout()); }
This is an extension trait, and it is governed by the orphan rule: you may implement your own trait for anyone's type, or anyone's trait for your own type, but never someone else's trait for someone else's type. That is what makes it impossible for two libraries to define conflicting behavior for str — a class of breakage the Lua column invites, which is exactly why Lua libraries are told not to touch the shared metatable. The method is also only visible where Shout is in scope, so importing it is a local decision rather than a global mutation.
Gotchas for Lua Developers
Overflow is checked, not silent
Lua gives you one integer type and never mentions its width, so overflow is something you have probably never met. Rust makes you choose a width, which means you can meet it — and then goes out of its way to tell you when you do.
-- Lua integers wrap silently, and how wide they are depends on the build. print(math.maxinteger + 1 == math.mininteger) -- true: it wrapped, quietly print(math.maxinteger + 1 < 0) -- true, and nothing warned you
let largest = i32::MAX; println!("{}", largest.wrapping_add(1) == i32::MIN); // true: same wrap, but ASKED for println!("{}", largest.checked_add(1).is_none()); // true: the safe way to ask // largest + 1 panics in a debug build and wraps in a release build
Neither column names a width, and for Lua that is the point: math.maxinteger is whatever the interpreter was compiled for. A desktop build gives you 64-bit integers, and builds for smaller targets give 32-bit ones — so the same literal can be an integer on one machine and, being too large for the build, silently a float on another. Rust puts the width in the type name, so i32 means the same thing everywhere and the only question left is what happens at the boundary. Its answer: the bare + panics in a debug build and wraps in a release build, so an overflow a test caught can go quiet in production. The alternatives all state their intent — wrapping_add, checked_add returning an Option, saturating_add clamping at the limit, and overflowing_add returning both.
Nothing converts itself
Lua converts quietly and often: an integer meeting a float becomes a float, and a numeric string meeting + becomes a number. Rust converts nothing at all, not even between integer widths.
print(1 + 1.0) -- 2.0: integers and floats mix freely print("10" + 1 == 11) -- true: even a string coerces if it looks numeric print(7 // 2) print(7 / 2)
// let mixed = 1 + 1.0; // error[E0277]: cannot add float to integer println!("{:.1}", 1 as f64 + 1.0); // the cast is yours to write println!("{}", "10".parse::<i32>().unwrap() + 1 == 11); println!("{}", 7 / 2); println!("{}", 7.0 / 2.0);
Every conversion is written: as for a numeric cast, parse for text, into() for a widening that cannot lose information. It is more typing, and it removes the whole family of bug where a value became a float three functions ago and a comparison has quietly been approximate ever since. Note the division too: Lua 5.3 made / always produce a float and added // for floor division, where Rust reads the operand types — so 7 / 2 is 3 in Rust and 3.5 in Lua.
Building a string in a loop
The Lua habit here is a workaround for immutability: result = result .. piece in a loop allocates a fresh string every turn, so you collect into a table and join once. Rust does not need the workaround.
local pieces = {} for index = 1, 5 do pieces[#pieces + 1] = tostring(index) end print(table.concat(pieces)) -- accumulate, then join once
let mut pieces = String::new(); for index in 1..=5 { pieces.push_str(&index.to_string()); } println!("{}", pieces); // one buffer, appended to in place
A Rust String owns a growable buffer, so push_str appends in place and there is nothing to detour around — the same reason C# reaches for a StringBuilder and Rust does not. This is the clearest single consequence of the two string types: &str is immutable like every Lua string, and String is the mutable one Lua has no equivalent of.
Capturing the loop variable
Both languages create a fresh binding per iteration, so both columns behave the way you would hope — which makes this a convergence rather than a trap. The interesting part is what Rust needs in order to store the closures at all.
local callbacks = {} for index = 1, 3 do callbacks[index] = function() return index end -- a FRESH index each turn end print(callbacks[1]()) print(callbacks[3]())
fn main() { let mut callbacks: Vec<Box<dyn Fn() -> i32>> = Vec::new(); for index in 1..=3 { callbacks.push(Box::new(move || index)); // 'move' copies it in } println!("{}", callbacks[0]()); println!("{}", callbacks[2]()); }
Two pieces of ceremony, each earning its place. move is required because the closure outlives the iteration that created it, and since i32 is Copy the capture is a copy. Box<dyn Fn() -> i32> is required because every closure has its own anonymous type, so three of them will not sit in one Vec without being boxed behind a trait object. A Lua table of functions needs neither, and pays for it with a pointer chase and a collector.
What == compares
Half of this is a convergence and half of it is an inversion, and the second half catches Lua programmers out because it is the safer behavior arriving where they expect the sharp one.
local first = "hello" local second = "hel" .. "lo" print(first == second) -- true: strings compare by content local left, right = {}, {} print(left == right) -- false: tables compare by identity
let first = "hello"; let second = format!("hel{}", "lo"); println!("{}", first == second); // true: str compares by content let left: Vec<i32> = Vec::new(); let right: Vec<i32> = Vec::new(); println!("{}", left == right); // ALSO true: Vec compares by content
Lua special-cases strings and compares everything else by identity, so two empty tables are not equal. In Rust == is the PartialEq trait, and every standard collection implements it by comparing contents — so two empty Vecs are equal, and so are two HashMaps with the same entries. Comparing identity is the thing you have to ask for, with std::ptr::eq. This is also why the derive list in the Traits section matters: a struct without PartialEq cannot be compared with == at all.