JavaとRust

Rustには継承がない――Javaのinterface・抽象クラス・DIは何に置き換わるのか

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

前回は、JavaとRustが「安全なプログラムを書く」という同じ目標に対して、実行基盤とコンパイル時という違う場所に賭けた、という分岐点を見た。

今回はその続きで、設計の語彙を扱う。Javaの現場で長く使われてきた言葉たち――interface、抽象クラス、継承、DI――は、Rustで何に対応するのか。そして、対応しないものは何か。

先に今回の結論を置く。

JavaとRustの設計語彙の差は、道具の有無というより、「設計の意図を、いつ・どこに・どの形で表明するか」の差である。

Javaは意図の一部を実行時・設定・フレームワークに委ねることで柔軟さを得た。Rustは意図の多くをコンパイル時・型・コード上の明示に集約することで予測可能性を得た。

前回と同じく、掲載するコードはすべて実際にコンパイル・実行して確認した(JDK 25.0.4.1 / rustc 1.96.0、Rust 2024 edition)。エラーメッセージは実際の出力である。

traitとinterface――まず、似ているところから

Rustのtraitは、型に「この振る舞いを持つ」と約束させる仕組みで、Javaのinterfaceに一見よく似ている。実装済みのメソッドを持てるところまで同じだ。並べてみる。

interface Describe {
    String name();
    default String describe() {                 // デフォルトメソッド(Java 8以降)
        return "これは " + name() + " です";
    }
}
trait Describe {
    fn name(&self) -> String;
    fn describe(&self) -> String {              // デフォルト実装
        format!("これは {} です", self.name())
    }
}

どちらも、実装側は name だけ書けば describe はただで付いてくる。実行結果も同じになる。ここまでなら「traitはinterfaceのRust語」と言いたくなる。

だが、この理解のまま進むと、後で設計を読み違える。違いは次の一点から始まる。

決定的な違い――他人の型に、後から実装できる

Javaで、標準の StringDescribe を実装させることはできない。そもそもどのinterfaceを実装するかは、型を定義する側にしか書けない。継承したクラスで実装を足すという抜け道も、String がfinalクラスなので使えない。できるのはラッパーを作ることだ。

record Wrapped(String value) implements Describe {
    public String name() { return "文字列「" + value + "」"; }
}
// 使うたびに new Wrapped("注文") と包み直す

Rustでは、他人が定義した型に、自分のtraitを後から実装できる

impl Describe for String {                      // 標準ライブラリの String に後付け
    fn name(&self) -> String { format!("文字列「{self}」") }
}

fn main() {
    let s = String::from("注文");
    println!("{}", s.describe());               // これは 文字列「注文」 です
}

コンパイルは通り、これは 文字列「注文」 です と出る。包み直しは要らない。実装宣言が、型定義から独立している――これがtraitとinterfaceの決定的な違いであり、「traitはinterfaceと同じ」という理解が崩れる場所である。

ただし、歯止めがある

なら何でも実装できるのかというと、そうではない。自分のtraitを他人の型には書けるが、他人のtraitを他人の型に書こうとすると弾かれる。

ここでは Display を例にする。Display は「この型を人が読む文字列にする方法」を定める標準のtraitで、実装しておくと println!("{}", 値) でそのまま表示できるようになる。Javaの toString() に相当すると考えればよい。

use std::fmt::Display;
impl Display for Vec<i32> {          // 標準のtrait × 標準の型
    // ...
}
error[E0117]: only traits defined in the current crate can be implemented
              for types defined outside of the crate
  |
3 | impl Display for Vec<i32> {
  | ^^^^^^^^^^^^^^^^^--------
  |                  `Vec` is not defined in the current crate

これは孤児規則(orphan rule)と呼ばれる。traitか型か、少なくとも一方は自分のものでなければならない。もし誰でも Display for Vec<i32> を書けたら、二つのライブラリが別々の実装を持ち込んだときに、どちらを使うか決められなくなるからだ。後付けの自由には、衝突を防ぐ境界線がセットになっている。

どうしても必要なら、自分の型で包むという定石がある。

struct MyVec(Vec<i32>);                 // 自分の型で包む
impl Display for MyVec {                // これなら他人のtraitでも実装できる
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "整数{}個", self.0.len())
    }
}

整数3個 と出力される。結局のところ、Javaでラッパーを作るのと同じ発想である。 違いは、包む必要がある場面がRustのほうが狭い、という程度差にすぎない。

継承が、ない

もう一つの大きな不在を確認しておく。Rustにはクラス継承が存在しない。 構造体が別の構造体の実装を受け継ぐ仕組みはない(あるtraitが別のtraitを前提とする仕組みはあるが、実装そのものを受け継ぐものではない)。

では、Javaで継承を使って組んできた設計はどうなるのか。議論の整理はこうだった。

Javaでの語彙Rustでは
そのまま使える型で契約を表す(interface設計)traitでそのまま通用する
そのまま使える継承より合成(composition over inheritance)むしろ唯一の選択肢として徹底される
そのまま使える単一責務・処理の分離という判断軸変わらない
組み替えるクラス継承による実装の共有合成+traitのデフォルト実装へ
組み替える継承ツリーでドメインを表す発想enumとtraitの組み合わせへ
組み替えるDI・AOPの自動配線明示的な配線かコンパイル時マクロへ(後述)

「継承より合成」は、Javaの世界でも長く推奨されてきた原則である。つまりRustは、Javaの設計文化がベストプラクティスとして薦めてきた側だけを残し、もう一方の選択肢を言語から消した、と見ることができる。テンプレートメソッドパターンのような実装継承の定石は、デフォルト実装付きのtraitと合成で組み直すことになる。

呼び出し方式を、書き手が選ぶ

Javaのインスタンスメソッドの呼び出しは、既定で動的ディスパッチである。どの実装が呼ばれるかは実行時に決まり、JITコンパイラが実行時の情報を使ってインライン化などの最適化を行う。書き手がここを選ぶことは基本的にない。

Rustは、同じtraitに対して二つの呼び出し方式を用意し、書き手に選ばせる

trait Notifier { fn send(&self, msg: &str); }

struct Email; struct Slack;
impl Notifier for Email { fn send(&self, m: &str) { println!("メール: {m}"); } }
impl Notifier for Slack { fn send(&self, m: &str) { println!("Slack: {m}"); } }

// 静的ディスパッチ: 呼び先はコンパイル時に確定する
fn alert_static(n: &impl Notifier, msg: &str) { n.send(msg); }

// 動的ディスパッチ: 呼び先は実行時に決まる
fn alert_dynamic(n: &dyn Notifier, msg: &str) { n.send(msg); }

違いは impldyn かの一語だけである。どちらも & で受け取っており、呼び出し側の書き方も変わらない。

impl Trait と書けばコンパイル時に呼び先が確定し、dyn Trait と書けば実行時に決まる。

前者は単相化(monomorphization)と呼ばれ、使われた型ごとに専用の関数がコンパイラによって複製される。呼び先が確定しているぶんインライン化などの最適化が効きやすい代わりに、生成されるバイナリは大きくなる。後者は仮想関数テーブル(vtable)――どの実装を呼ぶかを実行時に引くための対応表――を経由するもので、Javaの仮想メソッド呼び出しに近い。

もう一つ、dyn にしかできないことがある。違う型を同じ入れ物にまとめることだ。

let all: Vec<&dyn Notifier> = vec![&Email, &Slack];   // 別々の型を一つの配列に
for n in &all { n.send("まとめて"); }
メール: まとめて
Slack: まとめて

impl で同じことを書こうとすると、コンパイラに断られる。呼び先を一つに確定させる方式なので、そもそも複数の型を混ぜられない。Javaで List<Notifier> に別々の実装を入れるのに相当するのは、dyn のほうである。

これは性能の話にとどまらない。

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

implとdynの書き分けは、性能のつまみというより「この依存関係をコンパイル時に確定させるか、実行時まで持ち越すか」の宣言として読んでいます。Javaでは実行基盤が黙って引き受けていた判断が、Rustではシグネチャに残る。コードレビューのときに、これが効きます。

網羅的な分岐――「Rustだけの強み」ではもうない

Rustの紹介記事で必ず出てくるのが、enumとmatchによる網羅性検査である。

enum Payment {
    Card { number: String },
    Bank { account: String },
    Cash,
}
fn describe(p: &Payment) -> String {
    match p {
        Payment::Card { number } => format!("カード {number}"),
        Payment::Bank { account } => format!("振込 {account}"),
        // Cash の腕を書き忘れた
    }
}
error[E0004]: non-exhaustive patterns: `&Payment::Cash` not covered

ケースを書き漏らすとコンパイルが止まる。バリアントを後から追加すれば、既存のmatchが全部エラーになり、対応漏れを機械が列挙してくれる。

ここで正直に書いておきたい。同じことは、現代のJavaでもできる。 sealed(JDK 17でGA)とswitchのパターンマッチング(JDK 21でGA)を組み合わせる。

sealed interface Payment permits Card, Bank, Cash {}
record Card(String number) implements Payment {}
record Bank(String account) implements Payment {}
record Cash() implements Payment {}

static String describe(Payment p) {
    return switch (p) {
        case Card c -> "カード " + c.number();
        case Bank b -> "振込 " + b.account();
        // Cash のケースを書き忘れた
    };
}
エラー: switch式がすべての可能な入力値をカバーしていません

Javaでもコンパイルが止まる。 「代数的データ型と網羅的な分岐」に相当する道具は、sealed+record+switchという形で、現行のLTSに揃っている。JDK 8や11の記憶で「Javaにはできない」と書くのは、もう正確ではない。

なお sealed は網羅性検査そのものの必須条件ではない。実装できる型を制限しない普通のインターフェースでも、パターンを使ったswitch式は網羅性を要求する(この場合は誰でも実装を足せるので、結局 default が要ることになる)。sealed が効くのは、「実装はこの三つで全部だ」とコンパイラに教えられる点である。だからこそ、四つ目を足したときに既存のswitchが壊れて教えてくれる。

残る差と、共通の罠

では差は消えたのか。二つ残っている。

一つは、すでに見た後付け実装だ。分岐の網羅性はどちらも検査できるが、型に振る舞いを足す側の自由度が違う。

もう一つは差ではなく、両言語に共通する罠である。Rustの _(それ以外)も、Javaの default も、書いた瞬間に網羅性検査を手放す。

enum Payment { Card, Bank, Cash, Point }   // ← Point を後から追加した
fn describe(p: &Payment) -> &'static str {
    match p {
        Payment::Card => "カード",
        Payment::Bank => "振込",
        _ => "現金",                        // ← Point もここに吸い込まれる
    }
}

これはコンパイルが通り、Point を渡すと「現金」と表示される。新しいバリアントを追加したのに、誰も気づかない。 Java側も同じ形を実測した。sealedなswitchに default を置くと、後から追加した Point はコンパイルエラーにならず、やはり「現金」として通る。コンパイラに漏れを教えてほしいなら、_default を使わずに全部の腕を書く――これは言語機能ではなく書き方の規律であり、両言語で同じことが言える。

不在と失敗を、何で表すか

小さいが、日々のコードに最も効く違いがここにある。

不在の表現。 Javaの Optional はライブラリの型であり、Optional 型の変数そのものにnullが入りうる

static Optional<String> find(boolean ok) {
    return ok ? Optional.of("見つかった") : null;   // ← Optionalにnullを返せてしまう
}
// 呼び出し側: find(false).isPresent() → NullPointerException

コンパイルは通り、実行時にNPEになる(実測済み)。「不在を表す型」自体が不在になれる、という構造は言語からは防げない。Rustの Option は言語の唯一の不在表現で、nullという概念がそもそも存在しない。

失敗の表現。 Javaの検査例外は、握りつぶしてもコンパイルが通る。

try {
    return new String(new FileInputStream(path).readAllBytes());
} catch (IOException e) {
    // 何もしない(握りつぶし)
}
return "";

Rustの Result は戻り値なので、無視すると警告が出る。

warning: unused `Result` that must be used
  = note: this `Result` may be an `Err` variant, which should be handled

もっとも、Rustでも let _ = read(...) と書けば黙って捨てられる。差は「握りつぶしの禁止」ではなく、何も考えずに書いたとき、どちらに倒れるかである。Javaは黙って素通りし、Rustは警告を出す。

そして ? 演算子が、失敗の伝播を一文字で書かせる。? の付いた行は「失敗したらそのまま呼び出し元へ返す」の意味になる。

境界のエラー設計――宣言するか、その場で包むか

議論でコード対比の題材として最有力に挙がったのが、ライブラリの内部エラーを外に漏らさない設計だった。どちらの言語でも定石だが、書く場所が違う。

Javaは、投げ直す場所で包む。

class ConfigException extends RuntimeException {
    ConfigException(String msg, Throwable cause) { super(msg, cause); }
}

static int readPort(String path) {
    try {
        String text = Files.readString(Path.of(path));
        return Integer.parseInt(text.trim());
    } catch (IOException | NumberFormatException e) {
        throw new ConfigException("設定を読めない: " + path, e);  // ラップして投げ直す
    }
}

Rustは、変換規則そのものを先に宣言しておく

#[derive(Debug)]
enum ConfigError {
    NotFound(io::Error),
    BadNumber(num::ParseIntError),
}
impl From<io::Error> for ConfigError {
    fn from(e: io::Error) -> Self { ConfigError::NotFound(e) }
}
impl From<num::ParseIntError> for ConfigError {
    fn from(e: num::ParseIntError) -> Self { ConfigError::BadNumber(e) }
}

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let text = fs::read_to_string(path)?;   // io::Error → ConfigError に自動変換
    let port = text.trim().parse()?;        // ParseIntError → ConfigError に自動変換
    Ok(port)
}

From の実装を書いておけば、? が通過するたびに内部エラーが境界エラーへ自動で変換される。逆に言えば、変換の宣言を書き忘れると ? がコンパイルを通らない

error[E0277]: `?` couldn't convert the error to `ConfigError`
  = the trait `From<std::io::Error>` is not implemented for `ConfigError`

境界を通す前に、通し方を宣言しろ、とコンパイラが要求している。どちらの数字が良いという話ではなく、Javaは境界を「例外を包む場所」として書き、Rustは境界を「型変換の宣言」として書く。エラーの境界がどこにあるかを、コードのどこで読み取れるかが違う。

DIとAOPは、移植できない

第2回で最も正直に書くべき箇所がここである。

JavaのDIコンテナやAOPは、リフレクションと動的プロキシの上に成立している。Rustには実行時リフレクションが実質的に存在しないため、Springの世界観をそのままRustへ持ち込むことはできない。代替は、明示的なコンストラクタ配線か、コンパイル時に展開されるマクロになる。

これを「魔法がなくなって健全」とだけ書くのは公平ではない。議論では、Javaが実行時の仕組みで得ていたものが具体的に挙げられた。

  • ロギング・トランザクション・セキュリティといった横断的関心事の一元管理。アノテーション一つで全DBアクセスに監査を強制する、といった統制はAOPの独壇場だった
  • 設定による差し替え。テスト環境ではインメモリ実装へ、といった切り替えを、コードを変えずに行える

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

コンパイル時に全部確定させることが、常に正義とは限りません。設計を段階的に育てている時期には、実行時に差し替えられる柔らかさそのものが価値でした。これは議論でも灰島さんと意見が一致した数少ない点です。

一方で、Javaが支払ってきたコストも明確で、配線ミスが実行時まで分からないこと、フレームワークの裏側を知らなくても動いてしまうことだった。Rustではそのリスクが消える代わりに、横断的関心事は型と明示的なコードに降りてくる。切り替えの柔らかさはコード量として可視化される――それを得と見るか損と見るかは、システムの時期と性質による。

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

統制の観点では、一元管理の場所が消えるのは軽くない話です。「全部のコードに書いてある」は「どこか一箇所で保証している」の代わりにはなりません。Rustで同じ統制をやるなら、設計の別の場所で担保する必要があります。

Java経験者が読み違えやすい点

議論で挙がった誤解を、確認した事実とともにまとめる。

  1. 「traitはinterfaceと同じ」 ── 違いは後付け実装にある。interfaceの実装宣言は型定義の側にしか書けず、traitは(孤児規則の範囲で)利用者側からも書ける。
  2. 「defaultメソッドとtraitのデフォルト実装は同じもの」 ── 形は同じでも、出自が違う。Javaのdefaultメソッドは、既存の実装を壊さずにinterfaceへメソッドを足すための後方互換の道具として導入された。Rustのデフォルト実装は最初から既定動作を積極的に提供する道具で、たとえば標準のIteratorは next 一つを実装すれば残りの多数のメソッドが付いてくる設計になっている。
  3. 「RustにはDIがない」 ── 依存を注入する行為自体は普通にある(コンストラクタで渡す)。無いのは実行時の自動配線であり、それは欠落ではなく設計選択である。
  4. 「実行時の柔軟性は悪」 ── 前節のとおり、実行時の差し替えが正当に価値を持つ場面はある。問いは善悪ではなく、自分の設計意図をどちらの語彙がより正直に表現できるかである。

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

  • どちらの分岐(sealed+switch / enum+match)が優れているかは論じていない。網羅性検査という機能は両方にあり、残る差は後付け実装と、_default をめぐる書き方の規律である。
  • DI・AOPの代替をRustでどう設計するかの具体構成(マクロベースのフレームワーク等)は、エコシステムの成熟度の話と切り離せないため、第5回(いま強い領域と実績)へ回す。
  • ディスパッチ方式の違いによる性能差は測っていない。単相化はコード複製を伴い、動的呼び出しはJITの最適化対象になるため、優劣はワークロード次第である。
  • 議論では「JavaのイテレータとRustのIteratorの必須メソッド数の対比」も題材候補に挙がったが、参加者間で確認が取れなかったため本文では扱っていない。

梧桐(編集長・進行)

今回の持ち帰りも一点に絞ります。両者の設計語彙の差は、道具のあるなしではなく、設計の意図をいつ・どこに・どの形で表明するかの差です。Javaは一部を実行時と設定に委ね、Rustはコンパイル時と型に集めた。次回は、その「型に集める」がメモリとリソースの管理でどう効くかを見ます。

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

キーワード:#トレイト#sealed#網羅性検査

出典

コメント