Rustの文字列を極める: String vs &strとUTF-8
Rustの文字列システム徹底解説:Stringと&strの違い、UTF-8エンコーディング、スライスの安全性、フォーマットとメモリ最適化。
Rustの文字列(Strings)は初学者が最もつまずきやすい概念の1つです。なぜRustには String と &str の2種類が存在するのか? なぜ s[0] のようにインデックスで直接アクセスできないのか? 文字列の内部構造を理解することで、Rustがメモリ安全性と国際的なUTF-8の正確性をどのように保証しているかが明確になります。
10歳でもわかる説明:リングノート vs 透明な栞(しおり)#
テキストの扱い方を2つの道具に例えてみます:
String(ページを追加できるリングノート): 自分の机の上にある実体のノート(Heapメモリ)。新しい文を書き加えたり、消したり、白紙ページを追加(mut)したり、ノート自体を人に譲渡(所有権の移動)できます。&str(文字の上に置く透明な栞): 図書館の本や他人のノートの特定行の上に重ねて置く透明なプラスチックの栞。紙を所有したりコピーしたりすることなく、下の文字をそのまま読み取ることができます。
graph TD
subgraph HeapMemory ["ヒープメモリ領域"]
StringBuf["String バッファ (ヒープ上のUTF-8バイト列)"]
end
subgraph StackVariables ["スタック上の変数"]
StringObj["String 構造体<br/>[ ptr | len | capacity ]"] -->|所有するデータを参照| StringBuf
StrSlice["&str スライス<br/>[ ptr | len ]"] -->|データを借用して参照| StringBuf
LiteralStr["静的文字列 static str<br/>[ ptr | len ]"] -->|バイナリ内の静的文字列を参照| ReadOnlyData["読み取り専用セグメント (.rodata)"]
end
String と &str の根本的な違い#
| 項目 | String | &str (文字列スライス) |
|---|---|---|
| 所有権 | ヒープ上のバッファを所有 | 文字列データのビューを借用 |
| 可変性 | 可変(mut を指定してサイズ伸縮可能) | 不変(固定長、読み取り専用) |
| メモリ配置 | データはヒープ(スタックにptr, len, cap) | スタック上のFatポインタ(ptr + len) |
| 主な生成方法 | String::new(), String::from("..."), s.to_string() | "hello" (リテラル), &s[0..4], &s |
文字列の生成と操作#
fn main() {
// 1. String の生成
let mut greeting = String::from("Hello");
// 2. 文字およびスライスの追加
greeting.push(' '); // 単一の char を追加
greeting.push_str("world!"); // 文字列スライスを追加
println!("{greeting}");
// 3. + 演算子による結合
let s1 = String::from("Hello, ");
let s2 = String::from("Rust!");
let s3 = s1 + &s2; // s1 の所有権が移動し、s1 は使用不能になる
println!("{s3}");
// 4. format! マクロによる安全な文字列結合(所有権を消費しない)
let part1 = "tic";
let part2 = "tac";
let part3 = "toe";
let game = format!("{part1}-{part2}-{part3}");
println!("{game}");
}rust[!NOTE]
s1 + &s2の+演算子はfn add(self, s: &str) -> Stringというシグネチャを呼び出します。s1の所有権を受け取り、s2の内容をコピーして末尾に追加したバッファを返します。
なぜ直接のインデックスアクセス (s[0]) が禁止されているのか?#
多くのプログラミング言語では s[0] で先頭の文字を取得できますが、Rustではコンパイルエラーになります:
let s = String::from("hello");
// let h = s[0]; // ❌ コンパイルエラー: the type `String` cannot be indexed by `{integer}`rustUTF-8 エンコーディングの理由#
Rustの文字列は常に有効なUTF-8バイト列です。UTF-8ではUnicode文字によってバイト長が1〜4バイトに変動します:
graph LR
subgraph English ["英語: 'Hello' (1文字1バイト)"]
H["'H' [0x48]"] --- E["'e' [0x65]"] --- L1["'l' [0x6C]"] --- L2["'l' [0x6C]"] --- O["'o' [0x6F]"]
end
subgraph Japanese ["日本語: 'こんにちは' (1文字3バイト)"]
J1["'こ' [0xE3, 0x81, 0x93]"] --- J2["'ん' [0xE3, 0x82, 0x93]"]
end
subgraph Emoji ["絵文字: '🦀' (4バイト)"]
Crab["'🦀' [0xF0, 0x9F, 0xA6, 0x80]"]
end
もし s[0] を許可してしまうと、マルチバイト文字(日本語の「こ」など)の先頭1バイト(0xE3)だけが取り出され、無意味で壊れたデータになってしまいます。また、 番目の文字を探すために先頭から の走査が必要になるため、Rustのゼロコスト抽象化の原則にも反します。
文字列の走査: バイトと文字#
安全に文字列を検査するため、走査する単位を明示的に指定します:
fn main() {
let word = "こんにちは 🦀";
// 1. Unicodeスカラ値(文字)単位で走査
println!("--- 文字 (chars) ---");
for c in word.chars() {
println!("{c}");
}
// 2. 生のUTF-8バイト単位で走査
println!("--- バイト (bytes) ---");
for b in word.bytes() {
print!("{b:02X} ");
}
println!();
}rust文字列スライスの安全性と注意点#
範囲指定でスライスを作成できますが、範囲の境界は必ずUTF-8文字の正しい境界に位置していなければなりません:
fn main() {
let hello = "Здравствуйте";
// キリル文字は1文字2バイト
let s = &hello[0..4]; // 先頭2文字(4バイト)を取得 -> "Зд"
println!("{s}");
// ❌ 文字の途中で分割すると実行時パニックが発生:
// let invalid = &hello[0..1]; // byte index 1 is not a char boundary
}rust[!WARNING] 国際化テキストを扱う場合は、手動のバイト範囲スライスを避け、
.chars().take(n)などのイテレータを使用することを推奨します。
関数の引数には &str を使用する慣例#
文字列を読み取るだけの関数を定義する場合、引数には &String ではなく &str を受け取るのがRustの標準的なプラクティスです:
// Deref型強制により &String と &str の両方を受け取れる
fn print_length(text: &str) {
println!("長さ: {} バイト", text.len());
}
fn main() {
let owned = String::from("Rustacean");
let literal = "Rustacean";
print_length(&owned); // &String から &str へ自動変換
print_length(literal); // &str を直接渡す
}rustまとめ#
Stringはヒープに確保され、伸縮可能な所有権を持つUTF-8バッファ。&strはUTF-8データを参照する不変のスライス(借用ビュー)。- UTF-8文字は1〜4バイト可変長であるため、整数インデックス
s[0]は禁止。 - 文字走査には
.chars()、生バイト走査には.bytes()を使い分ける。 - 関数の引数には
&strを指定することで、柔軟性とゼロコストを実現。