PONYλM2Modula-2

Rust.CodeCompared.To/Kotlin

An interactive executable cheatsheet comparing Rust and Kotlin

Rust 1.97.1 Kotlin 2.4
Output & Running
Hello, World
The entry point is the same shape and the same word, minus three characters. This page will keep doing that: a great deal transfers, and the places it does not are worth the whole page.
fn main() { println!("Hello, World!"); }
fun main() { println("Hello, World!") }
println! is a macro because it checks its format string at compile time; println is an ordinary function taking one value, and formatting is done by string interpolation instead. Semicolons are optional in Kotlin and nobody writes them. A Kotlin file may hold top-level functions, top-level properties and several classes with no relationship to its filename, so the file organization feels closer to a Rust module than to a Java one.
Formatting and debug output
Rust splits presentation into Display and Debug and makes you opt into the second. Kotlin has one toString(), and what it does depends on whether the class was declared data.
#[derive(Debug)] struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; println!("{:?}", point); println!("{point:?}"); println!("{:.2}", 1.0_f64 / 3.0); eprintln!("this goes to stderr"); }
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) println(point) println("$point") println("%.2f".format(1.0 / 3.0)) System.err.println("this goes to stderr") }
A data class generates a toString() printing the type name and every constructor property, which is what #[derive(Debug)] produces; a plain class prints its name and an identity hash, so a missing data shows up as Point@6d06d69c rather than as a compile error. Interpolation is $name for a bare name and ${expression} for anything else — note that the braces are the opposite way round from Rust's, where they are always required. Format specifiers do not live in the interpolation at all; "%.2f".format(value) is the equivalent of {:.2}. One thing to expect when you run this row here: the Kotlin cell renders with the red error styling, because the runner treats any output on stderr as a failure and this row writes to stderr on purpose. The output itself is correct.
Ownership, Gone
Moves, borrows and lifetimes simply vanish
Everything the borrow checker does is gone, and the second half of that sentence matters as much as the first: aliasing is back, and nothing tells you about it.
fn consume(text: String) -> usize { text.len() } fn main() { let owned = String::from("hello"); let length = consume(owned); // println!("{}", owned); <- moved; will not compile println!("{length}"); let shared = String::from("world"); let borrowed = &shared; println!("{shared} {borrowed}"); }
fun consume(text: String): Int = text.length fun main() { val owned = "hello" val length = consume(owned) println(owned) // still perfectly usable println(length) val shared = mutableListOf(1, 2, 3) val alias = shared alias.add(4) println(shared.size) // 4 — nothing warned about the aliasing }
You stop writing moves, borrows, lifetimes, clone() and & — an enormous amount of ceremony, and with it the entire category of "fighting the borrow checker". What you get in exchange is a program where any two references may point at the same mutable object and no tool will say so. Kotlin's mitigations are all conventions rather than proofs: val by default, data class with copy(), read-only collection interfaces, and — the strongest of them — the compiler's refusal to smart-cast a mutable property, which is the one place it admits that something might change underneath you.
Drop becomes Closeable and use
Deterministic cleanup survives the move to a garbage collector, as a convention the compiler does not enforce.
struct Connection { name: String, } impl Drop for Connection { fn drop(&mut self) { println!("closing {}", self.name); } } fn main() { { let _connection = Connection { name: "db".to_string() }; println!("working"); } println!("scope ended"); }
class Connection(private val name: String) : AutoCloseable { override fun close() = println("closing $name") } fun main() { Connection("db").use { println("working") } println("scope ended") }
use { } is an extension function on AutoCloseable that runs the block and calls close() in a finally, so it is drop scoped to a block — and it is a library function rather than syntax, which the trailing-lambda rule makes invisible. The gap is that nothing requires it: forgetting the use compiles fine and leaks the handle until a finaliser runs, if there is one at all. Memory itself needs no cleanup, so this only matters for files, sockets and native handles. Rust's guarantee is that drop runs and cannot be forgotten; Kotlin's is that close runs if you remember to ask.
Rc, RefCell and Weak have no counterpart
The machinery a Rust graph needs — Rc, RefCell, Weak, and the judgment to know which goes where — has no counterpart at all, because a tracing collector does not care about cycles.
use std::rc::{Rc, Weak}; use std::cell::RefCell; struct Node { name: String, parent: RefCell<Weak<Node>>, } fn main() { let parent = Rc::new(Node { name: "parent".to_string(), parent: RefCell::new(Weak::new()), }); let child = Rc::new(Node { name: "child".to_string(), parent: RefCell::new(Rc::downgrade(&parent)), }); println!("{} {}", parent.name, child.name); }
class Node(val name: String) { var parent: Node? = null var child: Node? = null } fun main() { val parent = Node("parent") val child = Node("child") parent.child = child child.parent = parent // a cycle — and it does not matter println("${parent.name} ${child.name}") }
A JVM cycle is collected as soon as nothing outside it is reachable, so a parent/child back-reference is two fields. WeakReference exists and is for caches rather than for breaking cycles. What you pay is that release is non-deterministic: you cannot say when memory comes back, allocation-heavy code produces collection pauses, and the explicit cost of Rc<RefCell<T>> is replaced by an implicit one that appears in a profiler rather than in the type. The RefCell half — a run-time borrow check — is simply not a concept here, because there are no borrows to check.
val and var against let and let mut
The immutable-by-default habit carries straight over, and the thing being controlled is subtly different in a way that catches people.
fn main() { let total = 41; // total += 1; <- will not compile let mut counter = 0; counter += 1; let items = vec![1, 2, 3]; // items.push(4); <- needs let mut let mut growable = vec![1, 2, 3]; growable.push(4); println!("{total} {counter} {items:?} {growable:?}"); }
fun main() { val total = 41 // total += 1 <- will not compile var counter = 0 counter += 1 val items = listOf(1, 2, 3) // items.add(4) <- List has no add val growable = mutableListOf(1, 2, 3) growable.add(4) println("$total $counter $items $growable") }
Rust's mut governs the value: let items makes the whole vector unmodifiable. Kotlin's val governs only the binding: val growable = mutableListOf(…) cannot be reassigned and can absolutely be added to. Deep immutability comes from choosing an immutable type instead, which is why Kotlin splits every collection into a read-only interface and a mutable one. The honest caveat is that List is a read-only view, not an immutable value — hand the same MutableList to two places and one can still change what the other sees, which & versus &mut would have prevented.
Option becomes Nullable
Option<T> becomes T?
This is the most satisfying convergence on the page. Kotlin's nullable types are a language-level Option with none of the map/and_then ceremony, and the guarantee is as real as Rust's.
fn find_name(id: u32) -> Option<String> { if id == 1 { Some("Ada".to_string()) } else { None } } fn main() { println!("{}", find_name(1).unwrap_or_else(|| "(nobody)".to_string())); println!("{}", find_name(2).map(|name| name.len()).unwrap_or(0)); match find_name(1) { Some(name) => println!("found {name}"), None => println!("nothing"), } }
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun main() { println(findName(1) ?: "(nobody)") println(findName(2)?.length ?: 0) val name = findName(1) if (name != null) { println("found $name") } else { println("nothing") } }
String? is a genuinely different type from String, and calling a method on one without handling the null case does not compile. The operators map one for one: ?. is map, ?: (Elvis) is unwrap_or, and !! is unwrap — it throws where Rust panics, and carries the same reviewer's eyebrow. Instead of binding a new name, Kotlin smart-casts: after if (name != null) the compiler narrows name itself, so there is no if let shadowing dance. Two things Option has that nullable types do not: it nests (Option<Option<T>> is meaningful, String?? is not), and it is a value you can put in a collection or map over generically.
Early return: the Elvis guard
Rust's let … else, stabilized in 1.65, has a Kotlin counterpart that predates it and reads more compactly.
fn find_name(id: u32) -> Option<String> { if id == 1 { Some("Ada".to_string()) } else { None } } fn shout(id: u32) -> String { let Some(name) = find_name(id) else { return "(nobody)".to_string(); }; name.to_uppercase() } fn main() { println!("{}", shout(1)); println!("{}", shout(2)); }
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun shout(id: Int): String { val name = findName(id) ?: return "(nobody)" return name.uppercase() } fun main() { println(shout(1)) println(shout(2)) }
The Elvis operator's right-hand side may be a return, a throw, or a break, because those are expressions of type Nothing — the empty type, which is a subtype of everything and therefore fits anywhere. That is exactly the role of Rust's !. The result is the same shape as let … else in about half the characters, and it is the single most common line in idiomatic Kotlin. ?.let { } is the other form, for when you want a block rather than an early exit.
The hole: values arriving from Java
Kotlin's null guarantee is airtight within Kotlin and stops at the boundary with Java — which on Android and on the server is a boundary you cross constantly.
// Rust's guarantee has no boundary of this kind. // Even FFI is explicit: an extern "C" function returning // a pointer is *mut T, and reading it needs unsafe. fn main() { let maybe: Option<&str> = None; println!("{}", maybe.is_none()); println!("nothing can be null without saying so"); }
fun main() { // System.getenv returns Java's String, which carries NO nullability // information. Kotlin calls this a PLATFORM TYPE and declines to check it. val value: String? = System.getenv("NO_SUCH_VARIABLE") println(value == null) // Written as String (not String?), the same call compiles // and throws at run time. The compiler allowed it. println("the compiler trusted the Java signature") }
A value from an unannotated Java method has a platform type, assignable to either String or String? with no complaint, so the check you skipped becomes a NullPointerException after all. The mitigations are real — Java libraries increasingly carry @Nullable/@NotNull annotations that Kotlin honors, and the habit is to declare the nullable type at the boundary and narrow at once. For a Rust reader the useful framing is that this is Kotlin's unsafe: a place where a guarantee is asserted rather than proved. The difference is that Rust makes you write the word.
Result & Exceptions
Result<T, E> becomes an exception
The default failure mechanism is an exception, and both properties a Rust reader relies on disappear: failure is not in the signature, and nothing forces the caller to deal with it.
#[derive(Debug)] enum ParseError { NotANumber(String), } fn read_port(text: &str) -> Result<u16, ParseError> { text.parse().map_err(|_| ParseError::NotANumber(text.to_string())) } fn main() { match read_port("8080") { Ok(port) => println!("{port}"), Err(error) => println!("{error:?}"), } println!("{:?}", read_port("eighty")); }
fun readPort(text: String): Int = text.toInt() fun main() { try { println(readPort("8080")) println(readPort("eighty")) } catch (error: NumberFormatException) { println("NumberFormatException: ${error.message}") } }
Kotlin has no checked exceptions — a deliberate rejection of Java's design — so there is no throws clause and the compiler never asks you to handle anything. A function that can fail looks exactly like one that cannot. What Kotlin does keep is that try is an expression, so it produces a value and reads much like a match on a Result. An uncaught exception unwinds the thread and prints a stack trace, which is roughly what a panic does, except that a panic's default is to abort a thread you probably wanted alive and this one is caught by a framework somewhere up the stack.
runCatching is the closest thing to Result
The standard library ships a Result<T> type and a helper that runs a block and captures whatever it throws — so an exception becomes a value you can chain over.
fn read_port(text: &str) -> Result<u16, String> { text.parse().map_err(|_| format!("not a number: {text}")) } fn main() { let doubled = read_port("21").map(|port| port * 2).unwrap_or(0); println!("{doubled}"); println!("{}", read_port("eighty").unwrap_or_else(|message| { println!("{message}"); 0 })); }
fun main() { val doubled = runCatching { "21".toInt() } .map { it * 2 } .getOrDefault(0) println(doubled) println(runCatching { "eighty".toInt() } .onFailure { println("not a number: eighty") } .getOrDefault(0)) }
It supports map, mapCatching, recover, getOrNull, getOrElse, getOrDefault, onSuccess and onFailure, which covers most of what Result gives you. Three real limitations. The error type is always Throwable — there is no E, so a typed error enum has to be modelled as a sealed class instead (the next section). It cannot be a function's declared return type without a compiler flag, because of a JVM signature clash, so it is used inside a function rather than across an API. And there is no ? operator, so propagation is manual. The caution to carry over: runCatching catches Throwable, cancellation included, so coroutine code must re-throw CancellationException.
A typed error enum becomes a sealed result
When the error type actually matters, the idiomatic Kotlin answer is not an exception and not runCatching — it is a sealed hierarchy that models success and every failure as sibling cases.
#[derive(Debug)] enum ConfigError { Missing(String), Malformed { key: String, value: String }, } fn load(key: &str) -> Result<u16, ConfigError> { match key { "port" => Ok(8080), "timeout" => Err(ConfigError::Malformed { key: key.to_string(), value: "soon".to_string(), }), _ => Err(ConfigError::Missing(key.to_string())), } } fn main() { for key in ["port", "timeout", "host"] { println!("{:?}", load(key)); } }
sealed interface ConfigResult data class Loaded(val port: Int) : ConfigResult data class Missing(val key: String) : ConfigResult data class Malformed(val key: String, val value: String) : ConfigResult fun load(key: String): ConfigResult = when (key) { "port" -> Loaded(8080) "timeout" -> Malformed(key, "soon") else -> Missing(key) } fun main() { for (key in listOf("port", "timeout", "host")) { println(load(key)) } }
This is Result<T, E> written out by hand, and it gets the property that matters: a when over a sealed type is checked for exhaustiveness at compile time, so adding a fourth case breaks every consumer that has not been updated. What it does not get is the ? operator, so a three-step fallible pipeline is three nested whens rather than three question marks — which is why teams reach for the Arrow library's Either and its bind(), or accept exceptions for the propagation and use sealed types only at boundaries.
Types & Inference
Integer overflow wraps, silently, always
Rust panics on overflow in a debug build and wraps in release, with four explicit families for saying which you meant. Kotlin wraps, in every build, with no warning and no alternative operator.
fn main() { let biggest: i32 = i32::MAX; println!("{}", biggest.wrapping_add(1)); println!("{:?}", biggest.checked_add(1)); println!("{}", biggest.saturating_add(1)); // A plain biggest + 1 PANICS in a debug build. }
fun main() { val biggest = Int.MAX_VALUE println(biggest + 1) // wraps, silently, in every build try { println(Math.addExact(biggest, 1)) // or ask for the check } catch (overflow: ArithmeticException) { println("ArithmeticException") } println("no saturating arithmetic; widen instead: ${biggest.toLong() + 1}") }
There is no checked_add, no saturating_add and no debug-mode assertion; the closest thing is Math.addExact from the Java library, which throws ArithmeticException. In practice Kotlin code avoids the problem by using Long for anything that accumulates and BigInteger when it genuinely cannot bound the value. The type set is Byte, Short, Int, Long and their unsigned counterparts (UInt, ULong, still marked experimental in places), and — unlike Rust — there is no implicit widening either, so toLong() is written out.
The newtype becomes a value class
The newtype pattern survives, and Kotlin has a dedicated construct that makes it as close to free as the JVM allows.
struct Meters(f64); fn describe(distance: Meters) -> String { format!("{} m", distance.0) } fn main() { println!("{}", describe(Meters(4.5))); // describe(4.5) does not compile. }
@JvmInline value class Meters(val value: Double) fun describe(distance: Meters): String = "${distance.value} m" fun main() { println(describe(Meters(4.5))) // describe(4.5) does not compile. }
A value class (formerly "inline class") with one property is erased at run time to the underlying type wherever the compiler can manage it, so Meters is usually just a double in the generated bytecode — which is the zero-cost story a Rust reader expects. The boxing does come back in three places worth knowing: when the value is used as a generic argument, when it is stored in a collection, and when it is nullable. And where Rust's orphan rules keep two crates from implementing the same trait for the same type, Kotlin has no coherence check at all: two libraries can define conflicting extension functions on the same type, and the one that wins is decided by which is imported.
Type aliases
Aliases work the same way in both, name nothing new, and are most useful for the same thing: giving a function type a readable name.
type UserId = u64; type Callback = fn(i32) -> i32; fn double(value: i32) -> i32 { value * 2 } fn main() { let id: UserId = 7; let action: Callback = double; println!("{} {}", id, action(21)); }
typealias UserId = Long typealias Callback = (Int) -> Int fun double(value: Int): Int = value * 2 fun main() { val id: UserId = 7 val action: Callback = ::double println("$id ${action(21)}") }
A Kotlin typealias is file-level or top-level rather than scoped inside a function, and — like Rust's — it is purely a name, so nothing prevents a UserId from being passed where an OrderId is expected. That is what the value class in the previous row is for. Function types are written (Int) -> Int, which is the same arrow Rust uses, and referring to an existing function as a value needs ::double where Rust needs only the bare name.
The unit type and the empty type
Both languages name the type with one value and the type with none, which is unusual enough that the correspondence is worth stating outright.
fn log(message: &str) -> () { println!("{message}"); } fn always_fails() -> ! { panic!("no value can ever be returned"); } fn main() { let result: () = log("hello"); println!("{result:?}"); let name: Option<&str> = None; let value: &str = name.unwrap_or("seven"); println!("{value}"); }
fun log(message: String): Unit { println(message) } fun alwaysFails(): Nothing = throw RuntimeException("no value can ever be returned") fun main() { val result: Unit = log("hello") println(result) val name: String? = null val value: String = name ?: "seven" println(value) }
() is Unit — a real type with exactly one value, printed as kotlin.Unit, and the implicit return of any function that does not say otherwise. ! is Nothing: the type with no values, the type of throw and of a function that never returns, and a subtype of everything, which is exactly what lets it appear on the right of an Elvis operator or in one arm of a when without disturbing the result type. Any is the top type and corresponds to dyn Any without the downcasting ceremony — note it is not nullable, so "anything at all including null" is Any?.
Enums & Sealed Classes
Enums with payloads become sealed classes
Kotlin's enum class is a set of named constants and cannot carry per-case data, so the counterpart of a Rust enum is a sealed hierarchy — and it gets the property that matters most.
enum FetchResult { Success(String), Failure { reason: String }, Pending, } fn render(result: &FetchResult) -> String { match result { FetchResult::Success(value) => format!("ok: {value}"), FetchResult::Failure { reason } => format!("failed: {reason}"), FetchResult::Pending => "pending".to_string(), } } fn main() { println!("{}", render(&FetchResult::Success("data".to_string()))); println!("{}", render(&FetchResult::Failure { reason: "timeout".to_string() })); println!("{}", render(&FetchResult::Pending)); }
sealed interface FetchResult data class Success(val value: String) : FetchResult data class Failure(val reason: String) : FetchResult data object Pending : FetchResult fun render(result: FetchResult): String = when (result) { is Success -> "ok: ${result.value}" is Failure -> "failed: ${result.reason}" Pending -> "pending" } fun main() { println(render(Success("data"))) println(render(Failure("timeout"))) println(render(Pending)) }
sealed means every direct subtype is declared in the same module and package, so the compiler knows the world is closed and checks the when for exhaustiveness: the code above has no else and compiles, and adding a fourth subtype turns every such when into a compile error. That is the same guarantee match gives you. A payload-free case is a data object, which is a singleton — Rust's unit variant. The differences are in representation and ergonomics: a Rust enum is one value the size of its largest variant while this is a separate heap object per case, and there is no destructuring in the when arm, so you write result.value after the smart cast rather than binding it in the pattern.
Fieldless enums and methods on them
For the fieldless case Kotlin's enum class is the direct match, and it is a little richer than Rust's: each constant may carry constructor arguments and the type may hold methods and implement interfaces.
#[derive(Debug, Clone, Copy, PartialEq)] enum Priority { Low = 1, High = 3, } impl Priority { fn label(&self) -> &'static str { match self { Priority::High => "urgent", Priority::Low => "whenever", } } } fn main() { for priority in [Priority::Low, Priority::High] { println!("{:?} {} {}", priority, priority as i32, priority.label()); } }
enum class Priority(val weight: Int) { Low(1), High(3); fun label(): String = when (this) { High -> "urgent" Low -> "whenever" } } fun main() { for (priority in Priority.entries) { println("$priority ${priority.weight} ${priority.label()}") } }
Priority.entries (which replaced values() in Kotlin 1.9) is the iteration, and there is no equivalent of Rust's cast to the discriminant — the numeric value is an ordinary property you declared, and ordinal gives the declaration index. valueOf("High") parses a name and throws on a miss, where entries.find { … } returns null. A when over an enum is exhaustiveness-checked exactly as over a sealed type, which is why label() above needs no else.
match becomes when
match becomes when
when is an expression, arms are separated by an arrow, and the whole thing evaluates to a value — so far this is match with different punctuation.
fn describe(value: i32) -> String { match value { 0 => "zero".to_string(), 1..=9 => "single digit".to_string(), n if n < 0 => format!("negative {n}"), _ => "large".to_string(), } } fn main() { println!("{}", describe(0)); println!("{}", describe(5)); println!("{}", describe(-3)); println!("{}", describe(1000)); }
fun describe(value: Int): String = when { value == 0 -> "zero" value in 1..9 -> "single digit" value < 0 -> "negative $value" else -> "large" } fun main() { println(describe(0)) println(describe(5)) println(describe(-3)) println(describe(1000)) }
It comes in two forms: with a subject, when (value) { 0 -> …; in 1..9 -> …; is String -> … }, and without one, where each arm is a full boolean condition — which is the form used above and has no match counterpart, since Rust arms are patterns rather than conditions. Ranges use in 1..9 against Rust's 1..=9, and a guard becomes an ordinary && in the subject-less form. Exhaustiveness is required when the result is used as a value and the subject is sealed, an enum, or a Boolean; over an Int the else is mandatory, exactly as _ is in Rust.
Destructuring is positional, and there are no patterns
Destructuring exists and pattern matching does not. That is the sharpest ergonomic loss on this page, and it shows up every time you touch a sealed hierarchy.
struct Point { x: i32, y: i32, } fn main() { let point = Point { x: 3, y: 4 }; let Point { x, y } = point; println!("{x} {y}"); let pair = (3, 4); let quadrant = match pair { (0, 0) => "origin", (x, y) if x > 0 && y > 0 => "first", _ => "elsewhere", }; println!("{quadrant}"); }
data class Point(val x: Int, val y: Int) fun main() { val point = Point(3, 4) val (x, y) = point println("$x $y") val quadrant = when { x == 0 && y == 0 -> "origin" x > 0 && y > 0 -> "first" else -> "elsewhere" } println(quadrant) }
Kotlin's destructuring is positional and driven by generated componentN() functions, so val (y, x) = point compiles and silently gives the wrong values, and reordering a data class's properties quietly breaks every destructuring of it — where a Rust struct pattern is by field name and cannot be transposed. There is also no way to destructure inside a when arm, no nested patterns, no @ bindings, and no or-patterns beyond comma-separated constants. The compensation is the smart cast: after is Success the arm can read result.value directly, which covers the common case if not the elegant one.
Traits & Interfaces
Traits become interfaces with default methods
An interface with default implementations is a trait with provided methods, and the mapping is close enough to be unremarkable.
trait Greeter { fn name(&self) -> String; fn greeting(&self) -> String { format!("Hello, {}", self.name()) } } struct French; impl Greeter for French { fn name(&self) -> String { "Amelie".to_string() } } fn main() { println!("{}", French.greeting()); }
interface Greeter { fun name(): String fun greeting(): String = "Hello, ${name()}" } class French : Greeter { override fun name(): String = "Amelie" } fun main() { println(French().greeting()) }
Two differences matter. A Kotlin interface must be implemented where the class is declared — there is no impl Greeter for String for a type you do not own — and the substitute is the next row. And override is mandatory on the implementing member, so a typo in a method name is a compile error rather than a method that is silently never called. Interfaces may declare properties as well as functions, may not hold state, and are multiply implementable, so the trait-as-capability style transfers directly. There is no equivalent of associated types; you use a generic parameter on the interface instead.
Extension functions against impl Trait for ForeignType
This is Kotlin's answer to "add behavior to a type you do not own", and it gets there by a different route: no trait, no impl block, and no coherence rules.
trait Shout { fn shout(&self) -> String; } impl Shout for str { fn shout(&self) -> String { format!("{}!", self.to_uppercase()) } } fn main() { println!("{}", "hello".shout()); // Legal only because Shout is defined in THIS crate: // the orphan rule forbids impl ForeignTrait for ForeignType. }
fun String.shout(): String = "${uppercase()}!" fun List<Int>.median(): Double { val ordered = sorted() val middle = ordered.size / 2 return if (ordered.size % 2 == 1) ordered[middle].toDouble() else (ordered[middle - 1] + ordered[middle]) / 2.0 } fun main() { println("hello".shout()) println(listOf(5, 1, 3, 2).median()) }
An extension function is resolved statically and compiles to a static method taking the receiver as its first argument — so it is not dynamic dispatch, cannot be overridden by a subclass, and loses to a real member method of the same name. That last point is the trap: adding a member with your extension's name silently changes what every call site does. What it buys is scoping: an extension is visible only where it is imported, so its blast radius is one file rather than one crate graph, which is a different and arguably better answer than the orphan rule to the same problem. There is no way to make a type satisfy an interface from outside, though — extensions add functions, not conformance — so impl ForeignTrait for MyType genuinely has no counterpart.
dyn Trait, impl Trait, and delegation
Rust makes you choose between static and dynamic dispatch and write the choice down. Kotlin has one syntax, and dynamic dispatch is what you get unless the compiler can prove otherwise.
trait Shape { fn area(&self) -> f64; } struct Square(f64); struct Circle(f64); impl Shape for Square { fn area(&self) -> f64 { self.0 * self.0 } } impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.0 * self.0 } } fn total(shapes: &[Box<dyn Shape>]) -> f64 { shapes.iter().map(|shape| shape.area()).sum() } fn main() { let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0)), Box::new(Circle(1.0))]; println!("{:.3}", total(&shapes)); }
interface Shape { fun area(): Double } class Square(private val side: Double) : Shape { override fun area() = side * side } class Circle(private val radius: Double) : Shape { override fun area() = 3.14159 * radius * radius } fun total(shapes: List<Shape>): Double = shapes.sumOf { it.area() } fun main() { val shapes = listOf(Square(2.0), Circle(1.0)) println("%.3f".format(total(shapes))) }
An interface-typed variable is Box<dyn Trait>: a reference plus a method table. There is no Box, because every object is already a reference, and no object-safety rules, because a JVM interface has no equivalent restriction. A generic function with an interface bound (fun <T : Shape> total(…)) looks like impl Trait and is not monomorphized — the JVM erases it to one compiled body — unless it is marked inline, which the generics section covers. Kotlin adds one thing Rust has no form of: class Service(logger: Logger) : Logger by logger generates every forwarding method automatically, so composition costs one line instead of one method per member.
From and Into become constructors and extensions
Rust's conversion traits give a function the ability to accept anything convertible to what it needs. Kotlin has no such trait and the conversion has to happen at the call site.
struct Celsius(f64); struct Fahrenheit(f64); impl From<Celsius> for Fahrenheit { fn from(value: Celsius) -> Self { Fahrenheit(value.0 * 9.0 / 5.0 + 32.0) } } fn describe(temperature: impl Into<Fahrenheit>) -> String { format!("{:.1}F", temperature.into().0) } fn main() { println!("{}", describe(Celsius(100.0))); }
@JvmInline value class Celsius(val degrees: Double) @JvmInline value class Fahrenheit(val degrees: Double) fun Celsius.toFahrenheit() = Fahrenheit(degrees * 9.0 / 5.0 + 32.0) fun describe(temperature: Fahrenheit): String = "%.1fF".format(temperature.degrees) fun main() { println(describe(Celsius(100.0).toFahrenheit())) }
There is no From/Into pair and no impl Into<T> parameter, so a function declares exactly the type it wants and callers convert first. The convention is an extension function named toX(), matching the standard library's toInt(), toList() and toString(). Two consequences: the conversion is visible at every call, which some people prefer, and there is no TryFrom either — a fallible conversion is a toXOrNull() returning a nullable, which is exactly the naming convention the collections section described. Kotlin also has no AsRef, and its nearest equivalent to a blanket impl is an extension on a generic receiver.
Generics
Generics are erased, and reified undoes some of it
The JVM erases type arguments, so an ordinary generic function has no access to its own type parameter at run time — which is the biggest structural difference in this section.
use std::any::type_name; fn describe<T>(_value: T) -> &'static str { type_name::<T>() } fn main() { println!("{}", describe(1i32)); println!("{}", describe("text")); // Monomorphized: one compiled function per T, // and the type is known inside the body. }
inline fun <reified T> describe(value: T): String = T::class.simpleName ?: "?" fun <T> describeErased(value: T): String = "the type is gone" fun main() { println(describe(1)) println(describe("text")) println(describeErased(1)) }
List<String> and List<Int> are the same class at run time, you cannot write T() to construct one, and value is T does not compile. Kotlin's answer is inline fun <reified T>: the function body is inlined at every call site with the type substituted in, so T::class and is T work — which is monomorphization, opt-in, one function at a time. That is also how filterIsInstance<String>() is possible. There is no PhantomData because an unused type parameter is simply allowed, and boxing is the other cost: a List<Int> holds boxed integers where a Vec<i32> holds machine words.
Trait bounds become where clauses
The bound syntax is nearly identical — a colon after the type parameter, or a where clause when there are several — and so is the way it reads.
use std::fmt::Display; fn largest<T: PartialOrd + Copy + Display>(items: &[T]) -> T { let mut result = items[0]; for item in items.iter() { if *item > result { result = *item; } } println!("{result}"); result } fn main() { largest(&[3, 9, 2]); largest(&[1.5, 0.5]); }
fun <T : Comparable<T>> largest(items: List<T>): T { var result = items[0] for (item in items) { if (item > result) result = item } println(result) return result } fun main() { largest(listOf(3, 9, 2)) largest(listOf(1.5, 0.5)) }
Kotlin's where clause goes after the parameter list and lists constraints as where T : A, T : B. What is missing relative to Rust is anything about layout or copying: there is no Copy, no Sized, no 'static, because every reference is the same size and the collector handles lifetime. Comparable<T> stands in for PartialOrd, and because Kotlin maps its comparison operators onto compareTo, item > result works on any Comparable without an extra bound. Variance is declaration-site — out T and in T on the type parameter — rather than Rust's inferred variance, and it is the one part of the section a Rust reader has to learn from scratch.
Structs & Data Classes
A struct with derives becomes a data class
The derive list becomes one keyword. data generates equals, hashCode, toString, copy and the componentN functions that make destructuring work.
#[derive(Debug, Clone, PartialEq)] struct Order { id: u32, label: String, } fn main() { let first = Order { id: 7, label: "books".to_string() }; let second = first.clone(); println!("{}", first == second); let changed = Order { label: "ink".to_string(), ..first.clone() }; println!("{changed:?}"); }
data class Order(val id: Int, val label: String) fun main() { val first = Order(7, "books") val second = first.copy() println(first == second) val changed = first.copy(label = "ink") println(changed) }
copy(label = "ink") is the struct-update syntax and takes named arguments for whatever you want changed, which is tidier than ..first.clone() and does the same job. The comparison operators are the thing to internalize: Kotlin's == calls equals and is therefore structural — matching PartialEq — while === is reference identity. That is the reverse of Java, where == compares references, and it is a deliberate fix. Note that copy is shallow: it copies the references, so a data class holding a MutableList shares that list with its copy, which is exactly the aliasing the ownership section warned about.
impl blocks, constructors and companions
Methods live inside the class rather than in a separate impl block, and the associated-function pattern — Type::new — needs a construct with no Rust analogue.
struct Parser { text: String, } impl Parser { fn new(text: &str) -> Self { Parser { text: text.trim().to_string() } } fn length(&self) -> usize { self.text.len() } } fn main() { let parser = Parser::new(" hi "); println!("{} {}", parser.text, parser.length()); }
class Parser private constructor(val text: String) { val length: Int get() = text.length companion object { fun of(text: String) = Parser(text.trim()) } } fun main() { val parser = Parser.of(" hi ") println("${parser.text} ${parser.length}") }
There is no static: a companion object is a singleton attached to the class, and its members are reached through the class name, so Parser.of(…) reads like Parser::new(…) while actually being a method on a real object that can implement interfaces. There is no self parameter either — the receiver is this and is nearly always implicit — and no &self/&mut self/self distinction, since there is no ownership to express. The other addition is properties: val length: Int get() = … looks like a field to every caller and is a getter, so you can start with a stored value and add computation later without changing a single call site.
Default and named arguments replace the builder
Rust has no default arguments, so a type with optional fields needs Default and struct-update syntax, or a builder. Kotlin has both defaults and named arguments, and they remove the need for either.
#[derive(Debug)] struct Request { url: String, method: String, timeout: u32, } impl Default for Request { fn default() -> Self { Request { url: String::new(), method: "GET".to_string(), timeout: 30, } } } fn main() { let request = Request { url: "https://example.com".to_string(), ..Default::default() }; println!("{request:?}"); }
data class Request( val url: String, val method: String = "GET", val timeout: Int = 30, ) fun main() { val request = Request(url = "https://example.com") println(request) }
A parameter may have a default expression — evaluated on every call, so the mutable-default trap cannot exist — and callers may name any argument and give them in any order. That covers what the builder pattern exists for, which is why Kotlin codebases have far fewer builders than the Java ones they replaced. Two things to watch: the parameter name is now part of the public interface, so renaming it breaks callers; and calling a defaulted function from Java requires @JvmOverloads, which generates the overload set, because the JVM has no notion of a default argument.
Operator traits become operator functions
Operator overloading works by giving a method a known name in both languages, and Kotlin adds a second idea Rust has no form of.
use std::fmt; use std::ops::Add; #[derive(Clone, Copy, PartialEq)] struct Money(i64); impl Add for Money { type Output = Money; fn add(self, other: Money) -> Money { Money(self.0 + other.0) } } impl fmt::Display for Money { fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result { write!(formatter, "{}c", self.0) } } fn main() { println!("{}", Money(150) + Money(99)); // There is no infix-call syntax: it is always a method or an operator. println!("{}", Money(150).add(Money(99))); println!("{}", Money(150) == Money(150)); }
data class Money(val cents: Long) { operator fun plus(other: Money) = Money(cents + other.cents) infix fun and(other: Money) = Money(cents + other.cents) override fun toString() = "${cents}c" } fun main() { println(Money(150) + Money(99)) println(Money(150) and Money(99)) println(Money(150) == Money(150)) }
The names are ordinary words — plus, minus, times, get, contains, compareTo, invoke — marked with the operator keyword rather than declared through a separate trait, so there is no Output associated type and the return type is simply whatever you wrote. Implementing contains is what makes in work on your own type, and compareTo supplies all four comparison operators at once. The addition is infix: any single-argument method may be called without a dot or parentheses, which is where to, downTo, step and until come from — they are library functions, not syntax. The convention is to use it only where the result reads as English.
Collections
Vec, HashMap and their Kotlin names
The mapping is direct, and the difference worth noticing is what a missing key gives you and what the type promises about mutation.
use std::collections::{HashMap, HashSet}; fn main() { let mut numbers = vec![1, 2, 3]; numbers.push(4); println!("{numbers:?} {}", numbers.len()); let mut ages: HashMap<&str, u32> = HashMap::new(); ages.insert("Ada", 36); println!("{:?}", ages.get("Ada")); println!("{:?}", ages.get("Bo")); let unique: HashSet<i32> = [1, 2, 2, 3].into_iter().collect(); println!("{}", unique.len()); }
fun main() { val numbers = mutableListOf(1, 2, 3) numbers.add(4) println("$numbers ${numbers.size}") val ages = mutableMapOf("Ada" to 36) println(ages["Ada"]) println(ages["Bo"]) val unique = setOf(1, 2, 2, 3) println(unique.size) }
A Kotlin map's indexer returns V? rather than throwing, so ages["Bo"] is None spelled null and the get-returns-Option habit transfers exactly. The bigger idea is the read-only/mutable split: List, Map and Set have no mutating methods at all, and MutableList and friends do — so a function taking a List promises not to change it, which is a weak echo of & against &mut. Weak because it is a view: nothing prevents another holder of the same MutableList from changing it underneath. Mutating a collection while iterating it throws ConcurrentModificationException at run time, which is where Rust gives a compile error.
The entry API becomes getOrPut
The one-lookup insert-or-update that Rust spells with entry has a direct counterpart, and the whole loop has a shorter one.
use std::collections::HashMap; fn main() { let words = ["fig", "apple", "pear", "plum"]; let mut by_length: HashMap<usize, Vec<&str>> = HashMap::new(); for word in words { by_length.entry(word.len()).or_default().push(word); } let mut keys: Vec<&usize> = by_length.keys().collect(); keys.sort(); for key in keys { println!("{key} {:?}", by_length[key]); } }
fun main() { val words = listOf("fig", "apple", "pear", "plum") val byLength = mutableMapOf<Int, MutableList<String>>() for (word in words) { byLength.getOrPut(word.length) { mutableListOf() }.add(word) } for ((key, value) in byLength.toSortedMap()) { println("$key $value") } println(words.groupBy { it.length }.toSortedMap()) }
getOrPut(key) { default } is entry(key).or_insert_with(…), taking a lambda so the default is only built when needed. But the grouping itself is a single library call — groupBy { it.length } returns a Map<Int, List<String>> with the types worked out — and that pattern runs through the whole standard library: associate, partition, sumOf, maxByOrNull, chunked, windowed, zip, flatten. It is noticeably richer than Rust's std collections API, where the equivalent often means reaching for itertools.
Arrays, slices and the boxing underneath
Kotlin has arrays and lists as separate things, and the reason to care is that one of them holds machine words and the other holds pointers to boxed objects.
fn sum(values: &[i32]) -> i32 { values.iter().sum() } fn main() { let fixed: [i32; 4] = [1, 2, 3, 4]; let growable: Vec<i32> = vec![5, 6]; println!("{}", sum(&fixed)); println!("{}", sum(&growable)); println!("{}", sum(&fixed[1..3])); }
fun sum(values: List<Int>): Int = values.sum() fun main() { val fixed: IntArray = intArrayOf(1, 2, 3, 4) val growable: List<Int> = listOf(5, 6) println(fixed.sum()) println(sum(growable)) println(sum(fixed.slice(1..2))) }
IntArray, DoubleArray and the rest of that family compile to a JVM primitive array — the storage a Rust [i32; 4] or Vec<i32> has — while List<Int> holds boxed Integer objects, because generics are erased and a type argument must be a reference. So the specialized array types are what you reach for in numeric code, and they are deliberately awkward to encourage using List everywhere else. There is also no borrowed slice type: slice(1..2) copies, where &fixed[1..3] is a view with no allocation. That is one of the few places where the absence of borrows costs performance rather than only expressiveness.
Iterators & Sequences
Collection operations are EAGER by default
This is the row where a Rust reader's intuition is most likely to be quietly wrong, because the code looks identical and the evaluation is not.
fn main() { let numbers: Vec<i32> = (1..=1_000_000).collect(); // Lazy: nothing runs until take(3) pulls. let first_three: Vec<i32> = numbers .iter() .filter(|value| **value % 7 == 0) .map(|value| value * value) .take(3) .collect(); println!("{first_three:?}"); }
fun main() { val firstThree = (1..1_000_000) .asSequence() .filter { it % 7 == 0 } .map { it * it } .take(3) .toList() println(firstThree) }
Drop the asSequence() and the same chain builds a full intermediate list at every step — a million-element filtered list and then a million-element squared list, to take three values. Every operation on a Kotlin List is eager and returns a new list; laziness is opt-in through Sequence, which is the actual counterpart of Iterator. The rule of thumb the Kotlin documentation gives matches the cost model: for small collections the eager version is faster because it skips the per-element machinery, and for large ones or for early termination the sequence wins. sequence { yield(…) } is the builder form, and it is a coroutine underneath.
Iterator adapters, renamed
Most adapters keep their names, and the ones that change follow a naming convention worth learning early.
fn main() { let numbers = vec![1, 2, 3, 4, 5, 6]; let doubled: Vec<i32> = numbers.iter().map(|value| value * 2).collect(); println!("{doubled:?}"); println!("{}", numbers.iter().sum::<i32>()); println!("{:?}", numbers.iter().max()); println!("{:?}", numbers.iter().find(|value| **value > 4)); println!("{}", numbers.iter().fold(0, |total, value| total + value)); println!("{:?}", numbers.chunks(2).collect::<Vec<_>>()); }
fun main() { val numbers = listOf(1, 2, 3, 4, 5, 6) println(numbers.map { it * 2 }) println(numbers.sum()) println(numbers.maxOrNull()) println(numbers.find { it > 4 }) println(numbers.fold(0) { total, value -> total + value }) println(numbers.chunked(2)) }
A function ending in OrNull returns null where its plain sibling throws, so maxOrNull() is max() returning Option and max() — where it exists — is max().unwrap(). The renames are small: filter, map, find, fold, zip, take, drop all match; flat_map is flatMap; chunks is chunked; windows is windowed; any/all/count match. it is the implicit single-parameter name, which is where most of the brevity comes from. There is no collect because an eager operation has already produced the list.
Implementing Iterator against a sequence builder
Rust asks for a struct holding the state and a next method. Kotlin gives you a generator, which is a coroutine underneath.
struct Countdown { remaining: u32, } impl Iterator for Countdown { type Item = u32; fn next(&mut self) -> Option<u32> { if self.remaining == 0 { None } else { self.remaining -= 1; Some(self.remaining + 1) } } } fn main() { let values: Vec<u32> = Countdown { remaining: 3 }.collect(); println!("{values:?}"); }
fun countdown(from: Int) = sequence { var remaining = from while (remaining > 0) { yield(remaining) remaining-- } } fun main() { println(countdown(3).toList()) }
sequence { } is a suspend block in which yield suspends and hands a value to the consumer, so the state machine is generated rather than written — the thing Rust's unstable generator feature would provide. There is a matching yieldAll for delegating to another sequence. What you give up is control: you cannot implement size_hint, cannot provide a specialized count(), and cannot express ExactSizeIterator or DoubleEndedIterator. Implementing Iterator by hand is still available — an Iterable<T> with an iterator() method — and is what you write when the sequence needs to be re-iterable, since a sequence { } may be consumed only once.
Labeled breaks, and returning from a lambda
Labeled loops exist in both with the label on opposite sides of the name, and Kotlin extends the idea to lambdas in a way that catches people out.
fn main() { let rows = vec![vec![1, 2], vec![3, 4]]; let target = 4; 'outer: for (row_index, row) in rows.iter().enumerate() { for (column_index, value) in row.iter().enumerate() { if *value == target { println!("{row_index} {column_index}"); break 'outer; } } } }
fun main() { val rows = listOf(listOf(1, 2), listOf(3, 4)) val target = 4 outer@ for ((rowIndex, row) in rows.withIndex()) { for ((columnIndex, value) in row.withIndex()) { if (value == target) { println("$rowIndex $columnIndex") break@outer } } } listOf(1, 2, 3).forEach { if (it == 2) return@forEach // 'continue', not 'return' println(it) } }
Rust writes 'outer: for and break 'outer; Kotlin writes outer@ for and break@outer. What has no Rust counterpart is the second half: a bare return inside a lambda returns from the enclosing function, not from the lambda — a non-local return, legal only because forEach is inline — and return@forEach is what returns from the lambda itself, behaving like continue. Getting those two the wrong way round is a real and confusing bug, and it is the main reason to know that inline exists. Kotlin has no equivalent of Rust's loop { break value }, though a labeled run block gets close.
Strings
One string type, and it is UTF-16
The owned/borrowed split disappears — there is one String type, immutable, always heap-allocated — and the encoding changes underneath it.
fn main() { let owned: String = String::from("café"); let borrowed: &str = &owned; println!("{} {}", owned.len(), borrowed.chars().count()); let flag = "\u{1F1EC}\u{1F1E7}"; println!("{} {}", flag.len(), flag.chars().count()); let mut built = String::new(); built.push_str("a"); built.push_str("b"); println!("{built}"); }
fun main() { val owned = "café" println("${owned.toByteArray().size} ${owned.length}") val flag = "\uD83C\uDDEC\uD83C\uDDE7" println("${flag.toByteArray().size} ${flag.length}") val built = buildString { append("a") append("b") } println(built) }
A Rust String is UTF-8 and len() is a byte count. A JVM String is UTF-16, and length counts 16-bit code units — so the flag emoji, one grapheme from two code points, is 8 bytes in UTF-8, 2 chars in Rust, and length == 4 in Kotlin, because each code point needs a surrogate pair. codePointCount gives the Rust chars() answer. There is no &str, so a substring allocates where &text[..3] does not — and there is also no possibility of the panic Rust gives you for slicing mid-character. buildString { } wraps a StringBuilder, since += in a loop allocates each time.
Raw strings and interpolation
Kotlin has one triple-quoted form that is multi-line and raw at once — and, unlike Rust's r"", it still interpolates.
fn main() { let name = "Ada"; println!("{name} is here"); println!("{}", format!("{}-{}", name, name.len())); let path = r"C:\path\with\backslashes"; println!("{path}"); let letter = "Dear Ada,\nRegards"; println!("{letter}"); }
fun main() { val name = "Ada" println("$name is here") println("$name-${name.length}") val path = """C:\path\with\backslashes""" println(path) val letter = """ Dear $name, Regards """.trimIndent() println(letter) }
A bare name needs no braces ("$name") and anything more than a name does ("${name.length}"), which is the opposite of Rust's inline captures, where the braces are always there. Because the raw form still interpolates, a literal dollar sign inside one needs ${'$'} — the one genuinely ugly corner. Indentation is not stripped automatically; .trimIndent() removes the common leading whitespace and appears on essentially every multi-line literal. There is no compile-time check on interpolation the way println! checks its format string, because there is no format string.
Closures
One closure type instead of three traits
Rust has three closure traits because it has to say what the closure does to the values it captures. Kotlin has one function type, because there is nothing to say.
fn apply_twice<F: Fn(i32) -> i32>(function: F, value: i32) -> i32 { function(function(value)) } fn main() { println!("{}", apply_twice(|value| value + 1, 5)); println!("{}", apply_twice(|value| value * 2, 5)); let mut count = 0; let mut increment = || { count += 1; count }; println!("{} {}", increment(), increment()); let owned = String::from("moved"); let consume = move || owned; println!("{}", consume()); }
fun applyTwice(function: (Int) -> Int, value: Int): Int = function(function(value)) fun main() { println(applyTwice({ value -> value + 1 }, 5)) println(applyTwice({ it * 2 }, 5)) var count = 0 val increment = { count += 1 count } println("${increment()} ${increment()}") }
Fn, FnMut and FnOnce collapse into (Int) -> Int. There is no move keyword and no capture-by-value: a Kotlin closure captures the variable, so a captured var can be read and written by the closure and by the enclosing scope alike — which is FnMut with no borrow checker and no RefCell. The last Rust example above simply has no counterpart, because nothing is ever moved. A single-parameter lambda can leave its parameter unnamed and call it it, which is where most of Kotlin's brevity comes from, and a lambda may contain as many statements as it likes with the last expression as its value.
The trailing lambda, and inline functions
When a function's last parameter is a lambda, it may be written after the closing parenthesis — and if it is the only argument, the parentheses vanish. That rule is why so much Kotlin looks like it has custom syntax.
fn timed<T, F: FnOnce() -> T>(label: &str, work: F) -> T { let result = work(); println!("{label} finished"); result } fn main() { let value = timed("job", || (0..1000).sum::<i32>()); println!("{value}"); }
inline fun <T> timed(label: String, work: () -> T): T { val result = work() println("$label finished") return result } fun main() { val value = timed("job") { (0 until 1000).sum() } println(value) }
run, repeat, use, runBlocking, buildString and every Gradle build script are ordinary function calls dressed up by it. The inline keyword is the other half and matters to a performance-minded reader: it inlines the function body and the lambda at the call site, so there is no function object allocated and no virtual call — which is what makes filter { } and map { } cost roughly what a hand-written loop costs, and is the closest thing Kotlin has to Rust's zero-cost closures. It also enables reified type parameters and lets a return inside the lambda return from the enclosing function.
Async & Coroutines
async fn becomes suspend fun
Both languages mark a function that can pause, and the call site is where they part company: Rust requires .await on every call to one, and Kotlin requires nothing at all.
// With tokio: // // async fn fetch(name: &str) -> String { // tokio::time::sleep(Duration::from_millis(10)).await; // format!("data for {name}") // } // // #[tokio::main] // async fn main() { // println!("{}", fetch("first").await); // } // // A future is INERT until polled, and std has no executor. fn main() { println!("Rust: cold futures, an executor you choose, .await at every call"); }
import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking suspend fun fetch(name: String): String { delay(10) return "data for $name" } fun main() = runBlocking { println(fetch("first")) }
A suspend function is called exactly like an ordinary one, and the compiler enforces only that the caller is itself suspend or is inside a coroutine builder. That removes the .await noise and makes turning a plain function into a suspending one a one-word change rather than a cascade — at the cost that you cannot tell from a call whether it suspends. There is no executor to choose or thread through: the runtime supplies dispatchers, so #[tokio::main] becomes runBlocking and nothing is generic over the runtime. Coroutines are also hot in the sense that matters: calling a suspend function runs it, and there is no inert value to poll.
Structured concurrency replaces join! and select!
async { } plus awaitAll() is join!, and the surrounding coroutineScope is the idea Kotlin is proudest of and that tokio has no built-in equivalent for.
// With tokio: // // let (first, second) = tokio::join!(fetch("first"), fetch("second")); // // tokio::select! { ... } picks whichever finishes first, // and DROPPING a future cancels it — cancellation is Drop. // // A tokio::spawn'd task outlives the scope that made it // unless you hold and await its JoinHandle. fn main() { println!("Rust: join!, select!, cancel-by-drop, detached spawn"); }
import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking suspend fun fetch(name: String, millis: Long): String { delay(millis) return "$name done" } fun main() = runBlocking { val results = coroutineScope { listOf( async { fetch("first", 30) }, async { fetch("second", 10) }, ).awaitAll() } println(results) }
A coroutineScope does not return until every coroutine launched inside it has finished, and if any of them fails — or if the scope body throws — the rest are cancelled automatically. There is no way to start a coroutine that outlives its scope by accident, which is exactly the tokio::spawn hazard where a dropped JoinHandle leaves a task running unobserved. Cancellation itself is cooperative rather than by drop: every suspension point checks, and a tight computational loop must call yield() or check isActive or it will not stop. That is more predictable than Rust's cancel-at-any-await-point, which is a known source of bugs where a future is dropped between two operations that had to happen together.
Send, Sync and the guarantee that is gone
Coroutines on Dispatchers.Default run on a real thread pool, so this is genuine parallelism — and removing the lock compiles, runs, and prints a number smaller than 4000.
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..4 { let counter = Arc::clone(&counter); handles.push(thread::spawn(move || { for _ in 0..1000 { *counter.lock().unwrap() += 1; } })); } for handle in handles { handle.join().unwrap(); } println!("{}", *counter.lock().unwrap()); }
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock fun main() = runBlocking { val padlock = Mutex() var counter = 0 coroutineScope { repeat(4) { launch(Dispatchers.Default) { repeat(1000) { padlock.withLock { counter++ } } } } } println(counter) }
There is no Send and no Sync: any object may be touched from any thread, and no marker trait, no compiler check and no lint stands between you and a data race. The lock is also not attached to the data — padlock.withLock { } guards a region of code by convention, where Mutex<T> makes the data unreachable without acquiring it, and that design difference is the one to carry away. Arc disappears because every reference is shared and the collector handles the count. What Kotlin offers instead is a good library — Mutex, Channel, AtomicInteger, immutable data classes, confining state to a single coroutine — and none of it is a proof.
Channels and streams
Kotlin has both a Channel (the direct mpsc counterpart) and a Flow, and the second is the one you will actually meet.
use std::sync::mpsc; use std::thread; fn main() { let (sender, receiver) = mpsc::channel(); thread::spawn(move || { for value in 0..3 { sender.send(value * 10).unwrap(); } }); let received: Vec<i32> = receiver.iter().collect(); println!("{received:?}"); }
import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking fun ticks(count: Int) = flow { for (value in 0 until count) { delay(1) emit(value) } } fun main() = runBlocking { println(ticks(3).map { it * 10 }.toList()) }
A Flow is a cold asynchronous stream — nothing runs until something collects it, which is precisely the relationship a Rust future has to its executor — with a large operator library attached: map, filter, debounce, combine, flatMapLatest, where the Rust equivalent means futures::Stream plus StreamExt from a crate. StateFlow and SharedFlow are the hot variants and are what an Android view model exposes. Channel maps to mpsc closely, including a bounded form, and differs in one familiar way: sending a value does not move it, so nothing stops you from continuing to use what you sent.
Choosing where work runs
Both runtimes distinguish CPU work from blocking work from the interface thread, and Kotlin makes the choice a parameter rather than a separate function or crate.
// With tokio, the choice is which runtime and which pool: // // #[tokio::main(flavor = "multi_thread", worker_threads = 4)] // // tokio::task::spawn_blocking(|| heavy_cpu_work()) // rayon::iter::IntoParallelIterator (a different crate entirely) // // Blocking a worker thread starves the whole executor, // which is why spawn_blocking exists. fn main() { println!("{}", (1..=1_000).sum::<i32>()); println!("Rust: pick a runtime, and keep blocking work off it"); }
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext suspend fun heavyWork(): Int = withContext(Dispatchers.Default) { (1..1_000).sum() } fun main() = runBlocking { println(heavyWork()) println("Dispatchers: Default (CPU), IO (blocking), Main (UI)") }
Dispatchers.Default is a pool sized to the core count and is for CPU work; Dispatchers.IO is a much larger elastic pool for blocking calls and is spawn_blocking; Dispatchers.Main is the Android or Swing interface thread and has no Rust counterpart at all. withContext(dispatcher) { … } moves a block onto one and moves back when it finishes, which is a thing tokio has no single expression for. The rule is the same as Rust's and matters as much: blocking a dispatcher thread — a Thread.sleep, a synchronous file read, a JDBC call — starves every coroutine queued behind it. The parallel-iterator role that Rayon fills is played by launching coroutines on Dispatchers.Default.
Scope Functions
let, also, and the chain that has no Rust equivalent
Kotlin has five short library functions — let, run, with, apply, also — that exist only to give an expression a temporary scope. They have no Rust counterpart and they ambush you in real code, so they are worth meeting deliberately.
fn find_name(id: u32) -> Option<String> { if id == 1 { Some("Ada".to_string()) } else { None } } fn main() { if let Some(name) = find_name(1) { println!("found {}", name.trim().to_uppercase()); } let mut values = vec![3, 1, 2]; println!("about to sort: {values:?}"); values.sort(); println!("{values:?}"); }
fun findName(id: Int): String? = if (id == 1) "Ada" else null fun main() { findName(1)?.let { name -> println("found ${name.trim().uppercase()}") } val values = mutableListOf(3, 1, 2) .also { println("about to sort: $it") } .also { it.sort() } println(values) }
let passes the receiver as it and returns the block's value, so value?.let { … } is "do this only if it is not null" — if let Some(x), with it smart-cast to the non-null type. also passes it as well but returns the receiver, so it slots into a chain without changing its value, which makes it the natural home for logging and assertions in the middle of a pipeline. The distinction between all five reduces to two questions: is the receiver it or this, and is the result the block or the receiver. Rust's closest analogue is Option::map and the inspect adapters, which cover a fraction of the same ground.
apply against a builder
A block of assignments to a freshly built value is a shape both languages need. apply turns it into a single expression in which the object is the implicit receiver.
#[derive(Debug, Default)] struct Request { url: String, method: String, headers: Vec<(String, String)>, } fn main() { let mut request = Request::default(); request.url = "https://example.com".to_string(); request.method = "POST".to_string(); request.headers.push(("accept".to_string(), "application/json".to_string())); println!("{} {} {:?}", request.method, request.url, request.headers); }
class Request { var url: String = "" var method: String = "GET" val headers: MutableMap<String, String> = mutableMapOf() override fun toString() = "$method $url $headers" } fun main() { val request = Request().apply { url = "https://example.com" method = "POST" headers["accept"] = "application/json" } println(request) }
Inside the block, this is the new object, so its properties are addressed by bare name and the repeated request. prefix disappears; the block returns the receiver, so the whole thing is one expression and can be assigned, returned, or passed as an argument. That is why Kotlin needs the builder pattern so much less than Java did, and it is a different answer from Rust's, which is a ..Default::default() struct literal or a chain of with_ methods returning Self. The Kotlin version works on a mutable object, which the ownership section already warned is the trade being made everywhere on this page.
Cargo, Gradle & the JVM
Cargo becomes Gradle
This is the comparison where Rust comes off best, and saying so plainly is more useful than pretending otherwise.
// Cargo.toml // [dependencies] // serde = { version = "1", features = ["derive"] } // // cargo build / test / run / fmt / clippy / doc // Cargo.lock is written by default and committed for a binary. fn main() { println!("Rust: one tool, one manifest, one lockfile"); }
// build.gradle.kts // plugins { kotlin("jvm") version "2.4.0" } // dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") } // // ./gradlew build / test / run // A lockfile is OPT-IN (dependencyLocking), and most projects skip it. fun main() { println("Kotlin: a build script that is a program, and a daemon") }
The build file is itself a Kotlin program, which is why it reads as a sequence of trailing-lambda blocks and why an IDE can complete it — and also why a cold build downloads a great deal, takes minutes, and fails in ways that are considerably harder to reason about than a cargo build that went wrong. The Gradle daemon holds memory between builds to make later ones fast. Two concrete losses against Cargo: dependency locking is opt-in, so a default project does not have reproducible builds, and there is no cargo fmt/clippy equivalent in the box — ktlint, detekt and the IDE formatter are separate choices. The wrapper script ./gradlew pins the build tool's own version, which is one thing it does better than Cargo.
Modules, crates and visibility
A Kotlin import is a compile-time alias with no runtime effect, and the visibility vocabulary is smaller than Rust's in one direction and larger in another.
mod billing { pub struct Invoice { pub id: u32, total: u32, // private to this module } impl Invoice { pub fn new(id: u32) -> Self { Invoice { id, total: 0 } } pub fn total(&self) -> u32 { self.total } } } use billing::Invoice; fn main() { let invoice = Invoice::new(7); println!("{} {}", invoice.id, invoice.total()); }
// package billing (in src/main/kotlin/billing/Invoice.kt) class Invoice(val id: Int) { private var total: Int = 0 fun total(): Int = total } fun main() { val invoice = Invoice(7) println("${invoice.id} ${invoice.total()}") }
Kotlin has public (the default — the reverse of Rust's private-by-default), private, protected, and internal, which means visible within the compilation module and is roughly pub(crate). There is no pub(super) or pub(in path), and packages do not nest as a visibility hierarchy the way modules do — billing and billing.tax are unrelated names sharing a prefix. Nothing executes at import time, so there are no import-time side effects and no circular-import problem. A file may hold several top-level classes and need not be named after any of them, which makes a file feel like a Rust module even though the package is what actually scopes things.
Tests live in a separate source tree
Rust puts unit tests in the same file behind #[cfg(test)], compiled out of the release build. Kotlin puts them in a parallel source tree that is compiled separately.
fn add(left: i32, right: i32) -> i32 { left + right } #[cfg(test)] mod tests { use super::*; #[test] fn adds_two_numbers() { assert_eq!(add(2, 3), 5); } } fn main() { println!("{}", add(2, 3)); }
// src/test/kotlin/BillingTest.kt, run with: ./gradlew test // // import kotlin.test.Test // import kotlin.test.assertEquals // // class BillingTest { // @Test fun `adds two numbers`() { // assertEquals(5, add(2, 3)) // } // } fun add(left: Int, right: Int) = left + right fun main() { println(add(2, 3)) }
The visibility consequence is the one to know: a Rust test module is a child module and can see private items, while a Kotlin test class is in a different file and sees only what its visibility allows — though because tests are in the same compilation module by default, internal members are visible and private ones are not. Assertion arguments are expected-then-actual, the reverse of what most people guess, and there is no assert_eq!-style diff output; assertEquals reports both values. The pleasant part is the backtick-quoted function name, which lets a test read as a sentence. Kotest is the popular alternative and MockK is the mocking library.
Macros become annotations, KSP and inline
Kotlin has no macros. The three things a Rust reader might mistake for them — data, annotations, and inline — each cover a slice of what a macro does, and none of them is one.
#[derive(Debug, Clone, PartialEq)] struct Order { id: u32, } macro_rules! twice { ($value:expr) => { $value * 2 }; } fn main() { println!("{:?}", Order { id: 7 }); println!("{}", twice!(21)); // Procedural macros run at compile time and emit tokens. }
@JvmInline value class OrderId(val value: Int) data class Order(val id: Int) inline fun twice(value: Int) = value * 2 fun main() { println(Order(7)) println(twice(21)) // Annotations alone generate NOTHING. A KSP or kapt processor // reads them at build time and writes new source files. }
data class is a built-in derive and the only one; you cannot write your own. An annotation is metadata that does nothing by itself and is read either by reflection at run time or by a build-time processor — KSP (Kotlin Symbol Processing), which is the closest thing to a procedural macro and is what Room, Dagger and kotlinx.serialization use. Like a source generator it can only add files, never rewrite what you wrote. inline is a compiler transform on a function body and is what makes reified type parameters and non-local returns possible, but it substitutes code rather than manipulating syntax, so there is no macro_rules! equivalent anywhere in the language.