Перейти к содержимому

Rust для разработчиков на Java: практическое руководство

Если ты много лет пишешь на Java, у тебя уже есть почти всё, что нужно, чтобы выучить Rust. Ты мыслишь типами. Ты знаешь обобщения, интерфейсы, лямбды и конвейеры стримов. Всё это есть и в Rust: трейты очень похожи на интерфейсы с методами по умолчанию, итераторы читаются как стримы, а Cargo делает работу Maven или Gradle, только с гораздо меньшим количеством настроек.

Новое — всё то, что раньше за тебя делала JVM. В Rust нет сборщика мусора и нет виртуальной машины. Код компилируется в нативный исполняемый файл, а компилятор решает, когда освобождать память, проверяя владение (ownership) и заимствование (borrowing) до запуска программы. Ещё в Rust нет null (вместо него Option), нет исключений (вместо них Result) и нет наследования классов. Именно на эти четыре изменения уйдёт большая часть времени на обучение.

Зачем разработчики на Java берутся за Rust? Обычно по одной из этих причин: сервисы, которым нужно низкое и стабильное потребление памяти без пауз GC; консольные утилиты и serverless-функции, которые должны запускаться мгновенно, без JVM; горячий участок Java-приложения, который хочется перенести в нативный код (Rust можно вызывать из Java через JNI или более новый Foreign Function & Memory API); или просто любопытство: почему Rust год за годом возглавляет опросы «самый любимый язык». Эта страница сопоставляет то, что ты знаешь по Java, с Rust, чтобы ты мог сосредоточиться на том, что действительно отличается.

Если тебе нужно общее сравнение с C++, Go и Python, загляни в урок Rust против C++, Go и Python. Если ты знаешь ещё и .NET, есть отдельная страница: Rust для разработчиков на C#.

Пользуйся этой таблицей как разговорником. В последнем столбце — ссылка на урок этого сайта, который объясняет сторону Rust.

Java Rust Подробнее
class с полями и методами struct для данных + блок impl для методов Структуры
Конструкторы и new Обычная функция, по соглашению называемая new, которая возвращает Self Структуры
interface trait Трейты
Методы default в интерфейсе Методы по умолчанию в трейте (работают так же) Трейты
abstract class, extends, super Наследования нет. Для общего поведения используй трейты с методами по умолчанию, для общих данных — композицию (структура внутри структуры) Трейты, Паттерны проектирования
List<Shape> из разных классов Vec<Box<dyn Shape>> (трейт-объект) Трейт-объекты
sealed interface + record + switch с шаблонами (Java 17/21) enum, у которого варианты несут данные, плюс match Перечисления, match
enum с полями и конструкторами enum, у которого каждый вариант может хранить разные данные Перечисления
record Point(int x, int y) #[derive(Debug, Clone, PartialEq)] struct Point { x: i32, y: i32 } Структуры, Популярные трейты
toString(), equals(), hashCode(), compareTo() Display / Debug, PartialEq / Eq, Hash, PartialOrd / Ord (обычно через derive) Популярные трейты
== (тот же объект) и .equals() (то же значение) == всегда сравнивает значения. Ловушки «сравнения ссылок» нет Операторы
null и Optional<T> Option<T>: либо Some(value), либо None Option
orElse, orElseGet, orElseThrow, map, flatMap, ifPresent unwrap_or, unwrap_or_else, ok_or / expect, map, and_then, if let Some(...) Option, if let и while let
Проверяемые исключения и throws Result<T, E> в типе возвращаемого значения плюс оператор ? Result, Свои типы ошибок
Непроверяемые исключения для настоящих ошибок в коде (IllegalStateException) panic! (останавливает поток) Паника
Цепочки исключений (new ConfigException(msg, cause)) map_err или #[source] из thiserror и .context(...) из anyhow anyhow и thiserror
try-with-resources и AutoCloseable Drop, который срабатывает автоматически, когда владелец выходит из области видимости Область видимости, Умные указатели
Сборщик мусора Владение и заимствование, проверяемые при компиляции Владение, Заимствование
Локальные переменные final Обычный let (по умолчанию неизменяемый); пиши let mut, чтобы разрешить изменения Переменные
Константы static final const Переменные
Изменяемые поля static, синглтоны static с LazyLock или OnceLock, обёрнутый в Mutex, если его нужно менять Общее состояние
Обобщения с <T extends Comparable<T>> Обобщения с ограничениями трейтов: T: Ord или where T: Ord Обобщения
Стирание типов, List<Integer> вместо List<int> Мономорфизация: обобщённый код компилируется для каждого типа, а Vec<i32> хранит обычные числа Обобщения, Продвинутые обобщения
Лямбды, Function<T, R>, Supplier<T>, Consumer<T> Замыкания, описываемые трейтами Fn, FnMut, FnOnce Замыкания, Fn, FnMut и FnOnce
Стримы: filter, map, reduce, collect Адаптеры итераторов: filter, map, fold, sum, collect Итераторы
Iterable<T> и Iterator<T> IntoIterator и Iterator (один метод: next, который возвращает Option) Свои итераторы
parallelStream() par_iter() из крейта rayon Многопоточность
Thread, ExecutorService std::thread::spawn и std::thread::scope; rayon или tokio для пулов Многопоточность
synchronized, ReentrantLock Mutex<T>, который оборачивает данные, которые защищает Общее состояние
AtomicInteger, AtomicLong AtomicI32, AtomicI64, AtomicUsize Общее состояние
BlockingQueue между потоками Каналы (std::sync::mpsc) Каналы
CompletableFuture, виртуальные потоки async fn и .await, которые запускает среда выполнения вроде tokio Async/Await
String (неизменяемая) и StringBuilder &str (заимствованный взгляд на текст) и String (владеющая, растущая строка) Строки
char (единица UTF-16) char (любой символ Unicode, 4 байта); строки хранятся в UTF-8 Строки
int, long, double и упакованный Integer i32, i64, f64, плюс беззнаковые от u8 до u128. Никогда не упаковываются Типы данных
Тихое переполнение целых чисел Паника в отладочных сборках; checked_add, wrapping_add, saturating_add — выбирай сам Переполнение целых чисел
Приведения вроде (int) x as для чисел, From / TryFrom для безопасных преобразований Приведение типов, Преобразования
BigDecimal, BigInteger Крейты rust_decimal и num-bigint Типы данных
Пакеты и import Модули (mod) и use Модули
public, private, protected, доступ на уровне пакета По умолчанию всё приватно; pub и pub(crate). protected нет Модули
JAR / артефакт Maven Крейт (библиотека или исполняемый файл) Cargo и крейты
Аннотации (@Override, @Data из Lombok) Атрибуты вроде #[derive(Debug)] и #[test] Макросы
Рефлексия, обработка аннотаций во время работы Во время работы почти нет. Вместо этого макросы генерируют код при компиляции Процедурные макросы
Перегрузка методов Перегрузки нет. Используй разные имена или обобщённый параметр Функции
Проверки instanceof и приведение вниз match по перечислению (приведение вниз через Any существует, но нужно редко) Сопоставление с образцом
JNI, Foreign Function & Memory API Функции extern "C" FFI: вызов C

Java Collections Framework аккуратно ложится на std::collections. Главное отличие: коллекции Rust никогда не возвращают null. Поиск даёт тебе Option.

Java Rust Подробнее
ArrayList<T> Vec<T> Векторы
int[], String[] [T; N], если длина известна при компиляции, иначе Vec<T>. Передавать и то и другое можно как срез &[T] Массивы, Срезы
List.of(...) (неизменяемый) Массив или Vec, который ты отдаёшь только как &Vec<T> / &[T] (через общую ссылку его никто не изменит) Заимствование
HashMap<K, V> HashMap<K, V> (get возвращает Option<&V>) HashMap
map.merge(word, 1, Integer::sum) *map.entry(word).or_insert(0) += 1 HashMap
TreeMap<K, V> BTreeMap<K, V> (ключи остаются отсортированными) HashMap
LinkedHashMap<K, V> (порядок вставки) IndexMap из крейта indexmap
HashSet<T>, TreeSet<T> HashSet<T>, BTreeSet<T> HashSet
ArrayDeque<T> VecDeque<T> VecDeque и BinaryHeap
Stack<T> или Deque в роли стека Vec<T> с push / pop Векторы
PriorityQueue<T> (сначала наименьший) BinaryHeap<T> (сначала наибольший; оберни элементы в Reverse, чтобы сначала был наименьший) VecDeque и BinaryHeap
LinkedList<T> LinkedList<T> есть, но Vec или VecDeque почти всегда быстрее VecDeque и BinaryHeap
ConcurrentHashMap<K, V> Mutex<HashMap<K, V>> или крейт dashmap Общее состояние
list.sort(Comparator.comparing(...)) v.sort_by_key(...) или v.sort_by(...) Сортировка и поиск
list.removeIf(...) v.retain(...) (оставляет подходящие элементы) Векторы

Как и у HashMap в Java, у HashMap в Rust нет фиксированного порядка, и он может меняться от запуска к запуску. Если нужен отсортированный вывод, используй BTreeMap или сначала отсортируй ключи.

Большинство разработчиков на Java быстро влюбляются в Cargo. Один инструмент, один формат конфигурации и никакого XML для плагинов. Сборка, зависимости, тесты, документация, форматирование и проверки — всё из одного места.

Java Rust Примечания
Maven / Gradle cargo Собирает, запускает, тестирует, добавляет зависимости, публикует. См. Cargo и крейты
pom.xml / build.gradle.kts Cargo.toml Короткий, и для обычных проектов скрипты сборки не нужны
Блокировка зависимостей в Gradle Cargo.lock Создаётся и обновляется автоматически
Многомодульная сборка Рабочее пространство Cargo (workspace) Один Cargo.toml наверху перечисляет крейты-участники
Maven Central crates.io Документация по API каждого опубликованного крейта лежит на docs.rs
Добавление <dependency> cargo add serde
mvn archetype:generate / gradle init cargo new my_app (cargo new --lib для библиотеки)
mvn compile / mvn test / mvn package cargo build / cargo test / cargo build --release Отладочные сборки работают намного медленнее; замеряй производительность в release
java -jar app.jar ./target/release/app Один нативный исполняемый файл. Ставить JVM на сервер не нужно
Версии JDK, SDKMAN!, toolchains rustup и файл rust-toolchain.toml См. Инструменты и установка
javac rustc Cargo вызывает его за тебя; сам ты запускаешь его редко
JShell Rust Playground в браузере или evcxr_repl
Checkstyle, SpotBugs, PMD, Error Prone cargo clippy Сотни официальных проверок
google-java-format, Spotless cargo fmt Один стандартный стиль; никто не спорит о скобках
IntelliJ IDEA, Eclipse JetBrains RustRover или VS Code + rust-analyzer rust-analyzer даёт автодополнение, переход к определению и ошибки прямо в коде
Javadoc Doc-комментарии /// в Markdown + cargo doc Примеры кода в документации запускаются как тесты. См. Документация
JUnit 5 Встроенные #[test] и cargo test (cargo nextest — популярный более быстрый раннер) См. Тестирование
@ParameterizedTest rstest
Mockito mockall или написанные вручную фейки, реализующие твой трейт См. Тестирование: дополнительно
AssertJ / Hamcrest assert!, assert_eq! и pretty_assertions для читаемых диффов
WireMock, Testcontainers wiremock, testcontainers Те же идеи, версии для Rust
JaCoCo cargo llvm-cov или cargo tarpaulin
JMH criterion См. Производительность
JFR, async-profiler, VisualVM cargo flamegraph (ставится через cargo install flamegraph), perf Нет ни GC, ни JIT, которые нужно настраивать
GraalVM native-image Не нужен Любая сборка Rust и так нативный код

Вот небольшой Cargo.toml. Сравни его с pom.xml, который понадобился бы для тех же трёх зависимостей:

[package]
name = "orders-api"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }

Библиотеки и фреймворки: «Что использовать вместо…?»

Заголовок раздела «Библиотеки и фреймворки: «Что использовать вместо…?»»

Это первый вопрос, который задаёт любая команда на Java: «Мы сидим на Spring Boot и Hibernate. А что есть в Rust?» В Rust нет Spring — нет одного фреймворка, который приносит всё сразу. Вместо этого ты собираешь вместе несколько узкоспециализированных крейтов. Вот ближайшие соответствия.

Java Rust Примечания
Spring Boot (Spring MVC / WebFlux) axum (мы делаем такой в проекте веб-API), actix-web, Rocket axum и actix-web широко используются в продакшене
@RestController + @GetMapping Маршруты axum, указывающие на обычные обработчики async fn Никаких аннотаций и сканирования classpath; маршруты подключаются в коде
Фильтры сервлетов, перехватчики Spring Слои tower и крейт tower-http (CORS, сжатие, трассировка, таймауты) axum использует tower напрямую
Внедрение зависимостей Spring (@Autowired) Общий State в axum, обычные конструкторы и трейты См. строку про DI ниже
springdoc-openapi utoipa
Thymeleaf askama (шаблоны проверяются при компиляции) или tera
Spring WebSocket WebSocket в axum (фича ws) или tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic (с prost для protobuf)
java.net.http.HttpClient, OkHttp, WebClient reqwest По умолчанию асинхронный, есть необязательный блокирующий клиент
Netty tokio + hyper
Spring Security (JWT, хеширование паролей) jsonwebtoken, argon2 Фреймворка безопасности «всё в одном» нет
Java Rust Примечания
Hibernate / JPA / Spring Data JPA SeaORM: асинхронный ORM с сущностями, связями и миграциями Ближе всего к JPA; построен поверх sqlx
Hibernate, если важна строгая типизация Diesel: ORM и построитель запросов; запросы проверяются на типы при компиляции Зрелый (2.x), по умолчанию синхронный (есть diesel-async)
JDBC, JdbcTemplate, jOOQ, MyBatis sqlx: обычный SQL, а макрос query! проверяет его на настоящей базе данных при компиляции Асинхронный, очень популярный, не ORM
Flyway / Liquibase sqlx migrate (sqlx-cli), diesel migration или refinery
Jedis / Lettuce (Redis) redis
Клиенты Kafka, Spring Kafka rdkafka (построен на librdkafka)
Java-клиент RabbitMQ lapin
AWS SDK for Java Официальный AWS SDK для Rust: aws-config, aws-sdk-s3, …

Если тебе нравятся сущности JPA, начни с SeaORM. Если предпочитаешь писать SQL (как любители jOOQ и MyBatis) — попробуй sqlx. Если нужны самые сильные гарантии при компиляции — Diesel. В уроке Базы данных все три показаны на работающем коде.

Java Rust Примечания
Jackson / Gson serde + serde_json Стандарт. Также умеет TOML, YAML и другое. См. JSON и Serde
JAXB, Jackson XML quick-xml (с фичей serialize для serde)
SLF4J + Logback / Log4j 2 tracing (+ tracing-subscriber) или log + env_logger См. Логирование
Lombok Встроенные derive (Debug, Clone, PartialEq, Default, …); bon или derive_builder для билдеров Никаких трюков с байт-кодом; это всё обычные макросы
MapStruct Реализации From / Into Пишутся вручную, проверяются компилятором. См. Преобразования
IoC-контейнер Spring Обычно ничего: передавай зависимости в конструкторы, используй трейты для того, что хочешь подменять в тестах Проекты на Rust редко используют DI-контейнеры
Bean Validation (@NotNull, @Size) validator или garde (валидация через derive) @NotNull не нужен: используй Option только там, где значения может не быть
Resilience4j backon или middleware повторов и таймаутов из tower
Caffeine (кеширование) moka
Guava, Apache Commons В основном стандартная библиотека плюс itertools
java.time chrono или jiff См. Дата и время
picocli clap См. Проект: CLI-утилита на clap
@Scheduled, Quartz tokio-cron-scheduler
Micrometer metrics или opentelemetry
Jakarta Mail lettre
Java Rust Примечания
JavaFX / Swing Tauri (веб-интерфейс + бэкенд на Rust), Slint, egui, Iced Самые стабильные — Tauri 2 и Slint 1.x. См. GUI-приложения
LibGDX, jMonkeyEngine Bevy, macroquad Bevy ещё до 1.0 и меняет API между релизами. См. Разработка игр
Код Android-приложения Оставь Kotlin или Java для интерфейса; общую логику вынеси в библиотеку на Rust, собранную через Android NDK uniffi от Mozilla генерирует привязки для Kotlin
Вызов нативного кода из Java Открой функции extern "C" и вызывай их через FFM API (JDK 22+) или пиши JNI-функции с крейтом jni См. FFI: вызов C

Бок о бок: абстрактный класс становится трейтом

Заголовок раздела «Бок о бок: абстрактный класс становится трейтом»

Вот классическая иерархия на Java: абстрактный базовый класс с одним абстрактным методом и одним общим.

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " получает " + monthlyPay() + " в месяц"; }
}
class Salaried extends Employee {
private final int yearly;
Salaried(String name, int yearly) { super(name); this.yearly = yearly; }
@Override int monthlyPay() { return yearly / 12; }
}
class Contractor extends Employee {
private final int hours, rate;
Contractor(String name, int hours, int rate) { super(name); this.hours = hours; this.rate = rate; }
@Override int monthlyPay() { return hours * rate; }
}
List<Employee> staff = List.of(new Salaried("Ada", 60000), new Contractor("Linus", 120, 50));
for (Employee e : staff) System.out.println(e.payslip());

В Rust нет extends. Общий метод переезжает в трейт (trait) как метод по умолчанию, а каждая структура хранит свои данные:

trait Employee {
fn name(&self) -> &str;
fn monthly_pay(&self) -> u32;
// Метод по умолчанию: как конкретный payslip() в абстрактном классе.
fn payslip(&self) -> String {
format!("{} получает {} в месяц", self.name(), self.monthly_pay())
}
}
struct Salaried {
name: String,
yearly: u32,
}
struct Contractor {
name: String,
hours: u32,
rate: u32,
}
impl Employee for Salaried {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.yearly / 12
}
}
impl Employee for Contractor {
fn name(&self) -> &str {
&self.name
}
fn monthly_pay(&self) -> u32 {
self.hours * self.rate
}
}
fn main() {
// Box<dyn Employee> играет роль «ссылки на какого-то Employee».
let staff: Vec<Box<dyn Employee>> = vec![
Box::new(Salaried { name: "Ada".to_string(), yearly: 60_000 }),
Box::new(Contractor { name: "Linus".to_string(), hours: 120, rate: 50 }),
];
for e in &staff {
println!("{}", e.payslip());
}
}

Вывод:

Ada получает 5000 в месяц
Linus получает 6000 в месяц
▶ Попробуй сам

Трейт не может хранить поля, поэтому protected name превращается в небольшой метод name(), который реализует каждая структура. Поначалу это кажется лишним набором текста, но так исчезает целый класс проблем: нет хрупких базовых классов, нет вызовов super, о которых можно забыть, и нет глубоких иерархий, которые приходится листать. Если ты знаешь методы default в интерфейсах из Java 8, ты уже знаешь, как Rust делится поведением. А когда набор типов фиксирован (как у sealed interface в Java 17), перечисление с match часто проще трейта.

Optional в Java был большим шагом вперёд, но используется он только для возвращаемых значений, а map.get() или любое поле всё ещё может оказаться null:

static Optional<String> findEmail(int userId) {
return userId == 1 ? Optional.of("[email protected]") : Optional.empty();
}
for (int id : new int[] {1, 2}) {
System.out.println(findEmail(id).map(String::toUpperCase).orElse("email не указан"));
}
findEmail(1).ifPresent(email -> System.out.println("отправляем на " + email));

У Option в Rust почти такой же API, но используется он везде, где значения может не быть, включая поиск в коллекциях. И никакого null за ним не прячется:

fn find_email(user_id: u32) -> Option<String> {
if user_id == 1 {
Some("[email protected]".to_string())
} else {
None
}
}
fn main() {
for id in [1, 2] {
// map + orElseGet
let shown = find_email(id)
.map(|email| email.to_uppercase())
.unwrap_or_else(|| "email не указан".to_string());
println!("{shown}");
}
// ifPresent
if let Some(email) = find_email(1) {
println!("отправляем на {email}");
}
// flatMap называется and_then
let domain = find_email(1).and_then(|email| email.split_once('@').map(|(_, d)| d.to_string()));
println!("{domain:?}");
}

Вывод:

email не указан
отправляем на [email protected]
Some("example.com")
▶ Попробуй сам

Компилятор не даст вызвать .to_uppercase() у Option<String>, пока ты не разобрался с None, поэтому NullPointerException просто не существует. К тому же Option ничего не стоит: Option<&T> занимает столько же места, сколько обычная ссылка. Вспомогательные методы описаны в уроке Option.

Бок о бок: проверяемые исключения становятся Result

Заголовок раздела «Бок о бок: проверяемые исключения становятся Result»

В основе проверяемых исключений лежала хорошая идея: сигнатура говорит, что может пойти не так. Вот типичная версия на Java:

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("не хватает " + needed); }
}
static int withdraw(int balance, int amount) throws InsufficientFundsException {
if (amount > balance) throw new InsufficientFundsException(amount - balance);
return balance - amount;
}
static int pay(int balance, int amount) throws InsufficientFundsException {
int left = withdraw(balance, amount);
System.out.println("оплачено " + amount + ", осталось " + left);
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("ошибка: " + e.getMessage()); }

Rust сохраняет эту хорошую идею и отбрасывает остальное. Ошибка — часть типа возвращаемого значения (Result<u32, BankError>), а ? делает то же, что throws: «если здесь произошла ошибка, передай её вызывающему». Ошибки обычно описывают через enum, так что один тип может перечислить все способы, которыми операция может завершиться неудачей:

use std::fmt;
#[derive(Debug)]
enum BankError {
InvalidAmount,
InsufficientFunds { needed: u32 },
}
// Display играет роль getMessage().
impl fmt::Display for BankError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BankError::InvalidAmount => write!(f, "сумма должна быть больше нуля"),
BankError::InsufficientFunds { needed } => write!(f, "не хватает {needed}"),
}
}
}
fn withdraw(balance: u32, amount: u32) -> Result<u32, BankError> {
if amount == 0 {
return Err(BankError::InvalidAmount);
}
if amount > balance {
return Err(BankError::InsufficientFunds { needed: amount - balance });
}
Ok(balance - amount)
}
fn pay(balance: u32, amount: u32) -> Result<u32, BankError> {
let left = withdraw(balance, amount)?; // как вызов метода, который «бросает»
println!("оплачено {amount}, осталось {left}");
Ok(left)
}
fn main() {
for (balance, amount) in [(100, 80), (50, 80), (100, 0)] {
if let Err(e) = pay(balance, amount) {
println!("ошибка: {e}");
}
}
}

Вывод:

оплачено 80, осталось 20
ошибка: не хватает 30
ошибка: сумма должна быть больше нуля
▶ Попробуй сам

Смотри, чего больше нет: блоков try, раскрутки стека, исключений, завёрнутых в RuntimeException только ради того, чтобы протащить их через лямбду. Ошибки — обычные значения, поэтому они спокойно работают внутри замыканий и цепочек итераторов. ? — видимая метка в каждом месте, где функция может вернуться раньше. Писать Display вручную быстро надоедает; крейт thiserror генерирует его за тебя, а anyhow даёт приложениям общий тип ошибки на все случаи (см. anyhow и thiserror). Для настоящих ошибок в коде, о которых ты сообщил бы непроверяемым исключением, в Rust есть panic!.

Конвейер стримов с коллектором groupingBy:

record Order(String customer, String city, int total) {}
List<Order> orders = List.of(
new Order("Ada", "London", 120), new Order("Grace", "New York", 80),
new Order("Linus", "Helsinki", 200), new Order("Ken", "New York", 150));
List<String> big = orders.stream()
.filter(o -> o.total() > 100)
.sorted(Comparator.comparingInt(Order::total).reversed())
.map(Order::customer)
.toList();
System.out.println(String.join(", ", big));
Map<String, Integer> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::total)));
byCity.forEach((city, sum) -> System.out.println(city + ": " + sum));

В Rust роль .stream() играет .iter(), а у адаптеров знакомые имена:

use std::cmp::Reverse;
use std::collections::BTreeMap;
struct Order {
customer: String,
city: String,
total: u32,
}
fn order(customer: &str, city: &str, total: u32) -> Order {
Order { customer: customer.to_string(), city: city.to_string(), total }
}
fn main() {
let orders = vec![
order("Ada", "London", 120),
order("Grace", "New York", 80),
order("Linus", "Helsinki", 200),
order("Ken", "New York", 150),
];
// filter + toList
let mut big: Vec<&Order> = orders.iter().filter(|o| o.total > 100).collect();
// sorted(...reversed()): сортировка делается у Vec, а не внутри итератора.
big.sort_by_key(|o| Reverse(o.total));
// map
let names: Vec<&str> = big.iter().map(|o| o.customer.as_str()).collect();
println!("{}", names.join(", "));
// groupingBy(city, TreeMap::new, summingInt(total))
let mut by_city: BTreeMap<&str, u32> = BTreeMap::new();
for o in &orders {
*by_city.entry(o.city.as_str()).or_insert(0) += o.total;
}
for (city, sum) in &by_city {
println!("{city}: {sum}");
}
}

Вывод:

Linus, Ken, Ada
Helsinki: 200
London: 120
New York: 230
▶ Попробуй сам

Названия городов оставлены на английском, потому что BTreeMap сортирует ключи по алфавиту, и с русскими названиями порядок в выводе был бы другим.

Как и стримы, итераторы в Rust ленивые и работают, только когда их что-то потребляет (collect, sum, цикл for). В отличие от стримов, класса Collectors нет: collect() смотрит на тип, который ты просишь (Vec, HashMap, String, …), и строит его. Для группировки обычный ответ — простой цикл с entry API; если тебе не хватает groupingBy, крейт itertools добавляет помощники. А цепочки итераторов компилируются в тот же машинный код, что и написанный вручную цикл, без всякой упаковки int по дороге. Подробнее — в уроках Итераторы и Сортировка и поиск.

Бок о бок: стирание типов против мономорфизации

Заголовок раздела «Бок о бок: стирание типов против мономорфизации»

Обобщения Java существуют только при компиляции. Во время работы T стирается, поэтому нельзя написать new T(), и нельзя положить int в List без упаковки:

static <T> List<T> filled(int n) {
List<T> list = new ArrayList<>();
for (int i = 0; i < n; i++) list.add(new T()); // не компилируется: T стёрт
return list;
}
List<int> ids; // это тоже не компилируется: нужен List<Integer>

Rust компилирует отдельную копию каждой обобщённой функции для каждого типа, с которым ты её используешь (это называется мономорфизацией). Поэтому функция точно знает, что такое T, и может вызвать T::default():

use std::mem::size_of;
// «У T должно быть значение по умолчанию, и T должен уметь клонироваться»
fn filled<T: Default + Clone>(n: usize) -> Vec<T> {
vec![T::default(); n]
}
fn main() {
let ids: Vec<i32> = filled(3);
let names: Vec<String> = filled(2);
let flags: Vec<bool> = filled(2);
println!("{ids:?} {names:?} {flags:?}");
// Никакой упаковки: Vec<i32> хранит обычные 4-байтовые числа подряд.
println!("i32 занимает {} байта", size_of::<i32>());
println!("bool занимает {} байт", size_of::<bool>());
}

Вывод:

[0, 0, 0] ["", ""] [false, false]
i32 занимает 4 байта
bool занимает 1 байт
▶ Попробуй сам

Цена такая: больше копий кода — значит, больше исполняемые файлы и дольше компиляция, зато каждый вызов прямой и может быть встроен. Когда тебе нужна одна общая копия (как в Java), используй трейт-объект вроде Box<dyn Trait>. Ограничения после двоеточия (T: Default + Clone) — это версия Rust для <T extends ...>. См. Обобщения и Продвинутые обобщения.

Четыре потока увеличивают общий счётчик. В Java блокировка живёт на объекте, и помнить про synchronized — твоя забота:

class Counter {
private int count = 0;
synchronized void increment() { count++; }
synchronized int get() { return count; }
}
Counter counter = new Counter();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 4; i++) {
Thread t = new Thread(() -> { for (int j = 0; j < 1000; j++) counter.increment(); });
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
System.out.println("счётчик = " + counter.get());

В Rust Mutex владеет числом. Добраться до него, не заблокировав мьютекс, невозможно. Arc (указатель с атомарным подсчётом ссылок) позволяет нескольким потокам делить один мьютекс:

use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = Vec::new();
for _ in 0..4 {
// Каждый поток получает свой дескриптор того же Mutex.
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
// lock() возвращает guard; блокировка снимается, когда guard удаляется.
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("счётчик = {}", *counter.lock().unwrap());
}

Вывод:

счётчик = 4000
▶ Попробуй сам

Вот что разработчики на Java особенно любят: если забыть synchronized в Java, код скомпилируется и время от времени будет выдавать неверные числа. В Rust, если попытаться поделить между потоками обычный i32 или Vec и менять его, программа не скомпилируется. Гонки данных ловит система типов (через трейты Send и Sync). Для одного счётчика AtomicUsize был бы легче мьютекса — так же, как AtomicInteger. См. Многопоточность и Общее состояние.

Каждый разработчик на Java натыкается на одни и те же кочки. Если знать их заранее, они становятся меньше.

  • Передача значения в функцию перемещает его. В Java при передаче объекта передаётся ссылка, и переменной можно пользоваться дальше. В Rust передача String или Vec по значению перемещает (move) его в функцию:

    fn greet(name: String) {
    println!("Hello, {name}!");
    }
    fn main() {
    let name = String::from("Ada");
    greet(name);
    greet(name);
    }
    error[E0382]: use of moved value: `name`
    --> src/main.rs:8:11
    |
    6 | let name = String::from("Ada");
    | ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
    7 | greet(name);
    | ---- value moved here
    8 | greet(name);
    | ^^^^ value used here after move

    Компилятор говорит, что name уже перемещено при первом вызове. Обычное исправление — дать функции заимствовать: fn greet(name: &str) и вызов greet(&name). Сначала прочитай урок Владение: он объясняет это правило, а E0382 подробно разбирает эту ошибку.

  • Анализатор заимствований. Одновременно может быть много читателей (&T) или один писатель (&mut T), но никогда и то и другое. Графы объектов, обычные для Java, — например, дочерний объект со ссылкой обратно на родителя или много объектов, которые держат и меняют один общий объект, — требуют умных указателей или другой архитектуры. Первые несколько недель могут раздражать. Потом действительно становится легче.

  • Нет наследования, нет protected, нет super. Будет соблазн воссоздать иерархии классов с помощью трейтов. Сначала попробуй перечисления и композицию: обычно они подходят лучше.

  • == сравнивает значения. На этот раз хорошая новость: a == b для двух String сравнивает текст, так что классическая ошибка == вместо .equals() невозможна. Для своих структур == работает только после того, как ты сделаешь derive или реализуешь PartialEq.

  • BinaryHeap выдаёт наибольший элемент. PriorityQueue в Java сначала выдаёт наименьший. В Rust оберни элементы в std::cmp::Reverse, чтобы получить поведение min-кучи. См. VecDeque и BinaryHeap.

  • Переполнение целых чисел не молчит. Integer.MAX_VALUE + 1 в Java тихо переходит через край. В отладочной сборке Rust это паника. Говори, что имеешь в виду, через checked_add, wrapping_add или saturating_add. См. Переполнение целых чисел.

  • String и &str, и нет charAt. Строки в Rust хранятся в UTF-8, поэтому s[0] не скомпилируется. Чтобы пройти по символам, используй s.chars(). Функции обычно принимают &str, структуры обычно хранят String. См. Строки.

  • Нет перегрузки методов. Нельзя иметь два метода print с разными параметрами. Используй разные имена (print, print_with_color) или обобщённый параметр.

  • Никакой магии во время работы. Нет сканирования classpath, нет @Autowired, нет прокси, которые оборачивают твои методы транзакциями. Всё связывается явно в коде, а макросы работают при компиляции. Печатать приходится больше, зато всегда видно, что происходит.

  • Нет null и нет исключений. Поначалу Option и Result повсюду кажутся лишней работой. Через несколько недель заметишь, что давно не видел ни NullPointerException, ни неожиданного стек-трейса. Вызывать .unwrap() во время обучения нормально, но в настоящем коде это версия Rust для «пусть падает».

  • Время компиляции. Чистая сборка скачивает и компилирует все зависимости из исходников, поэтому она медленнее javac. Инкрементальные сборки намного быстрее, а чаще всего во время редактирования ты будешь запускать cargo check (он пропускает генерацию кода). Хорошая новость с другой стороны: программа запускается мгновенно, без прогрева JVM.

  • Для async нужна среда выполнения. Виртуальных потоков нет. async fn ничего не делает, пока её не запустит исполнитель, поэтому веб-проекты добавляют tokio и пишут #[tokio::main] над main. См. Async/Await.

Этот сайт не обязательно читать по порядку. Этот маршрут пропускает то, чему тебя уже научила Java, и тратит время там, где Rust отличается:

  1. Первые шаги и Инструменты и установка: установи Rust, выбери редактор, запусти cargo new.
  2. Переменные и типы данных: пробеги глазами, в основном чтобы узнать let mut и числовые типы.
  3. Владение, затем заимствование, затем срезы: самые важные для тебя страницы. Не торопись.
  4. Строки: String и &str.
  5. Структуры и перечисления, затем match и сопоставление с образцом.
  6. Option и Result, затем свои типы ошибок и anyhow и thiserror.
  7. Трейты, обобщения, популярные трейты и трейт-объекты: твои интерфейсы и абстрактные классы.
  8. Замыкания и итераторы: твои лямбды и стримы.
  9. Векторы, HashMap и VecDeque и BinaryHeap.
  10. Модули, Cargo и крейты и тестирование.
  11. Времена жизни и умные указатели.
  12. Многопоточность, общее состояние, каналы и async/await.
  13. JSON и Serde, логирование и базы данных, а потом собери проект веб-API.
  14. Хочешь ускорить существующее Java-приложение? Прочитай урок FFI: вызов C, чтобы увидеть, как Rust открывает функции extern "C".

В обоих упражнениях дан код на Java. Перенеси его на Rust.

Этот код выводит студентов, которые сдали экзамен, есть ли у кого-то идеальный результат, и лучший балл. Подсказка: у итераторов есть any (как anyMatch) и max (который возвращает Option, как OptionalInt). Вместо Collectors.joining собери всё в Vec<&str> и вызови .join(", ").

record Student(String name, int score) {}
List<Student> students = List.of(
new Student("Ada", 91), new Student("Brian", 42),
new Student("Grace", 100), new Student("Ken", 67));
String passed = students.stream()
.filter(s -> s.score() >= 50)
.map(Student::name)
.collect(Collectors.joining(", "));
boolean perfect = students.stream().anyMatch(s -> s.score() == 100);
int best = students.stream().mapToInt(Student::score).max().orElse(0);
System.out.println("сдали: " + passed);
System.out.println("есть 100 баллов: " + perfect);
System.out.println("лучший балл: " + best);
Показать решение
#[derive(Debug, Clone, PartialEq)]
struct Student {
name: String,
score: u32,
}
fn student(name: &str, score: u32) -> Student {
Student { name: name.to_string(), score }
}
fn main() {
let students = vec![
student("Ada", 91),
student("Brian", 42),
student("Grace", 100),
student("Ken", 67),
];
let passed: Vec<&str> = students
.iter()
.filter(|s| s.score >= 50)
.map(|s| s.name.as_str())
.collect();
let perfect = students.iter().any(|s| s.score == 100);
let best = students.iter().map(|s| s.score).max().unwrap_or(0);
println!("сдали: {}", passed.join(", "));
println!("есть 100 баллов: {perfect}");
println!("лучший балл: {best}");
}

Вывод:

сдали: Ada, Grace, Ken
есть 100 баллов: true
лучший балл: 100
▶ Попробуй сам

Атрибуты derive дают то, что record в Java даёт бесплатно: отладочную строку ({:?}), копирование (.clone()) и сравнение по значению (==). Вспомогательная функция student играет роль конструктора record. В отличие от стрима в Java, students.iter() можно вызывать сколько угодно раз, потому что каждый вызов создаёт новый итератор по тому же Vec.

2. Перенеси проверяемое исключение с причиной

Заголовок раздела «2. Перенеси проверяемое исключение с причиной»

Этот метод читает строку port=<число> из файла конфигурации. Он бросает проверяемое ConfigException и оборачивает NumberFormatException, которое получает от Integer.parseInt. Перенеси его так, чтобы read_port возвращала Result<u16, String>. Подсказка: line.split_once('=') возвращает Option<(&str, &str)>; преврати его в Result с помощью .ok_or_else(...). .parse::<u16>() возвращает Result, а .map_err(...) заменяет его ошибку на твоё сообщение — точно так же, как обёртывание одного исключения в другое.

class ConfigException extends Exception {
ConfigException(String message) { super(message); }
}
static int readPort(String line) throws ConfigException {
String[] parts = line.split("=", 2);
if (parts.length != 2 || !parts[0].trim().equals("port"))
throw new ConfigException("ожидалось port=<число>, получено '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("неверный номер порта: " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=восемьдесят", "host=localhost")) {
try { System.out.println("порт: " + readPort(line)); }
catch (ConfigException e) { System.out.println("ошибка конфигурации: " + e.getMessage()); }
}
Показать решение
fn read_port(line: &str) -> Result<u16, String> {
let expected = || format!("ожидалось port=<число>, получено '{line}'");
let (key, value) = line.split_once('=').ok_or_else(expected)?;
if key.trim() != "port" {
return Err(expected());
}
let value = value.trim();
value
.parse::<u16>()
.map_err(|_| format!("неверный номер порта: {value}"))
}
fn main() {
for line in ["port = 8080", "port=восемьдесят", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("порт: {port}"),
Err(e) => println!("ошибка конфигурации: {e}"),
}
}
}

Вывод:

порт: 8080
ошибка конфигурации: неверный номер порта: восемьдесят
ошибка конфигурации: ожидалось port=<число>, получено 'host=localhost'
▶ Попробуй сам

Предложение throws ConfigException стало типом возвращаемого значения Result, try/catch вокруг parseInt — одним map_err, а throw — значением Err(...). u16 для порта — небольшой бонус: число больше 65535 отвергает сам parse, чего int в Java не сделал бы. Маленькое замыкание expected строит сообщение об ошибке, только когда оно действительно нужно, как поставщик в orElseThrow(() -> ...).

  • Многое из Java переносится: обобщения, интерфейсы (в виде трейтов), лямбды (в виде замыканий) и конвейеры стримов (в виде итераторов).
  • Главные сдвиги: владение вместо сборщика мусора, Option вместо null, Result и ? вместо исключений, трейты плюс композиция вместо наследования.
  • Обобщения компилируются для каждого типа отдельно (мономорфизация), поэтому нет ни стирания типов, ни упаковки чисел.
  • Общие данные между потоками живут внутри Mutex (обёрнутого в Arc), и забыть про блокировку — это ошибка компиляции, а не гонка.
  • Cargo заменяет Maven или Gradle; clippy и rustfmt заменяют Checkstyle, SpotBugs и форматтеры; тестирование встроено.
  • Вместо Spring Boot смотри на axum или actix-web, вместо JPA — на SeaORM или Diesel, вместо JDBC и jOOQ — на sqlx, а вместо Jackson используй serde.

Проверь себя

1. В абстрактном классе на Java один абстрактный метод и один конкретный, который его вызывает. Чем это обычно заменяют в Rust?

2. PriorityQueue в Java сначала возвращает наименьший элемент. Что возвращает BinaryHeap::pop в Rust?

3. Почему обобщённая функция в Rust может вызвать T::default(), а в Java нельзя написать new T()?

4. Ты делишь обычный Vec между потоками и пытаешься добавлять в него элементы без Mutex. Что произойдёт в Rust?

Результат: 0 / 4

  • Язык программирования Rust: официальная книга (перевод на русский). Для разработчиков на Java важнее всего главы 4 (владение), 9 (обработка ошибок), 10 (обобщения и трейты) и 16 (многопоточность).
  • std::collections (на английском): собственное руководство стандартной библиотеки по выбору между Vec, VecDeque, HashMap, BTreeMap и остальными.
  • JEP 454: Foreign Function & Memory API (на английском): возможность JDK 22 для вызова нативных библиотек, в том числе на Rust, из Java без JNI-кода.
  • Крейт jni на docs.rs (на английском): привязки Rust к JNI, если нужно вызывать Rust из старых JDK или обращаться обратно к Java.
  • Are We Web Yet? (на английском): обзор веб-фреймворков, библиотек для баз данных и не только от сообщества — если ты ищешь замену своему стеку Spring Boot.