Open4

Rust勉強手記ーStringの扱いが面倒

convers39convers39

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")から構成するのが一般的みたい。

convers39convers39

次は文字列の合併(concatenation)も面倒。

これは主に所有権(ownership)と絡んでいるが、例えば:

    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + s2;

で書くと、s3hellow 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}");
convers39convers39

最後と言いたいところですが、高級言語に慣れすぎると非常にびっくりする仕様:文字列のインデックス問題。

例えば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個目のバイト」という意味なのだ。

convers39convers39

ならどうしても「ほげ」から「ほ」を取りたい!!という時はどうすんだよ!

日本語は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を通して改めて勉強になった気がする。

(この辺りについてこちらにも参照)