RustのArena Patternは依存関係逆転の原則(DIP)の実装だった話

 最近『Clean Architecture』を読んでいたら、RustのArena Patternが実はSOLIDの「依存関係逆転の原則(DIP)」にめっちゃ似ていると気づいた。ので共有。

前提 : Rustの所有権と木構造のジレンマ

 Rustにはオブジェクト(インスタンス)について

  • 各値には必ずオーナーがいる
  • 同時に複数のmutable参照はNG
  • 循環参照は基本的に作れない(Rc/Arc + Weakを使わない限り)

という制約があり、自然とデータ構造は木構造になる。

    graph TD
  Root --> A
  A --> B
  A --> C
  B --> D
  B --> E

 他言語だと親→子→親みたいな参照が当たり前。ですが、Rustでは親→子の単方向。

Arena Pattern:制約回避のテクニック

 とはいえやっぱり、再帰的な構造だったり、グラフ構造を作りたいタイミングもある。
 ここで伝家の宝刀こと、 Arena Pattern を抜けばよろしい。直接に親子関係を作るのではなくて、すべてのオブジェクトを、配列に突っ込んでしまうという代物。

// インスタンスを直接参照する代わりに、インデックスを使う
struct Arena<T> {
    items: Vec<T>,
}

struct NodeIndex(usize);

struct Node {
    value: i32,
    parent: Option<NodeIndex>,  // 親へのインデックス
    children: Vec<NodeIndex>,   // 子へのインデックス
}

 たったこれだけで循環参照の悩みから解放される。というのも、Arenaだけ適切な所有権が保持されていれば、中のオブジェクト同士はIndexを使って、擬似的に参照できるのだ!(天才)

    flowchart TD
  Index1["Index = 0"] --> Node1["Node { value: 1 }"]
  Index2["Index = 1"] --> Node2["Node { value: 2 }"]
  Index3["Index = 2"] --> Node3["Node { value: 3 }"]

「依存」について

 ここからの主張について誤解が無いに、先にこの記事での「依存」という言葉の運用を伝えたい。ここでの依存は、「あるオブジェクトが他のオブジェクトを参照 or 保有すること」としている。
 つまり、クラスなり構造体なりに、他のクラスが含まれていることを、依存と称している。

Arenaはメモリ上で、依存関係の逆転を起こす

 さて、改めて先のArena Patternを、依存という観点からみてる。DIPでは、

  1. 高水準モジュールは低水準モジュールに依存すべきではない
  2. 両方とも抽象に依存すべきである

ということが提言されている。

 Arena Patternの場合、

  • 高水準なオブジェクトは低水準なオブジェクトに直接依存すべきではない
  • 両者ともNodeIndexという抽象に依存している
    flowchart BT
  Parent -->|index| Arena["Arena<Node&rt;"]
  Child -->|index| Arena

 ここで、ParentとChildをみると、互いにArenaに対してIndex経由で依存をしてることがわかる。従来の言語であれば、直接に

    flowchart TB
Parent --> Child

のような関係になっていたが、 親と子のどちらもがArenaに対して、indexを介した依存をもっている。 この構図でみると、正に依存性逆転だ!

Arena Patternが必要な理由

 たかだがグラフ構造を作るために、なぜこんな面倒が必要なのか。それはRustの所有権の制約条件が関わっている。

 前述の通り、所有権の関係でRustのオブジェクトの依存関係は木構造になる。しかし、muttableな参照は同時に1つしか存在できない。

 この制約によって、 子から親のメソッドを呼び出すことができない という事案が発生する。つまり、コールバックが基本的に不可能になる。

 例えば、「子要素のボタンが押されたときに、親要素のカウンタを1カウントアップする」という処理を考えてみる。Reactのようなものを、Rustで擬似的に実装するといイメージ。

 このとき、下のようなイメージを思い浮かべる人が多いと思う。(<>内のジェネリクスは考えなくてok!)

// ChatGPTによる実例
// https://chatgpt.com/share/684548dd-e750-8010-a9d6-bb3f86074127
// ライフタイムで Widget 同士の参照関係を持とうとするNaive実装
struct Widget<'a> {
    counter: i32,
    parent: Option<&'a mut Widget<'a>>,  // 親への可変参照を直接保持
}

impl<'a> Widget<'a> {
    /// 子要素の「ボタン押下」で、自身と親の counter を +1 しようとする
    fn on_button_press(&mut self) {
        // (1) 自分をインクリメント
        self.counter += 1;

        // (2) 親を持っていれば、親の counter もインクリメント
        if let Some(parent) = self.parent.as_mut() {
            parent.counter += 1;
            // ← ここで borrow checker がエラーを検出!
        }
    }
}

fn main() {
    // 親Widget を作成
    let mut parent = Widget { counter: 0, parent: None };
    // 子Widget を作成し、親への可変参照を設定
    let mut child  = Widget { counter: 0, parent: Some(&mut parent) };

    // 子のボタン押下を呼び出し
    child.on_button_press();
}

つまり、

    flowchart TB
Parent -->|依存| Child
Child -->|on_button_press| Parent

って形。

 これ、自然に見えるけど実はRustではアウト。というのも、コールバックである on_button_press で、可変参照が2つ生まれるから。
 詳しく見ると

fn on_button_press(&mut self)

まず関数自体が、self(子要素)を可変参照していて

if let Some(parent) = self.parent.as_mut()

ここで、さらに親要素の可変参照を作り出そうとしている。が、 self.parent.as_mut() では、selfを可変参照する。ここで、子要素の可変参照が2つ生まれることになる。

- self: &mut Widget(自分自身の参照)
  └─ .parent.as_mut(): &mut Widget(親の参照)
      └─ → ここでもselfを通ってる!(自己ループ)

→ 結果として、「1つのオブジェクトに2つの可変参照」が存在してしまう。
(ChatGPT 4o作)

 これは、Rustではアウト。つまり、基本的に可変・所有権はコールスタックのように、葉側から解放されない限り、根方向には適用できないのだ!

 話がややこしいが、 可変と所有―― つまり依存は基本的に、上から下しか許可されない ってこと。

 ここだけ切り取ると、ただ面倒なように見えるが、このルールを守ることで、データレースが原則発生しない。これが、とても重要。

 可変な参照が複数ある場合、外部からの意図しない変更が入ったりする。また、データがどこでどのように書き変わったのか、その経路はグラフ構造となり......追跡が大変になる。

 特に非同期・並列処理周りでは、この現象が脅威となる。というのも、どのスレッドがどんなふうに動くかは環境次第。場合によっては、データが想定通りに書き変わって動く。が、プログラマの想定外な順番で読み書きが発生し、データの整合性が壊れることも。
(ここら辺は、「データ競合」とかで調べて)

 Arenaのカラクリは、すべてを一緒くたな配列に入れ込むことで、Rustの所有権上は一つの大きな要素でしかないようにふるまわせること。Indexはただの数字なので、これはコピー可能だし、所有権から解放されている。
 このように所有権をひとまとめにする――抽象するのがArenaなのだ!

ECSで試してみた

// Entity Component System の実装
struct World {
    entities: Arena<Entity>,
    positions: HashMap<EntityId, Position>,
    velocities: HashMap<EntityId, Velocity>,
}

#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct EntityId(usize);

impl World {
    fn create_entity(&mut self) -> EntityId {
        let id = EntityId(self.entities.len());
        self.entities.push(Entity::new());
        id
    }
    
    fn add_position(&mut self, entity: EntityId, pos: Position) {
        self.positions.insert(entity, pos);
    }
    
    // システム:位置を速度で更新
    fn update_positions(&mut self) {
        for (id, vel) in &self.velocities {
            if let Some(pos) = self.positions.get_mut(id) {
                pos.x += vel.dx;
                pos.y += vel.dy;
            }
        }
    }
}

 EntityIdという抽象を噛ませるだけで、コンポーネント同士がゆるく結合されてイイ感じ。

まとめ:Rustは新しいパラダイム説

 Rustの所有権制約は一見ただの足かせに見える。が、むしろ良いアーキテクチャの道しるべ。OOPも関数型もいいけど、Rustは制約が設計を矯正してくれるのが魅力だなと思っています。

 今回の話も、「そもそも良い設計以外のコードは、コンパイルが通らない(通りにくい)」というRustの性質が現れている気がする。

 gotoの禁止から始まり、構造化・オブジェクト指向・関数型と、制約をもとにプログラミング言語は進化している。Rustはこれらの経験に学んで、いいところを上手く集約している気がする。

 実用するかは別として、その意味では学び甲斐があるなと思う。今日この頃。