PONY λ M2 Modula-2

Kotlin.CodeCompared.To/Rust

An interactive executable cheatsheet comparing Kotlin and Rust

Kotlin 2.3 Rust 1.95
Basics & Values
Hello, World
fun main() { println("Hello, World!") }
println!("Hello, World!");
The exclamation mark matters: println! is a macro, not a function — it expands at compile time so the format string can be checked against its arguments before the program ever runs. Rust programs also start at fn main(); the examples on this page show just the body.
val/var is let/let mut
The cleanest one-to-one mapping on this page: Kotlin's read-only val is Rust's plain let, and var is let mut.
fun main() { val fixed = 1 var counter = 0 counter += 1 println(fixed + counter) }
let fixed = 1; let mut counter = 0; counter += 1; println!("{}", fixed + counter);
Both languages make immutability the shorter spelling and mutation the deliberate choice. Rust goes further: mut is part of how the compiler reasons about the whole program — as the ownership section shows, who may mutate what is checked across function boundaries, not just at the declaration.
Inference & type annotations
fun main() { val answer = 42 val ratio: Double = 3.14 val greeting = "hello" println("$answer $ratio $greeting") }
let answer = 42; let ratio: f64 = 3.14; let greeting = "hello"; println!("{answer} {ratio} {greeting}");
Inference feels the same day to day, with two differences underneath. Rust's numeric menu is wider — i8 through i128, unsigned u* types, f32/f64 — where Kotlin mostly lives on Int/Long/Double. And Rust's inference flows backward from usage: a later line can determine an earlier binding's type, which is why you will sometimes see turbofish annotations like collect::<Vec<i32>>().
String templates
fun main() { val name = "world" println("Hello, $name! ${6 * 7}") }
let name = "world"; println!("Hello, {name}! {}", 6 * 7);
Rust's format strings capture plain variables inline — {name} reads like Kotlin's $name — but arbitrary expressions go in trailing arguments filling empty {} slots, where Kotlin embeds them directly with ${…}. The macro checks every placeholder against its argument at compile time.
Ownership & Borrowing
Assignment moves — the GC is gone
This is the concept everything else on this page orbits. In Kotlin, assignment copies a reference: two names, one object, and the garbage collector cleans up later. In Rust, assignment moves ownership: the old name is dead.
fun main() { val first = mutableListOf(1, 2, 3) val second = first second.add(4) println(first) }
let first = vec![1, 2, 3]; let second = first; println!("{:?}", second); // println!("{:?}", first); // ^ compile error: value moved to `second`
Run the Kotlin side and see [1, 2, 3, 4]first observed a mutation made through second, because they alias the same heap object. Rust rules that entire class of surprise out: exactly one owner exists at a time, the compiler knows who it is, and it frees the memory the moment the owner goes out of scope. No garbage collector, no pause, no aliasing bugs — paid for by the discipline the next three concepts introduce.
Borrowing: &, the read-only loan
Moving everything would be unusable, so Rust lets you borrow: &value is a temporary read-only reference, and any number may exist at once.
fun totalOf(numbers: List<Int>): Int = numbers.sum() fun main() { val numbers = listOf(1, 2, 3) println(totalOf(numbers)) println(numbers) }
fn total_of(numbers: &Vec<i32>) -> i32 { numbers.iter().sum() } let numbers = vec![1, 2, 3]; println!("{}", total_of(&numbers)); println!("{:?}", numbers);
Without the &, calling total_of(numbers) would move the vector into the function and the final println! would not compile. Kotlin passes every object "by reference" implicitly; Rust makes the loan explicit at both the call site and the signature, so you can always see whether a function takes ownership or just looks.
Borrowing: &mut, exclusive and enforced
A mutable borrow &mut grants write access — and the compiler enforces that while it lives, no other reference exists at all. Shared XOR mutable is the borrow checker's one rule.
fun main() { val numbers = mutableListOf(1, 2, 3) val alias = numbers // Two names can mutate the same list freely — // convenient, and the root of a whole bug family. alias.add(4) numbers.add(5) println(numbers) }
let mut numbers = vec![1, 2, 3]; let shared_one = &numbers; let shared_two = &numbers; println!("{:?} {:?}", shared_one, shared_two); let exclusive = &mut numbers; exclusive.push(4); println!("{:?}", numbers);
Many readers or one writer, never both — checked at compile time. This is the rule that makes "iterator invalidation" (mutating a collection while looping over it, Kotlin's ConcurrentModificationException) a compile error instead of a runtime crash, and it is the same rule that later makes data races impossible across threads.
clone(): copying on purpose
When you genuinely want two independent values, say so: .clone() is Rust's explicit deep copy — roughly Kotlin's data class copy() or toMutableList(), except nothing ever copies implicitly.
fun main() { val original = mutableListOf(1, 2, 3) val independent = original.toMutableList() independent.add(4) println(original) println(independent) }
let original = vec![1, 2, 3]; let mut independent = original.clone(); independent.push(4); println!("{:?}", original); println!("{:?}", independent);
Cheap scalar types (i32, f64, bool, char) are Copy and duplicate automatically on assignment — which is why the earlier move examples used a Vec, not an integer. Everything heap-owning moves unless you clone(). The visible call is the point: every allocation-costing copy in a Rust codebase can be found with a text search.
Null Safety vs Option
T? becomes Option<T>
Both languages killed the billion-dollar mistake, differently: Kotlin builds nullability into the type (String?), Rust wraps the value in an ordinary enum (Option<String>).
fun main() { val name: String? = null println(name?.length ?: 0) val nickname: String? = "Ada" println(nickname?.length ?: 0) }
let name: Option<String> = None; println!("{}", name.map(|value| value.len()).unwrap_or(0)); let nickname: Option<String> = Some(String::from("Ada")); println!("{}", nickname.map(|value| value.len()).unwrap_or(0));
The translation table: ?. is .map(…), the elvis operator ?: is .unwrap_or(…), and ?.let { } chains are and_then. Because Option is just an enum, everything pattern matching can do applies to it — no special operators required, though the combinators read almost identically to Kotlin's.
Smart casts become if let
Kotlin's compiler smart-casts String? to String inside a null check. Rust binds the inner value by pattern matching with if let.
fun lengthOf(text: String?): Int { if (text != null) { return text.length } return 0 } fun main() { println(lengthOf("hello")) println(lengthOf(null)) }
fn length_of(text: Option<&str>) -> usize { if let Some(value) = text { return value.len(); } 0 } println!("{}", length_of(Some("hello"))); println!("{}", length_of(None));
Same shape, different mechanism: Kotlin's flow analysis retypes the same name, while if let Some(value) introduces a new name bound to the unwrapped contents. The Rust version generalizes to any enum, not just Optionif let is just a one-armed match.
!! is unwrap()
Both languages have the "I know better" escape hatch, and both crash when you don't: Kotlin's !! throws, Rust's .unwrap() panics.
fun main() { val name: String? = "Ada" println(name!!.length) // (null)!! would throw NullPointerException }
let name: Option<&str> = Some("Ada"); println!("{}", name.unwrap().len()); // None::<&str>.unwrap() would panic: // "called `Option::unwrap()` on a `None` value"
The cultural difference is what code review says. A !! in Kotlin raises eyebrows; an unwrap() in production Rust often does too, with .expect("reason it cannot be None") as the self-documenting version. In examples and tests both are routine.
Data Classes, Enums & Matching
data class is struct + derive
Everything data class generates in one keyword, Rust opts into per capability with #[derive(…)]: Debug is toString(), PartialEq is equals(), Clone is copy().
data class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) println(person) println(person == Person("Ada", 36)) }
#[derive(Debug, Clone, PartialEq)] struct Person { name: String, age: u32, } let person = Person { name: String::from("Ada"), age: 36 }; println!("{:?}", person); println!("{}", person == Person { name: String::from("Ada"), age: 36 });
The {:?} format specifier prints via the derived Debug implementation — that is Rust's toString() for development output. The derive list is the data-class feature set unbundled, which means a struct can have exactly the capabilities it should: skip PartialEq and nobody can accidentally compare two values that have no meaningful equality.
copy(age = 37) is struct update syntax
data class Person(val name: String, val age: Int) fun main() { val person = Person("Ada", 36) val older = person.copy(age = 37) println(older) println(person) }
#[derive(Debug, Clone)] struct Person { name: String, age: u32, } let person = Person { name: String::from("Ada"), age: 36 }; let older = Person { age: 37, ..person.clone() }; println!("{:?}", older); println!("{:?}", person);
Rust's ..person spread fills the remaining fields from an existing value. The ownership rules apply here too: a plain ..person would move the String field out of person, so this example clones — one more place where what Kotlin does implicitly (share the reference) becomes a visible decision.
Sealed classes are enums with payloads
Kotlin models closed hierarchies with sealed class plus data-class variants. A Rust enum is that entire pattern in one declaration — each variant can carry its own fields.
sealed class Shape data class Circle(val radius: Double) : Shape() data class Rectangle(val width: Double, val height: Double) : Shape() fun area(shape: Shape): Double = when (shape) { is Circle -> 3.14159 * shape.radius * shape.radius is Rectangle -> shape.width * shape.height } fun main() { println(area(Rectangle(3.0, 4.0))) }
enum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, } fn area(shape: &Shape) -> f64 { match shape { Shape::Circle { radius } => 3.14159 * radius * radius, Shape::Rectangle { width, height } => width * height, } } println!("{}", area(&Shape::Rectangle { width: 3.0, height: 4.0 }));
Where Kotlin's when needs is checks and smart casts to reach each subclass's fields, Rust's match destructures the variant's payload directly into names. Both compilers enforce exhaustiveness over the closed set — add a Triangle and every match/when in the codebase becomes a compile error until handled. Option and Result are exactly this construct, from the standard library.
when becomes match
fun describe(number: Int): String = when { number == 0 -> "zero" number < 0 -> "negative" number % 2 == 0 -> "positive even" else -> "positive odd" } fun main() { println(describe(-4)) }
fn describe(number: i32) -> String { match number { 0 => String::from("zero"), value if value < 0 => String::from("negative"), value if value % 2 == 0 => String::from("positive even"), _ => String::from("positive odd"), } } println!("{}", describe(-4));
Both are expressions that return a value, both demand a case for everything (else / _). Rust's match works on patterns with if guards, so it destructures while it branches; Kotlin's subjectless when is closer to an if-else chain. Ranges work in both: Kotlin's in 1..9 is Rust's 1..=9.
Functions & Lambdas
Expression bodies, two ways
fun add(first: Int, second: Int): Int = first + second fun describe(count: Int): String { return "count = $count" } fun main() { println(add(2, 3)) println(describe(5)) }
fn add(first: i32, second: i32) -> i32 { first + second } fn describe(count: i32) -> String { format!("count = {count}") } println!("{}", add(2, 3)); println!("{}", describe(5));
Rust has no = expression short form because it does not need one: a block's final expression — no return, no semicolon — is its value. The semicolon rule is real and bites: adding one to first + second; turns the body into a statement returning (), and the function no longer compiles. return exists for early exits only.
No default or named arguments
Kotlin's default values and named arguments simply do not exist in Rust — the gap Kotlin developers notice first.
fun greet(name: String = "world", punctuation: String = "!") = "Hello, $name$punctuation" fun main() { println(greet()) println(greet(punctuation = "?")) }
fn greet(name: Option<&str>, punctuation: Option<&str>) -> String { format!( "Hello, {}{}", name.unwrap_or("world"), punctuation.unwrap_or("!") ) } println!("{}", greet(None, None)); println!("{}", greet(None, Some("?")));
Rust's substitutes are Option parameters (shown here), separate constructor functions, or — for anything complex — the builder pattern, which is what Kotlin's named-and-defaulted constructors were designed to replace. It is a real ergonomic regression from Kotlin, accepted for the sake of keeping every call site explicit.
Lambdas & closures
Kotlin's { it * 2 } becomes pipe-delimited parameters: |number| number * 2. Both capture their environment; Rust tracks how.
fun main() { val doubled = listOf(1, 2, 3).map { it * 2 } println(doubled) val multiplier = 10 val scaled = listOf(1, 2, 3).map { it * multiplier } println(scaled) }
let doubled: Vec<i32> = vec![1, 2, 3].into_iter().map(|number| number * 2).collect(); println!("{:?}", doubled); let multiplier = 10; let scaled: Vec<i32> = vec![1, 2, 3].into_iter().map(|number| number * multiplier).collect(); println!("{:?}", scaled);
There is no implicit it — Rust closures always name their parameters. Capturing multiplier works as in Kotlin, but the compiler classifies every closure by what it does with its captures (borrow, mutably borrow, or take ownership — the move keyword forces the last, as the threads section shows). Also note collect(): the map is not a list until you ask for one, which the collections section makes a headline.
Extension functions need a trait
Kotlin bolts a method onto any type with one line. Rust can do the same, but the method must belong to a trait you define and import — extension is never anonymous.
fun String.shout(): String = uppercase() + "!" fun main() { println("hello".shout()) }
trait Shout { fn shout(&self) -> String; } impl Shout for str { fn shout(&self) -> String { format!("{}!", self.to_uppercase()) } } println!("{}", "hello".shout());
More ceremony, one payoff: because the method arrives through a named trait, a reader can always answer "where did .shout() come from?" — it is whatever Shout implementation is in scope, and removing the use removes the method. Kotlin's extension imports work similarly but the declaration side is a single function with no interface to point to.
Strings
Two string types
Kotlin has String. Rust has two: String (owned, growable, heap) and &str (a borrowed view — what literals are). Ownership explains the split.
fun main() { val owned = "hello" val alsoTheSameType = owned + " world" println(alsoTheSameType) println(alsoTheSameType.length) }
let owned: String = String::from("hello"); let borrowed: &str = &owned; let literal: &str = "world"; println!("{owned} {borrowed} {literal}"); println!("{}", owned.len());
The working rule: functions accept &str (any string-ish thing can be lent as one) and produce String (the caller gets ownership). A &String coerces to &str automatically, so in practice you convert with String::from(…) or .to_string() going one way and & going the other. Every Kotlin String was secretly playing both roles; Rust names them.
Concatenation & building
fun main() { val message = buildString { append("Hello") append(", Kotlin") append('!') } println(message) println("compile" + " " + "time") }
let mut message = String::from("Hello"); message.push_str(", Rust"); message.push('!'); println!("{message}"); println!("{}", format!("{} {}", "compile", "time"));
A let mut String plus push_str is Kotlin's StringBuilder/buildString — except it is just the ordinary string type; no separate builder class exists because mutation is already opt-in. format! is the general-purpose combiner and returns a fresh owned String.
You cannot index a string
Kotlin's text[1] hands back a UTF-16 code unit. Rust strings are UTF-8 bytes, and text[1] does not compile — by design.
fun main() { val text = "héllo" println(text[1]) println(text.length) }
let text = "héllo"; // text[1] does not compile: bytes are not characters println!("{:?}", text.chars().nth(1)); println!("{}", text.len()); println!("{}", text.chars().count());
Watch the numbers: Kotlin reports length 5 (UTF-16 units), Rust's len() says 6 (é is two UTF-8 bytes) and chars().count() says 5. Rust refuses to pretend byte positions are character positions, so "give me character 1" must go through chars() — honest about the O(n) cost that Kotlin's indexing hides and sometimes gets wrong (surrogate pairs split the same way in UTF-16).
Collections & Iterators
listOf/mutableListOf is one Vec
fun main() { val readOnly = listOf(1, 2, 3) println(readOnly) val growable = mutableListOf(1, 2, 3) growable.add(4) println(growable) }
let read_only = vec![1, 2, 3]; println!("{:?}", read_only); let mut growable = vec![1, 2, 3]; growable.push(4); println!("{:?}", growable);
Kotlin distinguishes read-only and mutable at the interface level (List vs MutableList — and the read-only one is famously just a view). Rust has one Vec, and the binding's mut decides everything, checked at compile time: there is no way to hold a "read-only view" that someone else mutates behind your back, because the borrow rules forbid exactly that.
Eager chains become lazy iterators
A semantic inversion hiding under identical syntax: Kotlin's filter/map build a whole new list at every step, while Rust's iterator adapters do nothing until a consumer like sum() or collect() pulls.
fun main() { val result = listOf(1, 2, 3, 4, 5, 6) .filter { it % 2 == 0 } .map { it * it } .sum() println(result) }
let result: i32 = vec![1, 2, 3, 4, 5, 6] .into_iter() .filter(|number| number % 2 == 0) .map(|number| number * number) .sum(); println!("{result}");
Rust's chain allocates no intermediate collections — elements stream one at a time through the whole pipeline, which the optimizer typically compiles down to a plain loop. Kotlin gets the same behavior only when you opt in with asSequence(). So the mapping is: Kotlin collections ↔ nothing in Rust; Kotlin Sequence ↔ Rust iterators, and Rust makes the lazy one the default.
mapOf is HashMap — with Option lookups
fun main() { val ages = mapOf("Ada" to 36, "Grace" to 45) println(ages["Ada"]) println(ages["Alan"] ?: 0) }
use std::collections::HashMap; let mut ages = HashMap::new(); ages.insert("Ada", 36); ages.insert("Grace", 45); println!("{:?}", ages.get("Ada")); println!("{}", ages.get("Alan").copied().unwrap_or(0));
Kotlin's map[key] returns a nullable; Rust's get returns Option<&V> — same idea, plus a borrow: the map still owns the value, you get a reference. .copied() turns Option<&i32> into Option<i32> for cheap types. There is no hashMapOf(…)-style literal in std; HashMap::from([("Ada", 36)]) is the closest.
Ranges: the inclusivity flip
A genuine trap: Kotlin's 1..5 includes 5; Rust's 1..5 excludes it. The dots mean opposite things in the language you are leaving and the one you are entering.
fun main() { println((1..5).toList()) println((1 until 5).toList()) println((10 downTo 6 step 2).toList()) }
let inclusive: Vec<i32> = (1..=5).collect(); println!("{:?}", inclusive); let exclusive: Vec<i32> = (1..5).collect(); println!("{:?}", exclusive); let descending: Vec<i32> = (6..=10).rev().step_by(2).collect(); println!("{:?}", descending);
The table to memorize: Kotlin 1..5 = Rust 1..=5; Kotlin 1 until 5 = Rust 1..5. Rust ranges cannot count down on their own — 10..6 is just empty — so descending iteration is an ascending range reversed. Slices use the exclusive form (&items[1..3]), which is why Rust made exclusive the shorter spelling.
Error Handling
Exceptions become Result values
Rust has no exceptions. Fallible functions return Result<T, E> — an enum of Ok(value) or Err(error) — and the caller must decide, in the type system, what happens on failure.
fun safeDivide(numerator: Double, denominator: Double): Double { require(denominator != 0.0) { "cannot divide by zero" } return numerator / denominator } fun main() { try { println(safeDivide(10.0, 2.0)) } catch (exception: IllegalArgumentException) { println(exception.message) } }
fn safe_divide(numerator: f64, denominator: f64) -> Result<f64, String> { if denominator == 0.0 { return Err(String::from("cannot divide by zero")); } Ok(numerator / denominator) } match safe_divide(10.0, 2.0) { Ok(quotient) => println!("{quotient}"), Err(reason) => println!("{reason}"), }
The failure is in the signature — Result<f64, String> — so forgetting to handle it is a compiler warning, not a 2 a.m. stack trace. Kotlin's runCatching { } returning kotlin.Result is the same idea bolted on afterward; in Rust it is the only channel, and the whole ecosystem's errors compose because of it.
The ? operator: propagation without try
Kotlin propagates errors by not catching the exception. Rust propagates explicitly, in one character: ? unwraps an Ok or returns the Err to the caller.
fun parseAndDouble(text: String): Int { // toInt() throws NumberFormatException, which // simply propagates to whoever calls us. return text.toInt() * 2 } fun main() { println(parseAndDouble("21")) try { println(parseAndDouble("oops")) } catch (exception: NumberFormatException) { println("not a number") } }
fn parse_and_double(text: &str) -> Result<i32, std::num::ParseIntError> { let number: i32 = text.parse()?; Ok(number * 2) } println!("{:?}", parse_and_double("21")); println!("{:?}", parse_and_double("oops"));
That single ? after parse() is the whole propagation story: on Err it returns early with the error, on Ok it evaluates to the inner value. Unlike an uncaught exception, the propagation is visible at every call site and in every signature along the way — grep "\?" shows you every path an error can travel.
panic! is not an exception
Rust does have a crash mechanism — panic! — but it is for bugs, not expected failure, and idiomatic code never catches it.
fun checkedDivide(numerator: Int, denominator: Int): Int { check(denominator != 0) { "cannot divide by zero" } return numerator / denominator } fun main() { println(checkedDivide(10, 2)) }
fn checked_divide(numerator: i32, denominator: i32) -> i32 { if denominator == 0 { panic!("cannot divide by zero"); } numerator / denominator } println!("{}", checked_divide(10, 2));
The dividing line Kotlin blurs, Rust draws hard: expected failure (bad input, missing file) is a Result; an impossible state (broken invariant, out-of-bounds index) panics and unwinds. There is no try/catch for panics in normal code — a panicking thread dies. Kotlin's check/require preconditions are the same instinct, but they throw ordinary catchable exceptions.
Interfaces vs Traits
Interfaces become traits
A Rust trait is a Kotlin interface defined apart from the types that implement it: the impl block is separate, so you can implement your trait for types you did not write.
interface Speaker { fun speak(): String } class Dog : Speaker { override fun speak() = "Woof!" } fun main() { println(Dog().speak()) }
trait Speaker { fn speak(&self) -> String; } struct Dog; impl Speaker for Dog { fn speak(&self) -> String { String::from("Woof!") } } println!("{}", Dog.speak());
The separation is the superpower Kotlin's extension functions only approximate: implementing Speaker for a third-party type, or a third-party trait for your type, is ordinary Rust. Traits can carry default method bodies like Kotlin interfaces, and struct Dog; — no fields, no constructor call parentheses — is a zero-sized type.
Generic constraints
fun <T : Comparable<T>> largest(items: List<T>): T = items.max() fun main() { println(largest(listOf(3, 7, 2))) }
fn largest<T: PartialOrd + Copy>(items: &[T]) -> T { let mut result = items[0]; for &item in items { if item > result { result = item; } } result } println!("{}", largest(&[3, 7, 2]));
Kotlin's <T : Comparable<T>> is Rust's <T: PartialOrd> — trait bounds instead of upper bounds, with + stacking several. The extra Copy bound is ownership showing up in generics: returning T by value from a borrowed slice requires that T be copyable. Rust generics also monomorphize (a specialized copy per concrete type, like C++ templates) rather than erasing to Object as the JVM does — zero boxing, zero virtual dispatch.
Polymorphic lists cost extra
In Kotlin, a List<Shape> of mixed subclasses is free — everything is a boxed reference anyway. Rust makes you say it: Vec<Box<dyn Shape>>, a vector of heap boxes dispatched dynamically.
interface Shape { fun area(): Double } class Circle(private val radius: Double) : Shape { override fun area() = 3.14159 * radius * radius } class Square(private val side: Double) : Shape { override fun area() = side * side } fun main() { val shapes: List<Shape> = listOf(Circle(1.0), Square(2.0)) for (shape in shapes) { println(shape.area()) } }
trait Shape { fn area(&self) -> f64; } struct Circle { radius: f64 } struct Square { side: f64 } impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius } } impl Shape for Square { fn area(&self) -> f64 { self.side * self.side } } let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 1.0 }), Box::new(Square { side: 2.0 }), ]; for shape in &shapes { println!("{}", shape.area()); }
On the JVM every object already lives behind a pointer with a vtable, so polymorphism has no visible cost. Rust's default is the opposite (values inline, calls static), so dynamic dispatch is spelled out: dyn Shape is the vtable, Box is the heap allocation. When all elements are one type, skip both and get devirtualized, inlined calls — the choice Kotlin never offers.
Destructuring & let-else
Destructuring
data class Point(val x: Int, val y: Int) fun main() { val (x, y) = Point(3, 4) println(x + y) val (name, age) = Pair("Ada", 36) println("$name is $age") }
struct Point { x: i32, y: i32 } let Point { x, y } = Point { x: 3, y: 4 }; println!("{}", x + y); let (name, age) = ("Ada", 36); println!("{name} is {age}");
Kotlin destructures by position through generated componentN() functions — reorder the data class's fields and every destructuring silently changes meaning. Rust struct patterns bind by field name (Point { x, y }), immune to reordering; only tuples are positional. Patterns also nest arbitrarily deep, in let, match, function parameters, and loops.
?: return becomes let-else
Kotlin's beloved guard idiom — val name = maybeName ?: return — has a direct modern Rust equivalent: let-else.
fun describe(maybeName: String?): String { val name = maybeName ?: return "anonymous" return "Hello, $name!" } fun main() { println(describe("Ada")) println(describe(null)) }
fn describe(maybe_name: Option<&str>) -> String { let Some(name) = maybe_name else { return String::from("anonymous"); }; format!("Hello, {name}!") } println!("{}", describe(Some("Ada"))); println!("{}", describe(None));
Both keep the happy path unindented: bind it or bail. The Rust form works with any refutable pattern, not just Optionlet Ok(value) = … else, let [first, ..] = … else — and the else block must diverge (return, break, panic), which the compiler checks.
Threads & Shared State
Spawning threads
Both languages can spin up an OS thread in a line. The Rust closure says move — ownership of everything it captures transfers to the new thread, checked at compile time.
import kotlin.concurrent.thread fun main() { val worker = thread { val total = (1..100).sum() println("worker computed $total") } worker.join() println("main is done") }
use std::thread; let limit = 100; let worker = thread::spawn(move || { let total: i32 = (1..=limit).sum(); println!("worker computed {total}"); }); worker.join().unwrap(); println!("main is done");
The move keyword is the ownership system reaching into concurrency: the thread takes limit with it, so there is no question of the main thread mutating something the worker is reading. (Coroutines are Kotlin's real concurrency story, but they are a library; this compares the language-level primitives. Rust's async is likewise a separate, runtime-backed world.)
Shared state: data races cannot compile
Kotlin shares by handing the same object to every thread and hoping the synchronization is right. Rust will not compile shared mutation until it is wrapped in types that make it safe: Arc to share, Mutex to mutate.
import java.util.concurrent.atomic.AtomicInteger import kotlin.concurrent.thread fun main() { val counter = AtomicInteger(0) val workers = (1..4).map { thread { counter.addAndGet(25) } } workers.forEach { it.join() } println(counter.get()) }
use std::sync::{Arc, Mutex}; use std::thread; let counter = Arc::new(Mutex::new(0)); let mut workers = Vec::new(); for _ in 0..4 { let counter = Arc::clone(&counter); workers.push(thread::spawn(move || { let mut guard = counter.lock().unwrap(); *guard += 25; })); } for worker in workers { worker.join().unwrap(); } println!("{}", *counter.lock().unwrap());
The difference is what happens when you get it wrong. Forget the AtomicInteger in Kotlin and you have a data race that tests may never catch. Forget the Mutex in Rust and the program does not compile — the Send/Sync marker traits encode thread safety in the type system, so "fearless concurrency" is a checked property, not a slogan. The lock also releases automatically when guard drops: RAII, next section.
RAII vs use { }
use { } becomes automatic
Kotlin cleans up resources with an explicit use { } block on AutoCloseable. Rust cleans up everything, always: when an owner goes out of scope, its Drop implementation runs, deterministically.
class Connection(val name: String) : AutoCloseable { override fun close() = println("closing $name") } fun main() { Connection("database").use { connection -> println("using ${connection.name}") } println("after the block") }
struct Connection { name: String } impl Drop for Connection { fn drop(&mut self) { println!("closing {}", self.name); } } { let connection = Connection { name: String::from("database") }; println!("using {}", connection.name); } println!("after the scope");
This is RAII, and it is ownership's quiet payoff: files, sockets, locks, and memory all release at a known line — the end of the owner's scope — with no use block to remember and no GC finalizer running who-knows-when. The mutex guard in the previous example unlocked exactly this way. Forgetting cleanup is not a bug you can write.