Span<T>構造体Dev Basics/Keyword

Span<T>型は「連続したメモリ領域とその操作」を抽象化して、元のメモリ領域の種類によらず、それらを統一的かつ効率的に扱えるようにするもの。

» 2017年12月26日 05時00分 公開
[かわさきしんじInsider.NET編集部]
「Dev Basics/Keyword」のインデックス

連載「Dev Basics/Keyword」

 Span<T>型は「連続したメモリ領域を表現する」値型。配列やスタックに確保されたメモリ領域、.NETの管理外にあるメモリ領域などを統一的かつ効率的に扱える。

Span<T>型を使うには

 本稿執筆時点(2017年12月25日)では、Span<T>型を利用するにはSystem.MemoryパッケージをNuGetからインストールする必要がある。また、一部の機能は.NET Core 2.1(現時点ではプレビュー段階)でのみサポートされており、.NET Frameworkではサポートされないものもある(本稿では.NET Core 2.1 Previewと、dotnet.myget.orgからプレビュー版のSystem.Memory 4.5.0-preview1-26024-02をインストールし、Visual Studio 2017で.NET Core 2.1ベースのプロジェクトを作成した上でコードの動作を確認している)。Visual Studioなどで使用するC#の言語バージョンを設定する方法については「C# 7.1」を参照のこと。

連続したメモリ領域

 Span<T>型は配列や文字列、スタックに確保したメモリ領域、.NETの管理外のアンマネージドな(ネイティブな)メモリ領域など、任意の種類の連続したメモリ領域を統一的に扱うために導入された新たな種類の構造体(値型)である。以下はさまざまな種類のメモリ領域からSpan<T>型のデータ構造を作成している例だ。

// 配列
int[] intarray = { 1, 2, 3, 4, 5 };
Span<int> intspan = new Span<int>(intarray);
intspan = intarray;
intspan = intarray.AsSpan().Slice(2, 3);

// 文字列
string s = "insider.net";
ReadOnlySpan<char> rospan = s.AsReadOnlySpan();

// アンマネージドなメモリ領域(4バイトの領域にバイト単位でアクセス)
IntPtr p = Marshal.AllocHGlobal(sizeof(int));
Span<byte> umspan;
unsafe { umspan = new Span<byte>((byte*)p, 4); }
Marshal.FreeHGlobal(p);

// スタックに確保されたメモリ領域(.NET Core 2.1以降のみ)
Span<int> intspan2 = stackalloc int[10];

さまざまな種類のメモリ領域からSpan<T>型のデータ構造を作成
「using System.Runtime.InteropServices;」が必要。

 上の例にあるように、Span<T>型のデータ構造はコンストラクタに配列(や「アンマネージドなメモリ領域」の例にあるようにポインタ)を渡したり、あるいは暗黙の型変換を利用してSpan<T>型の変数に代入したり、AsSpan/AsReadOnlySpan拡張メソッドを利用したりすることで作成できる(これらの拡張メソッドは以前にはSystem.SpanExtensions名前空間で定義されていたが、本稿で使用したSystem.MemoryパッケージではSystem.MemoryExtensions名前空間で定義されている。今後も仕様の変更があるかもしれない)。

 文字列の例で使用しているAsReadOnlySpan拡張メソッドは「読み取り専用のメモリ領域」を返送する(文字列は変更不可能なので、読み書きが可能なメモリ領域を返送するAsSpan拡張メソッドではなくAsReadOnlySpan拡張メソッドを利用する必要がある)。

 アンマネージドなメモリ領域の例では4バイトのメモリ領域をAllocHGlobalメソッドで確保した後に、そこにバイト単位でアクセスできるようなSpan<byte>型を作成している。

 最後の例で使用しているstackallocはスタック上にメモリを割り当てるもので、通常はunsafeなコードとして実行しなければならないが、.NET Core 2.1のランタイムとSpan<T>型との組み合わせでは安全なコードとして実行できる(これにはランタイムレベルでのサポートが必要となるため、現状の.NET Frameworkではこの例のような記述はサポートされていない)。

 いずれにせよ、元のメモリ領域がどんなものかによらずに「連続したメモリ領域とそれに対する操作」を抽象化し、統一的な操作を行えるようにするのがSpan<T>型の大きなメリットの1つといえる。

Span<T>オブジェクトの操作

 このようにして作成したSpan<T>型の値はそのメモリ領域を連続的にアクセスするためのインデクサやメモリサイズに関する情報を管理している。また、配列の3番目の例ではSliceメソッドを呼び出しているが、これは整数配列からSpan<int>型のオブジェクトを作成し、その一部の要素だけを取り出している(スライシング)。スライシングされたオブジェクトは、元の配列と同じ要素を参照しながら、その一部分だけが管理の対象となる。範囲外の領域にアクセスすると例外が発生する。例えば、上で作成した配列を操作するコードを以下に示す。

// 省略
intspan = intarray.AsSpan().Slice(2, 3);

intspan[0] = 30;

for (int i = 0; i < intspan.Length; i++)
{
  Console.Write($"{intspan[i]}, "); // 出力結果:30, 4, 5,
}

Console.WriteLine($"\n{string.Join(", ", intarray)}");
// 出力結果:1, 2, 30, 4, 5

try
{
  intspan[3] = 6;
}
catch (Exception e)
{
  Console.WriteLine(e.Message);
  // 出力結果:Index was outside the bounds of the array.
}

整数配列から作成したSpan<int>オブジェクトの操作例

 コメントとして示した出力結果からはSpan<int>オブジェクト(intspan)と元の配列(intarray)との関係が次のようになっていることが分かる。

元の配列とSpan&lt;int&gt;オブジェクトの関係 元の配列とSpan<int>オブジェクトの関係

 インデクサを利用して「intspan[0] = 30」のようにしたコードにより元の配列が書き換えられているのは、Span<T>型のインデクサが元のメモリ領域への参照を返送するからだ。

 一方、文字列は変更不可能なデータ構造であり、先ほどの例ではAsReadOnlySpan拡張メソッドを利用してchar型の値を要素とする読み取り専用のオブジェクトを作成した。その要素は変更不可能であることを確認してみよう。

string s = "insider.net";
ReadOnlySpan<char> rospan = s.AsReadOnlySpan();
rospan[0] = 'I';  // エラー

変数rospanが参照しているのは読み取り専用のメモリ領域なので、要素を変更できない

変数rospanが参照しているのは読み取り専用のメモリ領域なので、要素を変更できない 変数rospanが参照しているのは読み取り専用のメモリ領域なので、要素を変更できない

 先ほど見たように、Span<T>型(およびReadOnlySpan<T>型)では元のメモリ領域の要素を直接参照すると同時に、独自にインデックスとメモリ領域のサイズを管理することから、ReadOnlySpan<char>型を使うと、文字列からその一部を効率的に取り出せる。string型のSubstringメソッドは部分文字列を取り出す際に新規にstringオブジェクトを生成するが、ReadOnlySpan<char>型は元の文字列を直接参照するので、オブジェクト生成のコストが発生しない(ただし、ReadOnlySpan<char>型はあくまでもメモリ領域を参照するものであり、通常の文字列と全く同様に利用できるものではない。現在では、StartsWith拡張メソッドなど、これを文字列ライクに利用できるようにするための拡張メソッドがSystem.MemoryExtensions名前空間に用意されている)。

string s = "insider.net";
ReadOnlySpan<char> rospan = s.AsReadOnlySpan();  // 文字列s全体を直接参照
rospan = s.AsReadOnlySpan().Slice(0, 6);  // 文字列sのインデックス0〜6を直接参照
var subst = s.Substring(0, 6);  // 新たにメモリ確保
Console.WriteLine(rospan.StartsWith("in"));  // 出力結果:True
Console.WriteLine(rospan.IndexOf(".net"));   // 出力結果:-1
Console.WriteLine(s.IndexOf(".net"));  // 出力結果:7
foreach (var item in rospan)
{
  Console.Write($"{item} ");  // 出力結果:i n s i d e
}

ReadOnlySpan<char>型の操作例

 なお、AsReadOnlySpan拡張メソッドは読み書き可能なメモリ領域に対して読み取り専用という特性を付加するのにも利用できる。例えば、最初の例の整数配列(intarray)から読み取り専用のReadOnlySpan<int>オブジェクトも作成できる。

ReadOnlySpan<int> rointspan = intarray.AsReadOnlySpan();
rointspan[0] = 10;  // エラー

読み書き可能な配列から読み取り専用のメモリ領域を作成

ref struct型

 先ほど「連続したメモリ領域とそれに対する操作」を抽象化することがSpan<T>型のメリットの1つと述べたが、もう1つの大きなメリットはこれが「効率的」であることだ。今までにも見てきた通り、Span<T>/ReadOnlySpan<T>型はその元となったメモリ領域を参照することで不要なメモリ確保やコピーを抑制できる。

 その一方で、Span<T>型のように配列と同様な特性を持ちながら、さまざまな種類のデータを効率的に扱えるデータ構造を実現するためには、これがスタック上にのみ存在する必要がある。その理由としては元のメモリ領域に対する参照情報の管理を低コストで行えるようにすることと、マルチスレッド環境での動作を保証することの2点が挙げられる(詳細についてはMSDN Magazineの記事「C# - All About Span: Exploring a New .NET Mainstay」(英語)や「C#でぐぐれの人」こと岩永氏による解説「ref構造体」などを参照されたい)。

 そして、これを実現するためのデータ型としてC# 7.2では「ref struct」型が導入された。ref struct型は「スタック上に確保することが強制される」データ型であり、Span<T>型は実際にこれを利用して次のように定義されている。

public readonly ref struct Span<T>
{
  // …… 省略 ……
}

Span<T>型はref struct型として定義されている

 「スタックにのみ存在できる」という制約からは、ref struct型のデータはボクシング不可、クラスおよびref struct型以外の構造体のフィールドに使えない、ローカル関数やラムダ式でキャプチャーできない、非同期メソッドやイテレーターのローカル変数として使用できないなどの制約も付随的に生まれる。

 ただし、これらは制約であると同時に配列ライクな高速性、効率的なガべージコレクション、マルチスレッド環境での安全性、オブジェクトの安全なライフタイム管理などのメリットを生み出す源泉でもある。また、言語レベルで(および IntelliSenseのレベルでも)これらの制約が効くので、ref struct型(Span<T>/ReadOnlySpan<T>型)を利用することで、こうしたメリットをコーディングの時点からプログラマーが享受できるのも魅力の1つだといえる。


 .NET Frameworkは生まれてから20年近くが過ぎようとしている。その間にコンピューティング環境は大きく変化した。ネットワークを介して大量のデータがやりとりされるのが当たり前となった環境で、それらをいかに効率よく処理するかが検討され、不要なメモリ確保やコピーを発生させずにさまざまな種類のデータを効率的、統一的、安全に取り扱う方法として生まれたのがSpan<T>型やその基盤となるref struct型であるといえる。

参考資料


「Dev Basics/Keyword」のインデックス

Dev Basics/Keyword

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。