前書き
この記事は、2023のUnityアドカレの12/24の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい!
はじめに
先日、DotNextについて、一部の機能を取り上げた記事を書きました。今回はその続きとして別の機能の紹介をしたいと思います。
DotNextとは?
詳しくは前回の記事を参照いただきたいのですが、C#のBCL(組み込みライブラリ)の発展・実験版みたいな位置づけのライブラリです。本家dotnetの一リポジトリとして開発されており、NuGetから利用できます。
それでは各機能を見ていきましょう!
コレクション関連
アプリケーション側(プロジェクト側)や社内ライブラリなどで実装されていそうな内容が実装されています。「あるある~」と思いながら見ましょう。
コレクションビュー
リスト(インデクサによるランダムアクセス可能なもの)、コレクション、ディクショナリに対して、要素を取り出すときに変換を噛ますビューを作ることができます。評価は遅延されていてアクセス時に評価されます。
var list = new List<string>() { "1", "2", "3" };
ReadOnlyListView lView = list.Convert(n => n * -1);
Console.WriteLine(lView[2]); // -3
var collection = (ICollection<string>)list;
ReadOnlyCollectionView cView = collection.Convert(n => n * -1);
Console.WriteLine(cView.ElementAt(2)); // -3
var dictionary = list.ToDictionary(keySelector: v => v);
ReadOnlyCollectionView dView = dictionary.Convert(n => n * -1);
Console.WriteLine(dView["2"]); // -3
ほぼLINQのIEnumerableの下位互換なのですが、これらは構造体なのでLINQに比べて若干アロケーションが少ない(あるいはJIT最適化により無くなる)かと思われます。
string.Join
の拡張メソッド版
Sequenceという静的クラスにコレクション関係の拡張メソッドが納められいます。(LINQでいうEnumerableみたいな)
その中に、コレクションに対するToString
拡張メソッドがあり、これを使うことでstring.Join
よりも簡潔にコレクションの文字列化結合が可能です。
var list = new string[] { "hoge", "huga", "piyo", }
var text = string.Join("\n", list.Select(e => e.ToUpper()));
var text = list.Select(e => e.ToUpper()).ToString("\n"); // 👍
// メソッドチェーンでの改行にも都合がよい👍
var text = list
.Select(e => e.ToUpper())
.ToString("\n");
IEnumerable<T>
のForEach
拡張メソッド
これもよくありますよね。foreach
だと行数を食いますが、メソッドチェーンで書けるとショートコードしやすくなります。
ちなみに、List<T>
にはForEach
のインスタンスメソッドがあります。配列にはArray.ForEach
のスタティックメソッドがあります。
var list = new string[] { "hoge", "huga", "piyo", };
list
.Where(text => text.StartWith("h"))
.Select(text => text.UpperCase(text))
.Reverse()
.ForEach(text => Console.WriteLine(text));
定数テンプレート(ジェネリック)
C++ではテンプレートに定数を渡すことができます。代表例だと、std::array<T, N>
、これは固定長Nの長さの配列です。C#配列(Array
)の場合、固定長ではありますが、型システムとしては長さを管理しておらず、int[10]
とint[100]
は同じ型です。
C#ではジェネリックに定数を与えることはできませんが、DotNextでは無理やり定数「値」を型にすることで実現しています。
// 定数値(10)を型化
class Ten: Constant<long>
{
public Ten(): base(10) { }
}
// 型の情報として10要素という情報がある
class IntVector<SIZE> where SIZE: Constant<long>, new()
{
static readonly long Size = new SIZE();
readonly int[] array = new int[Size];
}
範囲がbyte(<256)ぐらいならまだ何とかなりそうですが、short(<32,768)とかまでサポートしようとConstant型を並べて書くとなると、そもそもコンパイルが通るのかも不明ですよね。
ちなみに最新の.NET8ですとInlineArray
というものがあります。今のところコレクション式で宣言された配列にしか使われないようですが、今後、C++のテンプレートのようにジェネリックに整数を渡せる日が来るかもしれません。
汎用参照(refの拡張)
C#ではref
キーワードで値の参照をとることができます。しかし、このrefには制約があり、関数の外に漏らしたりすることができません。これを回避するのがDotNextのReference<T>
です。
class Hoge
{
int _field = 0;
public int Field => _field;
public ref int GetFieldRef() => ref _field;
}
static class Program
{
static ref int reference; // コンパイル不可
static Reference<int> reference; // OK
static void Main()
{
var hoge = new Hoge();
reference = Reference.Create(hoge, &field.GetField);
reference.Target = 20;
Console.WriteLine(hoge.Field); // 20
}
}
内部的には、_field
へのアクセス方法を関数ポインタとして持っておき、アクセスされたときにその関数ポインタにインスタンスを渡して呼び出しているようです。
readonly unsafe struct Reference<TValue>
{
private readonly void* accessor;
private readonly object? owner;
Reference(object owner, delegate*<object, ref TValue> accessor)
{
this.owner = owner;
this.accessor = accessor;
}
public ref TValur Target => ref ((delegate*<object, ref TValue>)accessor)(owner);
}
パワフル!
引数が関数ポインタで、ラムダ式が使えないのが痒い所に手が届かないですね。こんな感じでラップしたらいいんじゃないかとも思いましたが、ジェネリックなデリゲートから関数ポインタを引き摺り出すことはできないようでした😭
static class Reference2
{
public delegate ref TValue Accessor<TOwner, TValue>(TOwner owner)
where TOwner : class;
public static unsafe Reference<TValue> Create<TOwner, TValue>(
TOwner owner,
Accessor<TOwner, TValue> accessor)
where TOwner : class
{
// Exception has occurred: CLR/System.ArgumentException
var s = Marshal.GetFunctionPointerForDelegate(accessor);
var p = (delegate*<TOwner, ref TValue>)s;
return Reference.Create(owner, p);
}
}
高効率可変バッファライター(ArrayBufferWriterみたいなやつら)
BCLには、ArrayBufferWriterというクラスがあります。このクラスが何者なのか、物凄くザックリいうと、Listです。Listは、AddやAddRangeによってバッファに不足が出たら勝手に拡張してくれます。ArrayBufferWriterはこの操作が二つに分かれています。
var buff = new ArrayBufferWriter<byte>();
var span = buff.GetSpan(hintSize: 1024); // ①
var wrritenCount = Data.CopyTo(span);
buff.Adcance(wrritenCount); // ②
①のGetSpanはhintSize以上の空き領域があることを保証し、そのSpanを返します。空き領域がない場合はListと同じように内部の配列を再確保&コピーして拡張します。②のAdvanceはArrayBufferWriterのカウントを進めます。GetSpanした時点で帰ってきたSpan分の領域は保証されているので、Spanの範囲内への書き込み分をAdvanceしてもキャパを超えてしまうことはありません。
これは、IBufferWriterで抽象化されています。DotNextのバッファライターもIBufferWriterを実装したり、似たようなインターフェースになっています。
PooledArrayBufferWriter
ArrayBufferWriterは不足が生じた場合に新たに配列を確保しますが、PooledArrayBufferWriterはプールから貸し借りします。また、使い終わったときには借りているバッファを返すためにDisposeする必要があります。それ以外はArrayBufferWriterと変わりません。コンストラクタには任意のArrayPoolを渡すことができます。
SparseBufferWriter
SparseBufferWriterはIBufferWriterの一味ではありません。そもそもIBufferWriterは、連続したバッファを要していますが、SparseBufferWriter内のバッファは連続していません。GetSpanでSpanをとってきて書き込むのではなく、SparseBufferWriter.Writer
を経由して書き込みます。SparseBufferWriter.Writer
は、引数で受け取った入力の内容を内部のバッファに書き込みますが、一つのバッファに収まらない場合分割して次の次のバッファへと書き込んでいきます。バッファが足りなくなった場合、拡張ではなく、新しいバッファが追加されます。
class SparseBufferWriter<T>
{
int count = 0;
List<T[]> buffers = new();
public void Writer(Span<T> data) { ... }
}
BufferWriterSlim
これは前回も紹介しましたが、バッファ関係なのでもう一度。
BufferWriterSlimはヒープではなくスタックを用います。コンストラクタで渡したSpanを内部バッファとして使います。
var writer = new BufferWriterSlim<byte>(stackalloc byte[128]);
writer.Writer(Data);
まとめ
前回に引き続き、DotNextの機能の一部をご紹介しました。相変わらずハイパフォーマンス関連多めでした。あともうピースかけてる感じもしますが、これを埋めるにランタイム側の修正なども必要そうですね。
残りの機能はさらにニッチそうなので、これで紹介は終わりにしたいと思います。ほかの機能も気になった方はぜひ本家のドキュメントも読んでみてください。