Rust for Kotlin developers
Kotlin and Rust share much syntax: fun and fn, name: Type, when and match, T? and Option<T>. What differs is not the syntax. On the JVM the garbage collector frees an object when nothing refers to it, so a variable that holds an object is a way to reach a shared object. In Rust each value has one owner, and the compiler checks who may read or change it.
RememberThe Kotlin you know already checks absence and covers every case of a sealed class. Unlearn the rest of the JVM’s assumptions: assignment shares an object, a List is a view someone else can change, and a coroutine starts by default.
The programs below were run in the image behind this page: Kotlin with kotlinc 2.4.21 on JDK 25, Rust with 1.99.1.
kotlin-habits/ ├── src/ │ └── bin/ │ ├── h1.rs one program per habit below (h1 to h10) │ ├── h10.rs │ ├── h2.rs │ ├── h3.rs │ ├── h4.rs │ ├── h5.rs │ ├── h6.rs │ ├── h7.rs │ ├── h8.rs │ └── h9.rs ├── Cargo.lock └── Cargo.toml no dependencies
What carries over
Section titled “What carries over”val,var, type inference.letandlet mutare the closest spelling, with one difference in habit 4. Variables.T?isOption<T>. Absence is in the type and the compiler makes you handle it (habit 1).if,whenandtryare expressions. So areif,matchand{ }in Rust: Statements and expressions.- A
sealed classwithwhenis anenumwithmatch. In both, the compiler names the case you forgot (habit 6). - Lambdas and
map/filterpipelines are closures and iterators. Rust iterators are lazy like aSequence, not likeList.map, which builds a list each time: Closures, Iterators.
Ten habits to unlearn
Section titled “Ten habits to unlearn”1. ?., ?: and !! are methods, and ? means something else
Section titled “1. ?., ?: and !! are methods, and ? means something else”class Address(val city: String?)class User(val address: Address?)
fun cityLen(u: User?): Int? = u?.address?.city?.length
fun main() { val ana = User(Address("Lyon")) val bo = User(Address(null)) println(cityLen(ana)) println(cityLen(bo)) println(cityLen(null)) println(cityLen(null) ?: -1) val m: Map<String, String?> = mapOf("k" to null) println("${m["k"]} ${m["z"]}") println("${"k" in m} ${"z" in m}")}4 null null -1 null null true false
use std::collections::HashMap;
struct Address { city: Option<String>,}
struct User { address: Option<Address>,}
// Kotlin: u?.address?.city?.lengthfn city_len(u: Option<&User>) -> Option<usize> { let city = u?.address.as_ref()?.city.as_ref()?; Some(city.len())}
fn main() { let ana = User { address: Some(Address { city: Some("Lyon".to_string()), }), }; let bo = User { address: Some(Address { city: None }), }; println!("{:?}", city_len(Some(&ana))); println!("{:?}", city_len(Some(&bo))); println!("{:?}", city_len(None)); println!("{}", city_len(None).map_or(-1, |n| n as i32)); let m: HashMap<&str, Option<&str>> = HashMap::from([("k", None)]); println!("{:?} {:?}", m.get("k"), m.get("z")); println!("{} {}", m.contains_key("k"), m.contains_key("z"));}Some(4) None None -1 Some(None) None true false
?. chains become ? inside a function that returns an Option, or map and and_then. ?: is unwrap_or for a plain default and unwrap_or_else(|| ...) when the right side costs something or has an effect (the Option lesson shows why), and !! is unwrap, which panics. The ? operator is an early return, not a safe call. Two differences show in the last lines. T? is a mark on a type, so Kotlin’s m["k"] gives null whether the key holds null or is missing, and you ask in to tell them apart. Option is an ordinary type, so get returns Some(None) for one and None for the other. And a value from unannotated Java reaches Kotlin as a platform type: no compile-time nullability error, and Kotlin inserts a run-time check where you assign it to a non-null type (the Kotlin docs); safe Rust has no such thing. See Option.
2. Exceptions become return values
Section titled “2. Exceptions become return values”fun parseAge(s: String): Int = s.trim().toInt()
fun total(a: String, b: String): Int = parseAge(a) + parseAge(b)
fun main() { println(total("4", "5")) try { println(total("4", "x")) } catch (e: NumberFormatException) { println("caught: ${e.message}") } println(runCatching { total("4", "x") }.isFailure)}9 caught: For input string: "x" true
use std::num::ParseIntError;
fn parse_age(s: &str) -> Result<i32, ParseIntError> { s.trim().parse()}
fn total(a: &str, b: &str) -> Result<i32, ParseIntError> { Ok(parse_age(a)? + parse_age(b)?)}
fn main() { println!("{:?}", total("4", "5")); match total("4", "x") { Ok(n) => println!("{n}"), Err(e) => println!("error: {e}"), } println!("{}", total("4", "x").is_err());}Ok(9) error: invalid digit found in string true
toInt() throws, and nothing in the signature of parseAge says so. Kotlin’s runCatching and toIntOrNull are the nearest thing; the difference is that Rust’s compiler makes you handle the failure. In Rust the failure is part of the return type, and ? hands it to the caller. Treat the Result as the number and the build fails:
use std::num::ParseIntError;
fn parse_age(s: &str) -> Result<i32, ParseIntError> { s.trim().parse()}
fn main() { let n: i32 = parse_age("42"); println!("{}", n + 1);}error[E0308]: mismatched types --> src/bin/h2.rs:8:18 | 8 | let n: i32 = parse_age("42"); | --- ^^^^^^^^^^^^^^^ expected `i32`, found `Result<i32, ParseIntError>` | | | expected due to this | = note: expected type `i32` found enum `Result<i32, ParseIntError>` help: consider using `Result::expect` to unwrap the `Result<i32, ParseIntError>` value, panicking if the value is a `Result::Err` | 8 | let n: i32 = parse_age("42").expect("REASON"); | +++++++++++++++++
A panic is for a bug, not for input you expected to be wrong. See Errors.
3. val b = a shares the object; in Rust it moves it
Section titled “3. val b = a shares the object; in Rust it moves it”fun main() { val a = mutableListOf(1, 2, 3) val b = a b.add(4) println(a) println(a === b)}[1, 2, 3, 4] true
fn main() { let a = vec![1, 2, 3]; let b = a; println!("{:?}", b); println!("{:?}", a);}error[E0382]: borrow of moved value: `a` --> src/bin/h3.rs:5:22 | 2 | let a = vec![1, 2, 3]; | - move occurs because `a` has type `Vec<i32>`, which does not implement the `Copy` trait 3 | let b = a; | - value moved here 4 | println!("{:?}", b); 5 | println!("{:?}", a); | ^ value borrowed here after move | help: consider cloning the value if the performance cost is acceptable | 3 | let b = a.clone(); | ++++++++
The value now belongs to b. Where Kotlin has two names for one object, Rust has one owner, and a cannot be read. The compiler’s own hint is .clone(), which makes a second value. There is no collector to free it later: the owner’s scope end does it, at a point you can read in the code. In the program below, a value with a Drop impl prints when b’s scope ends.
struct Noisy(&'static str);
impl Drop for Noisy { fn drop(&mut self) { println!("drop {}", self.0); }}
fn main() { let a = Noisy("list"); { let b = a; println!("b owns {}", b.0); } println!("after the block");}b owns list drop list after the block
See Ownership and Copy or reference.
4. val freezes the name, and List is a view
Section titled “4. val freezes the name, and List is a view”fun total(xs: List<Int>): Int = xs.sum()
fun main() { val xs = mutableListOf(1, 2, 3) val view: List<Int> = xs xs.add(4) println("$view ${total(view)}") (view as MutableList<Int>).add(5) println(xs)}[1, 2, 3, 4] 10 [1, 2, 3, 4, 5]
fn main() { let mut xs = vec![1, 2, 3]; let view: &[i32] = &xs; xs.push(4); println!("{:?}", view);}val xs stops you reassigning xs, not calling xs.add. The List<Int> named view is read-only for whoever holds it, but it printed the new item, and a cast got the writes back. A Rust &[i32] is a borrow: while it is still going to be used, nobody can change the Vec.
error[E0502]: cannot borrow `xs` as mutable because it is also borrowed as immutable --> src/bin/h4.rs:4:5 | 3 | let view: &[i32] = &xs; | --- immutable borrow occurs here 4 | xs.push(4); | ^^^^^^^^^^ mutable borrow occurs here 5 | println!("{:?}", view); | ---- immutable borrow later used here
Use the view first, then push. A borrow ends at its last use:
fn total(xs: &[i32]) -> i32 { xs.iter().sum()}
fn main() { let mut xs = vec![1, 2, 3]; let view: &[i32] = &xs; println!("{:?} {}", view, total(view)); xs.push(4); println!("{:?}", xs);}[1, 2, 3] 6 [1, 2, 3, 4]
A Rust let without mut also freezes the contents, so a Vec there cannot be pushed to (the Variables lesson runs it). See Borrowing and Mutable borrows.
5. A data class becomes a struct with derives, and copy becomes ..
Section titled “5. A data class becomes a struct with derives, and copy becomes ..”data class User(val name: String, val tags: MutableList<String>)
fun main() { val a = User("ana", mutableListOf("admin")) val b = a.copy(name = "bo") b.tags.add("guest") println(a) println(b) println(a == User("ana", mutableListOf("admin", "guest")))}User(name=ana, tags=[admin, guest]) User(name=bo, tags=[admin, guest]) true
#[derive(Debug, Clone, PartialEq)]struct User { name: String, tags: Vec<String>,}
fn main() { let a = User { name: "ana".to_string(), tags: vec!["admin".to_string()], }; let mut b = User { name: "bo".to_string(), ..a.clone() }; b.tags.push("guest".to_string()); println!("{a:?}"); println!("{b:?}"); let other = User { name: "ana".to_string(), tags: vec!["admin".to_string(), "guest".to_string()], }; println!("{}", a == other);}User { name: "ana", tags: ["admin"] }
User { name: "bo", tags: ["admin", "guest"] }
falsecopy is shallow in Kotlin: b shares tags with a, so adding a tag to b changed a, and the last line is true. In Rust, printing with {:?}, == and clone are opt-in (derive), and ..a.clone() is a separate list, so a is untouched and the comparison is false. See Structs and Common traits.
6. A subclass is a type; an enum variant is not
Section titled “6. A subclass is a type; an enum variant is not”sealed class Shapeclass Circle(val r: Double) : Shape()class Rect(val w: Double, val h: Double) : Shape()
fun area(s: Shape): Double = when (s) { is Circle -> 3.0 * s.r * s.r is Rect -> s.w * s.h}
fun circleArea(c: Circle): Double = area(c)
fun main() { println(area(Rect(2.0, 3.0))) println(circleArea(Circle(1.0)))}6.0 3.0
enum Shape { Circle(f64), Rect(f64, f64),}
fn area(s: &Shape) -> f64 { match s { Shape::Circle(r) => 3.0 * r * r, Shape::Rect(w, h) => w * h, }}
fn main() { println!("{}", area(&Shape::Rect(2.0, 3.0))); println!("{}", area(&Shape::Circle(1.0)));}6 3
Exhaustiveness is the same idea in both: Enums runs the missing-case error. The difference is that circleArea(c: Circle) compiles in Kotlin, and a variant is not a type in Rust:
enum Shape { Circle(f64), Rect(f64, f64),}
fn area(s: &Shape) -> f64 { match s { Shape::Circle(r) => 3.0 * r * r, Shape::Rect(w, h) => w * h, }}
fn circle_area(c: Shape::Circle) -> f64 { area(&c)}
fn main() { println!("{}", area(&Shape::Rect(2.0, 3.0)));}error[E0573]: expected type, found variant `Shape::Circle` --> src/bin/h6.rs:13:19 | 13 | fn circle_area(c: Shape::Circle) -> f64 { | ^^^^^^^^^^^^^ not a type | help: try using the variant's enum | 13 - fn circle_area(c: Shape::Circle) -> f64 { 13 + fn circle_area(c: crate::Shape) -> f64 {
If you need a function for circles only, make Circle a struct and let the variant hold it (A variant is not a type).
7. An extension function adds a name; a trait impl adds a promise
Section titled “7. An extension function adds a name; a trait impl adds a promise”fun Int.speak(): String = "number $this"
fun String.shout(): String = uppercase() + "!"
fun main() { println(21.speak()) println("hi".shout())}number 21 HI!
interface Speaker { fun speak(): String}
fun Int.speak(): String = "number $this"
fun announce(s: Speaker) = println(s.speak())
fun main() { announce(21)}h7_fail.kt:10:14: error: argument type mismatch: actual type is 'Int', but 'Speaker' was expected.
announce(21)
^^21.speak() works, but Int does not become a Speaker, so announce(21) is refused. In Rust the impl is the implementation, and the value goes wherever the trait is asked for:
trait Speaker { fn speak(&self) -> String;}
impl Speaker for i32 { fn speak(&self) -> String { format!("number {self}") }}
fn announce(s: &impl Speaker) { println!("{}", s.speak());}
fn main() { let n: i32 = 21; println!("{}", n.speak()); announce(&n);}number 21 number 21
The price is the orphan rule: roughly, you can implement your trait for any type, or any trait for your own type, but not a foreign trait for a foreign type; generic parameters add exceptions (Traits).
8. Smart casts and component1() become patterns that go by name
Section titled “8. Smart casts and component1() become patterns that go by name”data class Point(val x: Int, val y: Int)
fun describe(o: Any): String = when { o is String && o.length > 3 -> "long text ${o.length}" o is Point -> "point at ${o.x}" else -> "other"}
fun main() { val (y, x) = Point(1, 2) println("x=$x y=$y") println(describe("hello")) println(describe(Point(5, 6))) println(describe(3.5))}x=2 y=1 long text 5 point at 5 other
struct Point { x: i32, y: i32,}
enum Value { Text(String), Pt(Point), Other,}
fn describe(v: &Value) -> String { match v { Value::Text(s) if s.len() > 3 => format!("long text {}", s.len()), Value::Pt(Point { x, .. }) => format!("point at {x}"), _ => "other".to_string(), }}
fn main() { let Point { y, x } = Point { x: 1, y: 2 }; println!("x={x} y={y}"); println!("{}", describe(&Value::Text("hello".to_string()))); println!("{}", describe(&Value::Pt(Point { x: 5, y: 6 }))); println!("{}", describe(&Value::Other));}x=1 y=2 long text 5 point at 5 other
val (y, x) = Point(1, 2) binds by position, so x is 2 (the names are only labels). let Point { y, x } = ... binds by field name, so x is 1. Where Kotlin tests is and then uses the smart-cast variable, Rust matches the variant and binds its contents in one step, with an if guard for the length check. Anything the when called else is _. A value of unknown type (Any) becomes an enum listing the cases you accept. See Patterns and Match.
9. Default arguments and scope functions have no direct equivalent
Section titled “9. Default arguments and scope functions have no direct equivalent”class Server(val host: String = "localhost", val port: Int = 8080, val tls: Boolean = false)
fun main() { val s = Server(port = 9000) println("${s.host}:${s.port} tls=${s.tls}") val cfg = mutableMapOf<String, String>().apply { put("a", "1") } println(cfg) val next = "41".toIntOrNull()?.let { it + 1 } println(next)}localhost:9000 tls=false
{a=1}
42use std::collections::HashMap;
struct Server { host: String, port: u16, tls: bool,}
impl Default for Server { fn default() -> Self { Server { host: "localhost".to_string(), port: 8080, tls: false, } }}
fn main() { let s = Server { port: 9000, ..Default::default() }; println!("{}:{} tls={}", s.host, s.port, s.tls); let cfg = { let mut m = HashMap::new(); m.insert("a", "1"); m }; println!("{cfg:?}"); let next = "41".parse::<i32>().ok().map(|n| n + 1); println!("{next:?}");}localhost:9000 tls=false
{"a": "1"}
Some(42)Rust has no default or named arguments and no overloads. Name the fields in a struct literal and fill the rest from Default with ..Default::default(). apply { } is a block that ends in the value, and x?.let { } is map. See Methods.
10. A coroutine starts by default; a future waits to be polled
Section titled “10. A coroutine starts by default; a future waits to be polled”import kotlinx.coroutines.*
suspend fun fetch(id: Int): String { println("fetch $id starts") return "user$id"}
fun main() = runBlocking { val first = async { fetch(1) } println("after async") println(first.await()) async { fetch(2) } println("end of block")}after async fetch 1 starts user1 end of block fetch 2 starts
use std::future::Future;use std::pin::pin;use std::task::{Context, Poll, Waker};
async fn fetch(id: u32) -> String { println!("fetch {id} starts"); format!("user{id}")}
// std has the Future trait but no runtime; this is the smallest executor.fn block_on<F: Future>(fut: F) -> F::Output { let mut fut = pin!(fut); let mut cx = Context::from_waker(Waker::noop()); // these futures never wait, so the first poll returns Ready loop { if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) { return v; } }}
fn main() { let first = fetch(1); println!("after calling fetch"); println!("{}", block_on(first)); let _second = fetch(2); println!("end of main");}after calling fetch fetch 1 starts user1 end of main
Inside runBlocking, async schedules the coroutine by default (CoroutineStart.LAZY changes that; not run here), and it ran even though fetch(2) was never awaited. In Rust, calling fetch(1) ran nothing until block_on polled it, and fetch(2) never printed. The standard library has the Future trait but no runtime, so the program writes the smallest executor (The standard library has no runtime); real programs use Tokio. suspend is a function colour and .await marks each wait (function colour). Cancelling is dropping the future (Dropping a future cancels it). kotlinx.coroutines comes from the jar in the compiler’s lib directory.
Idiom by idiom
Section titled “Idiom by idiom”The same 19 lines in each language, from idioms.kt and src/main.rs. The session compiles both, diffs their output, and prints it.
data class Pt(val x: Int, val y: Int) { companion object { fun origin() = Pt(0, 0) }}
sealed class Shapeclass Circle(val r: Double) : Shape()object Dot : Shape()
val answer by lazy { 6 * 7 }
fun f(s: String): Int = s.toInt()
fun main() { val xs = listOf(3, 1, 2) println("2 ${xs.map { it * 2 }}") println("3 ${xs.filter { it > 1 }.sum()}") val sb = StringBuilder() for ((i, x) in xs.withIndex()) sb.append("$i:$x ") println("4 ${sb.toString().trim()}") val name = "ana" val age = 30 val s = "$name is $age" println("5 $s") println("6 ${"x".toIntOrNull() ?: 0}") val m = mutableMapOf<String, MutableList<Int>>() m.getOrPut("a") { mutableListOf() }.add(1) println("7 ${m["a"]}") val pair = Pair(1, "one") val (a, b) = pair println("8 $a $b") val o: Shape = Circle(2.0) println("9 ${if (o is Circle) o.r else 0.0}") val x: Int? = 4 println("10 ${x?.let { it + 1 }}") println("11 ${xs.firstOrNull { it > 1 }}") val ys = mutableListOf("bb", "a", "ccc") ys.sortBy { it.length } println("12 ${ys.joinToString(" ")}") val p = Pt(1, 2) val q = p.copy(x = 5) println("13 ${q.x},${q.y}") println("14 ${Pt.origin().x},${Pt.origin().y}") println("15 $answer") val n = try { f("x") } catch (e: NumberFormatException) { 0 } println("16 $n") val words = listOf("a", "b", "c") println("17 ${words.joinToString(", ")}") val r1 = StringBuilder() for (i in 1 until 3) r1.append(i) val r2 = StringBuilder() for (i in 1..3) r2.append(i) println("18 $r1 $r2") val k = 2 val size = when (k) { 1, 2 -> "low"; else -> "high" } println("19 $size") println("22 ${"kotlin".substring(1, 3)}")}use std::collections::HashMap;use std::fmt::Display;use std::num::ParseIntError;use std::sync::LazyLock;
#[derive(Debug, Clone, Copy)]struct Pt { x: i32, y: i32,}
impl Pt { fn origin() -> Pt { Pt { x: 0, y: 0 } }}
enum Shape { Circle(f64), Dot,}
static ANSWER: LazyLock<i32> = LazyLock::new(|| 6 * 7);
fn f(s: &str) -> Result<i32, ParseIntError> { s.parse()}
// Prints a missing value the way Kotlin does, so the two programs print the same lines.fn show<T: Display>(o: Option<T>) -> String { match o { Some(v) => v.to_string(), None => "null".to_string(), }}
fn main() { let xs = [3, 1, 2]; println!("2 {:?}", xs.iter().map(|x| x * 2).collect::<Vec<_>>()); println!("3 {}", xs.iter().filter(|&&x| x > 1).sum::<i32>()); let mut sb = String::new(); for (i, x) in xs.iter().enumerate() { sb.push_str(&format!("{i}:{x} ")); } println!("4 {}", sb.trim()); let name = "ana"; let age = 30; let s = format!("{name} is {age}"); println!("5 {s}"); println!("6 {}", "x".parse::<i32>().unwrap_or(0)); let mut m: HashMap<&str, Vec<i32>> = HashMap::new(); m.entry("a").or_default().push(1); println!("7 {:?}", m["a"]); let pair = (1, "one"); let (a, b) = pair; println!("8 {a} {b}"); let o = Shape::Circle(2.0); let r = if let Shape::Circle(r) = o { r } else { 0.0 }; println!("9 {r:?}"); let _dot = Shape::Dot; let x: Option<i32> = Some(4); println!("10 {}", show(x.map(|v| v + 1))); println!("11 {}", show(xs.iter().find(|&&x| x > 1))); let mut ys = ["bb", "a", "ccc"]; ys.sort_by_key(|s| s.len()); println!("12 {}", ys.join(" ")); let p = Pt { x: 1, y: 2 }; let q = Pt { x: 5, ..p }; println!("13 {},{}", q.x, q.y); println!("14 {},{}", Pt::origin().x, Pt::origin().y); println!("15 {}", *ANSWER); let n = f("x").unwrap_or(0); println!("16 {n}"); let words = ["a", "b", "c"]; println!("17 {}", words.join(", ")); let mut r1 = String::new(); for i in 1..3 { r1.push_str(&i.to_string()); } let mut r2 = String::new(); for i in 1..=3 { r2.push_str(&i.to_string()); } println!("18 {r1} {r2}"); let k = 2; let size = match k { 1 | 2 => "low", _ => "high", }; println!("19 {size}"); println!("22 {}", &"kotlin"[1..3]);}
#[cfg(test)]mod tests { use super::*;
#[test] fn answer_is_forty_two() { assert_eq!(*ANSWER, 42); }
#[test] fn missing_prints_null() { assert_eq!(show::<i32>(None), "null"); }}the two programs print the same 19 lines$ cat rs.txt
2 [6, 2, 4] 3 5 4 0:3 1:1 2:2 5 ana is 30 6 0 7 [1] 8 1 one 9 2.0 10 5 11 3 12 a bb ccc 13 5,2 14 0,0 15 42 16 0 17 a, b, c 18 12 123 19 low 22 ot
| You write in Kotlin | In Rust |
|---|---|
xs.map { it * 2 } |
xs.iter().map(|x| x * 2).collect::<Vec<_>>() |
xs.filter { it > 1 }.sum() |
xs.iter().filter(|&&x| x > 1).sum::<i32>() |
for ((i, x) in xs.withIndex()) |
for (i, x) in xs.iter().enumerate() |
"$name is $age" |
format!("{name} is {age}") |
"x".toIntOrNull() ?: 0 |
"x".parse::<i32>().unwrap_or(0) |
m.getOrPut("a") { mutableListOf() }.add(1) |
m.entry("a").or_default().push(1) |
val (a, b) = pair |
let (a, b) = pair; |
if (o is Circle) o.r else 0.0 |
if let Shape::Circle(r) = o { r } else { 0.0 } |
x?.let { it + 1 } |
x.map(|v| v + 1) |
xs.firstOrNull { it > 1 } |
xs.iter().find(|&&x| x > 1) |
ys.sortBy { it.length } |
ys.sort_by_key(|s| s.len()) |
p.copy(x = 5) |
Pt { x: 5, ..p } |
companion object { fun origin() = Pt(0, 0) } |
impl Pt { fn origin() -> Pt { Pt { x: 0, y: 0 } } } |
val answer by lazy { 6 * 7 } |
static ANSWER: LazyLock<i32> = LazyLock::new(|| 6 * 7); |
try { f("x") } catch (e: NumberFormatException) { 0 } |
f("x").unwrap_or(0) |
words.joinToString(", ") |
words.join(", ") |
1 until 3, 1..3 |
1..3, 1..=3 |
when (k) { 1, 2 -> "low"; else -> "high" } |
match k { 1 | 2 => "low", _ => "high" } |
"kotlin".substring(1, 3) |
&"kotlin"[1..3] |
Three rows need care. 1..3 includes 3 in Kotlin and stops before 3 in Rust, so the spellings look alike and mean different things. In Kotlin f("x") throws, and the Rust f returns a Result that unwrap_or unpacks. The Rust [1..3] counts bytes, where substring counts UTF-16 units, so they agree on ASCII text; for other text the numbers count different things, and a Rust range that ends inside a character panics (Strings). The Rust program uses a small helper, show, to print a missing value as null, so that the two outputs can be compared. It uses arrays for fixed lists; use vec! for a list that grows.
Toolchain map
Section titled “Toolchain map”| Job | Kotlin | Rust |
|---|---|---|
| New project | IntelliJ wizard, gradle init (not run: no Gradle in the image) |
cargo new demo (run) |
| Dependencies | dependencies { } in Gradle (not run) |
[dependencies] in Cargo.toml, or cargo add (needs network: not run). Using crates |
| Build and run | kotlinc x.kt -include-runtime -d x.jar, java -jar x.jar (run) |
cargo run (run) |
| Test | kotlin.test with JUnit, through Gradle (not run) |
cargo test (run) |
| Format | ktlint or ktfmt (not in the image, not run) | cargo fmt (run) |
| Lint | detekt (not run) | cargo clippy (run) |
| Docs | KDoc with Dokka (not run) | /// comments with cargo doc (run) |
| Debugger | IntelliJ, or jdb (found in the image, not run) |
rust-gdb, rust-lldb (wrappers found, but the image printed no gdb or lldb: not run) |
$ cargo --version && rustc --version cargo 1.99.1 (5f94df478 2026-08-27) rustc 1.99.1 (58cf8f9c3 2026-10-13) $ kotlinc -version info: kotlinc-jvm 2.4.21 (JRE 25.0.5+8-LTS) $ java -version openjdk version "25.0.5" 2026-10-20 LTS OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS) OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing) $ command -v gradle ktlint detekt jdb gdb lldb rust-gdb rust-lldb || true /opt/jdk/bin/jdb /opt/cargo/bin/rust-gdb /opt/cargo/bin/rust-lldb $ cargo new demo Creating binary (application) `demo` package note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html $ cat demo/Cargo.toml [package] name = "demo" version = "0.1.0" edition = "2024" [dependencies] $ cargo fmt --check && echo formatted formatted $ cargo clippy $ cargo test running 2 tests test tests::answer_is_forty_two ... ok test tests::missing_prints_null ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s $ cargo doc --no-deps Documenting idioms v0.1.0 (/home/you/kotlin-habits) Generated target/doc/idioms/index.html
This output changes from run to run. This is one real run.
The command -v line prints only the tools it found. @Test is #[test], and the test ran above.
A reading path
Section titled “A reading path”- Rust at a glance: the syntax you will meet, mapped to Kotlin.
- What Rust lacks:
++,?., default arguments,openclasses and exceptions, and what replaces each. - Stack and heap and Copy or reference: which assignments share (habits 3 and 4).
- Ownership: moves, which have no Kotlin counterpart.
- Borrowing and Mutable borrows: the read-only view you know, enforced.
- Option then Errors: the two types you will write most.
- Enums and Patterns: sealed classes and destructuring, with the compiler checking the cases.
- Traits and Generics: interfaces, extensions and
reified. - Closures and Iterators: lambdas and sequences.
- Box, Rc and RefCell and Working with the borrow checker: sharing when you want it, and what to do when the compiler says no.
- Threads, Shared state and Send and Sync.
- Async basics, then Tokio.
- Compiler errors: an index to look an error code up.
What you will miss
Section titled “What you will miss”- Default and named arguments, overloads. The substitutes are in habit 9; What Rust lacks runs the errors.
- Expressions in string templates.
"${a + b}"has no Rust twin, because{ }takes a name, not an expression:
fn main() { let a = 2; let b = 3; println!("{a+b}");}error: invalid format string: the `+` sign flag must appear after `:` in a format string --> src/bin/template.rs:4:17 | 4 | println!("{a+b}"); | ^ expected `:` before `+` sign flag in format string | = note: `+` comes after `:`, try `{:+}` instead of `{+}`
- Run-time reflection. Kotlin lists a class’s properties while the program runs (with
kotlin-reflect.jar, which ships in the compiler’slibdirectory). Thestd::anydocs describe type tests and downcasts, not a list of fields. Code that needs the field names, like serde, gets them from aderivethat writes code at compile time.
import kotlin.reflect.full.memberProperties
data class Pt(val x: Int, val y: Int)
fun main() { println(Pt::class.memberProperties.map { it.name }.sorted())}[x, y]
- Inheritance. A type that reuses another holds it as a field (No inheritance).
- A collector for cycles. An
Rccycle leaks, andWeakis how you break it (Cycles andWeak<T>). - Coroutines in
std. Habit 10: you add a runtime crate.
Try it yourself
Section titled “Try it yourself”- In
src/bin/h6.rs, add aTriangle(f64, f64)variant and run it. The compiler points at thematchinareathat has no arm for it. Kotlin refuses awhenover a sealed class in the same way (Enums). - In
src/bin/h5.rs, change..a.clone()to..a.bnow takesa’stags, and the build fails whenais used afterwards. - In
src/bin/h10.rs, replacelet _second = fetch(2);withprintln!("{}", block_on(fetch(2)));. Nowfetch 2 startsprints, because something polled the future.
$ # add a Triangle(f64, f64) variant to the enum in src/bin/h6.rs $ cargo run --bin h6 error[E0004]: non-exhaustive patterns: `&Shape::Triangle(_, _)` not covered --> src/bin/h6.rs:8:11 | 8 | match s { | ^ pattern `&Shape::Triangle(_, _)` not covered | note: `Shape` defined here --> src/bin/h6.rs:1:6 | 1 | enum Shape { | ^^^^^ ... 4 | Triangle(f64, f64), | -------- not covered = note: the matched value is of type `&Shape` help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown | 10 ~ Shape::Rect(w, h) => w * h, 11 ~ &Shape::Triangle(_, _) => todo!(),
$ # in src/bin/h5.rs, change ..a.clone() to ..a $ cargo run --bin h5 error[E0382]: borrow of partially moved value: `a` --> src/bin/h5.rs:17:16 | 12 | let mut b = User { | _________________- 13 | | name: "bo".to_string(), 14 | | ..a 15 | | }; | |_____- value partially moved here 16 | b.tags.push("guest".to_string()); 17 | println!("{a:?}"); | ^ value borrowed here after partial move | = note: partial move occurs because `a.tags` has type `Vec<String>`, which does not implement the `Copy` trait
$ # in src/bin/h10.rs, replace let _second = fetch(2); with println!("{}", block_on(fetch(2))); $ cargo run --bin h10 after calling fetch fetch 1 starts user1 fetch 2 starts user2 end of main
Read more
Section titled “Read more”- Kotlin null safety, sealed classes, extensions, coroutines basics and scope functions: the Kotlin side of habits 1, 6, 7, 10 and 9.
- The Rust Book: Understanding ownership, Enums and pattern matching, Error handling.
- Rust by Example.
Learn Rust is an independent project, not affiliated with the Rust Project or the Rust Foundation. Code and text are under the MIT licence.Source on GitHub.