【Rust】「可変でないなら&strの方が良いんでしょ?」と思ってたけど、そんな単純な話でもなかった
久々の更新。
タイトルの通りです。
はじめに
「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
に対する考え方はこんな感じでした。
-
メモリ効率が良い
スタック領域だけで済み、ヒープアロケーションが不要! -
読み取り専用なら十分
値を変更しないなら参照で十分。むしろ、変更できない方が安全だ! -
パフォーマンスが良い
メモリのコピーが発生しない。アロケーションのオーバーヘッドがない!
...完璧な理論に思えました。でも、ある日、私の&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も同様です。つまり
- RefCell::borrowで取得した参照は、その関数内でしか有効でない
- その参照から得た&strも同様に、関数を超えて生存できない
- したがって、関数から&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のエラーを通してそんな単純な話ではないことに気づかされました。
この記事が、私と同様「可変性の有無」だけで型を選んでいた方の、新しい視点になれば嬉しいです。
参考文献
- The Rust Programming Language - Understanding Ownership
- Rust Reference - Lifetime Elision
- Rust By Example - Strings
実行可能ファイルの構造について
-
参考:OS Dev Wiki - ELFの"Program Header Types"セクション ↩︎
Discussion