Span<T>を使うべき5つの理由

NOTE: こちらは以前Qiitaに投稿した記事のバックアップです。


C# 7.2からSpan構造体というのが使えるようになって、こいつが結構すごいやつなんだが、いかんせん日本語記事が少ない。なら自分で書いて広めるしかないんじゃい!ということで、なんとなくでも凄さが伝わればいいなって思います。

マサカリぶんぶん大歓迎。

はじめに:Hello World

ランタイムが.Net Core2.1以降なら標準で使える。 それ以外の場合はNugetでSystem.Memoryというパッケージを入れよう。

それから、言語の方もC# 7.2以上が必要。 Visual Studio 2017でプロジェクトを作った場合、デフォルトではC# 7.0になっているので引き上げておこう。

で、Span<T>ってなんぞ?

  1. とりあえず、配列っぽい何かと思っておけばいい。

正確に言うなら、配列の一部分のビューである。 次のコードでは、arrayのビューとしてspanを作っている。

using System;

public static class Program
{
    public static void Main()
    {
        var array = new int[]{0, 1, 2, 3, 4, 5, 6};
        var span = new Span<int>(array);
        Console.WriteLine(span[3]); // result: 3
        Console.WriteLine(span.Slice(2)[3]); // result: 5
    }
}

Span<T>の読み取り専用版としてReadOnlySpan<T>という方も用意されている。

static class HogeClass
{
    public static void SomeMethod1(Span<int> span)
    {
        Console.WriteLine(span[0]); // OK
        span[0] = 3; // OK
    }

    public static void SomeMethod2(ReadOnlySpan<int> span)
    {
        Console.WriteLine(span[0]); // OK
        span[0] = 3; // NG
    }
}

Span<T>ReadOnlySpan<T>をつくる基本的な経路は以下の図の通りだ。上3つの型から直接ReadOnlySpan<T>をつくることもできるが、図がごちゃごちゃするのでここでは省略している。ReadOnlySpan<char>に限り、stringから直接生成することができる。

type.png

図を見てわかるように、Span<T>が取り扱う配列というのは単にSystem.Array派生型だけではなく、スタック上の配列やアンマネージヒープも含めた低レベルの線形データ構造全般を指す。

また、Span<T>/ReadOnlySpan<T>はref構造体と呼ばれる特別な構造体である。端的に言えば、オブジェクトの実体が必ずスタックに置かれることが保証されている型だ。これがどのような効果を生むのかについては後述する。

Span<T>を使うべき理由

1. よくできた配列ビュー

従来、.Netにおける配列ビューといえばSystem.ArraySegment<T>型であった。 Span<T>にはArraySegment<T>と比較して以下のような利点がある。

  • パフォーマンスが良い
  • 読み取り専用版(ReadOnlySpan<T>)が用意されている
  • System.Arrayだけでなくスタック配列やアンマネージヒープに対しても利用可能
  • Tがアンマネージ型のとき、MemoryMarshalにより型を越えた柔軟な読み書きが可能
  • IList<T>にキャストしなくてもインデクサが使える

ざっとこれだけでもSpan<T>の存在意義が伝わるのではないだろうか。 無論、ArraySegment<T>にできてSpan<T>にできないこともあるが、Span<T>のほうが優れている部分も多い。

2. ノーモアunsafe

すぐにポイする使い捨てコードならともかく、普通のプログラミングではいつだって抽象化が重要な関心事項だ。 C#のようなオブジェクト指向言語では、外から見た振る舞いをインターフェースとして定義し実装を隠蔽することで、煩雑な実装から抽象化された機能を取り出してきた。このようなオブジェクト指向の実現機構はある程度の計算力を必要としているが、今日の開発ではほとんど不可欠と言っていいほどの成功を収めている。

ところで、C#は相互運用性とかパフォーマンスとかにそこそこに気を使っている言語なので、一部には制限をかけながらも低レベルのデータ構造をサポートしている。すなわちSystem.Array、スタック配列、アンマネージヒープという3種類の配列である。

従来、セーフコンテキストでも利用できるのはSystem.Arrayのみで他2つの利用にはunsafeが必要だった。3種の配列は、いずれも最初の要素への参照と配列長を持ち、内部的にはメモリ上で連続しており、インデックスによるアクセスが可能という、いかにも抽象化できそうな共通した機能を持っているが、ポインタなしには実現できない。ライブラリならともかく、アプリケーションコードをアンセーフコンテキストで書くというのは気が進まない。

そんな折Span<T>が実装された。前述のとおり、Span<T>は3種の配列のいずれからでもつくることができ、同様に取り扱える。インターフェース型とは異なるが、『配列型』に期待される振る舞いを見事に抽象化できていると言えるだろう。

3. The type is the document, the type is the contract

僕が思うに静的型付けの最大の利点は、型自身がドキュメントとなり、また契約となることだと思う。型が明示されたコードはコンパイル時に検証され、ある種のコーディングミスは論理的保証付きで検出される。例えば、IReadOnlyList<T>型オブジェクトのインデクサに値をsetしようとすれば弾かれる。静的型付け言語でのプログラミングをする上ではこの利点が活きるようなコードを書くというのは大切にしたい指針だ。

従来、配列に対して型による契約を施す手段は乏しかった。 例として次のコードを見てみよう。

public class StreamReadingBuffer
{
    public int CurrentPosition { get; private set; }

    public byte[] Buffer => _buffer;
    private readonly byte[] _buffer;

    // ~~~
}

このクラスは、名前からわかるようにバイナリストリーム読み取りを効率化するためにバッファをかませるものだ。1 現下の読み取りバッファをBufferプロパティで公開しているが、byte[]型は内容の変更を防ぐことはできない。 それに、見なければいけない場所もCurrentPositionプロパティとして切り離されてしまっている。 普通、こういったケースでは生の型の代わりにIReadOnlyList<byte>などの形で読み取り専用制約をかけることが推奨されるが、わざわざ配列型を使っていることから想像できるように、こいつはかなりパフォーマンスに気を使うケースで使われることを想定している。余計なインターフェースを挟んで実行速度を悪化させるようなシグニチャは、例え安全であるとしても嫌われる。

Span<T>/ReadOnlySpan<T>が導入されたことで、このようなシーンでは効率と安全性を両立できるようになった。

public class StreamReadingBuffer
{
    private int _currentPosition;

    public ReadOnlySpan<byte> Buffer => _buffer.Slice(_currentPosition);
    private readonly byte[] _buffer;

    // ~~~
}

上記のコードではBufferの内容をクラスのユーザーが書き換えることはできず、受け取るバッファ自体が現在位置に従って切り出されたものになっている。この変更に伴う生の配列に対するオーバーヘッドもごく僅かだ。 読み取り専用であること、見るべき場所が確保されたメモリ全体ではなく一部であるということが、ReadOnlySpan<T>であるという事実によってコード中で表現されているわけだ。

4. それでも僕はバイナリが読みたいんだ

普通の配列に対してもその表現力により役割を持てるSpan<T>だが、その真価はTがアンマネージ型であるときに発揮される。

アンマネージ型とは.Netのマネージド参照を含まない型のことだ。 ガベージコレクタが気を使わなくても済む型、あるいはC言語でも同等な構造体が作れる型と考えてもよい。

すべてのアンマネージ型オブジェクトは等価なバイト配列を考えることができる。 メモリ上でそうなってる通りにバイナリに起こすだけだから当然といえば当然だ。 Span<T>は、MemoryMarshalを使うことで任意のアンマネージ型間で変換することができる。

using System.Runtime.InteropServices;

// float配列をint配列として読み書きできるようにする
public void Foo(Span<float> bufferAsFloat)
{
    Span<int> bufferAsInt = MemoryMarshal.Cast<float, int>(bufferAsFloat);
    // ~~~
}

この機能を使いたい最大のユースケースはバイナリストリームの読み書きだろう。 独自の(あるいは独自ではないが特殊な)プロトコルやファイル形式を実装するとき、構造体を定義しておけば高速かつ簡単な読み書きが可能になる。例えば、次のコードはPortableExecutableファイルのヘッダを読み取るコードだ。

using System;
using System.Runtime.InteropServices;

// DosHeader, ImageFileHeader, ImageOptionalHeaderなどは割愛
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct NtHeader
{
    public readonly uint Signature;
    public readonly ImageFileHeader FileHeader;
    public readonly ImageOptionalHeader OptionalHeader;
}

public static class Sample
{
    public static NtHeader ReadHeader(byte[] buffer)
    {
        var dosHeader = MemoryMarshal.Cast<byte, DosHeader>(buffer)[0];
        return MemoryMarshal.Cast<byte, NtHeader>(buffer.AsSpan(dosHeader.e_lfanew))[0];
    }
}

いわゆるC++のreinterpret_castみたいな動作だが、これがお手軽にできるようになったというわけだ。2

5. API足りてる

身もふたもない話だが、ArraySegment<T>はBCL内部での対応さえしょっぱく、標準APIとして扱うには微妙だった。3 ある型が使われるかどうかにおいてAPIの充足は大事な要素だ。 渡すことも受け取ることもできないなら結局手元での変換が必要になるし、目にする機会もぐっと減ってしまうだろう。

Span<T>の導入にあたり、以下の型などに対応が入れられている。

  • プリミティブ数値型
  • System.BitConverter
  • System.IO.Stream
  • Sytem.Text.Encoding

つまり、今まで低レベル用途のために配列を受け取っていたようなAPIに対してかなりの部分でSpan<T>に対応されたのだ。 前述の通りSpan<T>はstackallocも使えるので、ヒープを使わずにストリームを読み出すなんてこともできる。

Span<T>を使うべきでない3つのケース

とまあ、ここまでSpan<T>の宣伝をしてきたわけだが、あらゆるケースで最適解というわけではない。 むしろ、ある方面での利便性を求めた結果、別の方面では不便な型になった。 ここでは、その性質からSpan<T>の利用が適さない、または利用できないケースを紹介する。

クラスのメンバにできないんですけど?

A. 仕様です。

前述のとおり、Span<T>はref構造体と呼ばれる型だ。 ref構造体のオブジェクトは必ずスタック上に置かれていることが保証されている。 裏を返せば、ヒープに載る可能性があることは一切できないということだ。

public class Hoge
{
    private Span<object> _span; // NG: ref構造体でない型のフィールドになれない

    public static Span<int> Foo()
    {
        Span<int> span = stackalloc int[16];
        Bar(span); // NG: ジェネリック型引数にできない
        var obj = (object)span; // NG: objectにアップキャストできない
        Func<int> baz = () => span[0]; // NG: デリゲートでキャプチャできない

        // True。アップキャストできないにもかかわらず内部的にはobjectの派生型。
        // あくまでC#コンパイラが静的検証で禁止してるだけなので当たり前といえば当たり前。
        Console.WriteLine($"Span<T> is a subtype of object: {typeof(Span<>).IsSubclassOf(typeof(object))}");

        return span; // NG: stackallocはメソッドスコープから抜けると死ぬので戻り値にできない
    }

    public static void Bar<T>(T value){}
}

public ref struct Fuga : IEnumerable<int> // NG: インターフェースを実装できない
{
}

当然だが、クラスとインターフェースを通してオブジェクト指向を実現するC#においてこれは非常に厳しい制限だ。 ここで制限に引っかかる用途なら、配列を使うかSystem.Memory<T>という型を使おう。

IList<T>/IReadOnlyList<T>/ICollection<T>/IEnumerable<T>で充分では?

もしもそのように感じるのであれば、大抵の場合その感覚は正しいと思う。 すでに述べたように、Span<T>は低レベルプログラミングのために存在する。 大規模なアプリもそこそこ簡単で保守性よく書けることが売りのC#において、生のバイナリに注目するようなケースは決して多くない。 実体はList<T>なりを使っておいて適切なインターフェースで公開するようにした方が.Netの型システム的に素直だし、使う側にとっても便利でわかりやすいものに仕上がる場合がほとんどだ。 パフォーマンスとても大事だとか、レガシーなプロトコルを実装したいとか、明確な理由がなければSpan<T>を使う必要はない。

LINQ使えねーなんてダッセーよな!

ref構造体であるSpan<T>は当然IEnumerable<T>を実装していない4のでLINQを使うことはできない5。forなりforeachなりを使わざるを得ないのでLINQに慣れ親しんだC#er諸君にとってはストレスの貯まるコーディングになるかもしれない。 繰り返すが、Span<T>じゃないといけないのでなければIEnumerable<T>が使える方にしたほうが幸せになれる。

終わりに

Span<T>はとにかく使えというレベルのものではないが、速度が大事なプログラミングには確実に力を与えてくれる。 もしもあなたがI/Oや通信のコアライブラリを書く仕事についているなら、ぜひ使ってみて欲しい。


参考文献


  1. こういった型は普通publicにしないが、ここではまあ例として。

  2. 大きな構造体に対してパフォーマンスを求めるならUnsafe.As<TFrom, TTo>を使っても良い。

  3. なぜかWebSocketのみ充実している。

  4. foreachは使えるので安心して欲しい。

  5. ここでいうLINQLINQ to Objectsの話。LINQ to Spanを実装すればいけるかもしれないが、遅延評価を筋よく書ける気はあまりしない・・・