blog.dopana

Back

Rustの文字列(Strings)は初学者が最もつまずきやすい概念の1つです。なぜRustには String&str の2種類が存在するのか? なぜ s[0] のようにインデックスで直接アクセスできないのか? 文字列の内部構造を理解することで、Rustがメモリ安全性と国際的なUTF-8の正確性をどのように保証しているかが明確になります。

10歳でもわかる説明:リングノート vs 透明な栞(しおり)#

テキストの扱い方を2つの道具に例えてみます:

  1. String (ページを追加できるリングノート): 自分の机の上にある実体のノート(Heapメモリ)。新しい文を書き加えたり、消したり、白紙ページを追加(mut)したり、ノート自体を人に譲渡(所有権の移動)できます。
  2. &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

文字列の生成と操作#

[!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}`
rust

UTF-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)だけが取り出され、無意味で壊れたデータになってしまいます。また、NN 番目の文字を探すために先頭から O(N)O(N) の走査が必要になるため、Rustのゼロコスト抽象化の原則にも反します。

文字列の走査: バイトと文字#

安全に文字列を検査するため、走査する単位を明示的に指定します:

文字列スライスの安全性と注意点#

範囲指定でスライスを作成できますが、範囲の境界は必ずUTF-8文字の正しい境界に位置していなければなりません:

src/main.rs
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の標準的なプラクティスです:

src/main.rs
// 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 を指定することで、柔軟性とゼロコストを実現。

参考資料#