🔎

Rustの参照外しについて調べてみた

2024/12/01に公開3

はじめに

はじめまして。レバウェル開発部のりょうすけです。
私の所属するレバウェル開発部のアドベントカレンダー1日目を担当させていただきます。

プライベートでRustを学習する中で、「参照」した変数がC言語と異なり、値まで自動的に参照外しされることに驚きました。「なぜRustは値まで自動的に参照外しされるのか?」「参照が深くなる場合、実行時にはどのように処理されているのか?」という疑問が浮かび、その答えを知るために内部実装を調べてみることにしました。

CとRustの参照の動作

CとRustの参照動作の違いを確認します。

Cでは以下のようにプログラマーが明示的に参照外し(*)をしないとポインタが出力されます。
※ポインタアドレスは環境や実行条件により異なるため、出力が異なる場合があります。

#include <stdio.h>

int main() {
    const char *name_1 = "ryosuke";
    const char **name_2 = &name_1;

    printf("*有り: %s\n", *name_2);  // 参照外しをして値を出力
    printf("*無し: %p\n", (void *)name_2);  // ポインタを出力
    return 0;
}
*有り: ryosuke
*無し: 0x7ffe974fdfc0

Rustでは参照外し(*)を明示的に使わなくても、値そのものが出力されます。

fn main() {
    let name_1 = "ryosuke";
    let name_2 = &name_1;

    println!("*有り={}", *name_2); // 参照外し
    println!("*無し={}", name_2);
}
*有り=ryosuke
*無し=ryosuke

Rustは参照を使っていても値そのものを扱えるようになっており、この動作がC言語との違いです。

参照のポインタ

Rustの参照について知るために変数のポインタを確認することにしました。
以下のコードで変数のポインタを表示してみました。

  • name_1
  • name_2
  • name_2の参照先
fn main() {
    let name_1 = "ryosuke";
    let name_2 = &name_1;

    println!("name_1 p={:p}", name_1);
    println!("name_2 p={:p}", name_2);
    println!("name_2 *p={:p}", *name_2); //「*」は参照外し
}
name_1 p=0x1004eed30
name_2 p=0x16f946c88
name_2 *p=0x1004eed30

name_1name_2のポインタは異なりますが、name_2の参照先がname_1であることが確認できました。
さらにもう1段階参照を増やしてみます。

fn main() {
    let name_1 = "ryosuke";
    let name_2 = &name_1;
    let name_3 = &name_2;

    println!("name_1 p={:p}", name_1);
    println!("name_2 p={:p}", name_2);
    println!("name_2 *p={:p}", *name_2);
    println!("name_3 p={:p}", name_3);
    println!("name_3 *p={:p}", *name_3);
    println!("name_3 **p={:p}", **name_3);
}
name_1 p=0x102f6ecd0
name_2 p=0x16cec6b20
name_2 *p=0x102f6ecd0
name_3 p=0x16cec6b30
name_3 *p=0x16cec6b20
name_3 **p=0x102f6ecd0

name_3の参照先はname_2となっており、name_2を経由してname_1を参照していることが確認できました。
ここから、参照した変数は直接値を参照しているのではなく、Cと同じように参照先のアドレスを持っており、Rustでは何らかの方法で自動的に値まで参照外しされていることがわかります。

以下の図は、name_3から文字列までのポインタの関係を視覚的に示したものです

Derefトレイトの実装

Rustでは、Derefトレイトが参照外しを可能にする仕組みの一部を担っています。
Rust公式ドキュメント](https://doc.rust-jp.rs/rust-nomicon-ja/vec-deref.html)やライブラリコードを読むと、参照外しの実装がDerefトレイトを通じて行われていることがわかります。
実装を確認してみました。

以下はDerefトレイトの定義の一部です:
library/core/src/ops/deref.rs

以下のコードで、「*」のシンタックスシュガーが定義されています。

#[lang = "deref"]
#[doc(alias = "*")]
#[doc(alias = "&*")]

以下のコードを見ると、Deref関数は参照外しを行い、値でない場合は再帰的に自身を呼び出していることが分かります。この実装により、複数回参照された変数であっても値まで参照外しがされます。

#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_diagnostic_item = "Deref"]
#[cfg_attr(not(bootstrap), const_trait)]
pub trait Deref {
    /// The resulting type after dereferencing.
    #[stable(feature = "rust1", since = "1.0.0")]
    #[rustc_diagnostic_item = "deref_target"]
    #[lang = "deref_target"]
    type Target: ?Sized;

    /// Dereferences the value.
    #[must_use]
    #[stable(feature = "rust1", since = "1.0.0")]
    #[rustc_diagnostic_item = "deref_method"]
    fn deref(&self) -> &Self::Target;
}

#[cfg(bootstrap)]
#[stable(feature = "rust1", since = "1.0.0")]
impl<T: ?Sized> Deref for &T {
    type Target = T;

    #[rustc_diagnostic_item = "noop_method_deref"]
    fn deref(&self) -> &T {
        *self
    }
}

コンパイル結果の確認

コンパイルしたファイルを実行する時に、複数回参照された変数が毎回参照外しされているかを確認するため、アセンブリコードを調べてみました。

Rustファイル

以下のコードのアセンブリコードを確認します。

fn main() {
    let name_1 = "ryosuke";
    let name_2 = &name_1;
    let name_3 = &name_2;

    println!("name_1={}", name_1);
    println!("name_3={}", name_3);
}

アセンブリ

以下のコマンドで出力されるアセンブリコードを確認します。

cargo rustc --release -- --emit=asm
→ target/release/deps/<crate_name>-<hash>.s

コンパイル後のコードは以下の通りで、再帰的な参照外しはされず、最終的な値のアドレスを直接たどる形に最適化されていることが分かります。

  • 変数「name_1」の設定
    • Lloh3にてオフセット(l___unnamed_2@PAGEOFF)を設定
  • 変数「name_2」の設定
    • Lloh3にてname_1の値を設定している
  • 変数「name_3」の設定
    • Lloh7で計算したname_2のアドレスがLloh11で設定されている
      ※関係する部分のみ抜粋
      ※以下のアセンブリコードは筆者の環境で得られたもので、コンパイラのバージョンや最適化設定、CPUアーキテクチャによって出力は異なります
Lloh2:
	adrp	x8, l___unnamed_2@PAGE
Lloh3:
	add	x8, x8, l___unnamed_2@PAGEOFF
	mov	w9, #7
	stp	x8, x9, [sp]
	mov	x8, sp
	add	x9, sp, #16
	stp	x8, x9, [sp, #16]
Lloh7:
	add	x8, x8, l___unnamed_3@PAGEOFF
	mov	w19, #2
	stp	x8, x19, [sp, #32]
	sub	x20, x29, #48
	mov	w21, #1
	str	x20, [sp, #48]
	stp	x21, xzr, [sp, #56]
	add	x0, sp, #32
	bl	__ZN3std2io5stdio6_print17h03106e215f254c7bE
	add	x8, sp, #24
Lloh11:
	add	x10, x10, l___unnamed_4@PAGEOFF
	stp	x8, x9, [x29, #-48]
	stp	x10, x19, [sp, #32]

l___unnamed_2:
	.ascii	"ryosuke"

まとめ

  • 参照外しの実装はDerefトレイトに依存している
  • Derefトレイト内では値を参照するまで再帰的に実行されている
  • 複数回参照された変数はコンパイル時に最適化されるため実行時には再帰処理は発生しない

感想

Rustを学ぶ中で、「なぜ自動参照外しが可能なのか?」という疑問から始まり、Derefトレイトの仕組みやコンパイル時の最適化まで深掘りしました。調査を通して、Rustが効率的な設計を目指していることを再認識できました。
今後も言語の使い方を学ぶだけでなく、その裏にある仕組みを探ることで、より深く理解したいと思いました。Rustへの理解と愛着がさらに深まりました。

この記事が、同じ疑問を持つ方や、Rustを学ぶ方の参考になれば幸いです。

参考

The Rust Programming Language 日本語版

  • 参照と借用
  • Derefトレイトでスマートポインタを普通の参照のように扱う

Discussion

kanaruskanarus

感想
… Derefトレイトの仕組みや実行時の最適化まで深掘りしました。…

「実行時の最適化」だと JIT コンパイル的な何かを想起させてしまうように思います。「コンパイル時の最適化」でいいのでは?
( ここ以外の「実行時」の使われ方は「実行時にどんな処理が走っているのか」というニュアンスなので違和感ないです )

りょうすけりょうすけ

kanarusさん

コメントありがとうございます。
ご指摘部分と念のため全体も見直して修正させていただきました。

技術的にそこまで自信がない部分だったので、とても助かりました!