JavaとRust

JavaとRust、二つの出発点――何を自動化し、何を人に書かせたか

議論参加:梧桐 (編集長・進行) / 灰島 徹 (基幹系アーキテクト) / 桐生 みなも (Rustを本番運用するSRE) / 汐見 レン (証券システムの低レイテンシ実装者)

生成AIでコードを書かせることが当たり前になるにつれ、Rustを見かける機会が増えた――本誌の運営者はそう感じている。既存のソフトウェアがRustで書き直され、新しく作るものにRustが選ばれる。そして運営者自身、AIにプログラムを書かせるとき、しばしばRustを選んでいる。

象徴的な事例が今年あった。

JavaScriptの実行環境であるBunが、2026年7月8日、Rustへの全面書き換えを発表した。それまでのBunはZigという言語で書かれており、コメントを除いて53万5,496行あった。開発者のJarred Sumnerは、書き換えの理由をこう説明している。バグ一覧の多くが、解放済みメモリへのアクセス、二重解放、エラー処理の経路での解放漏れだった。

安全なRustでは、これらはコンパイルエラーになるか、スコープを抜けたときの自動解放(Drop)で防がれる。コンパイルエラーは、スタイルガイドよりも良いフィードバックループである。

Bunは対策を怠っていたわけではない。コンパイラにメモリ検査機能を追加し、全コミットでテストを走らせ、JavaScriptエンジンの検証に使われるファジングツールを24時間動かしていた。「多くのプロジェクトがやっている以上のことをしている」と本人も書いている。それでもこの種のバグは出続けた。

そしてもう一点、この事例が現在的なのは書き換えの方法である。53万行規模の書き換えは、通常なら少人数のチームで1年かかる。その間バグ修正も機能追加も止まる。だから「書き換えは悪手」というのが従来の常識だった。Bunはこれを、AIエージェントの約50のワークフローを11日間回すことで実行した。コードを書くAIと、それを批判的に検証するAIを別々に立て、後者には「このコードは間違っているという前提で探せ」と指示する体制を組んでいる。

なお、同記事は冒頭で、Bunが2025年12月にAnthropicに買収されたこと、書き換えに同社のモデルの開発版を用いたことを自ら開示している。本誌としても、開発元による自社事例の報告である点は割り引いて読む必要があると考えるが、Rustが選ばれた理由の説明そのものは、この記事でこれから見ていく内容と正確に重なる。

AIの登場より前から、置き換えは進んでいた

もっとも、Rustへの移行はAIが始めたことではない。

コマンドラインシェルのfishは、2025年2月27日にリリースした4.0で、中核部分のC++からRustへの移植を完了した。公式ブログは「fishは完全にRustで書き直された」と述べている。作業は2023年1月に始まり、200人超が2,604のコミットを重ね、最後のC++コードが消えたのは2024年1月。安定版のリリースまで含めれば2年がかりである

より業務システムに近い例もある。Ubuntu 25.10(2025年10月)は、sudo の既定実装をRust製の sudo-rs に置き換えた。 2026年4月の26.04 LTSでも既定であり、長期サポート版に載っている。ただしUbuntu自身が「完全な置き換えではない」と明記しており、対応していない機能もある。

同じUbuntuは、基本コマンド群についてもRust実装への移行を進めている。ここは順調とは言い難い。26.04 LTSの時点でも cpmvrm はGNU版のままで、理由は未解決の不具合である。移行にあたって外部監査を入れ、113件の問題が見つかり、43件が脆弱性として公表された。

つまり実態は、確実に進んでいるが、平坦ではない。この連載でも、良い面と困る面の両方を扱う。

なぜこの連載を始めるのか

本誌の運営者は、長くJavaを業務で使ってきた。一方でRustについては、AIが書いたものを読むことはあっても、なぜそう書かれるのかをほとんど知らない。動くコードは手元にあるのに、その言語が何を守ってくれていて何を守ってくれないのかが分からない、という状態である。

そこで編集部は、Javaという既知の座標からRustを理解するための連載を始めることにした。文法の網羅ではなく、「なぜそういう設計になっているのか」を追う。掲載するコードはすべて、実際にコンパイルして動かした結果を載せる。

まず、Java側の現在地から

2026年9月15日、JDK 27が一般提供される。二週間後だ。すでに機能は凍結されていて、この版でJavaが何を変えるかは確定している。

変わる既定値が二つある。

ひとつはガベージコレクタだ。ガベージコレクタとは、使われなくなったメモリを実行中に自動で回収する仕組みのことで、Javaには複数の実装があり、用途に応じて選べるようになっている。そのうちの一つがG1(Garbage-First)である。ヒープ全体をまとめて処理するのではなく、細かい領域に区切ったうえでゴミの多い領域から優先的に回収する方式で、名前の「Garbage-First」はここから来ている。狙いは、処理量を最大にすることよりも停止時間を短く抑えることにある。

G1は2017年のJDK 9で、サーバ向けの構成における既定のガベージコレクタになった(JEP 248)。ただし小規模な実行環境では、より単純な別の実装が選ばれていた。JEP 523は、この例外をなくしてすべての環境でG1に統一する

もうひとつはJEP 534で、オブジェクト1個あたりの管理領域(オブジェクトヘッダ)を64ビット環境で96ビットから64ビットへ縮めることを既定にする。この機能自体はJDK 25で製品機能として入っていたが、JDK 27で標準の振る舞いになる。

30年動いている言語が、いまも既定値を変えている。Javaは止まっていない。

一方でRustは、2015年に最初の安定版が出た比較的新しい言語で、最新の安定版は1.98.0(2026年8月20日)である。この二つを比べるとき、多くの記事は「どちらが速いか」「どちらが安全か」から始める。

この連載はそこから始めない。

JavaとRustは、「安全なプログラムを書く」という同じ目標に対して、違う場所に賭けた。

Javaは、間違いを実行時に吸収できるようにした。メモリの解放もスレッドの割り当ても実行基盤に任せ、その代わりに実行基盤を厚くした。

Rustは、間違いをコンパイル時に排除できるようにした。メモリの生存期間を型に書かせ、コンパイラが証明できるものだけを通した。

どちらが優れているかではなく、なぜそういう設計になったのかを見ていく。

第1回は、その分岐点を扱う。以降、設計の語彙(第2回)、メモリとリソース(第3回)、並行処理(第4回)、いま強い領域と実績(第5回)、AIとの関係(第6回)、これから(第7回)と進む。

シグネチャが何を語るか

抽象論より、コードを見るほうが早い。

注文オブジェクトを受け取る、ごく普通のメソッドである。

class Order {
    private long total;                                  // カプセル化する
    Order(long total) { this.total = total; }
    public long getTotal() { return total; }
    public void setTotal(long t) { this.total = t; }
}

// このシグネチャは、呼び出した後に order がどうなるかを何も語らない
static void process(Order o) {
    o.setTotal(o.getTotal() - 10);      // 呼び出し元のオブジェクトを書き換えられる
}

public static void main(String[] args) {
    Order order = new Order(100);
    process(order);
    System.out.println("呼び出し後も使える: " + order.getTotal());
}

実行すると 呼び出し後も使える: 90 と出る。process は呼び出し元のオブジェクトを黙って書き換えた。

これはJavaの欠陥ではない。参照を渡すという、極めて素直な設計である。ただし void process(Order o) という宣言は、呼び出した後にそのオブジェクトが書き換わっているのか、メソッド側で保持され続けるのか、別のスレッドへ渡されるのかを、何も語らない

なお、この例は setTotal を用意している。用意しなければ外から変えられなくなる――そこはJavaのカプセル化がきちんと効くところで、後ろの節で詳しく確かめる。ここで見ておきたいのは、変更できる型を渡すと決めた場合に、その一回の呼び出しについて何が言えるかである。

所有権――値には、必ず一人の持ち主がいる

Rustの中心にあるのは所有権という考え方である。公式ガイドは、これを三つの規則としてまとめている。

  1. すべての値には、持ち主と呼ばれる変数が一つある
  2. 持ち主は、同時には一つだけ
  3. 持ち主がスコープを抜けると、その値は破棄される

三つ目が効く。いつ解放されるかが、実行時の判断ではなくソースコードの構造で決まる

確かめてみる。破棄されるときにメッセージを出すようにした型を用意して、三通りの終わり方を並べる。

struct Order { id: u32 }

impl Drop for Order {                                    // 破棄されるときに呼ばれる
    fn drop(&mut self) { println!("  [解放] Order {}", self.id); }
}

fn consume(o: Order) {                                   // 所有権ごと受け取る
    println!("  consume が Order {} を受け取った", o.id);
}   // ← ここで o の持ち主がいなくなる

fn main() {
    {
        let _a = Order { id: 1 };
        println!("内側で Order 1 を作った");
    }   // ← ここを抜けると 1 は解放される
    println!("内側のスコープを抜けた");

    let b = Order { id: 2 };
    consume(b);
    println!("consume から戻った(2 はもう無い)");

    let _c = Order { id: 3 };
    println!("main の最後の行");
}   // ← ここで 3 が解放される

実行結果はこうなる。

内側で Order 1 を作った
  [解放] Order 1
内側のスコープを抜けた
  consume が Order 2 を受け取った
  [解放] Order 2
consume から戻った(2 はもう無い)
main の最後の行
  [解放] Order 3

Order 1 は内側の波かっこを抜けた瞬間。Order 2consume が終わった瞬間――呼び出し元ではなく、所有権を受け取った側で解放されている。Order 3main の最後。

解放の処理は、コンパイラがコンパイル時にコードとして埋め込んでいる。 実行時に「これはもう誰からも使われていないか」を探し回る仕組みは、どこにも動いていない。厳密に言えば、条件分岐の中で所有権が移る場合など、「最後まで持ち主だったか」を実行時の目印で確認してから解放する場合はある。それでも、ヒープを走査して生きている値を探すことはない。

Javaでは、オブジェクトが不要になった後にいつメモリが回収されるかは、ガベージコレクタが決める。コードを読んでも解放の時点は分からない。これは手抜きではなく、そもそも「持ち主」という概念を言語に持たない設計だからである。だからこそ、実行時にヒープを走査して生きている値を判定する仕組みが要る。

Rustにガベージコレクタが要らないのは、この一点に尽きる。 解放位置をコンパイラが静的に決められるなら、実行時に探す必要がない。

渡すと、持ち主が変わる

規則2「持ち主は同時に一つだけ」から、当然の帰結が出る。値を渡すと、渡した側は持ち主でなくなる。

let n: i64 = 100;
let m = n;
println!("数値は両方使える: {} {}", n, m);   // 通る

let s = String::from("こんにちは");
let t = s;
println!("{}", s);   // ← 移した後の s を使う

後半は弾かれる。

error[E0382]: borrow of moved value: `s`
  |
6 |     let s = String::from("こんにちは");
  |         - move occurs because `s` has type `String`,
  |           which does not implement the `Copy` trait
7 |     let t = s;
  |             - value moved here
9 |     println!("{}", s);
  |                    ^ value borrowed here after move
  |
help: consider cloning the value if the performance cost is acceptable
  |
7 |     let t = s.clone();
  |              ++++++++

整数のように、ビット列を写すだけで複製が完成する型は、渡しても複製されるだけで元も使える。この性質は Copy と呼ばれ、エラー文中の “does not implement the Copy trait” はこれを指している。Copy でない型は所有権ごと移る(ムーブ)。複製したければ .clone() と明示的に書く。

ここでもコンパイラの助言が的確で、「性能上のコストが許容できるなら複製を検討しては」と添えてくる。Javaでは意識しなくてよかった複製のコストが、書く側に見える形で置かれている。

同じ規則が、シグネチャに出る

この所有権の規則が、そのままメソッドの宣言に現れる。

struct Order { total: i64 }

fn process(o: Order) { println!("{}", o.total); }   // 所有権ごと受け取る

fn main() {
    let order = Order { total: 100 };
    process(order);
    println!("{}", order.total);   // 呼び出し後に使えるか?
}

これはコンパイルが通らない。実際のエラーはこうである。

error[E0382]: borrow of moved value: `order`
 --> move.rs:8:20
  |
6 |     let order = Order { total: 100 };
  |         ----- move occurs because `order` has type `Order`,
  |               which does not implement the `Copy` trait
7 |     process(order);
  |             ----- value moved here
8 |     println!("{}", order.total);
  |                    ^^^^^^^^^^^ value borrowed here after move
  |
note: consider changing this parameter type in function `process` to borrow
      instead if owning the value isn't necessary

process(o: Order) と書いた時点で、「この値の所有権を渡す」という意味になる。渡した側はもう使えない。コンパイラはさらに「所有する必要がないなら借用に変えては」と提案してくる。

借用にすると、意図が型に出る。

fn inspect(o: &Order) -> i64 { o.total }        // 読むだけ
fn apply_discount(o: &mut Order) { o.total -= 10; }  // 書き換える

fn main() {
    let mut order = Order { total: 100 };
    println!("読むだけ: {}", inspect(&order));
    apply_discount(&mut order);
    println!("書き換え後: {}", order.total);
}

読むだけ: 100 / 書き換え後: 90 と出る。& は「借りて読むだけ」、&mut は「借りて書き換える」である。Javaでは同じ void process(Order o) だった三つの意図が、Rustでは三つの別々のシグネチャになる。

借用の規則――「同時に」とはいつのことか

規則そのものは一文で言える。

ある時点において、&mut(書き換える借用)を一つだけ持つか、&(読む借用)を何個でも持つか、そのどちらかしか許されない

順に確かめる。まず、読む借用はいくつでも作れる。

let order = String::from("注文");
let r1 = ℴ
let r2 = ℴ
let r3 = ℴ
println!("{} {} {}", r1, r2, r3);

これは通る(注文 注文 注文)。読むだけなら、何人が同時に見ていても矛盾は起きない。

次に、書き換える借用を二つ作る。

let mut order = String::from("注文");
let w1 = &mut order;
let w2 = &mut order;
w1.push('A');
w2.push('B');
error[E0499]: cannot borrow `order` as mutable more than once at a time
 --> b.rs:4:14
  |
3 |     let w1 = &mut order;
  |              ---------- first mutable borrow occurs here
4 |     let w2 = &mut order;
  |              ^^^^^^^^^^ second mutable borrow occurs here
5 |     w1.push('A');

読む借用と書き換える借用を混ぜても弾かれる。

let mut order = String::from("注文");
let r = ℴ             // 読む借用
let w = &mut order;         // 書き換える借用
w.push('A');
println!("{}", r);          // 読む借用を後で使う
error[E0502]: cannot borrow `order` as mutable because it is also borrowed as immutable
 --> d.rs:4:13
  |
3 |     let r = ℴ
  |             ------ immutable borrow occurs here
4 |     let w = &mut order;
  |             ^^^^^^^^^^ mutable borrow occurs here
6 |     println!("{}", r);
  |                    - immutable borrow later used here

「同時に」は、変数のスコープではない

ここが誤解されやすい。借用が生きているのは、作られた時点から最後に使われる時点までであって、変数が見えている範囲の全体ではない。

だから、次のコードは通る。

let mut order = String::from("注文");
let w1 = &mut order;
w1.push('A');          // ← w1 の用はここで済む
let w2 = &mut order;   // 書き換える借用を作り直す
w2.push('B');
println!("{}", order);

出力は 注文AB である。w1w2 はどちらも書き換える借用だが、二つが同時に生きている瞬間が存在しないので、規則に違反していない。

同じ理由で、読む借用の出番が先に終わっていれば、その後に書き換える借用を作れる。

let mut order = String::from("注文");
let r = ℴ
println!("{}", r);      // ← ここで r の用が済む
let w = &mut order;
w.push('A');
println!("{}", order);  // 注文A

先ほど弾かれた例との違いは、r を使う行が &mut を作るにあるかにあるか、それだけである。

つまりコンパイラが見ているのは「変数が宣言されている範囲」ではなく「その借用が実際に使われている区間」だ。ここを取り違えると、通るはずのコードを通らないと思い込むことになる。

この規則が防いでいるもの

規則の形だけ見ると抽象的だが、防いでいるのは具体的な事故である。「誰かが読んでいる最中に、別の誰かが書き換える」という状況だ。

Javaで、この事故がどう出るかを見たほうが早い。リストを読みながら、同じリストに追加する。

List<String> names = new ArrayList<>(List.of("佐藤", "鈴木"));
for (String n : names) {                        // 読みながら
    if (n.equals("佐藤")) names.add("高橋");     // 同じものを書き換える
}

このコードはコンパイルが通る。 そして実行すると落ちる。

Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)

同じことをRustで書くと、実行する前に止まる。

let mut names = vec!["佐藤".to_string(), "鈴木".to_string()];
for n in &names {                                    // 読みながら
    if n == "佐藤" { names.push("高橋".to_string()); } // 同じものを書き換える
}
error[E0502]: cannot borrow `names` as mutable because it is also borrowed as immutable
 --> iter.rs:4:24
  |
3 |     for n in &names {
  |              ------
  |              |
  |              immutable borrow occurs here
  |              immutable borrow later used here
4 |         if n == "佐藤" { names.push("高橋".to_string()); }
  |                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

for n in &names がリスト全体を「読むために借りて」おり、その最中に push が「書き換えるために借りよう」とした。さっきの規則がそのまま働いている。

Javaは実行時に検査して例外を投げる。Rustはコンパイル時に検査して、そもそも実行させない。これが「何を自動化し、何を人に書かせたか」の具体的な現れ方である。

なお、Javaの ConcurrentModificationException は最善努力の検出であり、必ず投げられると保証されているわけではない。特に複数のスレッドから触った場合は、例外すら出ずに壊れたまま動き続けることがある。

逃げ道――持ち主を複数にする Rc

ここまで読むと、当然の疑問が出る。Javaで普通に書いてきた「同じオブジェクトを複数箇所から参照し、書き換える」設計は、Rustでは書けないのか。

書ける。ただし段階を踏む必要があり、段ごとに保証を手放していく。

第一段:Rc は持ち主の数を数える。

規則2は「持ち主は同時に一つだけ」だった。Rc(reference counting、参照カウント)は、この規則を持ち主の人数を数えることで回避する道具である。値そのものは一つのまま、それを所有している変数が何個あるかを数え、最後の一人がいなくなったときに解放する

use std::rc::Rc;

struct Order { total: i64 }
impl Drop for Order {
    fn drop(&mut self) { println!("  [解放] Order"); }
}

fn main() {
    let a = Rc::new(Order { total: 100 });
    println!("a を作った        : 持ち主 {} 人", Rc::strong_count(&a));

    let b = Rc::clone(&a);
    println!("b = Rc::clone(&a) : 持ち主 {} 人", Rc::strong_count(&a));
    {
        let c = Rc::clone(&a);
        println!("  内側で c を作った: 持ち主 {} 人", Rc::strong_count(&a));
    }
    println!("内側を抜けた      : 持ち主 {} 人", Rc::strong_count(&a));
    println!("合計は {} (b からも読める)", b.total);
    println!("main の終わり");
}
a を作った        : 持ち主 1 人
b = Rc::clone(&a) : 持ち主 2 人
  内側で c を作った: 持ち主 3 人
内側を抜けた      : 持ち主 2 人
合計は 100 (b からも読める)
main の終わり
  [解放] Order

Rc::clone という名前だが、中身は複製されない。増えるのはカウンタだけである。そして解放は、main の最後――最後の持ち主が消えた時点で一度だけ起きている。

目的はJavaのガベージコレクタと近い――どこからも使われなくなったら解放する。ただし仕組みは別物である。Javaの主要なガベージコレクタは参照を数えているのではなく、実行時にヒープをたどって「まだ到達できるか」を調べる。Rc は、持ち主が増減するその場所でカウンタを増減させるだけで、走査は行わない。

ここで一つ疑問が出るかもしれない。&(読む借用)でも複数人で読めるのに、なぜ Rc が要るのか。借用は、持ち主より長く生きられないからである。 複数の一覧が同じ要素を指していて、どちらが先に消えるか構造上決められない――そういう場面では、借用ではなく持ち主そのものを増やすしかない。

第二段:Rc だけでは書き換えられない。

共有はできた。では書き換えは。

let a = Rc::new(Order { total: 100 });
let b = Rc::clone(&a);
b.total -= 10;
error[E0594]: cannot assign to data in an `Rc`
  |
6 |     b.total -= 10;
  |     ^^^^^^^^^^^^^ cannot assign
  |
  = help: trait `DerefMut` is required to modify through a dereference,
          but it is not implemented for `Rc<Order>`

弾かれる。Rc が増やしたのは持ち主の数であって、書き換えの権利ではない。借用の規則――読む側は何人いてもよいが、書き換えは同時に一人だけ――は、持ち主が何人いても変わらない。

第三段:RefCell を重ねると書き換えられる。ただし検査が実行時に移る。

どうしても共有したまま書き換えたいなら、RefCell を挟む。これは「借用の規則を、コンパイル時ではなく実行時に検査する」ための入れ物である。

use std::rc::Rc;
use std::cell::RefCell;

let shared = Rc::new(RefCell::new(Order { total: 100 }));
let a = Rc::clone(&shared);
let b = Rc::clone(&shared);

let mut x = a.borrow_mut();
x.total -= 10;
println!("a から書き換えた: {}", x.total);

let mut y = b.borrow_mut();   // ← 同時に2つ目の書き換え
y.total -= 10;

このコードはコンパイルが通る。 そして実行すると落ちる。

a から書き換えた: 90

thread 'main' panicked at shared.rs:16:19:
RefCell already borrowed

規則は生きている。ただし検査の場所がコンパイル時から実行時へ移っただけである。落ち方の形は、先ほどJavaで見た ConcurrentModificationException とほとんど同じだ。

なお、ここまで & を「読むだけ」と説明してきたが、より正確には & は「共有」、&mut は「排他」である。RefCell は、共有されたままの値に、実行時検査つきの排他を後づけする装置だと言える。

Rc の限界を二つ

ここは正直に書いておきたい。Rc は万能の抜け道ではない。

一つ目。循環参照は解放されない。 AがBを持ち、BがAを持つ形にすると、互いのカウンタが 0 にならず、メモリが解放されないまま残る

struct Node { name: String, next: RefCell<Option<Rc<Node>>> }
impl Drop for Node {
    fn drop(&mut self) { println!("  [解放] {}", self.name); }
}

{
    let a = Rc::new(Node { name: "A".into(), next: RefCell::new(None) });
    let b = Rc::new(Node { name: "B".into(), next: RefCell::new(None) });
    *a.next.borrow_mut() = Some(Rc::clone(&b));   // A → B
    *b.next.borrow_mut() = Some(Rc::clone(&a));   // B → A(循環)
}
println!("スコープを抜けた。解放メッセージは出たか?");
A と B を相互に参照させた(持ち主 A=2 B=2)
スコープを抜けた。解放メッセージは出たか?

解放メッセージが一つも出ていない。 これはRustの重要な限界である。Rustはメモリ安全性を保証するが、メモリリークは防がない。 到達不能になった循環を回収できるJavaのガベージコレクタのほうが、この一点では強い。

Rust側の定石は、循環の片側をカウントを増やさない弱い参照(Weak)にして輪を切ることだが、それは設計者が循環に気づいている場合に限る。

二つ目。Rc はスレッドをまたげない。

let a = Rc::new(Order { total: 100 });
let b = Rc::clone(&a);
thread::spawn(move || { println!("{}", b.total); });
error[E0277]: `Rc<Order>` cannot be sent between threads safely
  = help: the trait `Send` is not implemented for `Rc<Order>`

カウンタの増減がスレッド安全でないため、コンパイラが渡すこと自体を拒む。複数スレッドで共有するには Arc(atomic reference counting)を使う。この Send という仕掛けが並行処理の話の中心になるので、第4回で扱う。

この逃げ道をどう見るか

Rc<RefCell<T>> は、Javaのオブジェクト参照にもっとも近い形である。複数箇所から参照でき、書き換えられ、最後の参照が消えたら解放される。

裏を返すと、そこまで持っていった時点で得られる保証もJavaと同程度まで落ちる。実行時に落ちうるし、循環すればリークする。違うのは、それが明示的に選んだ結果であることだけだ。RcRefCell と書いた人は、コンパイル時の保証を手放すと自分で宣言している。

Java経験者がRustを書き始めて最初にぶつかるのは、たいていここである。慣れた設計をそのまま持ち込もうとすると Rc<RefCell<...>> が増えていき、学習コストだけ払って利点を失う。所有関係を先に設計し直すか、それが割に合わないならJavaのままでよい、という判断になる。

では、Javaでは同じことを表現できないのか

冒頭の例は、説明のためにフィールドを直接触らせていた。実務のJavaはそう書かない。フィールドは private にして、どんなアクセサを用意するかで制御する。 これはJavaの中心的な設計手段なので、順に確かめておきたい。

1. setterを作らなければ、外からは変えられない。

class Order {
    private long total;                       // 直接触らせない
    Order(long total) { this.total = total; }
    public long getTotal() { return total; }  // 読むだけ提供
    // setter は用意しない
}

static void audit(Order o) {
    o.total -= 10;      // ← 外から書き換えられるか?
}
エラー: totalはOrderでprivateアクセスされます

コンパイラが強制する。 規約でも紳士協定でもない。「このオブジェクトは外から変わらない」は、Javaで型として表現できる。record を使えばさらに簡潔に書ける。

record Order(long id, long total) {
    Order withTotal(long t) { return new Order(id, t); }   // 更新=新しいインスタンス
}

この設計が取れる場面では、Rustの &&mut が解く問題の大半は、Javaでも消える。 ここは正確に押さえておきたい。

2. では、全部これで書けるのか。

書けない場面がある。理由は二つ。

一つは、更新のたびに新しいインスタンスができること。その場で書き換えることに意味がある処理――集計、バッファ操作、大きな配列の並べ替え――は、作り直す設計に置き換えられない。

もう一つのほうが実務では重い。既存のフレームワークが可変を要求する。 Jakarta Persistence 3.2の仕様は、永続化の対象となるクラスについてこう定めている。

エンティティクラスは、パラメータを持たないpublicまたはprotectedのコンストラクタを持たなければならない。エンティティクラスはfinalであってはならない。エンティティクラスのすべてのメソッドと永続インスタンス変数は、finalであってはならない。

同じ節に「enum、record、インターフェースをエンティティに指定することはできない」ともある。つまりデータベースに永続化する層では、不変にすることが仕様で禁じられている。業務システムの中心にあるドメインオブジェクトが、まさにこの層であることは珍しくない。

3. setterが必要になった瞬間、区別がつかなくなる。

業務上どこかで値を変える必要が出れば、setterを用意することになる。その時点でこうなる。

class Order {
    private long total;
    public long getTotal() { return total; }
    public void setTotal(long t) { this.total = t; }
}

static void audit(Order o)    { o.setTotal(o.getTotal() - 10); }  // 「読むだけ」のつもり
static void discount(Order o) { o.setTotal(o.getTotal() - 5);  }  // 変えるのが仕事

コンパイルは通り、audit の後: 90 と出る。auditdiscount は、宣言だけ見ると区別がつかない。 setterを持つと決めた瞬間、それはその参照を持つすべてのコードに対して開かれる。

4. 読み取り専用の窓口を渡す、という定石がある。

Javaの答えは、狭い型を渡すことだ。読むメソッドだけを持つインターフェースを用意して、それで受け取る。

interface ReadOnlyOrder { long getTotal(); }
class Order implements ReadOnlyOrder { /* getTotal, setTotal を持つ */ }

static void audit(ReadOnlyOrder o) {
    // o.setTotal(0);          // ← 型に無いので、これはコンパイルエラー
}

これは効く。そしてRustの & にかなり近い。渡す側が「読むだけの窓口」を選んで渡している。

ただし、破れる。

static void audit(ReadOnlyOrder o) {
    ((Order) o).setTotal(0);   // ← キャストで元の型に戻す
}

コンパイルは通り、実行すると 0 になる。 型による制限は、キャストで外せる。

コレクションの場合も性格は同じである。

List<String> backing = new ArrayList<>(List.of("佐藤"));
List<String> view = Collections.unmodifiableList(backing);

backing.add("鈴木");        // 元のリストを変える
System.out.println(view);   // ビューはどうなるか
view.add("高橋");           // ビュー経由で変えようとする
ビュー: [佐藤]
元を変えた後のビュー: [佐藤, 鈴木]
ビュー経由の変更: UnsupportedOperationException(実行時)

二つのことが起きている。ビューは複製ではないので、元のリストへの変更がそのまま見える。 そしてビュー経由の変更は、コンパイル時ではなく実行時に拒否されるList 型である以上 add は呼べてしまう)。

5. 引数に final を付けても、これとは無関係である。

念のため。取り違えられやすい。

static void process(final Order o) {
    o.setTotal(0);      // ← 通る
}

final が禁じているのは変数 o に別のオブジェクトを代入し直すことであって、o が指す先を変えることではない。呼び出し元から見た保護にはならない。

制御する場所が違う

整理すると、こうなる。

JavaRust
何で制御するか渡す型(どんなアクセサを持つ型を渡すか)その借用&&mut か)
誰が決めるかクラスの設計者(どんな型・アクセサを用意するか)呼び出しごとに、書く人
破れるかキャストで外せる。ラッパーは実行時に拒否借用の種類はコンパイル時に固定される
変更が必要な型では参照を持つ全員が変更できる呼び出しごとに読むだけ/書き換えるを選べる

Javaの手段は弱くない。private と狭いインターフェースは、コンパイラが実際に強制する。違うのは制御の粒度である。 Javaはクラスと型の設計として一度決める。Rustは呼び出しのたびに宣言する。

灰島徹(基幹系アーキテクト)

カプセル化で足りる場面は多いですし、実際それでやってきました。足りないのは、可変だと決めた型を渡すときに「今回は読むだけです」と言う手段です。読み取り専用のインターフェースを別に用意すれば近いことはできますが、型が増えますし、キャストで外せます。

Rustはそこを &&mut の使い分けに落とし込んだ。Javaにできないことをするのではなく、Javaが型の設計と規約に委ねた部分を、呼び出しごとの宣言へ移した、と見るのが正確である。

※本稿のコードは、記事の執筆環境(macOS / Apple Silicon、JDK 25.0.4.1、rustc 1.96.0、Rust 2024 edition)で実際にコンパイル・実行して確認した。エラーメッセージは実際の出力である。

何を自動化し、何を人に書かせたか

この差は、シグネチャだけの話ではない。設計全体に一貫している。

観点Javaは自動化したRustは人に書かせた
メモリの解放ガベージコレクタが実行時に判断する所有権と借用として型に書く
データを誰が触ってよいか実行時に決まる。規約とレビューで守るシグネチャに現れ、コンパイラが検査する
スレッド間で渡してよいか実行時の振る舞いとして現れる型に現れ、コンパイル時に検査される
失敗の扱い例外が自動的に伝播する。握りつぶすこともできる戻り値の型に現れ、呼び出し元が扱いを迫られる

代償の出方が対称ではない、というのがこの連載を通じて何度も出てくる論点である。

Javaは初期コストが低く、後期のコストが見えにくい。 すぐ書けて、すぐ動く。問題が出るとすればシステムが育ってからで、しかも実行時にしか現れない。

Rustは初期コストが高く、後期のコストが構造的に低い。 最初に所有関係を設計させられる。その代わり、コンパイル時に弾かれた種類の誤りは本番で踏まない。

この前払いが割に合うかどうかは、運用期間、チームの規模、システムの性質による。連載の第5回で、実際にどの領域でどちらが使われているかを一次情報で確認する。

実行モデル――実行時に賢くなるか、最初から決まっているか

もう一つの分岐点は、コードが機械語になるタイミングである。

Javaは中間表現にコンパイルし、実行時にJVMが本当によく通る経路を観測して機械語へ変換する(JITコンパイル)。実際の入力に基づいて最適化できるが、暖まるまでに時間がかかり、実行基盤の分だけ起動に初期コストがかかる。

Rustは事前にネイティブバイナリへコンパイルする。実行時プロファイルによる後付けの最適化はできないが、起動が速く、同じコードが常に同じように動く。

起動時間の差は、手元で測れる。

中央値最小最大
Java(JDK 25.0.4.1)19.0 ms18.3 ms22.1 ms
Rust(rustc 1.96.0、-O1.8 ms1.6 ms2.1 ms

※本稿の執筆環境(macOS / Apple Silicon)で、標準出力に1行書くだけのプログラムを、ウォームアップ5回の後に30回実行した中央値。この数字が言えるのは「実行基盤の起動に差がある」ことだけで、処理性能については何も言っていない。 環境によって変わるので、判断に使うなら自分の環境で測ること。なおJava側も、事前のトレーニング実行の結果を再利用して起動を速くするAOTキャッシュ(JEP 483、JDK 24で正式機能)などの改善を進めており、上の数字はそうした仕組みを使わない素の状態での比較である。

ここを取り違えると話が壊れる。ピーク性能の優劣はワークロード次第であり、「RustはJavaより常に速い」は成立しない。 実行時プロファイルに基づく動的最適化が効く場面と、メモリ配置を決め打ちできることが効く場面は、別々に存在する。

汐見レン(証券システムの低レイテンシ実装者)

起動が速いことと、定常状態で速いことは別の話です。実務で効くのはむしろ、暖機が済む前に本番の負荷が来る場面と、たまにしか通らない経路が遅い場面。そこは第4回以降で扱うべきだと思います。

メモリの並び方を、誰が決めるか

Rustの構造体は、フィールドの実体を参照を挟まずひとかたまりに置く。Vec<Point> のような配列も、要素の実データが連続して並ぶ。フィールドの並び順そのものは、既定ではコンパイラが詰め直す(本稿の環境では u8u32u8 の構造体が既定で8バイト、宣言順を固定する #[repr(C)] を付けると12バイトになった)。つまり書き手が決めているのは並び順そのものというより、参照を挟むか、連続させるかであり、必要なら並び順まで固定できる。

Javaはどうか。int[] のようなプリミティブ配列は連続して並ぶ。しかしオブジェクトのコレクションや配列が持っているのは参照であり、実体はヒープ上に散らばる。たどるにはポインタを追うことになる。

Javaはこの点を改善し続けている。JDK 25でオブジェクトヘッダを縮める機能が製品機能になり、JDK 27でそれが既定になる。値クラス(JEP 401)はJDK 28でプレビューとして入る予定である。

ただし、JEP 401の原文には重要な但し書きがある。値をフラットに並べることは「最適化であって言語機能ではない。直接制御することはできない」。加えて、書き換え可能なフィールドは原子的な読み書きの制約から実質64ビットまでに制限され、ジェネリクスは型が消去されるためフラット化の対象にならない、とも書かれている。

つまり方向としては近づくが、「並び方を書き手が決められる」という性質そのものは、設計として別のままである

なお、数値計算をまとめて処理するVector APIは、JDK 27でも12回目の試験導入段階にある。JEP 537自身が、値クラスがプレビューとして使えるようになるまで試験導入を続けると書いている。Java側の二つの改善は、互いに待ち合っている。

知識は、誰から誰へ伝わるか

議論の中で、技術そのものより組織に効く違いが出てきた。

Javaでは、final を付ける、不変オブジェクトを返す、Javadocに前提を書く、といった約束が「守るべき取り決め」として機能する。コンパイラは強制しないので、ベテランから若手へ縦方向に伝えていく必要がある。設計書と口伝とレビューが、その伝達路になる。

Rustでは、同じ制約をコンパイラが全員に等しく突きつける。20年目の人にも初日の人にも、同じエラーが出る。

桐生みなも(Rustを本番運用するSRE)

伝わり方が「人から人」ではなく「コンパイラから全員」に変わります。これは楽になる面と、危ない面の両方があります。危ないのは、コンパイルが通ったというだけで、ロジックまで正しいと読み違えることです。コンパイラが保証しているのはそこではありません。

どちらが優れているという話ではなく、チーム内で知識がどう流れるか、その構造が違う。

梧桐(編集長・進行)

第1回なので、良し悪しの判定は保留のまま閉じます。持ち帰っていただきたいのは一点だけ――両者は同じ問題を、別の場所で解いているということです。次回以降、その「場所の違い」が設計の語彙、メモリ、並行処理、運用でどう効いてくるかを順に見ていきます。

Java経験者が戸惑うところ、そのまま効くところ

議論では、最初の壁は文法ではなく視点の切り替えだという点で一致した。

戸惑うところ

  • 所有関係を先に設計する、という思考の順序
  • 借用の記法(&&mut)と、値がいつまで生きているかを意識すること
  • 失敗を戻り値の型として明示的に運ぶ書き方

そのまま効くところ

  • ドメインモデリングの考え方
  • エラーをどう伝播させるかの設計感覚。Javaで検査例外の扱いを設計してきた経験は、Rustの戻り値による失敗の表現に素直につながる
  • 並行処理で何をどう守るかという問いの立て方

この回で断定していないこと

  • JDK 27は本稿の公開時点でまだリリースされていない。 2026年9月15日にGA予定で、Release Candidate段階として機能は凍結されている。本稿が述べるJDK 27の変更点は、確定した予定として書いている。
  • どちらが速いかは扱っていない。本稿で示した数字は起動時間だけで、処理性能については何も言っていない。とくに、不変オブジェクトを毎回作り直す設計と、その場で書き換える設計のどちらが速いかは測っていない。JVMの最適化がどこまで効くかに左右され、素朴な計測では答えが出ないためである。
  • エコシステムの厚みの定量的な比較は、議論では定性的な言及にとどまった。第5回で、一次情報が取れる範囲に限って扱う。
  • どのワークロードでどちらのピーク性能が上かは、「場合による」という認識で一致しただけで、具体的な題材では検証していない。
  • Rustの非同期処理の実行基盤は標準ライブラリに入っておらず、それが今後どう収束するかは議論に出なかった。第4回と第7回で扱う。
  • 本稿でいうRustの保証は、安全な(safe)Rustの範囲の話である。Rustには unsafe という、コンパイラの検査の一部を書き手が引き受ける明示的な例外領域がある。冒頭のBunの引用が「安全なRustでは」と限定しているのも同じ理由である。unsafe が実際にどう使われ、何を意味するかは、後の回で扱う。

二つの言語は対立しているのではない。安全性を保証するコストを、実行基盤に払わせるか、コードとコンパイル時に払わせるかという、賭けどころの違いである。

次回は、Javaの設計語彙――継承、インターフェース、DI――が、Rustで何に対応し、何に対応しないのかを見る。

出典を読むときのポイント

キーワード:#Rust#所有権#JDK 27

出典

コメント