2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

RUSTで相互参照

Last updated at Posted at 2023-09-08

概要

RUSTでオブジェクト同士の相互参照を(無理やり)やるための方法.オブジェクト指向でGCがある言語では,ごく普通にやりそうな構造ですが,RUSTでやろうとすると結構たいへん.大変ということは基本的にやってはいけないアンチパターンだと思うけれども,せっかくできたので,記録として残しておく.

目的

そもそもなぜ相互参照をしたかったか,というと,オブジェクト同士を相互参照し,お互いのメソッドを呼び合いながらデータの更新をしたかったから.具体的にJava風に書くと

コードはイメージです
class Parent {
    byte[] data;
    Child child;

    void Parent() {
        data = new byte[100];
        child = new Child(this);
    }

    public byte[] read(int begin, int end) { ... } // dataからある範囲のバイト列を読んで返す
    public void write(int idx, byte[] val) { ... } // dataのidxからvalを書き込む
}

class Child {
    Parent parent;
    void Child(Parent parent) {
        this.parent = parent;
    }

    public byte[] readData(int idx int len) {
        return parent.read(idx, idx+len);
    }
}

これをRUSTでやろうとすると非常に大変.

大変な理由

この構造がRUSTで大変なのは,以下の理由だと結論づけた.

  1. RAIIとコンストラクタ
  2. 所有権
  3. 不変・可変参照の制約

RAIIとコンストラクタ

RAII (Resource Acquisition Is Initialization) とはリソースの確保をオブジェクトの初期化時に行い,リソースの開放をオブジェクトの破棄と同時に行うこと.上のコードの例で言えば,Parent作成時にChildを作成し,その参照とbyte[]を初期化している.また,Child生成時にParentの参照をコンストラクタに渡している.Javaにはコンストラクタがあるので難なくこの構造を作ることができるが,RUSTにはコンストラクタという仕組みが存在しない.その代わり,RUSTではnew()という関連関数を作って初期化することが推奨されている.しかし,このnewはメソッドではないので,その中でselfが使えない.ということは,ParentChildを作るときには自身の参照が必要だが,その時点ではまだParentは生成されていない,という鶏と卵のような矛盾が生じる.この矛盾を解消するには,お互いの生成時には一旦NULLにしておいて,あとからセットする,という戦略を取るしかないので,後述するようにOptionを使うしかないが,このOptionの扱いも一苦労...

所有権

RUSTでは,値の所有者は1人だけで,基本がmoveセマンティクス,そして他の関数やオブジェクトに一時的に利用させるために借用という仕組みが用意されている(詳細は省略).なので,お互いに参照を取り合う構造の場合,

コードはイメージです
struct A {
    valb: &B
}

struct B {
    vala: &A
}

のような構造になるが,RUSTでは構造体に参照を取ることは推奨されていない.これはライフタイムの概念があるからで,値の実態は参照よりも長いことが保証されていなければならず,コンパイル時にそれが確認できないといけない.この制約をコンパイラにチェックさせるために,RUSTはライフタイムアノテーションを必要とし,これが入るとややこしくなる(だから推奨されてない?).単純なデータ型ならばあまり難しくないが,構造体の場合は内部に他のデータ型を内包している可能性が高く,再帰的にデータの所有者や借用ルールを考えなければならない.
よって,正確には以下のように書かかければコンパイルエラーになる

コードはイメージです
struct A<'a> {
    valb: &'a B
}

struct B<'b> {
    vala: &'b A
}

とはいえ,参照を持たせなければいけないので,この問題を解決しなければならない.

不変・可変参照の制約

RUSTの参照は

  1. 不変参照は何個でも同時に生成可能
  2. 可変参照は1つだけ
  3. 不変参照と可変参照が同時には存在できない

という制約がある.今回のように,参照を別の構造体に含めるような設計の場合,自由にメソッドを呼び合いたいし,ときには参照先が内包するデータを更新したい,という要求が発生する(Javaの例で,Parentwriteを呼び出すようなケース).このときには可変参照が必要で,だからといって

struct A<'a> {
    valb: &'a mut B
}

こんなことしたらBの不変参照は作れなくなり(もちろん可変参照も),使い物にならない.

解決策

この構造を実現するのに使ったのは以下の3つ

  1. Option<T>
  2. Rc<T>
  3. RefCell<T>

ごく簡単にこれらの説明をした後,具体的なコードを示す.

Option<T>

RustでNULLを表すために用いられる列挙型.相互参照するので,オブジェクト生成時にはお互いの参照をNULLにしておき,あとからセットするために使う

Rc<T>

Rustで複数の変数に所有権を与えるため利用する.Rc内部では参照カウンタで参照数を保持し,参照数が0になったときにオブジェクトを破棄する.こんなふうに使う

let orig = Rc::new(Hoge::new()) // Hogeが共有したいオブジェクト
let ref = Rc::clone(orig)       // origとrefの型は同じで,Rc<Hoge>になる.

これを使って,複数箇所であるオブジェクトの参照を保持する

RefCell<T>

(可変)参照をコンパイル時ではなく,実行時にチェックして取得できる仕組み.不変参照はborrow(),可変参照はborrow_mut()を呼び出すことで取得する.こんなふうに使う

struct A {
   name: String
}

let a:A  = A{name: "abc".to_string()}
a.name.push('d') // コンパイルエラー

このコードでは,aが不変なので,a.name.push('d')が呼べない.これを呼ぶためには

let mut a:A ...

このように,aを可変にしなければならない.
ところが,RefCellを使うと

struct A {
    name:RefCell<String>
}

let a:A = A {name: RefCell::new("abc".to_string())}
a.name.borrow_mut().push('d');

このように,aが不変であっても,実行時にnameの可変参照を取得して可変参照が必要なメソッドを呼び出すことができる.これを使って,可変参照にすることなく参照を持ち合う.

実装例

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


struct TestA {
    valb: Option<Rc<RefCell<TestB>>>,
}

impl TestA {
    fn new() -> Self {
        TestA {
            valb: None,
        }
    }

    fn setB(&mut self, val: Rc<RefCell<TestB>>) {
        self.valb.replace(val);
    }


    fn call_b3(&self) {
        if let Some(v) = self.valb.as_ref() { // as_refがポイント
            v.borrow().ihello();
            v.borrow_mut().mhello();
        }
    }

    fn ihelloA(&self) {                      // 不変参照のメソッド
        println!("iHelloA");
    }

    fn mhelloA(&mut self) {           // 可変参照が必要なメソッド
        println!("mHelloA");
    }

}

struct TestB {
    data:Vec<i32>,
    vala: Option<Rc<RefCell<TestA>>>,
}

impl TestB {
    fn new() -> Self {
        TestB {
            data: Vec::new(),
            vala: None,
        }
    }

    fn setA(&mut self, val: Rc<RefCell<TestA>>) {
        self.vala.replace(val);
    }

    fn ihello(&self) {
        println!("iHello");
    }

    fn mhello(&mut self) {
        println!("mHello");
        self.data.push(10);
    }

    fn call_a1(&self) {        
        if let Some(v) = self.vala.as_ref() {
            v.borrow().ihelloA();
            v.borrow_mut().mhelloA();
        }
    }
}

fn main() {
    let testb = Rc::new(RefCell::new(TestB::new()));      // TestBを生成し,Rc RefCellでラップ
    let testb_ref: Rc<RefCell<TestB>> = Rc::clone(&testb); // testbの参照を生成(所有権をもつ)


    let testa = Rc::new(RefCell::new(TestA::new()));     // TestAを生成し,Rc, RefCellでラップ
    let testa_ref = Rc::clone(&testa);                   // testaの参照を生成(所有権を持つ)
    testb.borrow_mut().setA(testa_ref);                  // TestBにTestAをセット
    testa.borrow_mut().setB(Rc::clone(&testb));          // TestAにTestBをセット
    
    println!("--------------");
    testa.borrow().call_b3();                            // TestAのメソッドを実行.内部でTestBの不変参照,可変参照が必要なメソッドを呼び出す
    testb.borrow().call_a1();                            // TestBのメソッドを実行.内部でTestAの不変参照,可変参照が必要なメソッドを呼び出す.
}

基本的にはこれまでの説明と,コードを見ればわかると思うけど,1つだけ補足.普通,Option<T>で中身がSomeの時,if let Some(v>式で中身を取り出すが,実はこの時vTがmoveされる.通常であれば問題ないが,今回Optionの中身は構造体が所有者なので,メソッドの中で所有権の移動が起こると,その後Optionの中身が使えなくなり,そのためにコンパイルエラーになってしまう.
そこで,

if let Some(v) = self.valb.as_ref() { // as_refがポイント

のように,Optionas_ref()を呼び出すことで,Option<T> -> Option<&T>を返してもらい,if letにつなげる.こうすると,所有権の移動が起こらないので,コンパイルエラーを回避できる

結論

とりあえず,出来ることは出来るけど,あまり良くない.今回のように,オブジェクト間で内部構造を変更するメソッドを呼び出すならば,その都度可変参照をメソッドの引数として与え,可変参照はそのメソッドだけで完結させるような設計にすべきだと思う.
間違いやより良い解決策があればぜひご指摘ください.

参考

  1. RcとCell/RefCellの関係
  2. 相互参照についての考察
  3. RcとRefCellの理解度
  4. Rustのグラフ表現
2
0
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?