文字列で UTF-8 エンコードされたテキストを格納する

Beginner

This tutorial is from open-source community. Access the source code

はじめに

文字列で UTF-8 エンコードされたテキストを保存するへようこそ。この実験は、Rust 本の一部です。LabEx で Rust のスキルを練習することができます。

この実験では、Rust の文字列の複雑さ、特に UTF-8 エンコードに関連するものと、他のコレクションと比較した String 型の操作と違いについて説明します。

文字列で UTF-8 エンコードされたテキストを保存する

第 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::fromto_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 と push を使って文字列を追加する

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 が含まれるようになります。

+演算子または format! マクロを使った連結

多くの場合、既存の 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 章で参照解決強制変換についてもっと深く説明します。addsパラメータの所有権を取得しないため、この操作の後もs2は有効なStringのままです。

2 番目に、シグネチャからaddselfの所有権を取得することがわかります。なぜならselfには&がないからです。これは、リスト 8-18 のs1add呼び出しに所有権が移され、その後はもはや有効でなくなることを意味します。したがって、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;

この時点で、stic-tac-toeになります。すべての+"文字があると、何が起こっているのかがわかりにくくなります。より複雑な方法で文字列を結合する場合は、代わりにformat!マクロを使用できます。

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{s1}-{s2}-{s3}");

このコードもstic-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 がメモリに文字列を格納する方法について説明する必要があります。

内部表現

StringVec<u8> のラッパーです。リスト 8-14 の適切にエンコードされた UTF-8 の例の文字列を見てみましょう。まず、この文字列です。

let hello = String::from("Hola");

この場合、len4 になります。これは、文字列 "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 でさらに実験を行って練習してください。