C# ReadOnlySpanとは?Spanとの違い・使い方・高速化の実例を初心者向けに解説

はじめに

C#で文字列解析やバイナリデータ処理を実装すると、Substringや配列コピーによって一時オブジェクトが大量に生成されることがあります。小規模な処理では問題になりにくいものの、ログ解析、CSV処理、通信データの解析などを繰り返す場合は、メモリ割り当てやGC(ガベージコレクション)の負荷が性能低下につながります。

このような場面で役立つのが、C#のReadOnlySpan<T>です。ReadOnlySpan<T>を使うと、配列や文字列の一部分をコピーせず、読み取り専用の連続領域として扱えます。

この記事では、C#のReadOnlySpanとは何か、Span<T>との違い、基本的な使い方、高速化できる理由、実務的なコード例、利用時の制約まで初心者向けに解説します。

1. C#のReadOnlySpanとは

1-1. ReadOnlySpan<T>の基本概念

ReadOnlySpan<T>は、連続したメモリ領域を読み取り専用で参照するための構造体です。

たとえば、次のようなデータを参照できます。

  • 配列全体または配列の一部分

  • 文字列全体または文字列の一部分

  • stackallocで確保した領域

  • アンマネージドメモリ

  • ほかのSpan<T>ReadOnlySpan<T>が参照している範囲

型引数のTには、参照する要素の型を指定します。

C#
ReadOnlySpan<int> numbers;
ReadOnlySpan<char> characters;
ReadOnlySpan<byte> bytes;

ReadOnlySpan<int>は整数の連続領域、ReadOnlySpan<char>は文字の連続領域、ReadOnlySpan<byte>はバイト列を表します。

ReadOnlySpan<T>自体はデータ本体を保持するコンテナではありません。参照先の先頭位置と長さを持つ、軽量な「窓」のような型です。配列だけでなく、マネージドメモリ、ネイティブメモリ、スタック上の領域などを統一的に参照できます。Microsoft Learn+1

1-2. データをコピーせずに参照できる仕組み

通常、文字列の一部分をSubstringで取得すると、新しい文字列が生成されます。

C#
string text = "ABC-12345";
string code = text.Substring(4, 5);

codeには"12345"が格納されますが、そのために新しい文字列オブジェクトが作られます。

ReadOnlySpan<char>では、元の文字列の指定範囲を参照できます。

C#
string text = "ABC-12345";
ReadOnlySpan<char> code = text.AsSpan(4, 5);

Console.WriteLine(code.ToString());

この時点では、"12345"を格納する新しい文字列は生成されません。codeは、元の文字列に含まれる5文字の範囲を参照しています。

このように、データを切り出すたびにコピーするのではなく、必要な範囲だけを参照できることがReadOnlySpan<T>の大きな特徴です。

1-3. 読み取り専用でも元のデータが不変とは限らない

ReadOnlySpan<T>の「読み取り専用」は、スパンを通じて要素を書き換えられないという意味です。参照元のデータまで不変になるわけではありません。

C#
int[] values = { 10, 20, 30 };

ReadOnlySpan<int> span = values;

values[1] = 999;

Console.WriteLine(span[1]); // 999

span[1]への代入はできません。

C#
span[1] = 999; // コンパイルエラー

しかし、元の配列valuesは変更可能です。配列が書き換えられると、それを参照しているReadOnlySpan<int>から見える値も変わります。

一方、stringは通常のC#コードから内容を書き換えられないため、文字列を参照するReadOnlySpan<char>では、この違いを意識する場面は少なくなります。

1-4. ReadOnlySpanが活躍する主な場面

ReadOnlySpan<T>は、次のような処理で活躍します。

  • CSVや固定長データの解析

  • ログ文字列の解析

  • URLやファイルパスの解析

  • 数値や日時のパース

  • ネットワークパケットの解析

  • ファイルヘッダーやバイナリデータの解析

  • 大量の文字列を繰り返し検索する処理

  • ライブラリの高性能な同期API

特に、SubstringSplitToArrayなどによる一時オブジェクトの生成が多い処理は、ReadOnlySpan<T>を検討する価値があります。

2. ReadOnlySpanとSpanの違い

2-1. ReadOnlySpan<T>は読み取り専用、Span<T>は書き換え可能

ReadOnlySpan<T>Span<T>の最大の違いは、参照先を書き換えられるかどうかです。

C#
int[] values = { 10, 20, 30 };

Span<int> writable = values;
ReadOnlySpan<int> readOnly = values;

writable[0] = 100; // 書き換え可能
// readOnly[0] = 100; // コンパイルエラー

Span<T>では要素を変更できます。ReadOnlySpan<T>では要素を読み取ることしかできません。

メソッドが入力データを参照するだけなら、Span<T>ではなくReadOnlySpan<T>を引数にすることで、「このメソッドは入力を変更しない」という意図を明確にできます。

2-2. 配列・文字列・メモリとの関係

配列は、Span<T>ReadOnlySpan<T>の両方から参照できます。

C#
int[] values = { 1, 2, 3 };

Span<int> span = values;
ReadOnlySpan<int> readOnlySpan = values;

文字列は変更できないため、基本的にはReadOnlySpan<char>として参照します。

C#
string text = "Hello";
ReadOnlySpan<char> span = text.AsSpan();

また、Memory<T>ReadOnlyMemory<T>からもスパンを取得できます。

C#
ReadOnlyMemory<char> memory = "Hello".AsMemory();
ReadOnlySpan<char> span = memory.Span;

Memory<T>系はフィールドへの保持や非同期処理に向き、Span<T>系はメソッド内の短時間の同期処理に向いています。

2-3. Span<T>からReadOnlySpan<T>への変換

Span<T>は、暗黙的にReadOnlySpan<T>へ変換できます。

C#
int[] values = { 1, 2, 3 };

Span<int> writable = values;
ReadOnlySpan<int> readOnly = writable;

反対に、ReadOnlySpan<T>Span<T>へ変換することはできません。読み取り専用の参照から書き換え可能な参照を作れてしまうと、安全性が損なわれるためです。

メソッドの引数をReadOnlySpan<T>にしておけば、配列、Span<T>ReadOnlySpan<T>などを柔軟に受け取れます。

2-4. どちらを使うべきか判断する基準

判断基準は単純です。

データを書き換える必要がある場合はSpan<T>を使います。

C#
static void ClearFirstElement(Span<int> values)
{
if (!values.IsEmpty)
{
values[0] = 0;
}
}

読み取るだけの場合はReadOnlySpan<T>を使います。

C#
static int Sum(ReadOnlySpan<int> values)
{
int total = 0;

foreach (int value in values)
{
total += value;
}

return total;
}

必要以上に書き換え可能な型を公開しないことは、APIの安全性と理解しやすさにつながります。.NETの利用ガイドラインでも、バッファを読み取るだけの場合はReadOnlySpan<T>またはReadOnlyMemory<T>を使用する考え方が示されています。Microsoft Learn+1

2-5. string・配列・IEnumerable<T>との違い

stringや配列は、データ本体を所有する型です。長期間保持したり、フィールドに保存したりできます。

一方、ReadOnlySpan<T>はデータを所有せず、既存の連続領域を一時的に参照します。

IEnumerable<T>は、要素を順番に列挙するための抽象化です。データが連続したメモリ上にあるとは限らず、列挙時に処理が実行される遅延評価の場合もあります。

主な違いを整理すると、次のようになります。

データ所有書き換え連続領域長期保持
stringする不可する可能
配列する可能する可能
IEnumerable<T>実装による実装による保証なし可能
Span<T>しない可能する不可
ReadOnlySpan<T>しない不可する不可

3. ReadOnlySpanの基本的な使い方

3-1. 配列からReadOnlySpanを作成する

配列は暗黙的にReadOnlySpan<T>へ変換できます。

C#
int[] values = { 10, 20, 30, 40 };

ReadOnlySpan<int> span = values;

Console.WriteLine(span.Length); // 4
Console.WriteLine(span[0]); // 10

コンストラクターを使って範囲を指定することもできます。

C#
int[] values = { 10, 20, 30, 40, 50 };

ReadOnlySpan<int> span =
new ReadOnlySpan<int>(values, 1, 3);

foreach (int value in span)
{
Console.WriteLine(value);
}

出力されるのは203040です。

3-2. 文字列からReadOnlySpan<char>を作成する

文字列からはAsSpanメソッドを使います。

C#
string text = "Hello, C#!";

ReadOnlySpan<char> span = text.AsSpan();

Console.WriteLine(span.Length);
Console.WriteLine(span[0]);

文字列を暗黙的にReadOnlySpan<char>へ代入できる環境もありますが、コードの意図を明確にするため、範囲指定時にはAsSpanを使うと分かりやすくなります。

3-3. AsSpanメソッドで範囲を指定する

AsSpanには開始位置と長さを渡せます。

C#
string text = "PRODUCT-12345";

ReadOnlySpan<char> productId = text.AsSpan(8, 5);

Console.WriteLine(productId.ToString()); // 12345

開始位置だけを指定すると、指定位置から末尾までが対象になります。

C#
ReadOnlySpan<char> productId = text.AsSpan(8);

3-4. Sliceメソッドで一部分を切り出す

Sliceメソッドでも範囲を切り出せます。

C#
ReadOnlySpan<char> span = "ABCDEFG".AsSpan();

ReadOnlySpan<char> part = span.Slice(2, 3);

Console.WriteLine(part.ToString()); // CDE

Slice(開始位置, 長さ)のほか、Slice(開始位置)で末尾まで取得できます。

C#
ReadOnlySpan<char> part = span.Slice(4);

Console.WriteLine(part.ToString()); // EFG

Sliceで作られたスパンも、元の領域を参照しています。切り出した時点では、文字列や配列のコピーは行われません。

3-5. インデックスとRange構文を使う

C#のインデックス構文とRange構文も利用できます。

C#
ReadOnlySpan<char> span = "ABCDEFG".AsSpan();

char first = span[0];
char last = span[^1];

ReadOnlySpan<char> middle = span[2..5];

Console.WriteLine(first); // A
Console.WriteLine(last); // G
Console.WriteLine(middle.ToString()); // CDE

^1は末尾から1番目、つまり最後の要素を表します。

2..5はインデックス2からインデックス5の直前までを表すため、取得されるのはCDEです。

3-6. foreachで要素を読み取る

ReadOnlySpan<T>foreachで列挙できます。

C#
ReadOnlySpan<int> numbers =
new int[] { 10, 20, 30 };

foreach (int number in numbers)
{
Console.WriteLine(number);
}

文字列の場合も同様です。

C#
ReadOnlySpan<char> characters = "CSharp".AsSpan();

foreach (char character in characters)
{
Console.WriteLine(character);
}

通常、ReadOnlySpan<T>の列挙では、一般的なIEnumerable<T>の列挙を行うためのオブジェクト生成は必要ありません。

3-7. ReadOnlySpanをメソッドの引数として受け取る

入力データを読み取るメソッドでは、引数にReadOnlySpan<T>を指定できます。

C#
static int Sum(ReadOnlySpan<int> values)
{
int total = 0;

foreach (int value in values)
{
total += value;
}

return total;
}

int[] numbers = { 10, 20, 30 };

Console.WriteLine(Sum(numbers)); // 60
Console.WriteLine(Sum(numbers.AsSpan(1))); // 50

文字列を受け取る例は次のとおりです。

C#
static bool IsAdmin(ReadOnlySpan<char> role)
{
return role.Equals(
"admin".AsSpan(),
StringComparison.OrdinalIgnoreCase);
}

Console.WriteLine(IsAdmin("ADMIN")); // True

4. ReadOnlySpanを文字列処理で活用する方法

4-1. Substringとの違いとメモリ割り当て

Substringは、文字列の一部分を新しいstringとして生成します。

C#
string line = "USER:Tanaka";

string name = line.Substring(5);

新しい文字列が必要であれば、この実装に問題はありません。

しかし、切り出した範囲をすぐに比較したり、数値へ変換したりするだけなら、新しい文字列を作らずに処理できます。

C#
string line = "USER:Tanaka";

ReadOnlySpan<char> name = line.AsSpan(5);

bool isTanaka = name.SequenceEqual("Tanaka".AsSpan());

ReadOnlySpan<char>の段階では、名前部分のコピーは発生していません。

4-2. 文字列の一部分をコピーせずに取得する

たとえば、キー=値形式の文字列から値を取得します。

C#
string input = "language=CSharp";

ReadOnlySpan<char> span = input.AsSpan();

int separatorIndex = span.IndexOf('=');

if (separatorIndex >= 0)
{
ReadOnlySpan<char> key = span[..separatorIndex];
ReadOnlySpan<char> value = span[(separatorIndex + 1)..];

Console.WriteLine(key.ToString());
Console.WriteLine(value.ToString());
}

keyvalueを取得した時点ではコピーされません。

ただし、サンプルのConsole.WriteLineで呼び出しているToStringでは、新しい文字列が生成されます。実際の処理では、可能な限りスパンのまま比較やパースを行うことで効果を得られます。

4-3. 区切り文字を検索して文字列を分割する

string.Splitは便利ですが、分割結果の配列や複数の文字列を生成することがあります。

必要な項目を順番に処理するだけなら、IndexOfとスライスを組み合わせられます。

C#
static void PrintFields(ReadOnlySpan<char> line)
{
while (true)
{
int separatorIndex = line.IndexOf(',');

if (separatorIndex < 0)
{
Console.WriteLine(line.ToString());
break;
}

ReadOnlySpan<char> field = line[..separatorIndex];

Console.WriteLine(field.ToString());

line = line[(separatorIndex + 1)..];
}
}

PrintFields("1001,Tanaka,Tokyo".AsSpan());

この例では説明のために各項目をToStringしています。比較やパースだけで処理できる場合は、文字列への変換を省略できます。

利用する.NETのバージョンによっては、スパン向けのSplit関連APIも利用できます。ただし、対象フレームワークやAPIの戻り値が異なることがあるため、プロジェクトのターゲット環境を確認してください。

4-4. 数値や日付をParse・TryParseする

多くの数値型や日時型には、ReadOnlySpan<char>を受け取るParseまたはTryParseのオーバーロードがあります。たとえばint.Parseは、文字スパンを直接整数へ変換できます。Microsoft Learn+2Microsoft Learn+2

C#
string input = "ID=12345";

ReadOnlySpan<char> numberText = input.AsSpan(3);

if (int.TryParse(numberText, out int id))
{
Console.WriteLine(id);
}

日付の例は次のとおりです。

C#
using System.Globalization;

ReadOnlySpan<char> dateText = "2026-06-20".AsSpan();

bool success = DateTime.TryParseExact(
dateText,
"yyyy-MM-dd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out DateTime date);

if (success)
{
Console.WriteLine(date);
}

Substringで一度文字列を生成してからパースする必要がないため、大量データの解析では割り当て量を減らせます。

4-5. StartsWith・EndsWith・IndexOfで検索する

ReadOnlySpan<char>では、文字列処理でよく使う検索操作を行えます。

C#
ReadOnlySpan<char> line =
"INFO: Application started".AsSpan();

bool isInfo = line.StartsWith(
"INFO:".AsSpan(),
StringComparison.Ordinal);

bool isStarted = line.EndsWith(
"started".AsSpan(),
StringComparison.Ordinal);

int colonIndex = line.IndexOf(':');

大文字と小文字を区別しない比較も可能です。

C#
bool isInfo = line.StartsWith(
"info:".AsSpan(),
StringComparison.OrdinalIgnoreCase);

機械的な識別子、ログレベル、プロトコル文字列などを比較するときは、カルチャの影響を受けないStringComparison.OrdinalまたはOrdinalIgnoreCaseを検討します。

4-6. 必要な部分だけ最後にstringへ変換する

ReadOnlySpan<char>を長期間保持することはできません。そのため、最終的に保存する値だけstringへ変換する設計が有効です。

C#
static string ExtractUserName(ReadOnlySpan<char> line)
{
int separatorIndex = line.IndexOf(':');

if (separatorIndex < 0)
{
return string.Empty;
}

ReadOnlySpan<char> name =
line[(separatorIndex + 1)..].Trim();

return name.ToString();
}

解析途中の一時的な範囲はスパンで扱い、最終的に必要な名前だけ文字列にしています。

すべてをスパンのまま扱うのではなく、「解析中はスパン、保存時は文字列」という使い分けが現実的です。

5. ReadOnlySpanで高速化できる理由

5-1. 不要なメモリコピーを減らせる

SubstringToArray、配列の範囲コピーなどを行うと、対象データが新しい領域へコピーされます。

ReadOnlySpan<T>のスライスは、参照開始位置と長さを調整するだけです。

C#
ReadOnlySpan<char> full = "ABCDEFGHIJ".AsSpan();
ReadOnlySpan<char> part = full[2..7];

partのために5文字をコピーする必要はありません。

ただし、part.ToString()part.ToArray()を呼び出すと、その時点でコピーが発生します。

5-2. ヒープ割り当てとGC負荷を抑えられる

一時的な文字列や配列が大量に生成されると、マネージドヒープの使用量が増えます。

不要になったオブジェクトはGCによって回収されますが、オブジェクトの生成と回収にはコストがあります。

ReadOnlySpan<T>を使って一時オブジェクトの生成を減らすと、次の効果が期待できます。

  • 1回の処理で割り当てるバイト数の削減

  • GCの実行回数の削減

  • 処理時間のばらつきの軽減

  • 高頻度処理のスループット改善

特に、同じ解析処理を何万回、何百万回と繰り返す場合に効果が表れやすくなります。

5-3. ref structとスタックの関係

ReadOnlySpan<T>ref structです。

ref structは、参照しているメモリより長く生存してしまうことを防ぐため、コンパイラーによって厳しい利用制限が課されます。通常のクラスのフィールドやボックス化されたobjectとして、マネージドヒープへ逃がすことはできません。

ただし、「参照先のデータが必ずスタック上にある」という意味ではありません。参照先は配列や文字列など、ヒープ上のデータでも構いません。

スタック上で扱われるのは、参照先と長さを表すReadOnlySpan<T>の値です。この制約により、寿命が切れたメモリを参照する危険をコンパイラーが防ぎます。

5-4. JITコンパイラによる最適化

ReadOnlySpan<T>は、JITコンパイラーが最適化しやすい形で連続領域を表します。

処理内容によっては、次のような最適化が期待できます。

  • メソッドのインライン化

  • 不要な境界チェックの削減

  • 効率的なメモリ検索

  • ベクトル命令を利用した比較や検索

  • 一時オブジェクト生成の除去

ただし、すべてのコードで同じ最適化が適用されるわけではありません。実際の効果は、ランタイム、CPU、データ量、処理内容によって変わります。

5-5. 高速化しやすい処理と効果が出にくい処理

効果が出やすいのは、次のような処理です。

  • Substringを何度も呼び出す文字列解析

  • Splitで多数の項目を作る処理

  • 配列の一部分を頻繁にコピーする処理

  • 大量のログやCSVを繰り返し処理する処理

  • バイナリデータのヘッダー解析

  • 割り当て量がボトルネックになっている処理

一方、次のような場合は効果が限定的です。

  • 数回しか実行されない処理

  • データ量が非常に小さい処理

  • データベースやネットワーク待ちが大部分を占める処理

  • 最後にすべての範囲をToStringToArrayしている処理

  • アルゴリズム自体の計算量が問題になっている処理

ReadOnlySpan<T>は、高速化を保証する魔法の型ではありません。不要なコピーや割り当てが原因になっている場合に、特に有効な手段です。

6. ReadOnlySpanによる高速化の実例

6-1. Substringを使った文字列解析の実装

2026-06-20形式の日付から、年、月、日を取得する例を考えます。

C#
static DateOnly ParseDateWithSubstring(string text)
{
int year = int.Parse(text.Substring(0, 4));
int month = int.Parse(text.Substring(5, 2));
int day = int.Parse(text.Substring(8, 2));

return new DateOnly(year, month, day);
}

このコードは分かりやすい一方、Substringを3回呼び出しているため、年、月、日を表す一時的な文字列が生成されます。

6-2. ReadOnlySpanを使った文字列解析への書き換え

同じ処理をReadOnlySpan<char>で実装します。

C#
static DateOnly ParseDateWithSpan(ReadOnlySpan<char> text)
{
int year = int.Parse(text[..4]);
int month = int.Parse(text.Slice(5, 2));
int day = int.Parse(text.Slice(8, 2));

return new DateOnly(year, month, day);
}

切り出した範囲をint.Parseへ直接渡しているため、年、月、日を表す一時文字列は不要です。

入力形式が不正な可能性がある場合は、TryParseや長さの検証を組み合わせます。

C#
static bool TryParseDate(
ReadOnlySpan<char> text,
out DateOnly date)
{
date = default;

if (text.Length != 10 ||
text[4] != '-' ||
text[7] != '-')
{
return false;
}

if (!int.TryParse(text[..4], out int year) ||
!int.TryParse(text.Slice(5, 2), out int month) ||
!int.TryParse(text.Slice(8, 2), out int day))
{
return false;
}

return DateOnly.TryParse(
$"{year:D4}-{month:D2}-{day:D2}",
out date);
}

ただし、この例の文字列補間では新しい文字列が生成されます。割り当てをさらに抑えるなら、範囲チェック後にコンストラクターを呼び出します。

C#
static bool TryParseDateWithoutAllocation(
ReadOnlySpan<char> text,
out DateOnly date)
{
date = default;

if (text.Length != 10 ||
text[4] != '-' ||
text[7] != '-')
{
return false;
}

if (!int.TryParse(text[..4], out int year) ||
!int.TryParse(text.Slice(5, 2), out int month) ||
!int.TryParse(text.Slice(8, 2), out int day))
{
return false;
}

try
{
date = new DateOnly(year, month, day);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}

実務では、例外を利用しない範囲検証やDateOnly.TryParseExactを使う方法も検討します。

6-3. CSVデータから必要な項目を切り出す例

単純なカンマ区切りデータから、ID、名前、年齢を取得します。

C#
static bool TryParseUser(
ReadOnlySpan<char> line,
out int id,
out string name,
out int age)
{
id = default;
name = string.Empty;
age = default;

int firstComma = line.IndexOf(',');

if (firstComma < 0)
{
return false;
}

ReadOnlySpan<char> remaining =
line[(firstComma + 1)..];

int secondComma = remaining.IndexOf(',');

if (secondComma < 0)
{
return false;
}

ReadOnlySpan<char> idText = line[..firstComma];
ReadOnlySpan<char> nameText =
remaining[..secondComma];
ReadOnlySpan<char> ageText =
remaining[(secondComma + 1)..];

if (!int.TryParse(idText, out id) ||
!int.TryParse(ageText, out age))
{
return false;
}

name = nameText.ToString();
return true;
}

IDと年齢はスパンから直接数値へ変換しています。名前は戻り値として長期間保持する必要があるため、最後にだけstringへ変換しています。

なお、この方法は引用符、カンマを含むフィールド、改行を含むフィールドなどを考慮していません。本格的なCSVを扱う場合は、CSV仕様に対応したライブラリを使用するほうが安全です。

6-4. ログ文字列から日時やステータスを抽出する例

次の形式のログを解析します。

2026-06-20T10:15:30Z|INFO|Application started
C#
using System.Globalization;

static bool TryParseLog(
ReadOnlySpan<char> line,
out DateTimeOffset timestamp,
out string level,
out string message)
{
timestamp = default;
level = string.Empty;
message = string.Empty;

int firstSeparator = line.IndexOf('|');

if (firstSeparator < 0)
{
return false;
}

ReadOnlySpan<char> remaining =
line[(firstSeparator + 1)..];

int secondSeparator = remaining.IndexOf('|');

if (secondSeparator < 0)
{
return false;
}

ReadOnlySpan<char> timestampText =
line[..firstSeparator];

ReadOnlySpan<char> levelText =
remaining[..secondSeparator];

ReadOnlySpan<char> messageText =
remaining[(secondSeparator + 1)..];

if (!DateTimeOffset.TryParse(
timestampText,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal,
out timestamp))
{
return false;
}

level = levelText.ToString();
message = messageText.ToString();

return true;
}

ログレベルを比較するだけなら、文字列に変換する必要はありません。

C#
bool isError = levelText.Equals(
"ERROR".AsSpan(),
StringComparison.Ordinal);

6-5. バイト配列からヘッダー情報を読み取る例

ReadOnlySpan<byte>は、通信データやファイル形式の解析にも利用できます。

次の例では、先頭2バイトをバージョン、その後の4バイトをデータ長として読み取ります。

C#
using System.Buffers.Binary;

static bool TryReadHeader(
ReadOnlySpan<byte> data,
out ushort version,
out uint payloadLength)
{
version = default;
payloadLength = default;

const int headerLength = 6;

if (data.Length < headerLength)
{
return false;
}

version = BinaryPrimitives.ReadUInt16BigEndian(
data[..2]);

payloadLength = BinaryPrimitives.ReadUInt32BigEndian(
data.Slice(2, 4));

return true;
}

呼び出し側は配列をそのまま渡せます。

C#
byte[] packet =
{
0x00, 0x01,
0x00, 0x00, 0x04, 0x00
};

if (TryReadHeader(
packet,
out ushort version,
out uint length))
{
Console.WriteLine(version);
Console.WriteLine(length);
}

ヘッダー部分を新しいバイト配列へコピーせずに解析できます。

6-6. BenchmarkDotNetで処理速度と割り当て量を比較する

性能を比較するときは、単純なStopwatchだけではなく、BenchmarkDotNetのようなベンチマークツールを利用すると便利です。

C#
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class DateParsingBenchmark
{
private const string Text = "2026-06-20";

[Benchmark(Baseline = true)]
public int SubstringVersion()
{
int year = int.Parse(Text.Substring(0, 4));
int month = int.Parse(Text.Substring(5, 2));
int day = int.Parse(Text.Substring(8, 2));

return year + month + day;
}

[Benchmark]
public int SpanVersion()
{
ReadOnlySpan<char> span = Text.AsSpan();

int year = int.Parse(span[..4]);
int month = int.Parse(span.Slice(5, 2));
int day = int.Parse(span.Slice(8, 2));

return year + month + day;
}
}

public static class Program
{
public static void Main()
{
BenchmarkRunner.Run<DateParsingBenchmark>();
}
}

MemoryDiagnoserを付けると、実行時間だけでなく、各メソッドが割り当てたメモリ量も確認できます。

Span版では、Substringによる一時文字列の割り当てがなくなるため、一般的には割り当て量の削減が期待できます。ただし、具体的な速度差は実行環境によって異なります。

6-7. ベンチマーク結果を正しく読み取るポイント

ベンチマークでは、平均実行時間だけを見ないことが重要です。

確認したい項目には、次のものがあります。

  • Mean:平均実行時間

  • Error:測定誤差

  • StdDev:測定値のばらつき

  • Ratio:基準メソッドに対する比率

  • Allocated:1回当たりのメモリ割り当て量

  • Gen0など:GCの発生状況

また、ベンチマーク対象が実際のアプリケーションと同じ条件になっているかも確認します。

極端に短い入力だけで測定した結果が、長いログや大規模なデータ処理でも同じになるとは限りません。短いデータ、長いデータ、正常データ、異常データなど、実運用に近いパターンを用意することが大切です。

7. ReadOnlySpanを使う際の制約と注意点

7-1. クラスのフィールドとして保持できない

ReadOnlySpan<T>は、通常のクラスのフィールドとして保持できません。

C#
public class Parser
{
// コンパイルエラー
// private ReadOnlySpan<char> _text;
}

通常の構造体のフィールドにもできません。フィールドとして含められるのは、基本的にref structとして定義した型です。

長期間保持する必要がある場合は、次の型を検討します。

  • string

  • 配列

  • ReadOnlyMemory<T>

  • Memory<T>

7-2. asyncメソッドやイテレーターで扱う際の制約

ReadOnlySpan<T>ref structであるため、awaityield returnをまたいで保持できません。

C# 13以降では、asyncメソッドやイテレーター内にref struct型のローカル変数を宣言できる場面が増えました。ただし、スパンをawait境界やyield return境界を越えて参照することはできません。C# 13より前は、さらに厳しい制限がありました。Microsoft Learn+3Microsoft Learn+3Microsoft Learn+3

安全で分かりやすい設計として、非同期処理ではReadOnlyMemory<T>を受け取り、必要な同期処理区間だけ.Spanを取得する方法があります。

C#
static async Task<int> ProcessAsync(
ReadOnlyMemory<byte> data)
{
await Task.Delay(10);

ReadOnlySpan<byte> span = data.Span;

int total = 0;

foreach (byte value in span)
{
total += value;
}

return total;
}

この例では、awaitが完了した後にスパンを作成しています。

7-3. ラムダ式やローカル関数でキャプチャできない場合がある

ReadOnlySpan<T>をラムダ式へキャプチャして、外側のスコープより長く保持することはできません。

C#
ReadOnlySpan<char> span = "Hello".AsSpan();

// コンパイルエラーになる使い方
// Action action = () => Console.WriteLine(span.Length);

ラムダ式はデリゲートとしてヒープ上へ保持される可能性があります。スタック上だけで扱う必要があるスパンをキャプチャすると、寿命の安全性を保証できなくなるためです。

必要な値だけ先に取り出す方法があります。

C#
int length = span.Length;

Action action = () => Console.WriteLine(length);

7-4. objectやインターフェース型として扱えない

ReadOnlySpan<T>objectへボックス化できません。

C#
ReadOnlySpan<int> span = new int[] { 1, 2, 3 };

// コンパイルエラー
// object value = span;

通常のインターフェース値として扱うこともできません。インターフェース型への変換ではボックス化やヒープ上での保持が必要になる可能性があるためです。

新しいC#では、独自のref structが一定の制約の下でインターフェースを実装できる仕組みも追加されていますが、インターフェース型へボックス化して利用できるという意味ではありません。ReadOnlySpan<T>を一般的なIEnumerable<T>として扱うこともできません。

7-5. スコープ外のメモリを参照しないための注意

ローカルなstackalloc領域を参照するスパンを、そのスコープの外へ返すことはできません。

C#
static ReadOnlySpan<int> CreateValues()
{
Span<int> values = stackalloc int[3];

values[0] = 10;
values[1] = 20;
values[2] = 30;

// コンパイルエラー
// return values;
}

メソッド終了時にスタック上の領域が無効になるため、戻り値として参照を残すことは危険です。

このような誤りはコンパイラーが検出します。ReadOnlySpan<T>の制約は不便に見えることがありますが、無効なメモリ参照を防ぐために必要です。

7-6. ToStringやToArrayによるコピーの発生に注意する

ReadOnlySpan<char>.ToString()は、参照範囲を新しい文字列へ変換します。

C#
ReadOnlySpan<char> span = "ABCDEFGHIJ".AsSpan(2, 4);

string text = span.ToString();

ReadOnlySpan<T>.ToArray()も、新しい配列を生成して要素をコピーします。

C#
ReadOnlySpan<int> span =
new int[] { 10, 20, 30 };

int[] copied = span.ToArray();

これらのメソッドが悪いわけではありません。長期間保存するためにコピーが必要な場合もあります。

問題は、割り当てを減らすためにスパンへ変更したのに、処理途中で何度もToStringToArrayを呼び出すことです。変換は必要な場所で一度だけ行うのが基本です。

7-7. 過度な最適化で可読性を下げない

ReadOnlySpan<T>を使った手動解析は、SplitSubstringを使うコードより複雑になる場合があります。

たとえば、1日に数回しか実行されない設定ファイルの読み込みを、複雑なスパン処理へ変更しても、利用者が体感できるほど高速にならない可能性があります。

次の順序で判断するとよいでしょう。

  1. 分かりやすいコードを書く

  2. プロファイリングで問題を確認する

  3. 割り当てやコピーの多い部分を特定する

  4. 対象部分だけReadOnlySpan<T>へ置き換える

  5. ベンチマークとテストで効果を確認する

性能だけでなく、保守性、テスト容易性、チームメンバーの理解しやすさも重要です。

8. ReadOnlySpanと関連型の使い分け

8-1. ReadOnlyMemory<T>との違い

ReadOnlyMemory<T>も、既存データの一部分を読み取り専用で参照する型です。

大きな違いは寿命と利用場所です。

ReadOnlySpan<T>はスタック上だけで扱うref structで、フィールドに保存できません。

ReadOnlyMemory<T>は通常の構造体なので、クラスのフィールドとして保持したり、非同期処理で利用したりできます。

C#
public sealed class Message
{
public ReadOnlyMemory<byte> Data { get; }

public Message(ReadOnlyMemory<byte> data)
{
Data = data;
}
}

必要な処理区間では、SpanプロパティからReadOnlySpan<T>を取得できます。

C#
ReadOnlySpan<byte> span = message.Data.Span;

8-2. Memory<T>との違い

Memory<T>は、書き換え可能なメモリ領域を長期間扱うための型です。

C#
Memory<int> memory =
new int[] { 10, 20, 30 };

Span<int> span = memory.Span;

span[0] = 100;

読み取り専用か書き換え可能か、短期間か長期間かで使い分けます。

用途適した型
短期間・読み取り専用ReadOnlySpan<T>
短期間・書き換え可能Span<T>
長期間・読み取り専用ReadOnlyMemory<T>
長期間・書き換え可能Memory<T>

Span<T>ReadOnlySpan<T>は、非同期呼び出しのように値を長期間保持する場面には適さず、その用途を補う型としてMemory<T>ReadOnlyMemory<T>があります。Microsoft Learn+1

8-3. ArraySegment<T>との違い

ArraySegment<T>は、配列の一部分を表す構造体です。

C#
int[] values = { 10, 20, 30, 40 };

ArraySegment<int> segment =
new ArraySegment<int>(values, 1, 2);

ArraySegment<T>は配列だけを対象とします。

一方、ReadOnlySpan<T>は配列以外にも、文字列、スタック領域、アンマネージドメモリなどを参照できます。

また、ArraySegment<T>はフィールドに保持できますが、ReadOnlySpan<T>は通常のクラスのフィールドに保持できません。

既存APIとの互換性や長期保持が必要ならArraySegment<T>、短時間の高性能な処理ならReadOnlySpan<T>が候補になります。

8-4. stringとの使い分け

stringは、文字データを所有し、長期間保持するための型です。

ReadOnlySpan<char>は、文字データの範囲を一時的に参照して処理するための型です。

次の用途ではstringが適しています。

  • DTOやエンティティのプロパティ

  • キャッシュする値

  • メソッド終了後も保持する値

  • 辞書のキー

  • UIに表示するテキスト

  • 複数コンポーネント間で受け渡す値

次の用途ではReadOnlySpan<char>が適しています。

  • 文字列の一部分を一時的に解析する

  • 数値や日時へ変換する

  • 接頭辞や区切り文字を検索する

  • コピーせずに範囲を比較する

  • 高頻度な同期処理の引数

8-5. async処理ではReadOnlyMemory<T>を検討する

非同期処理では、データがawaitの前後にわたって必要になることがあります。

その場合は、ReadOnlySpan<T>ではなくReadOnlyMemory<T>を受け取ると扱いやすくなります。

C#
static async Task<bool> SendAsync(
ReadOnlyMemory<byte> data,
CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);

ReadOnlySpan<byte> span = data.Span;

return !span.IsEmpty;
}

スパンを作るのは、awaitを終えた後の同期処理区間です。

ネットワーク送信やストリーム書き込みでは、APIがReadOnlyMemory<byte>を直接受け取ることも多いため、無理にスパンへ変換しないほうが自然な場合もあります。

8-6. API設計でReadOnlySpanを採用する判断基準

公開APIでReadOnlySpan<T>を採用する場合は、次の条件を確認します。

  • 処理が同期的に完了する

  • 入力データをメソッド内だけで利用する

  • 入力データを書き換えない

  • 配列、文字列、スパンなどを柔軟に受け取りたい

  • 呼び出し頻度が高い

  • 割り当て削減に意味がある

  • 対象フレームワークが必要なAPIをサポートしている

文字列との互換性を重視する場合は、オーバーロードを用意する設計もあります。

C#
public static bool IsValid(string text)
{
ArgumentNullException.ThrowIfNull(text);

return IsValid(text.AsSpan());
}

public static bool IsValid(ReadOnlySpan<char> text)
{
return !text.IsEmpty &&
text.IndexOfAny(' ', '\t', '\r', '\n') < 0;
}

実装の中心をスパン版にまとめることで、文字列版との重複を減らせます。

9. ReadOnlySpanを実務で導入する手順

9-1. プロファイリングでボトルネックを特定する

最初に、実際に性能問題が起きている場所を特定します。

確認する項目は、次のとおりです。

  • CPU使用時間が長いメソッド

  • メモリ割り当て量が多いメソッド

  • Gen0 GCが頻繁に発生する処理

  • 大量に生成されているstringや配列

  • 同じ解析処理の実行回数

  • 処理時間のばらつき

Visual Studioのプロファイラー、dotnet-countersdotnet-trace、BenchmarkDotNetなどを用途に応じて使います。

9-2. Substringや配列コピーの多い箇所を探す

コード内で、次の処理が高頻度に呼ばれていないか確認します。

C#
Substring
Split
ToCharArray
ToArray
Array.Copy
Enumerable.ToArray

ただし、これらを見つけたからといって、すべて置き換える必要はありません。

呼び出し回数、入力サイズ、割り当て量を確認し、影響の大きい場所から対応します。

9-3. ReadOnlySpan対応のAPIへ段階的に置き換える

最初からシステム全体を変更するのではなく、内部メソッドから段階的に導入します。

変更前は次のようなメソッドです。

C#
static int ParseId(string text)
{
return int.Parse(text);
}

スパン対応版を追加します。

C#
static int ParseId(ReadOnlySpan<char> text)
{
return int.Parse(text);
}

既存の文字列版を残すなら、スパン版へ処理を委譲します。

C#
static int ParseId(string text)
{
ArgumentNullException.ThrowIfNull(text);

return ParseId(text.AsSpan());
}

この方法なら、既存の呼び出しコードをすぐにすべて変更する必要はありません。

9-4. 処理速度とメモリ割り当てを計測する

変更前後で、少なくとも次の項目を比較します。

  • 平均実行時間

  • 上位パーセンタイルの実行時間

  • スループット

  • 1回当たりの割り当て量

  • GC回数

  • CPU使用率

  • 実際の入力データを使った処理時間

割り当て量が減っても、複雑な検索処理を追加したことでCPU時間が増える場合があります。

反対に、単体ベンチマークでは小さな差でも、大量のリクエストを処理するサーバーではGC負荷の減少が大きな効果を生むことがあります。

9-5. 可読性・保守性・性能のバランスを確認する

導入後は、性能だけでなくコード品質も確認します。

  • 入力形式の検証が十分か

  • 範囲外アクセスが起きないか

  • 空データを正しく扱えるか

  • 区切り文字がない場合を考慮しているか

  • 日本語やサロゲートペアなどを考慮すべきか

  • テストコードが読みやすいか

  • チーム内で保守できるか

  • 本当に割り当て量が減ったか

複雑な手動解析を複数箇所へコピーするのではなく、共通メソッドとしてまとめる方法も有効です。

10. ReadOnlySpanに関するよくある質問

10-1. ReadOnlySpanを使えば必ず高速になりますか

必ず高速になるわけではありません。

ReadOnlySpan<T>が特に効果を発揮するのは、次の条件がある場合です。

  • 文字列や配列のコピーが多い

  • 一時オブジェクトが大量に生成されている

  • 同じ処理を高頻度で繰り返す

  • GC負荷が性能問題になっている

処理回数が少ない場合や、データベース・ネットワーク待ちが支配的な場合は、効果を感じにくいことがあります。

また、スパンへ変更した後に毎回ToStringToArrayを呼び出すと、割り当て削減の効果が小さくなります。

10-2. ReadOnlySpanから元のデータを書き換えられますか

ReadOnlySpan<T>を通じて要素を書き換えることはできません。

C#
ReadOnlySpan<int> span =
new int[] { 1, 2, 3 };

// span[0] = 100; // コンパイルエラー

ただし、参照元が変更可能な配列などであれば、別の参照から元データを変更できます。

C#
int[] values = { 1, 2, 3 };
ReadOnlySpan<int> span = values;

values[0] = 100;

Console.WriteLine(span[0]); // 100

ReadOnlySpan<T>は、参照先そのものを不変にする型ではありません。

10-3. ReadOnlySpanをstringへ変換するにはどうしますか

ReadOnlySpan<char>ToStringメソッドを使います。

C#
ReadOnlySpan<char> span =
"Hello, C#".AsSpan(7, 2);

string text = span.ToString();

Console.WriteLine(text); // C#

この変換では、新しい文字列が生成されます。

長期間保持する必要がある場合や、文字列しか受け付けないAPIへ渡す場合に使用します。

10-4. ReadOnlySpanを配列へ変換するにはどうしますか

ToArrayメソッドを使います。

C#
ReadOnlySpan<int> span =
new int[] { 10, 20, 30 };

int[] array = span.ToArray();

ToArrayでは、新しい配列が確保され、要素がコピーされます。

一部分だけを配列として保存したい場合には便利ですが、高頻度な処理で繰り返すと割り当てが増えるため注意が必要です。

10-5. ReadOnlySpanを戻り値として返せますか

安全な参照先であれば、ReadOnlySpan<T>を戻り値として返せます。

C#
static ReadOnlySpan<char> GetExtension(
ReadOnlySpan<char> fileName)
{
int dotIndex = fileName.LastIndexOf('.');

return dotIndex >= 0
? fileName[dotIndex..]
: ReadOnlySpan<char>.Empty;
}

呼び出し例は次のとおりです。

C#
ReadOnlySpan<char> extension =
GetExtension("report.pdf".AsSpan());

Console.WriteLine(extension.ToString()); // .pdf

一方、メソッド内のstackalloc領域を参照するスパンは返せません。

C#
static ReadOnlySpan<int> Invalid()
{
Span<int> data = stackalloc int[10];

// return data; // コンパイルエラー
return ReadOnlySpan<int>.Empty;
}

コンパイラーは、参照先の寿命を超えてスパンが使われないように検査します。

10-6. ReadOnlySpanはasyncメソッドで使用できますか

利用できる場面はありますが、awaitをまたいで保持することはできません。

C# 13以降では、asyncメソッド内でReadOnlySpan<T>などのref struct型を扱える範囲が広がっています。しかし、スパンを作成してからawaitし、その後も同じスパンを使うようなコードは認められません。Microsoft Learn+1

非同期処理全体でデータを保持したい場合は、ReadOnlyMemory<T>を使います。

C#
static async Task<int> CountAsync(
ReadOnlyMemory<byte> memory)
{
await Task.Delay(10);

ReadOnlySpan<byte> span = memory.Span;

return span.Length;
}

10-7. 初心者はどの処理からReadOnlySpanを導入すべきですか

最初は、形式が単純で、入力範囲が分かりやすい処理から始めるのがおすすめです。

具体的には、次のような処理です。

  • 固定形式の日付から年月日を取得する

  • キー=値形式を解析する

  • ログレベルを検索する

  • 数値部分をTryParseする

  • バイト配列の固定長ヘッダーを読み取る

まずは次のようなSubstringを使ったコードを対象にします。

C#
int id = int.Parse(text.Substring(3, 5));

これをスパンへ変更します。

C#
int id = int.Parse(text.AsSpan(3, 5));

小さく置き換え、テストとベンチマークで動作と効果を確認すると、安全に理解を深められます。

まとめ

C#のReadOnlySpan<T>は、配列、文字列、バイト列などの連続したメモリ領域を、コピーせず読み取り専用で参照するための型です。

Span<T>が参照先を書き換えられるのに対し、ReadOnlySpan<T>は読み取り専用です。入力データを変更しないメソッドでは、ReadOnlySpan<T>を採用することでAPIの意図を明確にできます。

文字列処理では、SubstringSplitで一時的な文字列を生成する代わりに、AsSpanSlice、Range構文、IndexOfなどを使って必要な範囲を参照できます。数値型や日時型のスパン対応ParseTryParseへ直接渡せば、解析途中の文字列生成も抑えられます。

一方、ReadOnlySpan<T>ref structであるため、通常のクラスのフィールドに保持できず、awaityield returnをまたいで使用できません。長期間の保持や非同期処理では、ReadOnlyMemory<T>を検討します。

実務では、最初からすべてをReadOnlySpan<T>へ変更するのではなく、プロファイリングによってボトルネックを特定し、Substringや配列コピーが多い処理から段階的に導入することが重要です。処理速度、メモリ割り当て、可読性、保守性を比較しながら、効果のある場所で活用しましょう。