はじめに
文字列で UTF-8 エンコードされたテキストを保存するへようこそ。この実験は、Rust 本の一部です。LabEx で Rust のスキルを練習することができます。
この実験では、Rust の文字列の複雑さ、特に UTF-8 エンコードに関連するものと、他のコレクションと比較した String 型の操作と違いについて説明します。
This tutorial is from open-source community. Access the source code
💡 このチュートリアルは英語版からAIによって翻訳されています。原文を確認するには、 ここをクリックしてください
文字列で UTF-8 エンコードされたテキストを保存するへようこそ。この実験は、Rust 本の一部です。LabEx で Rust のスキルを練習することができます。
この実験では、Rust の文字列の複雑さ、特に UTF-8 エンコードに関連するものと、他のコレクションと比較した String 型の操作と違いについて説明します。
第4章で文字列について話しましたが、今ではもっと深く見ていきます。新しい Rust プログラマーは、主に3つの理由から文字列に詰まってしまうことがよくあります。Rust がエラーを明らかにする傾向があること、文字列が多くのプログラマーが思っているよりも複雑なデータ構造であること、そして UTF-8 の問題です。これらの要因が組み合わさることで、他のプログラミング言語から来たときに難しく見えることがあります。
文字列はバイトのコレクションとして実装されており、それらのバイトがテキストとして解釈されるときに有用な機能を提供するいくつかのメソッドがあるため、コレクションの文脈で文字列について説明します。このセクションでは、すべてのコレクション型が持つ String
の操作、つまり作成、更新、読み取りについて説明します。また、String
が他のコレクションと異なる点、つまり人間とコンピュータが String
データを解釈する方法の違いにより、String
へのインデックス付けが複雑になる方法についても説明します。
まず、「文字列」という用語の意味を定義します。Rust のコア言語には文字列型が1つだけあり、それは通常、借用形式の &str
で見られる文字列スライス str
です。第4章では、他の場所に格納されている UTF-8 エンコードされた文字列データへの参照である「文字列スライス」について話しました。たとえば、文字列リテラルはプログラムのバイナリに格納されており、したがって文字列スライスです。
Rust の標準ライブラリによって提供される String
型は、コア言語にコードされているわけではなく、拡張可能で可変で所有された UTF-8 エンコードされた文字列型です。Rust のプログラマーが Rust の「文字列」と言うとき、彼らは String
型または文字列スライス &str
型のどちらかを指している場合があり、そのどちらか一方だけではありません。このセクションは主に String
に関するものですが、両方の型が Rust の標準ライブラリで頻繁に使用されており、String
と文字列スライスの両方が UTF-8 エンコードされています。
Vec<T>
で利用可能な多くの操作が、String
でも利用可能です。なぜなら、String
は実際、バイトのベクターをラップしたものとして実装されており、追加の保証、制限、機能があるからです。Vec<T>
と String
で同じように機能する関数の例として、インスタンスを作成する new
関数があります。これはリスト8-11に示されています。
let mut s = String::new();
リスト8-11:新しい空の String
を作成する
この行は、新しい空の文字列 s
を作成します。その後、この文字列にデータを読み込むことができます。多くの場合、文字列の最初のデータがあり、それを使って文字列を始めたいと思います。そのためには、文字列リテラルと同じように、Display
トレイトを実装するすべての型で利用可能な to_string
メソッドを使います。リスト8-12に2つの例を示します。
let data = "initial contents";
let s = data.to_string();
// このメソッドは直接のリテラルでも機能します:
let s = "initial contents".to_string();
リスト8-12:文字列リテラルから String
を作成するための to_string
メソッドの使用
このコードは、initial contents
を含む文字列を作成します。
また、String::from
関数を使って、文字列リテラルから String
を作成することもできます。リスト8-13のコードは、to_string
を使ったリスト8-12のコードと同等です。
let s = String::from("initial contents");
リスト8-13:文字列リテラルから String
を作成するための String::from
関数の使用
文字列はたくさんの用途に使われるため、文字列用の多くの異なるジェネリックAPIを使うことができ、多くのオプションが用意されています。それらの一部は冗長に見えるかもしれませんが、すべてにその役割があります!この場合、String::from
と to_string
は同じことをしますので、どちらを選ぶかは、スタイルと読みやすさの問題です。
文字列は UTF-8 エンコードされていることを忘れないでください。したがって、正しくエンコードされたデータを含めることができます。これはリスト8-14に示されています。
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>
と同じように、もっと多くのデータを追加することでその内容を変更することができます。また、+
演算子や format!
マクロを使って、String
値を連結することができます。
push_str
メソッドを使って文字列スライスを追加することで、String
を拡張することができます。これはリスト8-15に示されています。
let mut s = String::from("foo");
s.push_str("bar");
リスト8-15:push_str
メソッドを使って文字列スライスを String
に追加する
この2行の後、s
には foobar
が含まれるようになります。push_str
メソッドは文字列スライスを受け取ります。なぜなら、必ずしもパラメータの所有権を取得したくないからです。たとえば、リスト8-16のコードでは、s2
の内容を s1
に追加した後でも s2
を使えるようにしたいと考えています。
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-17では、push
メソッドを使って String
に文字 l を追加しています。
let mut s = String::from("lo");
s.push('l');
リスト8-17:push
を使って String
値に1文字追加する
その結果、s
には lol
が含まれるようになります。
多くの場合、既存の2つの文字列を結合したいと思うでしょう。その方法の1つは、+
演算子を使うことです。これはリスト8-18に示されています。
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注:s1はここで所有権が移され、もはや使用できなくなります
リスト8-18:2つのString
値を新しいString
値に結合するために+
演算子を使用する
文字列s3
にはHello, world!
が含まれます。追加後にs1
がもはや有効でなくなる理由と、s2
への参照を使用した理由は、+
演算子を使用したときに呼び出されるメソッドのシグネチャに関係しています。+
演算子はadd
メソッドを使用しており、そのシグネチャは次のようになっています。
fn add(self, s: &str) -> String {
標準ライブラリでは、add
はジェネリックと関連型を使用して定義されています。ここでは、具体的な型を代入しています。これは、String
値でこのメソッドを呼び出したときに起こることです。第10章でジェネリックについて説明します。このシグネチャは、+
演算子の厄介な部分を理解するために必要な手がかりを与えてくれます。
まず、s2
には&
があります。これは、2番目の文字列を最初の文字列に追加する際に、2番目の文字列の「参照」を追加していることを意味します。これはadd
関数のs
パラメータのためです。String
には&str
だけを追加できます。2つのString
値を一緒に追加することはできません。しかし、待ってください。&s2
の型は&String
であり、add
の2番目のパラメータに指定されている&str
ではありません。では、なぜリスト8-18がコンパイルされるのでしょうか?
add
の呼び出しで&s2
を使用できる理由は、コンパイラが&String
引数を&str
に「強制変換」できるからです。add
メソッドを呼び出すとき、Rustは「参照解決強制変換」を使用します。ここでは、&s2
を&s2[..]
に変換します。第15章で参照解決強制変換についてもっと深く説明します。add
はs
パラメータの所有権を取得しないため、この操作の後もs2
は有効なString
のままです。
2番目に、シグネチャからadd
がself
の所有権を取得することがわかります。なぜならself
には&
がないからです。これは、リスト8-18のs1
がadd
呼び出しに所有権が移され、その後はもはや有効でなくなることを意味します。したがって、let s3 = s1 + &s2;
は2つの文字列をコピーして新しい文字列を作成するように見えますが、このステートメントは実際にはs1
の所有権を取得し、s2
の内容のコピーを追加してから、結果の所有権を返します。言い換えると、たくさんのコピーを作成しているように見えますが、そうではありません。実装はコピーよりも効率的です。
複数の文字列を連結する必要がある場合、+
演算子の動作は厄介になります。
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!
マクロを使用できます。
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の無効なコードを見てみましょう。
let s1 = String::from("hello");
let h = s1[0];
リスト8-19:String
にインデックス付けの構文を使用しようとする
このコードは次のエラーを引き起こします。
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:3:13
|
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for
`String`
エラーとメモは事情を物語っています。Rustの文字列はインデックス付けをサポートしていません。では、なぜでしょうか?この質問に答えるには、Rustがメモリに文字列を格納する方法について説明する必要があります。
String
は Vec<u8>
のラッパーです。リスト8-14の適切にエンコードされたUTF-8の例の文字列を見てみましょう。まず、この文字列です。
let hello = String::from("Hola");
この場合、len
は 4
になります。これは、文字列 "Hola"
を格納するベクトルが4バイト長であることを意味します。これらの文字のそれぞれはUTF-8でエンコードされるときに1バイトを占めます。しかし、次の行はあなたを驚かせるかもしれません(この文字列は大文字のキリル文字 Ze で始まり、アラビア数字の3ではありません)。
let hello = String::from("Здравствуйте");
文字列の長さがどれくらいか尋ねられた場合、あなたは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]
がバイト値を返す有効なコードだった場合、それは 104
を返し、h
ではないでしょう。
したがって、予期しない値を返して、すぐには発見されない可能性のあるバグを引き起こすことを避けるために、Rustはこのコードをまったくコンパイルせず、開発プロセスの初期段階で誤解を防ぎます。
UTF-8に関するもう1つのポイントは、Rustの視点から見ると、文字列は実際には3つの関連する方法で見ることができるということです。バイトとして、スカラー値として、そして文字素クラスタ(私たちが「文字」と呼ぶものに最も近いもの)としてです。
デヴァナガリ文字で書かれたヒンディー語の言葉 "नमस्ते" を見てみると、それは次のような u8
値のベクトルとして格納されています。
[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224,
164, 164, 224, 165, 135]
これは18バイトで、コンピュータが最終的にこのデータを格納する方法です。これらをRustの char
型であるUnicodeスカラー値として見ると、これらのバイトは次のようになります。
['न', 'म', 'स', '्', 'त', 'े']
ここには6つの char
値がありますが、4番目と6番目は文字ではありません。それらは単独では意味を持たないダイアクリティックです。最後に、これらを文字素クラスタとして見ると、ヒンディー語の言葉を構成する4つの文字が得られます。
["न", "म", "स्", "ते"]
Rustは、コンピュータが格納する生の文字列データを解釈するさまざまな方法を提供しています。これにより、各プログラムは、データがどの人間の言語であっても、必要な解釈を選択できるようになります。
Rustが String
に対して文字を取得するためのインデックス付けを許さない最終的な理由は、インデックス操作は常に一定時間(O(1))で行われることが期待されるからです。しかし、String
でそのパフォーマンスを保証することは不可能です。なぜなら、Rustは有効な文字がいくつあるかを判断するために、インデックスまでの内容を最初から辿らなければならないからです。
文字列に対してインデックス付けを行うことは、多くの場合、良い考えではありません。なぜなら、文字列のインデックス付け操作の戻り値の型が何であるべきかが明確ではないからです。バイト値、文字、文字素クラスタ、または文字列スライスのいずれかです。したがって、本当にインデックスを使って文字列スライスを作成する必要がある場合は、Rustはもっと具体的にするよう求めます。
単一の数値を使って []
でインデックス付けする代わりに、範囲を使って []
を使うことができます。これにより、特定のバイトを含む文字列スライスを作成できます。
let hello = "Здравствуйте";
let s = &hello[0..4];
ここで、s
は文字列の最初の4バイトを含む &str
になります。先ほど、これらの文字のそれぞれが2バイトであることを述べました。これは、s
が Зд
になることを意味します。
もし &hello[0..1]
のように、文字のバイトの一部のみをスライスしようとすると、Rustはランタイムでパニックになります。ベクトルで無効なインデックスにアクセスした場合と同じようにです。
thread 'main' panicked at 'byte index 1 is not a char boundary;
it is inside 'З' (bytes 0..2) of `Здравствуйте`', src/main.rs:4:14
範囲を使って文字列スライスを作成する際には注意が必要です。なぜなら、そうするとプログラムがクラッシュする可能性があるからです。
文字列を操作する最善の方法は、文字またはバイトを必要とするかどうかを明示することです。個々のUnicodeスカラー値に対しては、chars
メソッドを使用します。"Зд" に対して chars
を呼び出すと、2つの char
型の値に分離されて返されます。そして、結果を反復処理して各要素にアクセスすることができます。
for c in "Зд".chars() {
println!("{c}");
}
このコードは次のように出力されます。
З
д
代わりに、bytes
メソッドは各生のバイトを返します。これは、あなたのドメインに適しているかもしれません。
for b in "Зд".bytes() {
println!("{b}");
}
このコードは、この文字列を構成する4つのバイトを出力します。
208
151
208
180
ただし、有効なUnicodeスカラー値は1バイト以上で構成される場合があることを忘れないでください。
デヴァナガリ文字のように、文字列から文字素クラスタを取得することは複雑であり、標準ライブラリにはこの機能はありません。これが必要な機能の場合、https://crates.io でクレートを入手できます。
要約すると、文字列は複雑です。異なるプログラミング言語は、この複雑さをプログラマにどのように提示するかについて異なる選択を行っています。Rustは、String
データの正しい処理をすべてのRustプログラムのデフォルトの動作にすることを選択しました。これは、プログラマがUTF-8データの処理について事前により多くの考えをする必要があることを意味します。このトレードオフは、他のプログラミング言語では明らかでないような文字列の複雑さをより多く明らかにしますが、開発サイクルの後半で非ASCII文字に関するエラーを処理する必要がなくなります。
良い知らせは、標準ライブラリが String
と &str
型に基づいて構築された多くの機能を提供しており、これらの複雑な状況を正しく処理するのに役立ちます。文字列内の検索に役立つ contains
や、文字列を別の文字列で置き換える replace
などの便利なメソッドのドキュメントを必ず確認してください。
もう少し複雑さの少ないものに切り替えましょう:ハッシュマップ!
おめでとうございます!「文字列でUTF-8エンコードされたテキストを格納する」実験を完了しました。スキルを向上させるために、LabExでさらに実験を行って練習してください。