文字列でUTF-8でエンコードされたテキストを保持する
第4章で文字列について語りましたが、今度はより掘り下げていきましょう。新参者のRustaceanは、 3つの理由の組み合わせにより、文字列でよく行き詰まります: Rustのありうるエラーを晒す性質、 多くのプログラマが思っている以上に文字列が複雑なデータ構造であること、そしてUTF-8です。 これらの要因が、他のプログラミング言語から移ってきた場合、一見困難に見えるように絡み合うわけです。
文字列は、バイトのコレクションに、そのバイトをテキストとして解釈するときに便利な機能を提供するメソッドを足したものとして実装されているため、コレクションの文脈で文字列を議論していきます。
この節では、生成、更新、読み込みのような全コレクションが持つStringの処理について語ります。
また、Stringが他のコレクションと異なる点についても議論します。具体的には、人間とコンピュータがStringデータを解釈する方法の差異により、
Stringに添え字アクセスする方法がどう複雑なのかということです。
文字列とは?
まずは、文字列という用語の意味を定義しましょう。Rustには、言語の核として1種類しか文字列型が存在しません。
文字列スライスのstrで、通常借用された形態&strで見かけます。第4章で、文字列スライスについて語りました。
これは、別の場所に格納されたUTF-8エンコードされた文字列データへの参照です。例えば、文字列リテラルは、
プログラムのバイナリに格納されるので、文字列スライスになります。
String型は、言語の核として組み込まれるのではなく、Rustの標準ライブラリによって提供されますが、伸長可能、
可変、所有権のあるUTF-8エンコードされた文字列型です。RustaceanがRustにおいて「文字列」と言うとき、String型または文字列スライスの&str型のいずれかを指しているかもしれません。この節は、大方、
Stringについてですが、どちらの型もRustの標準ライブラリで重宝されており、
どちらもUTF-8エンコードされています。
新規文字列を生成する
Stringは実際のところ、いくつか追加の保障と制限と機能を持つバイトのベクタのラッパーとして実装されているので、Vec<T>で使用可能な操作の多くがStringでも使用できます。
Vec<T>とStringで同様に機能する関数の例としては、それぞれのインスタンスを作成するnew関数があります。
リスト8-11に示したようにですね。
fn main() { let mut s = String::new(); }
リスト8-11: 新しい空のStringを生成する
この行は、新しい空のsという文字列を生成しています。それからここにデータを読み込むことができるわけです。
だいたい、文字列の初期値を決めるデータがあるでしょう。そのために、to_stringメソッドを使用します。
このメソッドは、文字列リテラルのように、Displayトレイトを実装する型ならなんでも使用できます。
リスト8-12に2例、示しています。
fn main() { let data = "initial contents"; let s = data.to_string(); // the method also works on a literal directly: let s = "initial contents".to_string(); }
リスト8-12: to_stringメソッドを使用して文字列リテラルからStringを生成する
このコードは、initial contents(初期値)を含む文字列を生成します。
さらに、String::from関数を使っても、文字列リテラルからStringを生成することができます。
リスト8-13のコードは、to_stringを使用するリスト8-12のコードと等価です。
fn main() { let s = String::from("initial contents"); }
リスト8-13: String::from関数を使って文字列リテラルからStringを作る
文字列は、非常に多くのものに使用されるので、多くの異なる一般的なAPIを使用でき、たくさんの選択肢があるわけです。
冗長に思われるものもありますが、適材適所です!今回の場合、String::fromとto_stringは全く同じことをします。
従って、どちらを選ぶかは、スタイルと可読性次第です。
文字列はUTF-8エンコードされていることを覚えていますか?要するに文字列には、適切にエンコードされていればどんなものでも含めます。 リスト8-14に示したように。
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
リスト8-14: いろんな言語の挨拶を文字列に保持する
これらは全て、有効なStringの値です。
文字列を更新する
Stringは、サイズを伸ばすことができ、Vec<T>の中身のように、追加のデータをプッシュすれば、中身も変化します。
付け加えると、String値を連結する+演算子や、format!マクロを便利に使用することができます。
push_strとpushで文字列に追加する
push_strメソッドで文字列スライスを追記することで、Stringを伸ばすことができます。
リスト8-15の通りです。
fn main() { let mut s = String::from("foo"); s.push_str("bar"); }
リスト8-15: push_strメソッドでStringに文字列スライスを追記する
この2行の後、sはfoobarを含むことになります。push_strメソッドは、必ずしも引数の所有権を得なくていいので、
文字列スライスを取ります。例えば、リスト8-16のコードで、中身をs1に追加した後もs2を使いたいとします。
fn main() { let mut s1 = String::from("foo"); let s2 = "bar"; s1.push_str(s2); println!("s2 is {s2}"); }
リスト8-16: 中身をStringに追加した後に、文字列スライスを使用する
もし、push_strメソッドがs2の所有権を奪っていたら、最後の行でその値を出力することは不可能でしょう。
ところが、このコードは予想通りに動きます!
pushメソッドは、1文字を引数として取り、Stringに追加します。リスト8-15は、
pushメソッドで“l”をStringに追加しています。
fn main() { let mut s = String::from("lo"); s.push('l'); }
リスト8-17: pushでString値に1文字を追加する
この結果、sはlolを含むことになるでしょう。
編者注:
lolはlaughing out loud(大笑いする)の頭文字からできたスラングです。 日本語のwwwみたいなものですね。
+演算子、またはformat!マクロで連結
2つのすでにある文字列を組み合わせたくなることがよくあります。
これを行うための方法の一つは、リスト8-18に示したように、+演算子を使用することです。
fn main() { let s1 = String::from("Hello, "); let s2 = String::from("world!"); let s3 = s1 + &s2; // s1はムーブされ、もう使用できないことに注意 }
リスト8-18: +演算子を使用して二つのString値を新しいString値にする
s3という文字列は、Hello, world!を含むことになるでしょう。
追記の後、s1がもう有効でなくなった理由と、s2への参照を使用した理由は、
+演算子を使用した時に呼ばれるメソッドのシグニチャと関係があります。+演算子は、addメソッドを使用し、
そのシグニチャは以下のような感じです:
fn add(self, s: &str) -> String {
標準ライブラリでは、addはジェネリクスと関連型を使用して定義されているのがわかるでしょう。
ここでは具体的な型で置き換えていますが、これはこのメソッドをString値とともに呼び出した時に起こることです。
ジェネリクスについては、第10章で議論します。
このシグニチャが、+演算子の巧妙な部分を理解するのに必要な手がかりになるのです。
まず、s2には&がついてます。つまり、最初の文字列に2番目の文字列の参照を追加するということです。
これはadd関数のs引数のためのものです:
Stringには&strを追加することしかできません。要するに2つのString値を追加することはできないのです。
でも待ってください。addの第2引数で指定されているように、&s2の型は、&strではなく、
&Stringではないですか。では、なぜ、リスト8-18は、コンパイルできるのでしょうか?
add呼び出しで&s2を使える理由は、コンパイラが&String引数を&strに型強制してくれるためです。
addメソッド呼び出しの際、コンパイラは、参照外し型強制というものを使用し、ここでは、
&s2を&s2[..]に変えるものと考えることができます。参照外し型強制について詳しくは、第15章で議論します。
addがs引数の所有権を奪わないので、この処理後もs2が有効なStringになるわけです。
2番目に、シグニチャからaddはselfの所有権をもらうことがわかります。selfには&がついていないからです。
これはつまり、リスト8-18においてs1はadd呼び出しにムーブされ、その後は有効ではなくなるということです。
故に、s3 = s1 + &s2;は両文字列をコピーして新しいものを作るように見えますが、
この文は実際にはs1の所有権を奪い、s2の中身のコピーを追記し、結果の所有権を返すのです。言い換えると、
たくさんのコピーをしているように見えますが、違います; 実装は、コピーよりも効率的です。
複数の文字列を連結する必要が出ると、+演算子の振る舞いは扱いにくくなります:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = s1 + "-" + &s2 + "-" + &s3; }
ここで、sはtic-tac-toeになるでしょう。+と"文字のせいで何が起きているのかわかりにくいです。
もっと複雑な文字列の連結には、代わりにformat!マクロを使用することができます:
fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); let s = format!("{s1}-{s2}-{s3}"); }
このコードでも、sはtic-tac-toeになります。format!マクロは、println!と似た動作をしますが、
出力をスクリーンに行う代わりに、中身をStringで返すのです。format!を使用したコードの方がはるかに読みやすく、format! マクロによって生成されたコードはは参照を使用するので、この呼び出しは引数の所有権を奪いません。
文字列に添え字アクセスする
他の多くのプログラミング言語では、文字列中の文字に、添え字で参照してアクセスすることは、有効なコードであり、
一般的な処理です。しかしながら、Rustにおいて、添え字記法でStringの一部にアクセスしようとすると、
エラーが発生するでしょう。リスト8-19の非合法なコードを考えてください。
fn main() {
let s1 = String::from("hello");
let h = s1[0];
}
リスト8-19: 文字列に対して添え字記法を試みる
このコードは、以下のようなエラーに落ち着きます:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `String` cannot be indexed by `{integer}`
(エラー: 型`String`は`{Integer}`で添え字アクセスできません)
--> src/main.rs:3:16
|
3 | let h = s1[0];
| ^ `String` cannot be indexed by `{integer}`
| (`String`は`{Integer}`で添え字アクセスできません)
|
= help: the trait `Index<{integer}>` is not implemented for `String`
(ヘルプ: `Index<{Integer}>`というトレイトが`String`に対して実装されていません)
= help: the following other types implement trait `Index<Idx>`:
(ヘルプ: 以下の型であればトレイト`Index<Idx>`を実装しています:)
<String as Index<RangeFull>>
<String as Index<std::ops::Range<usize>>>
<String as Index<RangeFrom<usize>>>
<String as Index<RangeTo<usize>>>
<String as Index<RangeInclusive<usize>>>
<String as Index<RangeToInclusive<usize>>>
For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error
エラーと注釈が全てを物語っています: Rustの文字列は、添え字アクセスをサポートしていないのです。 でも、なぜでしょうか?その疑問に答えるには、Rustがメモリにどのように文字列を保持しているかについて議論する必要があります。
内部表現
StringはVec<u8>のラッパです。リスト8-14から適切にUTF-8でエンコードされた文字列の例をご覧ください。
まずは、これ:
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
この場合、lenは4になり、これは、文字列"Hola"を保持するベクタの長さが4バイトであることを意味します。
これらの各文字は、UTF-8でエンコードすると、1バイトになるのです。しかし、次の行にはびっくりするかもしれません。
(この文字列は大文字のキリル文字ゼーで始まり、数字の3では始まっていないことに注意してください)
fn main() { let hello = String::from("السلام عليكم"); let hello = String::from("Dobrý den"); let hello = String::from("Hello"); let hello = String::from("שָׁלוֹם"); let hello = String::from("नमस्ते"); let hello = String::from("こんにちは"); let hello = String::from("안녕하세요"); let hello = String::from("你好"); let hello = String::from("Olá"); let hello = String::from("Здравствуйте"); let hello = String::from("Hola"); }
文字列の長さはと問われたら、あなたは12と答えるかもしれません。実際には、Rustの答えは、24です: “Здравствуйте”をUTF-8でエンコードすると、この長さになります。この文字列に含まれる各Unicodeスカラー値は、2バイトの領域を取るからです。 それ故に、文字列のバイトの添え字は、必ずしも有効なUnicodeのスカラー値とは相互に関係しないのです。 デモ用に、こんな非合法なRustコードを考えてください:
let hello = "Здравствуйте";
let answer = &hello[0];
answerの値は、最初の文字のЗにはならないことを知っているでしょう。UTF-8エンコードされた時、
Зの最初のバイトは208、2番目は151になるので、answerは実際には208になりそうですが、
208は単独では有効な文字ではありません。この文字列の最初の文字を求めている場合、208を返すことは、
ユーザの望んでいるものではないでしょう; しかしながら、Rustには、バイト添え字0の位置には、そのデータしかないのです。
文字列がラテン文字のみを含む場合でも、ユーザは一般的にバイト値が返ることを望みません:
&"hello"[0]がバイト値を返す有効なコードだったら、hではなく、104を返すでしょう。
答えは、予期しない値を返し、すぐには判明しないバグを引き起こさないために、Rustはこのコードを全くコンパイルせず、 開発過程の早い段階で誤解を防いでくれるのです。
バイトとスカラー値と書記素クラスタ!なんてこった!
UTF-8について別の要点は、実際Rustの観点から文字列を見るには3つの関連した方法があるということです: バイトとして、スカラー値として、そして、書記素クラスタ(人間が文字と呼ぶものに一番近い)としてです。
ヒンディー語の単語、“नमस्ते”をデーヴァナーガリー(訳注: サンスクリット語とヒンディー語を書くときに使われる書記法)で表記したものを見たら、
以下のような見た目のu8値のベクタとして保持されます:
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]
18バイトになり、このようにしてコンピュータは最終的にこのデータを保持しているわけです。これをUnicodeスカラー値として見たら
(Rustのchar型はこれなのですが)このバイトは以下のような見た目になります:
['न', 'म', 'स', '्', 'त', 'े']
ここでは、6つchar値がありますが、4番目と6番目は文字ではありません: 単独では意味をなさないダイアクリティックです。
最後に、書記素クラスタとして見たら、このヒンディー語の単語を作り上げる人間が4文字と呼ぶであろうものが得られます:
["न", "म", "स्", "ते"]
Rustには、データが表す自然言語に関わらず、各プログラムが必要な解釈方法を選択できるように、 コンピュータが保持する生の文字列データを解釈する方法がいろいろ用意されています。
Rustで文字を得るのにStringに添え字アクセスすることが許されない最後の理由は、
添え字アクセスという処理が常に定数時間(O(1))になると期待されるからです。
しかし、Stringでそのパフォーマンスを保証することはできません。というのも、
合法な文字がいくつあるか決定するのに、最初から添え字まで中身を走査する必要があるからです。
文字列をスライスする
文字列に添え字アクセスするのは、しばしば悪い考えです。文字列添え字処理の戻り値の型が明瞭ではないからです: バイト値、文字、書記素クラスタ、あるいは文字列スライスにもなります。故に、文字列スライスを生成するのに、 添え字を使う必要が本当に出た場合にコンパイラは、もっと特定するよう求めてきます。
[]で1つの数値により添え字アクセスするのではなく、
範囲とともに[]を使って、特定のバイトを含む文字列スライスを作ることができます:
#![allow(unused)] fn main() { let hello = "Здравствуйте"; let s = &hello[0..4]; }
ここで、sは文字列の最初の4バイトを含む&strになります。先ほど、これらの文字は各々2バイトになると指摘しましたから、
sはЗдになります。
&hello[0..1]のようにして、文字を構成するバイトの一部のみをスライスしようとすると、
Rustはベクタの非合法な添え字にアクセスしたかのように実行時にパニックするでしょう:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/collections`
thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
('main'スレッドはsrc/main:4:19でパニックしました:
バイト添え字1は文字の境界ではありません; `Здравствуйте`の'З'(バイト0..2)の中です)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
範囲を使用して文字列スライスを作る際にはプログラムをクラッシュさせることがあるので、気をつけるべきです。
文字列を走査するメソッド群
文字列の部分に対して操作を行うための最良の方法は、文字に対して操作したいのかバイトに対して操作したいのかを明示することです。
個々のUnicodeスカラー値に対しては、charsメソッドを使用してください。
“Зд”に対してcharsを呼び出したら、分解して2つのchar型の値を返すので、各要素にアクセスするには、
その結果を走査すればいいわけです:
#![allow(unused)] fn main() { for c in "Зд".chars() { println!("{c}"); } }
このコードは、以下のように出力します:
З
д
あるいは、bytesメソッドは各バイトをそのまま返すので、ドメインによってはこちらが適切かもしれません:
#![allow(unused)] fn main() { for b in "Зд".bytes() { println!("{b}"); } }
このコードは、この文字列をなす4バイトを出力します:
208
151
208
180
ですが、合法なUnicodeスカラー値は、2バイト以上からなる場合もあることは心得ておいてください。
デーヴァナーガリー文字を含むような文字列から書記素クラスタを得る方法は複雑なので、この機能は標準ライブラリでは提供されていません。 この機能が必要なら、crates.ioでクレートを入手可能です。
文字列はそう単純じゃない
まとめると、文字列は込み入っています。プログラミング言語ごとにこの複雑性をプログラマに提示する方法は違います。
Rustでは、Stringデータを正しく扱うことが、全てのRustプログラムにとっての既定動作になっているわけであり、
これは、プログラマがUTF-8データを素直に扱う際に、よりしっかり考えないといけないことを意味します。
このトレードオフにより、他のプログラミング言語で見えるよりも文字列の複雑性がより露出していますが、
ASCII以外の文字に関するエラーを開発の後半で扱わなければならない可能性が排除されているのです。
一方で良い面としては、こうした複雑な状況に正しく対処するために、標準ライブラリがStringと&str型の上に構築された多数の機能を提供していることです。
文字列内の検索のためのcontainsや、文字列の一部を別の文字列で置換するためのreplaceなどの便利なメソッドについて、ドキュメントを確認してみてください。
もう少し複雑でないものに切り替えていきましょう: ハッシュマップです!