Rust の String と &str の違いをchatGPTを使って理解してみた
目次
追記:
String
の参照 &String
が &str
と同様に扱える場合について、今後記事を作成する予定。
この記事の内容はあまり正確ではありません。
はじめに
Rust の String
構造体と文字列リテラル &str
の違いが可変長かどうかくらいしか分かっていなかったので、 chatGPT に聞きつつ理解しようと思った。人生初のプログラミング言語として学部一年の時にC言語をやったので、データ構造がイメージしやすかったと思う。
結論
Srting
の実態はVec<u8>
&str
の実態は&[u8]
&[u8]
と&str
の決定的な違いはu8
が表す数値が何のエンコードかを表す情報を持つかどうか
as_ref()
と &
演算子
as_ref()
という関数を見かけたので、これがなんなのかを ChatGPT に聞くところから始まった。予想としては +
演算子を数値以外の型で使えるようにするには Add
トレイトの add()
を定義すれば可能なので、それと同じように &
演算子が使用された時の振る舞いを定義できるのかと考えた。
自分
変数.as_refと&変数は同義?
ChatGPT
変数.as_ref()
と&変数
は同じ結果を返すことがありますが、厳密には同じ意味ではありません。
&変数
は、単純に変数への不変の参照を作成します。これは元の変数と同じデータを指す新しい参照を作成します。変数の所有権は移動しません。これは値に対する不変の参照を取得するために使用されます。
変数.as_ref()
は、as_ref
メソッドが実装されている型に対してのみ使用できます。このメソッドは元の値を新しい参照型に変換しようと試みるものです。これは、元の値を新しい型への参照として扱うことができる場合に有用です。例えば、文字列型String
を&str
に変換する場合に使います。as_ref
は元の値を変更せず、新しい参照を作成するだけです。したがって、
変数.as_ref()
は、元の型がas_ref
メソッドを実装しており、かつそのメソッドが新しい型への変換を提供する場合にのみ使用できます。一方、&変数
はその型が不変の参照をサポートする限り、ほとんどの型で使用できます。
どうやら違うようだ。よく考えてみたら、as_ref()
で &
の振る舞いを定義したとしたら、ライブラリで as_ref()
を見かけるようなことも起きないはず。なぜなら &
で同じことができるのにわざわざ文字数を多く書く as_ref()
を使う理由がないからだ。
自分
&は変数のアドレス、as_refは独自に定義した参照で、その実装として&が使われた場合は通常の&と同義である。であってる?
ChatGPT
実際の挙動を説明します。
&
は値への不変の参照を作成します。as_ref
は、特定の型に対して元の値を新しい型への参照に変換しようとする方法を提供するトレイトです。通常、このトレイトは&self
メソッドを介して&
による参照を返します。この場合、&変数
と変数.as_ref()
は通常同じ意味になります。例えば、標準ライブラリの
String
型では、as_ref
は&str
への変換に使われます。そのため、変数.as_ref()
は&変数
と同じくString
を&str
に変換します。しかし、
as_ref
は他の型に対しても使われます。具体的な挙動は型やトレイトの実装に依存します。要するに、as_ref
は型の変換を行う方法を提供し、その方法は型ごとに異なることがあります。一般的には、as_ref
は&
による参照を返すことが多いですが、必ずしも同じ意味ではないことに注意してください。
大体予想通りだったが、ここで一番気になったのが 変数.as_ref()
は &変数
と同じく String
を &str
に変換します。 という言葉だ。つまり as_ref()
を使わなくても String
構造体の参照 &変数
が &str
と同じであると言っているということだ。
String
と &str
のデータ構造
String
String
構造体は Vec<u8>
と長さを基本構造としていると思っていた。簡単に書くと、
struct String {
vec: Vec<u8>,
len: usize,
}
という感じ。だから &
演算子で参照するとそのまま vec<u8>
と len
をもった構造体自体の参照が得られると思っていた。しかし ChatGPT は &String
は &str
と同義だと言っている。C言語ではポインタという1種類で表すが、Rust では参照とか生ポインタとか種類があるので、C言語の考えをそのまま持ち込むとおかしくなりそうだが、この辺は C++ を知っていればもっと理解できたのかもしれない。
as_ref()
で検索をかけてそれっぽものを見つけられた。impl AsRef<str> for String {
#[inline]
fn as_ref(&self) -> &str {
self
}
}
impl AsRef<[u8]> for String {
#[inline]
fn as_ref(&self) -> &[u8] {
self.as_bytes()
}
}
&self
って自分自身の参照を返してるだけじゃん。意味わからん。
&str
&str
は次のような構造体だと予想していた。
struct str {
vec: &[u8],
len: usize,
}
impl str
にするか impl &str
にするか迷うあたり自分が Rust を全く理解できていないと感じた。
自分
Stringは実態として 文字列をvecで保持するが、&strは通常の配列と同様に連続したデータ構造?
ChatGPT
はい、正しいです。
String
はUTF-8エンコードされた文字列データをバイトのベクタ (Vec<u8>
) として保持します。このため、String
は可変で、可変サイズの文字列データを格納できます。一方、
&str
は不変の文字列スライスで、通常は連続したバイトのデータ構造です。&str
は元のデータを参照し、そのデータは通常&[u8]
として表現されます。このスライスは文字列の一部分を指し示すため、文字列のサイズを示す情報を含むことが多いです。また、UTF-8エンコードの文字列に対しても正しく作用します。この違いは
String
が可変で所有権を持つ一方、&str
は不変で借用を表すことから生じます。String
は文字列データを生成、変更、所有するための型であり、&str
はその一部または全部を参照するための型です。
こちらも大体予想通りだった。もう一つ気になる事があったので聞いてみた。
自分
スライスと配列の違いは?
ChatGPT
スライス(Slice)と配列(Array)は、Rustにおいて異なるデータ構造です。以下にそれぞれの違いを説明します:
配列(Array):
固定サイズ:配列は宣言時に固定サイズを指定する必要があり、そのサイズは変更できません。
データはスタックに格納:配列の要素は通常、スタックメモリに連続して格納されます。
所有権:配列はコピー可能なデータ型であり、その値を他の変数に代入すると、元の配列のコピーが作成されます。
スライス(Slice):
- 可変サイズ:スライスは可変サイズのデータを参照するためのデータ構造で、配列やベクタなどのコレクションから部分的なデータを指し示します。
- データはヒープに格納:スライスは通常、ヒープメモリに格納されたデータの一部を参照します。
- 所有権を持たず、借用:スライスは元のデータの所有権を持たず、不変または可変な借用を提供します。これにより、元のデータを変更せずにその一部にアクセスできます。
スライスは、配列やベクタの特定の部分にアクセスするために非常に便利で、イテレーションやデータの部分的な処理に使用されます。また、スライスは配列を参照することで効率的なメモリ管理を提供します。
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
この例を見ると、&str
は参照している先頭アドレスと長さを保持している事がわかる。先程簡易的な自分が思う &str
の構造を書いたが、迷ってもおかしくはないと思えてきた。
String
から &str
への変換
大体のデータ構造が明らかになったので、String
から &str
への変換を考えてみる。
自分
&strはu8配列への参照だが、stringはvecである。そしてvecはヒープ上の配列への参照である。つまりstringから&strを作り出すときラッピングを2回外しているということ?
ChatGPT
はい、正確な説明です。
String
はVec<u8>
で、その内部データはヒープ上に格納されたバイトの配列です。&str
はu8
のバイトスライスへの不変な参照で、元のString
データへのアクセスを提供します。したがって、String
から&str
を作成する際、ラッピングを2回外していると言えます。具体的には、
String
はVec<u8>
へのラッパーであり、&str
はu8
のバイトスライスへのラッパーです。&str
は元のString
データを直接指すのではなく、バイトスライスとして参照します。このようなラッピングを介して、文字列データへの効率的なアクセスが実現されます。
このようなラッピングを外すような作業が2回もあるにも関わらず、&String
が &str
と同義であるらしい。
また、Vec
は可変長というが、実際にはヒープ領域ののある程度のサイズをもった配列への参照をもっており、その配列がいっぱいになると新たにより大きな領域をヒープに確保して、そこにデータをコピーしていくという仕組みになっている。つまり String
が持つ Vec
がいっぱいになると新たにメモリを確保してそこにデータを移してしまうから、その String
から作られた &str
の参照先は存在しなくなってしまうのではないかという疑問が生まれた。
自分
vecも内部ではu8の配列を持っていてstringの変更があった場合、仮に長さが超えた場合は新たにヒープに配列を作り出す?
ChatGPT
はい、
String
内部ではVec<u8>
が文字列データを格納しており、このデータはヒープ上に配置されます。String
は不定長の文字列を格納でき、文字列が変更されると必要に応じてヒープ上に新しい配列が確保されます。例えば、
String
に新しい文字を追加した場合、既存のヒープ上のデータに新しい文字を追加することはできません。その代わりに、新しい長さのデータを格納するために新しいヒープ領域にメモリが確保され、元のデータはコピーされることなく、新しいデータが追加されます。このプロセスは透過的に行われ、ユーザーには気にする必要はありません。この方法により、
String
は可変な文字列を効率的に扱えます。ヒープ上でのメモリ再割り当てやデータのコピーが必要な場合でも、Rustの所有権システムにより、データの整合性と安全性が保たれます。
自分
その場合変更が行われたstringから作り出した&strはアドレスが変わっているから使えなくない?
ChatGPT
String
から作成した&str
は、元のString
が変更されても利用可能です。なぜなら、&str
は元のデータへの不変な参照であり、元のデータが変更されても&str
自体は有効で、元のデータを参照し続けます。変更が行われても
&str
が無効になるのは、その参照の寿命(ライフタイム)が元のデータよりも短い場合です。通常、&str
の寿命は元のString
と同じかそれよりも長くなります。したがって、元のString
が変更されても、適切にスコープが管理されていれば、&str
を引き続き使用できます。しかし、
&str
を作成した後で元のString
が破棄される場合、&str
は無効になります。そのため、&str
を使用する際には、元のデータとの寿命に注意を払う必要があります。
Rust の所有権システムによって、変数に参照されなくなった値は自動的に破棄されるが、&str
変数によって参照されていれば、もともと String
が持っていた Vec
が持っていた配列の領域は破棄されないらしい。
これは ChatGPT が違うことを言っている可能性が高いので、最後にテストしてみる。
&[u8]
と &str
の違い
Vec
の実態はヒープ上に確保された [u8]
だが、っそれを同じように参照している &[u8]
と &str
の違いは何なのか。
自分
&strはu8配列への参照と長さを持った別名とも言えるね
ChatGPT
はい、その表現は正確です。
&str
はu8
バイトの配列への不変な参照と、その配列の長さ情報を持つデータ型です。&str
は文字列データを不変な形式で扱うためのデータ構造であり、UTF-8 エンコードされた文字列のバイト列を表現します。したがって、&str
をu8
バイトの配列への参照と見なすこともできます。
やはりどちらも [u8]
配列を参照していることには変わりないようだ。
自分
&strと&[u8]の違いは実装されているメソッドくらいしか差がない?
ChatGPT
&str
と&[u8]
はいくつかの面で類似していますが、重要な違いも存在します。以下は主な違いと類似点です:違い:
キャラクターエンコーディング: 重要な違いの1つは、
&str
がUTF-8エンコードの文字列を表現するのに対し、&[u8]
はバイトのスライスであり、文字列のエンコーディング情報を持たないことです。したがって、&str
は文字列としての操作(例: 文字数、文字列比較など)を行う際にエンコーディングに対処できますが、&[u8]
はバイトの配列としての操作に限定されます。メソッド:
&str
は文字列操作のための多くのメソッド(len()
、chars()
、contains()
など)を提供しますが、&[u8]
はバイトデータとしての操作に適したメソッド(len()
、iter()
など)を提供します。そのため、用途に合わせてメソッドを選択することができます。類似点:
スライス: どちらも元のデータをスライスして参照し、元のデータに対する変更を行わずにデータにアクセスできます。両方ともスライスのような操作をサポートしており、データの一部を表現するために使用できます。
長さ情報: 両方の型はスライスの長さ情報を保持しており、範囲外アクセスを防ぐのに役立ちます。
不変性: 両方の型は不変であり、元のデータを変更しません。したがって、参照元のデータは不変であることを保ちつつ、スライスを通じてデータにアクセスできます。
どちらの型を使用するかは、特定の用途やデータの種類に依存し、文字列データを扱う場合は通常
&str
を使用し、バイトデータを扱う場合は&[u8]
を使用することが一般的です。
自分
決定的な違いはその8ビットの値がutf8なのかshiftjisなのかとかのエンコードの情報の変数を持つってことだね
ChatGPT
正確です。
&str
と&[u8]
の決定的な違いは、エンコーディングに関する情報です。&str
はUTF-8エンコードの文字列データを表現し、エンコーディングに関する情報を持っています。一方、&[u8]
はバイトのスライスであり、エンコーディング情報を持たず、文字列のエンコーディングについては知識がありません。この違いにより、
&str
は文字列としての操作やエンコーディングに関連したタスクに適していますが、&[u8]
はバイトデータとしての操作に適しています。どちらを使用するかは、操作対象のデータの性質に依存します。
メソッドなどの違いももちろんあるが、決定的な違いはエンコードの情報を持つかどうかだということが分かった。
実際に試してみた
String
と &str
の違いが分かったので、実際に試してみた。
&str
が参照する String
が破棄された後でも &str
が参照する先のデータが残っているかどうか
1. let s: &str;
{
let string = String::from("hello");
s = &string[..];
}
println!("{}", s)
これはやはりコンパイルエラーになった。
String
が持つ Vec
が持つ [u8]
の場所がサイズオーバーで変更された場合
2. 次の方法で Vec
の最大容量を取り出せる。
let inner_vec = Vec::<u8>::with_capacity(2);
これを String
構造体の vec
に代入することで String
が持つ Vec
の最大容量を変更できるが、それが可能なメソッドは用意されていないらしい。
String
構造体に次のようなメソッドを発見した。
pub fn capacity(&self) -> usize {
self.vec.capacity()
}
これで String
構造体の vec
が持つ Vec
の最大容量を取り出せる。
let s1 = String::from("ab");
let s2 = String::from("abcd");
println!("{}", s1.capacity());
println!("{}", s2.capacity());
2
4
String::from()
で使った文字列リテラルの大きさになった。String
の vec
の大きさはピッタリサイズなのだろうか。
次に既存の String
に文字列を追加した
let mut s = String::from("ab");
s.push_str("cd");
println!("{}", s.capacity());
8
s
の vec
の最大容量が s
の文字列の長さを超えたことが確認できた。
つまりこの s.push_str()
する前に s
から作られた slice が有効かどうかでこの問題を検証できる。
let mut s = String::from("ab");
let str: &str = &s[..];
s.push_str("cd");
println!("{}", str);
println!("{}", s.capacity());
結果はコンパイルエラー。
ここで重大な見落としが発覚した。Rust ではそもそも、その変数が参照されているうちはその変数が持つ値を変更できない。つまり今まで疑問に思ってきたことは、Rust では起こりえないことだったのだ。
まとめ
String と &str の違いをある程度理解する事ができた。また、Rust という言語が極めて安全で、自分が疑問に思っているようなことは言語仕様ですでに淘汰されていることがわかった。
&String
が &str
になる理由はまだ理解できてないので、もう少し調べてみたい。