コンテンツにスキップ

Java開発者のためのRust

何年もJavaを書いてきたなら、Rustを学ぶのに必要なもののほとんどは、もう持っています。あなたは型で考えることに慣れていますし、ジェネリクス、インターフェース、ラムダ、Streamのパイプラインも知っています。Rustにはそのすべてがあります。トレイトはデフォルトメソッド付きのインターフェースによく似ていますし、イテレータはStreamのように読めます。そしてCargoは、MavenやGradleの仕事を、ずっと少ない設定でこなします。

新しく覚えることは、これまでJVMが代わりにやってくれていたことすべてです。Rustにはガベージコレクタも仮想マシンもありません。コードはネイティブのバイナリにコンパイルされ、いつメモリを解放するかは、コンパイラがプログラムの実行前に所有権(ownership)と借用(borrowing)をチェックして決めます。また、Rustには null がなく(Optionを使います)、例外もなく(Resultを使います)、クラスの継承もありません。学習時間のほとんどは、この4つの違いに使うことになるでしょう。

Java開発者がRustを学ぶのはなぜでしょうか。たいていは、次のどれかが理由です。メモリ使用量が少なく安定していて、GCによる停止がないサービスがほしい。JVMなしで一瞬で起動するコマンドラインツールやサーバーレス関数を作りたい。Javaアプリのホットパスをネイティブコードに移したい(RustはJNIや新しいForeign Function & Memory APIを通してJavaから呼び出せます)。あるいは、Rustがなぜ「最も賞賛される言語」の調査でいつも1位なのか、単純に気になる。このページでは、Javaで知っていることをRustに対応づけていきます。そうすれば、本当に違う部分に集中できます。

C++、Go、Pythonとの全般的な比較が知りたい場合は、Rust vs C++・Go・Pythonを見てください。.NETも知っているなら、別のページC#開発者のためのRustもあります。

この表は、旅行の会話帳のように使ってください。いちばん右の列は、Rust側を説明しているこのサイトのページへのリンクです。

Java Rust 詳しくは
フィールドとメソッドを持つ class データ用の struct + メソッド用の impl ブロック 構造体
コンストラクタと new Self を返す普通の関数。慣習として new という名前にする 構造体
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!(スレッドを止める) panic
例外の連鎖(new ConfigException(msg, cause)) map_err、または thiserror の #[source] と anyhow の .context(...) anyhowとthiserror
try-with-resourcesと AutoCloseable Drop。所有者がスコープを抜けると自動で実行される スコープ、スマートポインタ
ガベージコレクタ 所有権と借用。コンパイル時にチェックされる 所有権、借用
final なローカル変数 普通の let(デフォルトで不変)。変更したいときは let mut と書く 変数
static final 定数 const 変数
変更可能な static フィールド、シングルトン LazyLock や OnceLock を使った static。変更が必要なら Mutex で包む 共有状態
<T extends Comparable<T>> 付きのジェネリクス トレイト境界付きのジェネリクス:T: Ord または where T: Ord ジェネリクス
型消去、List<int> ではなく List<Integer> 単相化(monomorphization):ジェネリックなコードは型ごとにコンパイルされ、Vec<i32> はそのままの数値を格納する ジェネリクス、高度なジェネリクス
ラムダ、Function<T, R>、Supplier<T>、Consumer<T> クロージャ。Fn、FnMut、FnOnce というトレイトで表される クロージャ、Fn・FnMut・FnOnce
Stream:filter、map、reduce、collect イテレータアダプタ:filter、map、fold、sum、collect イテレータ
Iterable<T> と Iterator<T> IntoIterator と Iterator(メソッドは next ひとつだけで、Option を返す) 独自のイテレータ
parallelStream() rayon クレートの par_iter() 並行処理
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の1単位) 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 型変換、型変換(From・Into)
BigDecimal、BigInteger rust_decimal と num-bigint クレート データ型
パッケージと import モジュール(mod)と use モジュール
public、private、protected、パッケージプライベート デフォルトで非公開。pub と pub(crate)。protected はない モジュール
JAR / Mavenのアーティファクト クレート(ライブラリまたはバイナリ) Cargoとクレート
アノテーション(@Override、Lombokの @Data) #[derive(Debug)] や #[test] のような属性 マクロ
リフレクション、実行時のアノテーション処理 実行時のものはほぼない。代わりにマクロがコンパイル時にコードを生成する 手続き的マクロ
メソッドのオーバーロード オーバーロードはない。別の名前を付けるか、ジェネリックなパラメータを使う 関数
instanceof によるチェックとダウンキャスト enumに対する match(Any を使ったダウンキャストもあるが、まれ) パターンマッチング
JNI、Foreign Function & Memory API extern "C" 関数 FFI

Javaのコレクションフレームワークは、std::collections にきれいに対応します。いちばん大きな違いは、Rustのコレクションは決して null を返さないことです。検索の結果は Option で返ってきます。

Java Rust 詳しくは
ArrayList<T> Vec<T> ベクタ
int[]、String[] 長さがコンパイル時にわかるなら [T; N]、そうでなければ Vec<T>。どちらもスライス &[T] として渡せる 配列、スライス
List.of(...)(変更不可) 配列、または &Vec<T> / &[T] としてしか渡さない Vec(共有参照を通しては誰も変更できない) 借用
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とBTreeMap
ArrayDeque<T> VecDeque<T> VecDequeとBinaryHeap
Stack<T>、またはスタックとして使う Deque push / pop で使う Vec<T> ベクタ
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(...)(条件に合う要素を残す) ベクタ

Javaの HashMap と同じく、Rustの HashMap にも決まった順序はなく、実行するたびに順序が変わることもあります。並べて表示したいなら、BTreeMap を使うか、先にキーを並べ替えましょう。

ほとんどのJava開発者は、すぐにCargoを気に入ります。ツールはひとつ、設定ファイルの形式もひとつで、プラグインのXMLはありません。ビルド、依存関係、テスト、ドキュメント、フォーマット、リントが、すべて同じところから使えます。

Java Rust メモ
Maven / Gradle cargo ビルド、実行、テスト、依存関係の追加、公開。Cargoとクレートを参照
pom.xml / build.gradle.kts Cargo.toml 短く、普通のプロジェクトならビルドスクリプトもいらない
Gradleの依存関係ロック Cargo.lock 自動で作られ、更新される
マルチモジュールのビルド Cargoワークスペース 最上位の 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 デバッグビルドは実行がかなり遅い。性能はリリースビルドで測る
java -jar app.jar ./target/release/app ネイティブのバイナリがひとつ。サーバーにJVMをインストールする必要はない
JDKのバージョン、SDKMAN!、ツールチェーン rustup と rust-toolchain.toml ファイル Rustのツールと環境構築を参照
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 Markdownで書く /// ドキュメントコメント + cargo doc ドキュメント内のコード例はテストとして実行される。Rustのドキュメントを参照
JUnit 5 組み込みの #[test] と cargo test(より速いランナーとして cargo nextest が人気) Rustのテストを参照
@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 を載せておきます。同じ3つの依存関係のために必要な 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"] }

ライブラリとフレームワーク:「代わりに何を使えばいいの?」

Section titled “ライブラリとフレームワーク:「代わりに何を使えばいいの?」”

これは、どのJavaチームも最初にする質問です。「うちはSpring BootとHibernateを使っているけど、Rust版は何?」というものですね。RustにはSpringのような、何でもそろった単一のフレームワークはありません。その代わりに、目的のはっきりしたクレートをいくつか組み合わせます。ここでは、いちばん近いものを紹介します。

Java Rust メモ
Spring Boot(Spring MVC / WebFlux) axum(RustとAxumで作るWeb APIで実際に作ります)、actix-web、Rocket axumとactix-webは本番環境で広く使われている
@RestController + @GetMapping 普通の async fn ハンドラを指すaxumのルート アノテーションもクラスパススキャンもない。ルートはコードで組み立てる
サーブレットフィルタ、Springのインターセプタ tower のレイヤーと tower-http クレート(CORS、圧縮、トレース、タイムアウト) axumはtowerを直接使っている
Springの依存性注入(@Autowired) axumの共有 State、普通のコンストラクタ、トレイト 下のDIの行も参照
springdoc-openapi utoipa
Thymeleaf askama(テンプレートをコンパイル時にチェックする)または tera
Spring WebSocket axumのWebSocket(ws フィーチャー)、または tokio-tungstenite
Spring for GraphQL async-graphql
gRPC Java tonic(protobufには prost)
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の上に作られている)
RabbitMQのJavaクライアント lapin
AWS SDK for Java 公式のAWS SDK for Rust:aws-config、aws-sdk-s3、…

JPAのエンティティが気に入っていたなら、まずSeaORMを試しましょう。SQLを書くほうが好きなら(jOOQやMyBatis派の人なら)、sqlxを。コンパイル時の保証をいちばん強くしたいなら、Dieselを試してください。Rustのデータベースでは、3つとも動くコード付きで紹介しています。

Java Rust メモ
Jackson / Gson serde + serde_json 定番。TOML、YAMLなどにも対応。RustでJSONを扱うを参照
JAXB、Jackson XML quick-xml(serde用の serialize フィーチャーと一緒に)
SLF4J + Logback / Log4j 2 tracing(+ tracing-subscriber)、または log + env_logger ログ出力を参照
Lombok 組み込みのderive(Debug、Clone、PartialEq、Default、…)。ビルダーには bon か derive_builder バイトコードの細工はなく、すべて普通のマクロ
MapStruct From / Into の実装 手で書き、コンパイラがチェックする。型変換(From・Into)を参照
SpringのIoCコンテナ たいていは何も使わない。依存するものはコンストラクタに渡し、テストで差し替えたいものにはトレイトを使う DIコンテナを使うRustプロジェクトはまれ
Bean Validation(@NotNull、@Size) validator または garde(deriveを使ったバリデーション) @NotNull は不要。値がないかもしれないところにだけ Option を使う
Resilience4j backon、またはtowerのリトライ・タイムアウト用ミドルウェア
Caffeine(キャッシュ) moka
Guava、Apache Commons ほとんどは標準ライブラリ。足りなければ itertools
java.time chrono または jiff Rustの日付と時刻を参照
picocli clap Rustとclapで作るコマンドラインツールを参照
@Scheduled、Quartz tokio-cron-scheduler
Micrometer metrics、または opentelemetry
Jakarta Mail lettre

デスクトップ、ゲーム、Android、相互運用

Section titled “デスクトップ、ゲーム、Android、相互運用”
Java Rust メモ
JavaFX / Swing Tauri(Web UI + Rustのバックエンド)、Slint、egui、Iced いちばん安定しているのはTauri 2とSlint 1.x。RustのGUIアプリ開発を参照
LibGDX、jMonkeyEngine Bevy、macroquad Bevyは1.0前で、リリースのたびにAPIが変わる。Rustのゲーム開発を参照
Androidアプリのコード UIはKotlinかJavaのままにして、共通のロジックをAndroid NDKでビルドしたRustのライブラリに入れる Mozillaの uniffi がKotlinのバインディングを生成してくれる
Javaからネイティブコードを呼ぶ extern "C" 関数を公開してFFM API(JDK 22以降)から呼ぶか、jni クレートでJNI関数を書く FFIを参照

並べて比較:抽象クラスはトレイトになる

Section titled “並べて比較:抽象クラスはトレイトになる”

Javaの典型的なクラス階層です。抽象メソッドをひとつと、共通のメソッドをひとつ持つ抽象基底クラスがあります。

abstract class Employee {
protected final String name;
Employee(String name) { this.name = name; }
abstract int monthlyPay();
String payslip() { return name + " earns " + monthlyPay() + " a month"; }
}
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 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 も、スクロールしないと全体が見えない深い階層もありません。Java 8のインターフェースの default メソッドを知っていれば、Rustが振る舞いを共有する方法はもう知っています。そして型の種類が決まっているなら(Java 17の sealed interface のように)、トレイトより列挙型と match のほうがシンプルなことがよくあります。

並べて比較:Optional は Option になる

Section titled “並べて比較:Optional は Option になる”

Javaの Optional は大きな進歩でしたが、使われるのは戻り値だけです。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("no email on file"));
}
findEmail(1).ifPresent(email -> System.out.println("sending to " + email));

Rustの Option はほとんど同じ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(|| "メールアドレスが登録されていません".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 protected]に送信します
Some("example.com")
▶ 実際に試してみよう

None の場合を処理するまで、コンパイラは Option<String> に対して .to_uppercase() を呼ぶことを許しません。だから NullPointerException は、そもそも存在しないのです。しかも Option には余分なコストもかかりません。Option<&T> は普通の参照と同じサイズです。便利なヘルパーメソッドはOptionのページで学べます。

並べて比較:チェック例外は Result になる

Section titled “並べて比較:チェック例外は Result になる”

チェック例外の根っこには、良い考え方がありました。何がうまくいかなくなりうるのかを、シグネチャが教えてくれるという考え方です。Javaでの典型的な書き方はこうです。

class InsufficientFundsException extends Exception {
InsufficientFundsException(int needed) { super("need " + needed + " more"); }
}
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("paid " + amount + ", " + left + " left");
return left;
}
try { pay(100, 80); pay(50, 80); }
catch (InsufficientFundsException e) { System.out.println("error: " + 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, "金額は0より大きくなければなりません"),
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足りません
エラー: 金額は0より大きくなければなりません
▶ 実際に試してみよう

なくなったものに注目してください。try ブロックも、スタックの巻き戻しも、ラムダを通すためだけに RuntimeException で包んだ例外もありません。エラーは普通の値なので、クロージャやイテレータのチェーンの中でも問題なく使えます。? は、関数が途中で戻る可能性のあるすべての場所に付く、目に見える印です。Display を手で書くのはすぐに面倒になりますが、thiserror クレートが自動で生成してくれますし、anyhow はアプリケーション向けに何でも受け取れるエラー型を用意してくれます(anyhowとthiserrorを参照)。非チェック例外で知らせるような本物のバグには、Rustではpanic!を使います。

並べて比較:Stream はイテレータになる

Section titled “並べて比較:Stream はイテレータになる”

groupingBy コレクタを使ったStreamのパイプラインです。

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では、.iter() が .stream() の役割をし、アダプタにはおなじみの名前が付いています。

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 がキーをアルファベット順に並べるので、日本語にすると表示の順番が変わってしまうからです。

Streamと同じく、Rustのイテレータは遅延評価(lazy)で、何かが値を消費したとき(collect、sum、for ループ)に初めて実行されます。Streamと違って、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は、ジェネリックな関数を、使われる型ごとに別々にコンパイルします(これを単相化、monomorphizationと呼びます)。そのため、関数は T が何なのかを正確に知っていて、T::default() を呼ぶことができます。

use std::mem::size_of;
// 「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 ...> です。ジェネリクスと高度なジェネリクスを見てください。

並べて比較:synchronized は Arc と Mutex になる

Section titled “並べて比較:synchronized は Arc と Mutex になる”

4つのスレッドが、共有のカウンタを増やします。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("count = " + 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() はガードを返します。ガードがドロップされるとロックが外れます。
*counter.lock().unwrap() += 1;
}
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("カウント = {}", *counter.lock().unwrap());
}

実行結果:

カウント = 4000
▶ 実際に試してみよう

ここはJava開発者が大好きな部分です。Javaで synchronized を忘れると、コードはコンパイルできてしまい、ときどき間違った数値を出します。Rustでは、普通の i32 や Vec をスレッド間で共有して変更しようとすると、プログラムがコンパイルできません。データ競合は(Send と Sync というトレイトを通して)型システムが捕まえてくれるのです。カウンタひとつだけなら、AtomicInteger と同じように、ミューテックスより AtomicUsize のほうが軽量です。並行処理と共有状態を見てください。

Java開発者がつまずきやすいところ

Section titled “Java開発者がつまずきやすいところ”

Java開発者は、みんな同じところでつまずきます。前もって知っておけば、つまずきは小さくなります。

  • 値を関数に渡すと、ムーブされます。 Javaでは、オブジェクトを渡すと参照が渡され、そのあとも変数を使い続けられます。Rustでは、String や Vec を値として渡すと、関数にムーブされます。

    fn greet(name: String) {
    println!("こんにちは、{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 もありません。 トレイトでクラス階層を再現したくなるかもしれませんが、まずはenumとコンポジションを試してください。たいていはそのほうがうまく合います。

  • == は値を比較します。 これはうれしい知らせです。2つの String に対する a == b はテキストを比較するので、おなじみの == と .equals() のバグは起きようがありません。自分で定義した構造体では、PartialEq をderiveするか実装したあとでないと == は使えません。

  • BinaryHeap は最大の要素を取り出します。 Javaの PriorityQueue は最小のものから出てきます。Rustで最小ヒープのように使うには、要素を std::cmp::Reverse で包みます。VecDequeとBinaryHeapを見てください。

  • 整数オーバーフローは黙って起きません。 Javaでは Integer.MAX_VALUE + 1 は静かに折り返します。Rustのデバッグビルドではパニックします。checked_add、wrapping_add、saturating_add で、意図をはっきり書きましょう。整数オーバーフローを見てください。

  • String と &str、そして charAt がないこと。 Rustの文字列はUTF-8なので、s[0] はコンパイルできません。文字を順に見るには s.chars() を使います。関数はたいてい &str を受け取り、構造体はたいてい String を持ちます。Rustの文字列を見てください。

  • メソッドのオーバーロードがありません。 引数の違う print メソッドを2つ持つことはできません。別の名前(print、print_with_color)を付けるか、ジェネリックなパラメータを使いましょう。

  • 実行時の魔法がありません。 クラスパススキャンも、@Autowired も、メソッドの周りにトランザクションを追加するプロキシもありません。すべてはコードの中で明示的に組み立てられ、マクロはコンパイル時に動きます。書く量は増えますが、何が起きているのかがいつでも見えます。

  • null も例外もありません。 最初は、あちこちに Option と Result があるのが手間に感じます。でも数週間たつと、NullPointerException も予想外のスタックトレースも見かけなくなったことに気づくはずです。学習中は .unwrap() を使ってもかまいませんが、実際のコードでは、これは「クラッシュさせておけ」のRust版だと覚えておきましょう。

  • コンパイル時間。 クリーンビルドではすべての依存関係をソースから取ってきてコンパイルするので、javac より時間がかかります。インクリメンタルビルドはずっと速く、編集中にいちばんよく使うのは cargo check(コード生成を省略する)です。その代わり、うれしいこともあります。プログラムはJVMのウォームアップなしで一瞬で起動します。

  • asyncにはランタイムが必要です。 仮想スレッドはありません。async fn はエグゼキュータが実行するまで何もしないので、Webのプロジェクトではtokioを追加して main に #[tokio::main] を付けます。async/awaitを見てください。

このサイトを順番どおりに読む必要はありません。次のルートで進むと、Javaですでに学んだことは飛ばして、Rustならではの部分に時間をかけられます。

  1. Rustを始めるとRustのツールと環境構築:Rustをインストールし、エディタを選んで、cargo new を実行します。
  2. Rustの変数とRustのデータ型:ざっと目を通しましょう。主に let mut と数値の型を覚えるためです。
  3. 所有権、次に借用、そしてスライス:あなたにとっていちばん大事なページです。じっくり時間をかけてください。
  4. Rustの文字列:String と &str の違い。
  5. 構造体と列挙型、次にmatchとパターンマッチング。
  6. OptionとResult、次に独自のエラー型とanyhowとthiserror。
  7. トレイト、ジェネリクス、よく使うトレイト、トレイトオブジェクト:これがあなたのインターフェースと抽象クラスです。
  8. クロージャとイテレータ:これがあなたのラムダとStreamです。
  9. ベクタ、HashMap、VecDequeとBinaryHeap。
  10. モジュール、Cargoとクレート、テスト。
  11. ライフタイムとスマートポインタ。
  12. 並行処理、共有状態、チャネル、async/await。
  13. RustでJSONを扱う、ログ出力、Rustのデータベース。そのあとWeb APIのプロジェクトを作ってみましょう。
  14. 既存のJavaアプリを速くしたいですか? FFIを読んで、Rustが extern "C" 関数を公開する方法を見てみましょう。

どちらの問題も、Javaのコードを少し見せます。それをRustに移植(ポーティング)してください。

1. recordとStreamのパイプラインを移植する

Section titled “1. recordとStreamのパイプラインを移植する”

このコードは、合格した生徒、満点を取った人がいるかどうか、そして最高点を表示します。ヒント: イテレータには any(anyMatch にあたる)と max(OptionalInt のように Option を返す)があります。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: " + passed);
System.out.println("perfect score: " + perfect);
System.out.println("best: " + 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!("満点の人がいる: {perfect}");
println!("最高点: {best}");
}

実行結果:

合格: Ada, Grace, Ken
満点の人がいる: true
最高点: 100
▶ 実際に試してみよう

deriveを付けると、Javaの record が自動で用意してくれるものが手に入ります。デバッグ用の文字列({:?})、コピー(.clone())、値の比較(==)です。小さな student ヘルパー関数が、recordのコンストラクタの役割をしています。JavaのStreamと違って、students.iter() は何度でも呼べます。呼ぶたびに、同じ Vec に対する新しいイテレータが作られるからです。

2. 原因付きのチェック例外を移植する

Section titled “2. 原因付きのチェック例外を移植する”

このメソッドは、設定ファイルから port=<数値> の行を読み取ります。チェック例外の ConfigException を投げ、Integer.parseInt から受け取った NumberFormatException を包み直します。read_port が Result<u16, String> を返すように移植してください。ヒント: line.split_once('=') は Option<(&str, &str)> を返します。.ok_or_else(...) で Result に変えましょう。.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("expected port=<number>, got '" + line + "'");
try {
return Integer.parseInt(parts[1].trim());
} catch (NumberFormatException e) {
throw new ConfigException("bad port number: " + parts[1].trim());
}
}
for (String line : List.of("port = 8080", "port=eighty", "host=localhost")) {
try { System.out.println("port is " + readPort(line)); }
catch (ConfigException e) { System.out.println("config error: " + 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=eighty", "host=localhost"] {
match read_port(line) {
Ok(port) => println!("ポートは{port}です"),
Err(e) => println!("設定エラー: {e}"),
}
}
}

実行結果:

ポートは8080です
設定エラー: ポート番号が不正です: eighty
設定エラー: port=<数値> の形式が必要ですが、'host=localhost' でした
▶ 実際に試してみよう

throws ConfigException の部分は Result という戻り値の型になり、parseInt を囲む try/catch はひとつの map_err になり、throw は Err(...) になりました。ポートに u16 を使うと、ちょっとしたおまけもあります。65535より大きい数値は parse 自体がはじいてくれるのです。Javaの int ではそうはいきません。小さな expected クロージャは、orElseThrow(() -> ...) のサプライヤーのように、必要になったときだけエラーメッセージを組み立てます。

  • Javaの多くはそのまま活かせます。ジェネリクス、インターフェース(トレイトとして)、ラムダ(クロージャとして)、Streamのパイプライン(イテレータとして)です。
  • 大きな違いは、ガベージコレクタの代わりに所有権、null の代わりに Option、例外の代わりに Result と ?、継承の代わりにトレイトとコンポジションを使うことです。
  • ジェネリクスは型ごとにコンパイルされる(単相化)ので、型消去も数値のボックス化もありません。
  • スレッド間で共有するデータは(Arc で包んだ)Mutex の中に置きます。ロックを忘れると、競合ではなくコンパイルエラーになります。
  • CargoがMavenやGradleの代わりになり、clippyとrustfmtがCheckstyle、SpotBugs、フォーマッタの代わりになります。テストは最初から組み込まれています。
  • Spring Bootならaxumかactix-web、JPAならSeaORMかDiesel、JDBCやjOOQならsqlx、Jacksonならserdeを見てみましょう。

理解度チェック

1. Javaの抽象クラスに、抽象メソッドがひとつと、それを呼ぶ具体的なメソッドがひとつあります。Rustでは、ふつう何に置き換えますか?

2. Javaの PriorityQueue は最小の要素から返します。Rustの BinaryHeap::pop は何を返しますか?

3. Javaでは new T() と書けないのに、Rustのジェネリックな関数が T::default() を呼べるのはなぜですか?

4. 普通の Vec をスレッド間で共有し、Mutex なしで要素を追加しようとしました。Rustではどうなりますか?

スコア: 0 / 4

  • The Rust Programming Language 日本語版:公式の本の日本語訳です。Java開発者にとって特に大事なのは、4章(所有権)、9章(エラー処理)、10章(ジェネリクスとトレイト)、16章(並行処理)です。
  • std::collections(英語):Vec、VecDeque、HashMap、BTreeMap などの中からどれを選ぶかについて、標準ライブラリ自身が書いたガイドです。
  • JEP 454: Foreign Function & Memory API(英語):JNIのコードを書かずに、Rustで書いたものも含むネイティブライブラリをJavaから呼ぶための、JDK 22の機能です。
  • docs.rsのjniクレート(英語):JNI用のRustバインディングです。古いJDKからRustを呼ぶときや、Rustから逆にJavaを呼ぶときに使います。
  • Are We Web Yet?(英語):RustのWebフレームワーク、データベースライブラリなどをまとめたコミュニティのサイトです。Spring Bootに代わる構成を探しているなら役に立ちます。