4
2

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.

限界開発鯖Advent Calendar 2023

Day 19

Rust の &T は不変参照ではない

Last updated at Posted at 2023-12-18

結論

&T は不変参照 (immutable reference) ではなくて共有参照 (shared reference) です. これは同時に複数個存在できる参照であって, 不変とは限りません.

どういうことなのか

Rust 言語リファレンスには次のような記述があります.

Shared Reference (&)

Shared references point to memory which is owned by some other value. When a shared reference to a value is created, it prevents direct mutation of the value. Interior mutability provides an exception for this in certain circumstances. (後略)

-- The Rust Reference より

(以下は拙訳)

共有参照 (&)

共有参照はとある他の値によって所有されたメモリを指します. 共有参照が作成されると, その値の直接の変更を阻害します. 内部可変性 というのは, 特定の状況でこれに対する例外を設けるものです. (後略)

&T は「共有参照」という名前であるとはっきり書いてあります. なお, &mut T の方は可変参照となっています. ややこしいですね.

また, 内部可変性 という例外事項についてここで示唆されています.

エイリアシングルール

まず Rust のメモリモデルの中心となる考え方として, 次のような エイリアシングルール があります.

特定のメモリ領域に対して, それを変更できる 別名 (エイリアス) が存在してはならない

ここで「メモリ領域を変更できるエイリアス」というのは, mut な変数に関するルール以外に 既に何か別の共有/可変参照があるときに更に可変参照を作る ことも指しています. ここから, かの有名な構図「複数の共有参照 XOR 1 つの可変参照」が導き出されます.

また, エイリアスが存在していることをエイリアシングと言います. そして, エイリアシングする Rust プログラムは 未定義動作 になるとしています. 未定義動作になるコードを書いたが最後, どのように動いたり最適化されるのかは 予測不能 です (余談: このことをよく鼻から悪魔, nasal demons などと言います). エイリアスに該当する参照を作った時点で, たとえその参照にアクセスしないコードでも未定義動作です.

さて, そのようなルールを徹底しながら任意のアルゴリズムを実装することは理論上可能なはずです. しかし, 設計上の概念を反映させたオブジェクト指向プログラミングなどの実現やマルチスレッドプログラミングでの状態の受け渡しをやり始めると辛いものがあります. この要件はメモリ安全性やアグレッシブな最適化をもたらす代わりに非常に厳しい足枷となっています.

内部可変性とは

しかし特定の状況下であれば, 実行時にこの「エイリアスが存在しない」制約をクリアしていると保証できることがあります. 例えば,

  • 別スレッドに渡せず, 書き込みしかできない
  • この参照から共有参照/可変参照を作った個数を記憶して, ルール違反する作り方を拒否している
  • 一度書き込むと二度と変更できない
  • ……

このような制約が満たされたような, 参照型を拡張した型を実装するというアプローチはかなり良さそうです. これを実装するにあたって, 足枷となる要件を無視した unsafe な操作を提供する型 UnsafeCell が用意されています. といっても, これを直接扱うのは足枷どころか足ごと撃ち抜くでしょう. これに対する操作が安全であることを証明し保証するのはプログラマーの役目ですから.

Cell 展覧会

動作を保証するための論理学的労力をかけずとも, 安全な API で包んだ便利な実装例が std にいくつか用意されています. ここからはその実装を見ていきましょう.

Cell

Cell は, 先ほどの「別スレッドに渡せず, 書き込みしかできない」型を実装したものです. しかし, なぜこの性質でエイリアスを排除できているのか非自明でしょうから, 先にこれについて解説します.

あなたはこれから Cell<T> という参照のような型を作ろうと考えています. さてさて, どんな状況なら &Cell<T>書き込む瞬間だけ &mut T へと昇格させてよいのでしょうか.

ふつうの参照型は, いくらでも別スレッドに渡すことができます. つまり, 参照型を介した参照先へのアクセスは様々な別スレッドから同時にやってくる可能性があります. こちらのスレッドがアクセス中なのに, 別のスレッドからさらなるアクセスが割り込んでくることもよくあります.

しかし, 別スレッドに渡せない型であればそれを介したアクセスは同じスレッドに制限されます. 参照先へアクセスする存在は, 常に我がスレッド 1 つまでです.

しかし, これだけではエイリアスを排除できていません. まだ &Cell<T> から作った共有参照 &T が存在する可能性があります. この状態で &Cell<T>&mut T へ昇格させても, 別スレッドの &T から変更途中のおかしな値が見えるかもしれません. 読み取れてしまうのが問題なのなら……読み取りできなくしてしまえばよろしい. &Cell<T> から &T を作る操作を提供しないのです. こうすれば昇格しても問題なくなります.

🟩🟩🟩
🟩🟩🟩🟩
 🟩🟩🟩
    🟧
    🟪   虹 Cell ピクミンは
    🟦🟦🟥  何があっても
 🟦🟥🟥🟩🟩  "絶対に"
 🟥⚫🟩🟨⚫
 🟩🟨🟨🟧🟧  『共有参照を漏らさない』
  🟨🟧🟪
    🟪🟦
  🟦🟦🟥🟥
 🟨 🟥🟩🟨🟧
🟩 🟥🟩🟨 🟪
🟦 🟥🟩🟨🟧 🟥
🟪 🟩🟨🟧🟪 🟦
  🟨  🟦
  🟧   🟥

しかし, 値が読み取れないのは困るとかそういうレベルではありません. /dev/null を作ってるわけではないのですから. ですので, &Cell<T> から &T が得られなくても, 値の T の方は得られるメソッドをいくつか提供することにします.

結局のところ, 独自の参照型のようなものを用意して次の性質を与えてしまえばよいわけです.

  • 別スレッドに &Cell<T> を渡せないようにする → impl !Sync (実は UnsafeCell もこうなので不要)
  • 共有参照には変換できない
  • 書き込みや値の読み取りはできる

ここで stdCell の実装を見てみると, 実際に次のようなメソッドが提供されています.

  • set - 値を設定します.
  • replace - 新しい値を設定して, 古い値を取り出します.
  • take - T: Default ならば, その T::default() を設定して, 古い値を取り出します.
  • get - T: Copy ならば, 値をコピーして取り出します.

いくつかの API は書き込んだ後に古い値を返しますが, 古い値はもう共有されなくなった値です. 古い値を取っておきながら新しい値で上書きすれば, Clone でなくても常に安全に古い値をお持ち帰りできます. この処理は std::mem::replace そのものですので, 詳しくはそちらをご覧ください.

Cell::get の制約が T: Copy の理由

この Cell::getT に対する追加の制約が気になった人も多いでしょう. そこで T: Clone だとまずいことが起きるという例を示してみます.

次のような構造体 CounterExample を考えます.

use std::cell::Cell;
use std::rc::Rc;

struct CounterExample {
    data: i32,
    link: Rc<Cell<Option<CounterExample>>>,
}

impl Clone for CounterExample {
    fn clone(&self) -> Self {
        eprintln!("before: {:?}", self.data);
        // ここでリンクを切ってみる. これは自体 safe な操作
        self.link.set(None); 
        eprintln!("after: {:?}", self.data);
        Self { data: data.clone(), link: self.link.clone() }
    }
}

この型のオブジェクトを, 自己参照するような形で構築しておきます. そして, これを参照先から複製してくる形で無理やり読み取ってみましょう. この読み取り操作が安全なら T: Clone でもいいはずですが……

main.rs
fn main() {
    let cell = Rc::new(Cell::new(None));
    cell.set(Some(CounterExample {
        data: 12345,
        link: cell.clone(), // リンクに自身を使う!
    }));
    

    unsafe {
        // 無理やり読み取る
        let data = (*Cell::as_ptr(cell.as_ref())) // &Option<CounterExample>
            .clone() // Option<CounterExample>
            .unwrap() // CounterExample
            .data;
        println!("{}", data);
    }
}

これを実行してみるとセグフォが起こります.

% rustc main.rs && ./main
before: 12345
after: 14291552 # ← この値は実行のたびに変わります
zsh: segmentation fault  ./main

なぜなら, 次のようなことが起きているのです.

  1. cell: Rc<_> からその中身に対する参照 (&Option<CounterExample> 型) を作る.
  2. 参照を複製して新しい値 (Option<CounterExample> 型) を作る.
    1. Some(self) を複製する.
      1. self.data を読むと期待通りの値が得られる.
      2. self.link.set(None). self.linkNone で上書きする.
        1. 一時的に &self.link&Cell<Option<CounterExample>> から &mut Option<CounterExample> に昇格させる.
        2. しかし self もこの領域に対する共有参照なので, エイリアシング発生! † UB 確定 †
        3. そのまま上書きして, Rc で共有している Some(*self) は drop されて None に置き換えられる.
        4. この可変参照は破棄される.
      3. self が指すメモリ領域は既に drop されている! ダングリング参照発生!
      4. self.data を読むと期待通りの値が得られない!
  3. unwrap してその中の data を読み取ろうとしたが, これは解放済み領域!

そういうわけで, T: Clone だけでは特定の safeimpl Clone 実装のときに壊れてしまいます. T: Copy なら「データそのものをビット単位で移してもよい」ことが保証されて危険な実装が混入しないので, この場合に限り複製してくるような読み取り操作ができるわけです.

ここで Clone よりは制約が強いが Copy よりは弱い trait を導入して Cell で扱える型を増やすことはできるでしょう. その辺りの解説は難解かつ話題が逸れてしまうので, 他に譲ります.

RefCell

RefCell は, 「この参照から共有参照/可変参照を作った個数を記憶して, ルール違反する作り方を拒否している」型を実装したものです. 専用のメソッドを介して, 共有参照を意味する型 Ref や可変参照を意味する型 RefMut を産生します. そして, これらの個数を Cell<BorrowFlag> で数えています. Cell を使っていますから RefCell も当然 !Sync です. ソースコードによると, BorrowFlag の定義は以下のように なっています.

// Positive values represent the number of `Ref` active. Negative values
// represent the number of `RefMut` active. Multiple `RefMut`s can only be
// active at a time if they refer to distinct, nonoverlapping components of a
// `RefCell` (e.g., different ranges of a slice).
// (後略)
type BorrowFlag = isize;

(拙訳)

正の値は活動中の Ref の数を表します. 負の値は活動中の RefMut の値を表します. 複数の RefMut は, 別々の, RefCell 内の被覆しない要素 (例: スライスの相異なる範囲) をそれぞれ参照する場合にのみ活動中になりえます.
(後略)

どうやら「複数の共有参照 XOR 1 つの可変参照」という構図だけではなさそうです. 例えば, 可変参照を何個かの別々の可変参照へと切り分けることもサポートしているようです.

RefCell::borrow メソッドなどで産生できる Ref は, 以下のような関連関数を提供しています. Deref を実装しているので, 名前の衝突を防ぐために関連関数となっているようです.

  • Ref::clone - 参照を複製し, 共有参照の個数を増やします.
  • Ref::map - 参照の内容をクロージャで変換します.
  • Ref::filter_map - 参照の Optional な内容をクロージャで変換します.
  • Ref::map_split - 参照の内容をクロージャで 2 つに割り, 共有参照の個数を増やします.

RefCell::borrow_mut メソッドなどで産生できる RefMut も, clone を除いて Ref と同じ関連関数を提供しています. この RefMut::map_split を通じて, RefMut の個数が 2 個以上になることがあるようです.

OnceCell

OnceCell は, 「一度書き込むと二度と変更できない」型を実装したものです. 今までの実装とは異なり, これは &OnceCell<T> から &T を作成できるという大きな違いがあります. なぜこの書き込みの条件だけでこの操作を許容できるのでしょうか.

といっても仕掛けは単純です. 中身は UnsafeCell<Option<T>> であり, UnsafeCell の性質上これも !Sync ですから常にシングルスレッドで考えられます. もし一度しか書き込まれないのであれば, OnceCell の外において可変参照は発生しません.

あとは 一度だけ書き込む ことしかできない API を提供するだけです.

  • get - 格納した値を Option<&T> で返します.
  • set - まだ格納されていないなら, 格納のために可変参照へ昇格して新しい値を設定します. 既に格納されているなら, 格納しようとしていたその値をそのまま返します. &T が外に存在するかもしれないので, 格納されているかどうかチェックする時点では &mut T へ昇格できないようです.
  • get_or_init - 格納されていないなら初期化関数を実行してから, 格納されている値を &T で返します.

まとめ

ここまで色々と内部可変性の実装を見てきました. こうして見ると, 共有参照を使って参照先に書き込むことがそこまで珍しいことではないことが分かると思います.

状態の変更を伴うようなメソッドというだけで, レシーバーを迷わず &mut self にしていませんか? リソースを共有したいだけのオブジェクト指向な設計であれば, &self と内部可変性にするほうが適切かも……?

pub trait FooRepository {
    fn create(&self, new: Foo) -> Result<(), CreateError>;
    fn read(&self, id: Id<Foo>) -> Result<Foo, ReadError>;
    fn update(&self, new: Foo) -> Result<(), UpdateError>;
    fn delete(&self, id: Id<Foo>) -> Result<(), DeleteError>;
}

さて, この記事で見てきたものは UnsafeCell を触る !Sync なものばかりでした. 他に, AtomicU32 のようなアトミック整数もあります. これらは共有参照であってもアトミック命令によって Send + Sync のままで読み取り/変更/書き込み操作を実現できます. スレッド安全にリソースを共有するコンテナはほとんどのこの性質を利用して実装できます. アトミックまわりは別の機会に解説するかもしれません.

4
2
0

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
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?