Rust勉強手記ーStringの扱いが面倒
String、文字列型として、python, jsのような高級言語では割とシンプルなデータ型になっているが、rustではかなり複雑になっている。
まずはString型とstr型の違い。Stringはコレクションであり、キャラクターの集合として考えられる。strタイプは通常&str
の形で見られて、文字列の切片(slice)となっている。
コレクションはheapに保存される、一定の長さを持たない可変なデータタイプ。に対して、strはstackに保存される長さ確定の不可変なタイプ。ただ「string/文字列」という言葉は、いずれを指してもよいみたい。
let data = "hoge";
let s = data.to_string();
このようなコードを見たら、「何余計なことやってんだ?」と思ったりするかもしれませんが、str
(正確で&str
)からString
へ変換している。。
use std::any::type_name;
fn type_of<T>(_:T) -> &'static str {
type_name::<T>()
}
fn main() {
let data = "hoge";
let s = data.to_string();
println!("data type is {}", type_of(data)); // &str
println!("s type is {}", type_of(s)); // alloc::string::String
}
なので、最初から可変なString
型が欲しいければ、js, pythonのように""
で囲むと全く違うことになるので、String::from("xxxx")
から構成するのが一般的みたい。
次は文字列の合併(concatenation)も面倒。
これは主に所有権(ownership)と絡んでいるが、例えば:
let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + s2;
で書くと、s3
がhellow world
にならず、パニックしちゃう。。
理由はこの+
操作の定義、実際は次になっている:
fn add(self, s: &str) -> String { ... }
その中self
はs1に当たり、sはs2のポジションに当たる。そう、s2が定義の&str
タイプではなく、String
タイプだからダメなんだ。。
&str
の形ではレファレンスを渡しているので、所有権の移動(move)が行われず、借りる(borrow)だけになる。逆に言えば、s1はここでString
のままなので、所有権はs3
に移動してしまったため、s1へのアクセスができなくなる。これは、文字列の合併により、元の保存するメモリースペースが足りなくなる可能性があるため、所有権を持たないとメモリーのreallocateができないのも原因かと()。
任意数の&str
型に対して合併し続けることができるけど、ベースとなる文字列が必ずString
型でなければならない。このやり方はやはり慣れが必要で、format!
マクロの方がむしろjs, python, phpなどの文字列補填(string interpolation)の使い方に一致度が高いと感じている。所有権の移動がないので、意図しない問題がなくなる気もする。
let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{s1}-{s2}-{s3}");
最後と言いたいところですが、高級言語に慣れすぎると非常にびっくりする仕様:文字列のインデックス問題。
例えばpythonで文字列のどれかの要素をアクセスするときに、str[0]
で文字のインデックスでその文字を読みすることができる。
ただ、Stringはバイトのコレクションとなっている(後で説明)ため、このやり方ができない。
バイトのコレクションで何が問題になるかというと、キャラクターセット・エンコーディングによって文字の長さ・代表するコードが違ったりするからだ。
rustではutf-8
のキャラクターセットを使っているので、文字は基本的に1-4バイトの長さとなる。なので、
let s1 = String::from("hoge");
s1[0]; // パニックするが例のために、ここはhが出るはず
let s2 = String::from("ほげ");
s2[0]; // ここは何が出るか知らんが、utf-8で「ほ」の文字に当たるコードを調べればわかる
要は、1バイト以外にも、2バイトや3-4バイト(例えば日本語の文字)で保存されるキャラクターが多く存在するので、同じ0というインデックスでも、「どこまでが一個目の文字」か、rustはわからん。何より、インデックスでアクセスする場合はO(1)
の時間複雑度なので、このどこまで切れば分からない仕様だとO(1)
は無理。
なので結局、rustでは文字列の文字をアクセスしようとするときに、python,jsとかのように直接インデックスからアクセスすることができない。やろうとするとString cannot be indexed by {integer}
のエラーが出る。
この「バイトのコレクション」というは一体なんなのか。公式にも言及しているように、rustでは、Stirng
型がVec<u8>
のwrapperとなっている。言い換えれば、String
型はunsigned 8bit
=0-255までの数値、のベクター・配列となっている。なので、str[0]
でアクセスしたのは、一個目の文字というより、あくまでも「1個目の文字を表す1-4バイトの中の1個目のバイト」という意味なのだ。
ならどうしても「ほげ」から「ほ」を取りたい!!という時はどうすんだよ!
日本語はutf-8で基本3バイトなので、言い換えれば、0から3までのバイトを取り出せば、「ほ」とデコードできるはず。
ここでスライスをやってみると:
let s = String::from("ほげ");
let answer = &s[0..3];
println!("{}", answer) // ほ
行けた!!と言いたいところだが、毎回数えるのがどうかな。。てかこれだと文字列の長さをどう計算するの?
// ...
println!("{}", s.len()); // 6
println!("{}", s.chars().count()); // 2
len()
はバイト数の長さを返す。chars().count()
では**char
タイプの文字数**を返す。ほとんどの場合これでOK(日本語英語メインとか)。ほとんど。。
というのは、char
タイプの文字数=文字数が必ずしもtrueではないからだ。
use unicode_segmentation::UnicodeSegmentation;
// ...
let s = String::from("é");
println!("{}", s.len()); // 3
println!("{}", s.chars().count()); // 2
println!("{}", s.graphemes(true).count()) // 1
graphemes
は書記素のことで、文字の意味を区別することが可能な最小単位となっている。つまり、e
とé
は、上の́
の有無によって全く別の意味になることだ。公式にもあるが、rustにおける文字列というのは、書記素のクラスター(grapheme clusters)としてみられる。我々が通常考えている「文字」や「文字列」との概念との間に、交差する域は大半ではあるが、しない部分も存在する。実際に扱う文字データによって、この辺りの「エッジケース」と打つける可能性が十分ある。
本当に面倒いよな…
ただ、文字や文字列とは何か、違う言語やキャラクターセット、コンピューターと人間による解釈の差など、Rustを通して改めて勉強になった気がする。
(この辺りについてこちらにも参照)