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
特に、Substring、Split、ToArrayなどによる一時オブジェクトの生成が多い処理は、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);
}
出力されるのは20、30、40です。
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());
}
keyとvalueを取得した時点ではコピーされません。
ただし、サンプルの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+2
Microsoft 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. 不要なメモリコピーを減らせる
Substring、ToArray、配列の範囲コピーなどを行うと、対象データが新しい領域へコピーされます。
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を繰り返し処理する処理
バイナリデータのヘッダー解析
割り当て量がボトルネックになっている処理
一方、次のような場合は効果が限定的です。
数回しか実行されない処理
データ量が非常に小さい処理
データベースやネットワーク待ちが大部分を占める処理
最後にすべての範囲を
ToStringやToArrayしている処理アルゴリズム自体の計算量が問題になっている処理
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であるため、awaitやyield returnをまたいで保持できません。
C# 13以降では、asyncメソッドやイテレーター内にref struct型のローカル変数を宣言できる場面が増えました。ただし、スパンをawait境界やyield return境界を越えて参照することはできません。C# 13より前は、さらに厳しい制限がありました。Microsoft Learn+3
Microsoft Learn+3
Microsoft 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();
これらのメソッドが悪いわけではありません。長期間保存するためにコピーが必要な場合もあります。
問題は、割り当てを減らすためにスパンへ変更したのに、処理途中で何度もToStringやToArrayを呼び出すことです。変換は必要な場所で一度だけ行うのが基本です。
7-7. 過度な最適化で可読性を下げない
ReadOnlySpan<T>を使った手動解析は、SplitやSubstringを使うコードより複雑になる場合があります。
たとえば、1日に数回しか実行されない設定ファイルの読み込みを、複雑なスパン処理へ変更しても、利用者が体感できるほど高速にならない可能性があります。
次の順序で判断するとよいでしょう。
分かりやすいコードを書く
プロファイリングで問題を確認する
割り当てやコピーの多い部分を特定する
対象部分だけ
ReadOnlySpan<T>へ置き換えるベンチマークとテストで効果を確認する
性能だけでなく、保守性、テスト容易性、チームメンバーの理解しやすさも重要です。
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-counters、dotnet-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負荷が性能問題になっている
処理回数が少ない場合や、データベース・ネットワーク待ちが支配的な場合は、効果を感じにくいことがあります。
また、スパンへ変更した後に毎回ToStringやToArrayを呼び出すと、割り当て削減の効果が小さくなります。
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の意図を明確にできます。
文字列処理では、SubstringやSplitで一時的な文字列を生成する代わりに、AsSpan、Slice、Range構文、IndexOfなどを使って必要な範囲を参照できます。数値型や日時型のスパン対応Parse・TryParseへ直接渡せば、解析途中の文字列生成も抑えられます。
一方、ReadOnlySpan<T>はref structであるため、通常のクラスのフィールドに保持できず、awaitやyield returnをまたいで使用できません。長期間の保持や非同期処理では、ReadOnlyMemory<T>を検討します。
実務では、最初からすべてをReadOnlySpan<T>へ変更するのではなく、プロファイリングによってボトルネックを特定し、Substringや配列コピーが多い処理から段階的に導入することが重要です。処理速度、メモリ割り当て、可読性、保守性を比較しながら、効果のある場所で活用しましょう。

