😷

【Rust】「可変でないなら&strの方が良いんでしょ?」と思ってたけど、そんな単純な話でもなかった

2025/01/08に公開4

久々の更新。
タイトルの通りです。

はじめに

「Rustで文字列を扱うとき、可変でない場合は&strを使った方が効率的よね?」

...そう、私もそう思っていました。スタック領域のみを使う&strの方が、スタック+ヒープ領域を使うStringより効率的なはず。

でもそんな単純な話ではありませんでした。今回は、私が&str vs Stringについて理解を深めていく過程で気づいたことを記載します。

1. 私の「&str」に対する理解

「可変じゃないならメモリ効率の良い&strでしょ!」

私の頭の中はこんな感じでした

// Stringの場合:スタック+ヒープ領域を使う
let heavy = String::from("I am heavy");
// &strの場合:スタックだけで済むから「軽い」
let light = "I am light"; // スタックだけで済む!効率的だぜ!

「可変でないのに、なんでわざわざヒープ領域まで活用するStringにする必要があるの?」

そう思っていた私は、できるだけ&strを使おうとしていました。

考え方

ちょっとオーバーに書きますが、私の&strに対する考え方はこんな感じでした。

  1. メモリ効率が良い
    スタック領域だけで済み、ヒープアロケーションが不要!

  2. 読み取り専用なら十分
    値を変更しないなら参照で十分。むしろ、変更できない方が安全だ!

  3. パフォーマンスが良い
    メモリのコピーが発生しない。アロケーションのオーバーヘッドがない!

...完璧な理論に思えました。でも、ある日、私の&strへの認識は大きく揺らぐことになります。

2. 転機:RefCellとの出会い

ある日、私はブラウザエンジンのDOMパーサーの実装で、こんなコードを書いていました。

pub fn get_js_content(root: Rc<RefCell<Node>>) -> &str { // Stringではなく&strを返すように変更した
    let text_node = root.borrow().first_child();
    match &text_node.borrow().kind() {
        NodeKind::Text(ref s) => s, // ここでRefCell内のデータを参照している
        => "",
    }
    // エラー発生!
}

コンパイラからエラーが!

「あれ?なんで&strだと使えなくなるんだっけ?」

そう、これが私の転機でした。恥ずかしながらRefCellの借用の仕組みを理解していなかったのです。

3. 文字列データの実態を知る

get_js_contentが教えてくれたこと

// &strを返そうとした場合のエラー
pub fn get_js_content(root: Rc<RefCell<Node>>) -> &str {
    let text_node = root.borrow().first_child();
    match &text_node.borrow().kind() {
        NodeKind::Text(ref s) => s, // ここでRefCell内のデータを参照する
        _ => "",
    }
    // 問題1: RefCellの借用が関数終了と共に解放される
    // 問題2: 返された&strが参照するデータが無効に
    // ちなみに: &strをclone()しても参照のコピーを作るだけなので解決しない
}

// Stringを返す場合
pub fn get_js_content(root: Rc<RefCell<Node>>) -> String {
    let text_node = root.borrow().first_child();
    match &text_node.borrow().kind() {
        NodeKind::Text(ref s) => s.clone(),  // データ自体のコピーを作成するため、借用をすぐに解放できる
        _ => String::new(),
    }
    // メリット1: RefCellの借用はすぐに解放される(データのコピーを作ったため)
    // メリット2: 返値の所有権が明確
    // メリット3: ライフタイムを考慮する必要がない
}

エラーの原因は明確でした。
RefCellから取得した参照は関数スコープを超えて生存できず、その参照から得た&strも同様です。つまり

  1. RefCell::borrowで取得した参照は、その関数内でしか有効でない
  2. その参照から得た&strも同様に、関数を超えて生存できない
  3. したがって、関数から&strを返そうとすると、コンパイラは「関数を出た後は無効になる参照を返そうとしている」と教えてくれる

このように、&strを使う際は参照の生存期間(ライフタイム)を意識する必要があり、場合によってはコードを複雑化させる原因となります。

文字列データの配置場所を知る

このエラーをきっかけに、私は&strとStringの基本に立ち返って考えることにしました。
そもそも、私の「&strはスタックだけで済むから効率的」という認識は、実は大きな誤解だったのです。
文字列データの保存場所について、もう少し深く見ていきましょう。

実行可能ファイルは、以下のような領域で構成されています[1]

▼Linux実行可能ファイルの構造
.text : プログラムのコード(機械語)
.rodata : 読み取り専用データ(文字列リテラルなど)
.data : 初期化済みの静的/グローバル変数
.bss : 未初期化の静的/グローバル変数
heap : 動的に確保されるメモリ領域
stack : 関数呼び出しやローカル変数用の領域

これらの領域は、Rust特有のものではなく、多くのプログラミング言語で共通する基本的な構造です。
では、String と &str はそれぞれどこに配置されるのでしょうか?

// 文字列リテラルの場合
let a = "hello";  // 文字列データは.rodataに配置され、
                  // スタックには参照+長さ情報のみ

// Stringの場合
let b = String::from("hello");  // 文字列データはヒープに配置され、
                               // スタックにはポインタ等の管理情報

Rustの文字列について

Rustの文字列には主に以下の3つのケースがあります。

1. 文字列リテラル

    let a = "hello";  // 文字列リテラルの場合
  • データの実体は.rodataセクションに配置
  • スタックには参照(ポインタ)と長さ情報のみ
  • デフォルトで'staticライフタイム
  • 同じリテラルは1つのデータを共有

2. String型

    let b = String::from("hello");  // String型の場合
  • データの実体はヒープに配置
  • スタックには管理情報(ポインタ、長さ、容量)
  • 不要になったら解放される
  • データごとに独立した領域を確保

3. &str型(文字列スライス)

    // 文字列リテラルからの&str
    let c: &str = "hello";  // データは.rodataに
    
    // Stringからの借用による&str
    let d = String::from("hello");
    let e: &str = &d;      // データはヒープに

&strといっても、文字列スライスの場合は.rodataではなくヒープを活用します。
staticライフタイムではありません。

おわりに

ここまでの内容を踏まえ、文字列型の選択基準をまとめてみましょう。

// 文字列リテラル:コンパイル時に決まる固定文字列
const APP_NAME: &str = "My App";

// &str:既存データへの参照
fn process_name(name: &str) {
    println!("Name: {}", name);
}

// String:動的なデータ、所有権が必要な場合
fn get_full_name() -> String {
    format!("{} {}", first_name, last_name)
}

// 特にRefCellから取得する場合はString
fn get_js_content(root: Rc<RefCell<Node>>) -> String {
    let text_node = root.borrow().first_child();
    match text_node.borrow().kind() {
        NodeKind::Text(ref s) => s.clone(),
        _ => String::new(),
    }
}

「可変性の有無」というシンプルな基準に囚われ&strとStringを選んでいましたが、RefCellのエラーを通してそんな単純な話ではないことに気づかされました。
この記事が、私と同様「可変性の有無」だけで型を選んでいた方の、新しい視点になれば嬉しいです。

参考文献

実行可能ファイルの構造について

脚注
  1. 参考:OS Dev Wiki - ELFの"Program Header Types"セクション ↩︎

Discussion

Hidden comment