Skip to content
Learn Rust
Theme

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.

The Rust project
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
  • val, var, type inference. let and let mut are the closest spelling, with one difference in habit 4. Variables.
  • T? is Option<T>. Absence is in the type and the compiler makes you handle it (habit 1).
  • if, when and try are expressions. So are if, match and { } in Rust: Statements and expressions.
  • A sealed class with when is an enum with match. In both, the compiler names the case you forgot (habit 6).
  • Lambdas and map/filter pipelines are closures and iterators. Rust iterators are lazy like a Sequence, not like List.map, which builds a list each time: Closures, Iterators.

1. ?., ?: and !! are methods, and ? means something else

Section titled “1. ?., ?: and !! are methods, and ? means something else”
Kotlinh1.kt
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}")
}
$ kotlinc h1.kt -include-runtime -d h1.jar && java -jar h1.jar
4
null
null
-1
null null
true false
Rustsrc/bin/h1.rs
use std::collections::HashMap;
struct Address {
city: Option<String>,
}
struct User {
address: Option<Address>,
}
// Kotlin: u?.address?.city?.length
fn 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"));
}
$ cargo run --bin h1
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.

Kotlinh2.kt
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)
}
$ kotlinc h2.kt -include-runtime -d h2.jar && java -jar h2.jar
9
caught: For input string: "x"
true
Rustsrc/bin/h2.rs
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());
}
$ cargo run --bin h2
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:

Rusth2.rs
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);
}
$ cargo run --bin h2
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”
Kotlinh3.kt
fun main() {
val a = mutableListOf(1, 2, 3)
val b = a
b.add(4)
println(a)
println(a === b)
}
$ kotlinc h3.kt -include-runtime -d h3.jar && java -jar h3.jar
[1, 2, 3, 4]
true
Rusth3.rs
fn main() {
let a = vec![1, 2, 3];
let b = a;
println!("{:?}", b);
println!("{:?}", a);
}
$ cargo run --bin h3
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.

Rustsrc/bin/h3.rs
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");
}
$ cargo run --bin h3
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”
Kotlinh4.kt
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)
}
$ kotlinc h4.kt -include-runtime -d h4.jar && java -jar h4.jar
[1, 2, 3, 4] 10
[1, 2, 3, 4, 5]
Rusth4.rs
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.

$ cargo run --bin h4
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:

Rustsrc/bin/h4.rs
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);
}
$ cargo run --bin h4
[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 ..”
Kotlinh5.kt
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")))
}
$ kotlinc h5.kt -include-runtime -d h5.jar && java -jar h5.jar
User(name=ana, tags=[admin, guest])
User(name=bo, tags=[admin, guest])
true
Rustsrc/bin/h5.rs
#[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);
}
$ cargo run --bin h5
User { name: "ana", tags: ["admin"] }
User { name: "bo", tags: ["admin", "guest"] }
false

copy 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”
Kotlinh6.kt
sealed class Shape
class 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)))
}
$ kotlinc h6.kt -include-runtime -d h6.jar && java -jar h6.jar
6.0
3.0
Rustsrc/bin/h6.rs
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)));
}
$ cargo run --bin h6
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:

Rusth6.rs
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)));
}
$ cargo run --bin h6
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”
Kotlinh7.kt
fun Int.speak(): String = "number $this"
fun String.shout(): String = uppercase() + "!"
fun main() {
println(21.speak())
println("hi".shout())
}
$ kotlinc h7.kt -include-runtime -d h7.jar && java -jar h7.jar
number 21
HI!
Kotlinh7_fail.kt
interface Speaker {
fun speak(): String
}
fun Int.speak(): String = "number $this"
fun announce(s: Speaker) = println(s.speak())
fun main() {
announce(21)
}
$ kotlinc h7_fail.kt -include-runtime -d h7_fail.jar
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:

Rustsrc/bin/h7.rs
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);
}
$ cargo run --bin h7
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”
Kotlinh8.kt
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))
}
$ kotlinc h8.kt -include-runtime -d h8.jar && java -jar h8.jar
x=2 y=1
long text 5
point at 5
other
Rustsrc/bin/h8.rs
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));
}
$ cargo run --bin h8
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”
Kotlinh9.kt
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)
}
$ kotlinc h9.kt -include-runtime -d h9.jar && java -jar h9.jar
localhost:9000 tls=false
{a=1}
42
Rustsrc/bin/h9.rs
use 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:?}");
}
$ cargo run --bin h9
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”
Kotlinh10.kt
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")
}
$ kotlinc -cp kotlinx-coroutines-core-jvm.jar h10.kt -include-runtime -d h10.jar && java -cp h10.jar:kotlinx-coroutines-core-jvm.jar H10Kt # kotlinx.coroutines
after async
fetch 1 starts
user1
end of block
fetch 2 starts
Rustsrc/bin/h10.rs
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");
}
$ cargo run --bin h10
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.

The same 19 lines in each language, from idioms.kt and src/main.rs. The session compiles both, diffs their output, and prints it.

Kotlinidioms.kt
data class Pt(val x: Int, val y: Int) {
companion object {
fun origin() = Pt(0, 0)
}
}
sealed class Shape
class 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)}")
}
Rustsrc/main.rs
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");
}
}
$ kotlinc idioms.kt -include-runtime -d idioms.jar && java -jar idioms.jar > kt.txt
$ cargo run -q > rs.txt
$ diff kt.txt rs.txt && echo "the two programs print the same $(wc -l < rs.txt) lines"
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.

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)
the tools that ran
$ 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.

  1. Rust at a glance: the syntax you will meet, mapped to Kotlin.
  2. What Rust lacks: ++, ?., default arguments, open classes and exceptions, and what replaces each.
  3. Stack and heap and Copy or reference: which assignments share (habits 3 and 4).
  4. Ownership: moves, which have no Kotlin counterpart.
  5. Borrowing and Mutable borrows: the read-only view you know, enforced.
  6. Option then Errors: the two types you will write most.
  7. Enums and Patterns: sealed classes and destructuring, with the compiler checking the cases.
  8. Traits and Generics: interfaces, extensions and reified.
  9. Closures and Iterators: lambdas and sequences.
  10. Box, Rc and RefCell and Working with the borrow checker: sharing when you want it, and what to do when the compiler says no.
  11. Threads, Shared state and Send and Sync.
  12. Async basics, then Tokio.
  13. Compiler errors: an index to look an error code up.
  • 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:
Rusttemplate.rs
fn main() {
let a = 2;
let b = 3;
println!("{a+b}");
}
$ cargo run --bin template
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’s lib directory). The std::any docs describe type tests and downcasts, not a list of fields. Code that needs the field names, like serde, gets them from a derive that writes code at compile time.
Kotlinreflect.kt
import kotlin.reflect.full.memberProperties
data class Pt(val x: Int, val y: Int)
fun main() {
println(Pt::class.memberProperties.map { it.name }.sorted())
}
$ kotlinc -cp kotlin-reflect.jar reflect.kt -include-runtime -d reflect.jar && java -cp reflect.jar:kotlin-reflect.jar ReflectKt
[x, y]
  • Inheritance. A type that reuses another holds it as a field (No inheritance).
  • A collector for cycles. An Rc cycle leaks, and Weak is how you break it (Cycles and Weak<T>).
  • Coroutines in std. Habit 10: you add a runtime crate.
  1. In src/bin/h6.rs, add a Triangle(f64, f64) variant and run it. The compiler points at the match in area that has no arm for it. Kotlin refuses a when over a sealed class in the same way (Enums).
  2. In src/bin/h5.rs, change ..a.clone() to ..a. b now takes a’s tags, and the build fails when a is used afterwards.
  3. In src/bin/h10.rs, replace let _second = fetch(2); with println!("{}", block_on(fetch(2)));. Now fetch 2 starts prints, because something polled the future.
task 1
$ # 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!(),
task 2
$ # 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
task 3
$ # 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