どうもKutoです。
ということで、ZStringを読もうの第3回です。
前回はinitial commit時点でのディレクトリ構造やZString.csの紹介をしましたが、今回は実際にゼロアロケーション構築の根幹をなしているUtf16ValuesStringBuilderの実装を読んでいきたいと思います。
Utf16ValuesStringBuilder.cs
以下のようにUtf16ValuesStringBuilderにはpartial修飾子が付されており、複数ファイルにその実装がまたがるようになっています。
これは前回ちらっと話題に出したT4テンプレートでコードを生成させやすいように設定しているのだと考えられます。
よってまずは一番根幹の実装が詰まっているUtf16ValuesStringBuilder.csを確認していきます。
namespace Cysharp.Text
{
public ref partial struct Utf16ValueStringBuilder
{
さあ最初の4行です。namespaceと構造体を宣言しているだけのたった4行ではありますが、ここからもうゼロアロケーションへの取り組みが見て取れます。
classではなくstruct
通常このようなUtility系を組むときはclassを用いると思いますが、structを用いているというのが大きな工夫ポイントです。
何故classではいけないのか。これを説明するにはclassとstructの違いをしっかりと認識していなければいけません。
classとstructの違い
この2つ、違いをよく分からずに使っている人も多いのでしょうか?
「structは値型でclassは参照型である」
structとclassの違いをC#erに聞いたとき、ここまでは多くの方が回答として出せると思います。実際にこれは正しいですし、これに全てが詰まっているでしょう。
しかしでは値型と参照型の違いは? この疑問に対しては答えられるでしょうか。
値型と参照型の違い
ここら辺の知識は曖昧な人も多いのではないでしょうか。自分も正直曖昧でした。
自分が認識していた違いは以下のようなものです。
| class | struct | |
|---|---|---|
| 初期値 | null | 各メンバ変数にデフォルト値が入る |
| new演算子 | 必要 | 不要 |
| 継承 | 可能 | 不可能 |
まとめれば、「継承できるからclass使っておけば良いんでしょ?」これが私の元々の認識でした。
実際パフォーマンスをそこまで求めらない環境ではこの認識でも特に問題は生じない気がします。
しかし本ライブラリのようなパフォーマンスを強く求める場においては、この認識では全く足りません。それぞれに対しより根本的な実装内容を把握しなければいけないのです。
メモリ領域の違い
structとclass、つまり値型と参照型の違いの根本は使用されるメモリ領域の違いに行きつきます。
インスタンスを生成した際にそのインスタンスが保存されるメモリの領域が両者で異なるのです。
具体的に説明すると、構造体の場合インスタンスはメモリ上のスタックという領域に保存されます。これは他のint型やbool型などといったプリミティブ型の変数が格納される領域と一緒で、プログラミング上の変数が直接データの値を保存し持ちます。
それに対しクラスの場合、インスタンスはメモリ上のヒープという領域に保存されます。ヒープはプログラミング上から直接アクセスすることの出来ない領域です。なのでインスタンスが代入された変数は直接インスタンスのデータを持つことが出来ません。
そのためクラス等の参照型の変数を持つ際には、ヒープ上の該当データが保存されたメモリのアドレスをスタックに保存し、これを変数で持つのです。
そしてこのメモリのヒープ領域にデータを確保することは継承を可能にするなど多くのメリットがある反面、パフォーマンス的なデメリットが存在します。デメリットとは、つまりメモリアロケーションです。
メモリアロケーションとは単にデータを保存するための領域をメモリ上に確保することですが、アロケーションの中でも特にヒープアロケーションは注意をする必要があります。
ヒープアロケーションは確保/解放やGCの対象になるためコストが高く、頻繁な小さな割り当ては性能低下とGC負荷の増加を招いてしまうのです。
ZStringはstring操作のゼロアロケーション化を目的としたライブラリです。よってその実装もクラスではなく構造体にし、アロケーションを最大限減らそうとしているのでしょう。
自分だったら何も考えずにクラスを使いそうなものですが、こういったところでもゼロアロケーションへの取り組みが見れるのは流石としか言いようがありませんね。
ref修飾子
上述したように基本的にstructはスタック領域に割り当てられます。しかし、以下のような場合はどうでしょう?
struct Hoge
{
int bar;
}
class Fuga
{
Hoge hoge;
}
クラスはヒープ領域に割り当てられると先ほど言いました。従ってこの場合Hoge型のメンバ変数hogeはスタック領域ではなくヒープ領域に割り当てられてしまいます。
つまり、わかるでしょうか。先ほどの構造体に関する解説は少し正確ではありません。構造体はスタック領域に問答無用で割り当てられるのではなく、その構造体の変数が所属しているスコープで扱われているメモリ領域に割り当てられるということです。
しかしこのライブラリの掲げる目標はゼロアロケーションです。こんなのでヒープ領域のアロケーションが発生してはいけませんよね。
このようにどんな場合でもヒープ領域へのアロケーションを防ぐため、C#ではrefという修飾子を使ってヒープアロケーションを禁止することが出来ます。
public ref partial struct Utf16ValueStringBuilder
このようにUtf16ValueStringBuilderにはref修飾子を付けることで上述したような場合のヒープアロケーションを禁止しています。
ちなみに禁止というのはコンパイルエラーになるという意味です。つまり、以下はエラーになります。
class Hoge
{
Utf16ValueStringBuilder builder; // エラー
}
ここまでヒープアロケーションを避けるとは徹底していますよね。僕だったらこれでアロケーションが発生するのは使用者のせいだし~と考えて何もしません。
ちなみにここで使用されているref修飾子ですが、実はこれが本来の用途というわけではありません。詳しくは本筋とずれるためこちらの記事を見て欲しいですが、これは元は参照渡しのために実装された修飾子です。元々参照渡し用に実装されたものをref structの形で流用したんですね。
コンストラクタ
まだ3行しか見ていないというのにこの分量です。知らないことが多すぎますね...。
そんなことはさておき次の行を見ていきましょう。
delegate bool TryFormat<T>(T value, Span<char> destination, out int charsWritten, ReadOnlySpan<char> format);
const int DefaultBufferSize = 32768; // use 32K default buffer.
static char newLine1;
static char newLine2;
static bool crlf;
ふむ、これだけ見ても文脈が微妙ですね。一旦放置して次の行以降のコンストラクタを見てみましょう。
static Utf16ValueStringBuilder()
{
var newLine = Environment.NewLine.ToCharArray();
if (newLine.Length == 1)
{
// cr or lf
newLine1 = newLine[0];
crlf = false;
}
else
{
// crlf(windows)
newLine1 = newLine[0];
newLine2 = newLine[1];
crlf = true;
}
}
さてここまで見るとぼちぼち文脈が見えてきましたね。
改行コード
コンピューター上で扱う文字列にはいくつか特殊文字が存在します。特に有名なのが以下の改行コードでしょう。
"\n"
プログラミング上で文字列を扱う際にこの改行文字を入れるとDebug.Log()などで改行が表現できることは皆さん知っていることかと思います。しかし実は改行コードというのはこれだけでは無く、他の特殊文字を使って改行を表現する場合もあります。
それが以下の表現です。
"\r\n"
良く知る\nの前に\rという別の特殊文字が入れられていますね。
この\rはキャリッジリターン(Carriage Return)と呼ばれていて、カーソルを行の先頭に移動させる、ということを示す特殊文字です。ちなみに\nはカーソルを一段下の行に移動するということを示すので、\r\nはカーソルを先頭に戻して次の行に移動するということを表していることになります。
基本的にPCの改行コードは\nなのですが、改行の挙動をより正確に表しているという点でWindowsにおいては\r\nが改行コードに採用されていたりしています。
WindowsユーザーはVScode等のエディタで右下を見ると基本的にCRLFという文字が見えると思いますが、この場合は改行コードとして\r\nが設定されています。LFになっている場合は\nが設定されています。
改行コードの挙動(実験)
先ほど改行コードの違いについて解説しましたが、実際挙動がどのように異なるのか簡単に実験してみました。
なお使用言語はPythonです。
# CR
for i in range(10):
print(i, end='\r') # Carriage return to overwrite the line
print()
print("Done!") # Final output after the loop
# LF
for i in range(10):
print(i, end='\n') # New line after each number
print("Done!") # Final output after the loop
# CRLF
for i in range(10):
print(i, end='\r\n') # No line ending, all numbers on the same line
print("Done!") # Final output after the loop
このようにCR、LF、CRLFの3種類で0~9までの数字を単純にprintするコードで実験を行いました。
結果として得られた出力は以下になります。
# CR
9
Done!
# LF
0
1
2
3
4
5
6
7
8
9
Done!
# CRLF
0
1
2
3
4
5
6
7
8
9
Done!
概ね予想通りの結果ですが、Windows環境で実験しているのでLFに関しては以下のようになるのかなと思っていました。
0
1
2
3
4
5
6
7
8
9
Done!
実際は\nでもカーソルを先頭に戻しているみたいですね。LinuxやmacOSといった他のOSではLFが改行コードになっているので、合わせたのでしょうか。
CRはちゃんと改行されず全ての数字が同じ行に書かれています。個人的には0~9の文字が重なって描画されるのかなと思っていたのですが、単純に文字が上書きされて最終的に9が描画されるみたいです(少し残念)。
さて長々と説明をしてしまいましたが、以上の知識を把握しておくとコンストラクタで行っている処理が見えます。
↓再掲
static Utf16ValueStringBuilder()
{
var newLine = Environment.NewLine.ToCharArray();
if (newLine.Length == 1)
{
// cr or lf
newLine1 = newLine[0];
crlf = false;
}
else
{
// crlf(windows)
newLine1 = newLine[0];
newLine2 = newLine[1];
crlf = true;
}
}
ZStringではToString()を呼ぶまでcharの配列で一旦保存しておくことでヒープアロケーションを回避しているのですが、作成するStringに改行を入れたい場合があるかと思います。
このときこの改行コードをchar配列に追加することで改行を表現するわけですが、その環境で改行をどう表すか、具体的にはLFなのかCRLFなのかによって特殊文字を\nの1文字入れれば良いのか\r\nの2文字入れるべきなのかが変わってきてしまいます。
改行を追加するタイミングでどちらか判断しては効率が悪いため、この改行の扱いをどうするべきかをコンストラクタの段階で事前に判断しているということですね。
まとめ
さて今回でUtf16ValueStringBuilder.csを見終われば良いなと思っていたのですが、思ったよりも長くなってしまいました。
ということで今回はここまで。続きは次回書こうかなと思います。