C#のSpan<T>やReadOnlySpan<T>と戯れる

2021/09/08に公開

最近、フォント(.ttf)の読み込みや描画について調べていたところSpan<T>MemoryMarshalクラスのメソッド群に触れる機会が増えたのでその備忘録です。

Span<byte>からunmaneged typeを作る

using System;
using System.Runtime.InteropServices;
[StructLayout(LayoutKind.Sequential,Pack=1)]
readonly struct A
{
    public readonly int X;
    public readonly int Y;
}

var bytes = new byte[]{0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00}.AsSpan();
var a = MemoryMarshal.AsRef<A>(bytes);

Console.WriteLine($"{a.X} {a.Y}"); // 1 2

MemoryMarsha.AsRef<T>.(Span<byte> span)を使う場合、構造体Tは必ずunmanagedのみで構成されている必要があります。本来の制約としてはstructですが参照型を含めると例外が発生します。

また、[StructLayout(LayoutKind.Sequential,Pack=1)]はほぼ必須です。同じサイズの型が続いている場合、Pack=1は省略可能ですが、int, byte, intのような順序で並ぶ場合はPackを設定しないとbyteのフィールドと2つ目のintのフィールドの間に2byteの隙間が挿入されるので想定通りの値とならない場合があります。

ビッグエンディアンのバイナリから構造体を作成する

OpenType(TrueType)フォーマットのフォントファイルからバイナリを読んでTable Directory(オフセットテーブルとも呼ばれる)の構造体を作成したい時、.ttfはビッグエンディアンなので少し工夫が必要です。

[StructLayout(LayoutKind.Sequential, Pack=1)]
readonly struct TableDirectory
{
    public readonly ushort RangeShift;
    public readonly ushort EntrySelector;
    public readonly ushort SearchRange;
    public readonly ushort NumTables;
    public readonly uint SfntVersion;
}

var path = @"yumin.ttf";
using var fp = File.OpenRead(path); 
Span<byte> buffer = stackalloc byte[Unsafe.SizeOf<TableDirectory>()];
// TableDirectoryのサイズ(12byte)分読み取り
fp.Read(buffer);
buffer.Reverse();
var dir = MemoryMarshal.AsRef(buffer);
Console.WriteLine($"0x{SfntVersion:X8} {NumTables} {SearchRange} {EntrySelector} {RangeShift}");
0x00010000 21 80 256 4

こちらは游明朝のフォントファイルを読んだ時の値です。

エンディアンを指定してSpan<byte>からプリミティブ型を作成する

System.Buffers.Binary.BinaryPrimitivesクラスを使うと便利です。
前述のTable Directory読み取りも特に構造体を用意しない場合は以下のような形になります。

using var fp = File.OpenRead(@"yumin.ttf"); 
Span<byte> buffer = stackalloc byte[sizeof(ushort) * 4 + sizeof(uint)];
fp.Read(buffer);

var sfntVersion = BinaryPrimitives.ReadUInt32BigEndian(buffer.Slice(0,sizeof(uint)));
var numTables = BinaryPrimitives.ReadUInt16BigEndian(buffer.Slice(sizeof(uint),sizeof(ushort)));
// 略

内部的にはMemoryMarshal.Read<T>Span<byte>を読んだあと、BitConverter.IsLittleEndianを判定したのちReverseEndiannessメソッドで転回させています。

Span<TFrom>からSpan<TTo>を作成する

[StructLayout(LayoutKind.Sequential,Pack=1)]
readonly struct B
{
    public readonly ushort B1;
    public readonly ushort B2;
}

var bytes = new byte[]{0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00}.AsSpan();

var intSpan = MemoryMarshal.Cast<byte, int>(bytes);
foreach(var i in intSpan)
{
    Console.WriteLine(i);
}

var foo = MemoryMarshal.Cast<int, B>(intSpan);
foreach(var f in foo)
{
    Console.WriteLine($"{f.B1} {f.B2}");
}
1
16
1 0
16 0

ある型のSpanを別の型へ変換することもできます。
バイナリデータから構造体のSpanや配列を作るのに便利です。

stringからSpan<char>を作成して書き換える

少々邪悪かもしれませんがReadOnlySpan<char>Span<char>へ変換して中身を変更することができます。
unsafe無しにこれが出来ていいのだろうか……?

var str = "hello";
var span = MemoryMarshal.CreateSpan<char>(ref MemoryMarshal.GetReference(str.AsSpan()), str.Length);
span.Reverse();
Console.WriteLine(str);
span[0] = 'l';
Console.WriteLine(str);
olleh
llleh

GetReference<T>(Span<T> span)ではspan[0]の参照を返します。どういう訳かReadOnlySpan<T>を引数に取るメソッドもあります。
似たようなメソッドとしてはGetArrayDataReference<T>(T[] array)という配列の先頭の参照を返すものもあります。

MemoryMarshal.CreateSpan<T>internalとなっていて直接は呼べないnew Span<T>(ref T ptr, int length)のコンストラクタを呼んでいます。
これで晴れてstringの文字を直接操作できるようになりました。

配列の先頭の参照からSpanを作る

T[]の先頭の参照からCreateSpanSpan<T>を作成するのを利用するとこんなこともできます。

ref int GetArray() => ref new int[]{1,2,3,4,5}[0];

ref int a = ref GetArray();
var span = MemoryMarshal.CreateSpan(ref a, 5);
foreach(var i in span)
{
    Console.WriteLine(i);
}
1
2
3
4
5

CreateSpanの第二引数をGetArray()で返した配列の要素数以上にすると...

1
2
3
4
5
0
0
0
-1675796560
32763

lengthの値は一切チェックされない為、初期化されていない部分が覗けます。特に例外も発生しませんがかなりアヤシイ。

終わりに

MemoryMarshalで定義されているメソッドはSpan<T>ReadOnlySpan<T>と関連した型の相互運用の為に作られたように見えます。内部的にはUnsafeクラスのメソッドを使用しており、一見安全そうな顔をしていますが全然安全ではなさそうです。一周回ってポインタめいた話に戻ってきたところはありますが、上手く利用してハイパフォーマンスなコードを書いていきたいですね。業務でプログラミングしてませんが…。

dotnet/runtime のリポジトリ

MemoryMarshal.cs
Span.cs
BinaryPrimitives

Discussion