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もあります。
言語の概念:JavaからRustへ
Section titled “言語の概念:Javaから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 |
コレクション
Section titled “コレクション”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 を使うか、先にキーを並べ替えましょう。
ツール:MavenとGradleからCargoへ
Section titled “ツール:MavenとGradleからCargoへ”ほとんどの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のような、何でもそろった単一のフレームワークはありません。その代わりに、目的のはっきりしたクレートをいくつか組み合わせます。ここでは、いちばん近いものを紹介します。
Webとネットワーク
Section titled “Webとネットワーク”| 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 |
全部入りのセキュリティフレームワークはない |
データとメッセージング
Section titled “データとメッセージング”| 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つとも動くコード付きで紹介しています。
日常的な部品
Section titled “日常的な部品”| 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) {}
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 { } 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, AdaHelsinki: 200London: 120New York: 230都市名は英語のままにしています。BTreeMap がキーをアルファベット順に並べるので、日本語にすると表示の順番が変わってしまうからです。
Streamと同じく、Rustのイテレータは遅延評価(lazy)で、何かが値を消費したとき(collect、sum、for ループ)に初めて実行されます。Streamと違って、Collectors クラスはありません。collect() は、求められた型(Vec、HashMap、String、…)を見て、それを組み立てます。グループ化には、entry APIを使った普通のループがよく使われます。groupingBy が恋しければ、itertools クレートがヘルパーを追加してくれます。そして、イテレータのチェーンは手書きのループと同じ機械語にコンパイルされ、途中で int がボックス化されることもありません。詳しくはイテレータと並べ替えと検索を見てください。
並べて比較:型消去と単相化
Section titled “並べて比較:型消去と単相化”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` trait7 | greet(name);| ---- value moved here8 | 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開発者のための学習ルート
Section titled “Java開発者のための学習ルート”このサイトを順番どおりに読む必要はありません。次のルートで進むと、Javaですでに学んだことは飛ばして、Rustならではの部分に時間をかけられます。
- Rustを始めるとRustのツールと環境構築:Rustをインストールし、エディタを選んで、
cargo newを実行します。 - Rustの変数とRustのデータ型:ざっと目を通しましょう。主に
let mutと数値の型を覚えるためです。 - 所有権、次に借用、そしてスライス:あなたにとっていちばん大事なページです。じっくり時間をかけてください。
- Rustの文字列:
Stringと&strの違い。 - 構造体と列挙型、次にmatchとパターンマッチング。
- OptionとResult、次に独自のエラー型とanyhowとthiserror。
- トレイト、ジェネリクス、よく使うトレイト、トレイトオブジェクト:これがあなたのインターフェースと抽象クラスです。
- クロージャとイテレータ:これがあなたのラムダとStreamです。
- ベクタ、HashMap、VecDequeとBinaryHeap。
- モジュール、Cargoとクレート、テスト。
- ライフタイムとスマートポインタ。
- 並行処理、共有状態、チャネル、async/await。
- RustでJSONを扱う、ログ出力、Rustのデータベース。そのあとWeb APIのプロジェクトを作ってみましょう。
- 既存の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最高点: 100deriveを付けると、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を見てみましょう。
理解度チェック
Section titled “理解度チェック”理解度チェック
1. Javaの抽象クラスに、抽象メソッドがひとつと、それを呼ぶ具体的なメソッドがひとつあります。Rustでは、ふつう何に置き換えますか?
2. Javaの PriorityQueue は最小の要素から返します。Rustの BinaryHeap::pop は何を返しますか?
3. Javaでは new T() と書けないのに、Rustのジェネリックな関数が T::default() を呼べるのはなぜですか?
4. 普通の Vec をスレッド間で共有し、Mutex なしで要素を追加しようとしました。Rustではどうなりますか?
スコア: 0 / 4
さらに詳しく
Section titled “さらに詳しく”- 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に代わる構成を探しているなら役に立ちます。