C#を用いたアプリケーション開発において、大きな配列の中から特定の範囲のみを取り出す「部分配列(サブ配列)」の取得は、データ処理やアルゴリズムの実装で頻繁に登場する操作です。
従来はLINQやArray.Copyを用いるのが一般的でしたが、近年のC#(C# 8.0以降)ではRange(範囲)演算子やSpan<T>の導入により、パフォーマンスと記述性の両面で劇的な進化を遂げました。
本記事では、C#で部分配列を効率的に取得するためのさまざまな手法について、初心者から上級者まで役立つよう詳細に解説します。
メモリ効率を重視すべき大規模データ処理から、コードの簡潔さを重視する日常的な実装まで、シチュエーションに応じた最適な選択肢を提示します。
1. 従来の手法による部分配列の取得
C# 8.0より前の環境や、古いコードベースのメンテナンスでは、主にLINQやArray.Copyが使用されてきました。
これらは現在でも有効ですが、特性を理解しておく必要があります。
1.1 LINQ(SkipとTake)を使用した方法
LINQを使用すると、宣言的で読みやすいコードを書くことができます。
特定の要素を読み飛ばすSkipと、指定した個数を取得するTakeを組み合わせることで、部分配列を作成できます。
using System;
using System.Linq;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 3番目の要素から4つ分を取得(インデックス3, 4, 5, 6)
int[] subArray = source.Skip(3).Take(4).ToArray();
Console.WriteLine("LINQによる取得結果:");
Console.WriteLine(string.Join(", ", subArray));
}
}
LINQによる取得結果:
3, 4, 5, 6
LINQの最大の利点はその可読性にありますが、内部的には列挙子(Enumerator)を生成し、新しい配列をヒープ領域に確保するため、パフォーマンスが求められるループ内などでの多用は避けるべきです。
1.2 Array.Copyを使用した方法
より低レベルで高速な方法として、System.Array.Copyメソッドがあります。
これはメモリブロックを直接コピーするため、LINQよりも高速です。
using System;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
int[] destination = new int[4];
// sourceのインデックス3から、destinationへ4要素分コピー
Array.Copy(source, 3, destination, 0, 4);
Console.WriteLine("Array.Copyによる取得結果:");
Console.WriteLine(string.Join(", ", destination));
}
}
Array.Copyによる取得結果:
3, 4, 5, 6
Array.Copyは高速ですが、事前にコピー先の配列をインスタンス化しておく必要があり、コードが冗長になりやすいという欠点があります。
2. C# 8.0以降のRange(範囲)とIndexによるスライス
C# 8.0では、配列のインデックス指定と範囲指定を直感的に記述できるIndexとRangeという新しい機能が追加されました。
これにより、Pythonのようなスライス記法が可能になりました。
2.1 IndexとRangeの基本構文
Range(範囲演算子 ..)とIndex(末尾からのインデックス演算子 ^)を使用することで、部分配列の取得が驚くほどシンプルになります。
^n:末尾からn番目の要素を指します(^1が最後の要素)。a..b:インデックスaからbの手前まで(bは含まない)を指します。
using System;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// インデックス3から7の手前(3, 4, 5, 6)を取得
int[] subArray1 = source[3..7];
// 末尾から3番目から、末尾までを取得
int[] subArray2 = source[^3..];
Console.WriteLine($"subArray1: {string.Join(", ", subArray1)}");
Console.WriteLine($"subArray2: {string.Join(", ", subArray2)}");
}
}
subArray1: 3, 4, 5, 6
subArray2: 7, 8, 9
2.2 Range演算子の注意点:コピーの発生
非常に便利なRange演算子ですが、配列に対して直接使用した場合、内部的には新しい配列が生成(コピー)されるという点に注意が必要です。
つまり、見た目はスマートですが、メモリ消費量という観点では Array.Copy を実行しているのと同等です。
この問題を解決するのが、後述する Span<T> です。
3. Span<T> を活用したゼロコピー・スライス
パフォーマンスを極限まで追求する場合、C# 7.2で導入され、C# 8.0以降でより使いやすくなったSpan<T>およびReadOnlySpan<T>が最強の選択肢となります。
3.1 Span<T> とは何か
Span<T> は、メモリ上の連続した領域を抽象化した構造体です。
最大の特徴は、「新しい配列を作成(コピー)することなく、元の配列の特定範囲を直接参照できる」点にあります。
これを「ゼロコピー」や「参照のスライス」と呼びます。
3.2 Spanによる部分配列の操作
Span<T> を使用して部分配列を操作するコードを見てみましょう。
using System;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// 配列からSpanを作成し、スライス(インデックス3から4要素)
// ここで新しい配列は作成されない
Span<int> span = source.AsSpan();
Span<int> subSpan = span.Slice(3, 4);
// 値を書き換えてみる
subSpan[0] = 999;
Console.WriteLine("元の配列の状態:");
Console.WriteLine(string.Join(", ", source));
}
}
元の配列の状態:
0, 1, 2, 999, 4, 5, 6, 7, 8, 9
実行結果に注目してください。
スライスした subSpan の値を変更すると、元の配列 source の値も書き換わっています。
これは subSpan が元の配列のメモリ領域を直接指し示している証拠です。
3.3 SpanとRange演算子の組み合わせ
最新のC#では、Span<T> に対してRange演算子を使用することができます。
これにより、「コピーを発生させないスライス」を「簡潔な構文」で記述可能になります。
using System;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// Spanとしてスライス(コピーが発生しない)
ReadOnlySpan<int> slice = source.AsSpan()[3..7];
Console.WriteLine("ReadOnlySpanによるスライス結果:");
foreach (var item in slice)
{
Console.Write($"{item} ");
}
}
}
ReadOnlySpanによるスライス結果:
3 4 5 6
4. ArraySegment<T>:古い環境での部分参照
Span<T> が登場する前、メモリコピーを避けつつ部分配列を扱うための主要な手段は ArraySegment<T> でした。
現在では Span<T> に取って代わられつつありますが、特定のライブラリ(ネットワーク通信系など)では依然として使用されています。
using System;
class Program
{
static void Main()
{
int[] source = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
// インデックス3から4要素のセグメントを作成
ArraySegment<int> segment = new ArraySegment<int>(source, 3, 4);
Console.WriteLine($"Offset: {segment.Offset}, Count: {segment.Count}");
Console.WriteLine($"Segment[0]: {segment[0]} (元の配列の3)");
}
}
ArraySegment<T> はクラスのフィールドとして保持できる(Span<T> は ref 構造体のためスタック上に制限される)という利点がありますが、インターフェースの使い勝手は Span<T> に劣ります。
5. 各手法の比較と使い分け
これまでに紹介した手法を、用途と特性ごとにまとめました。
| 手法 | 構文の簡潔さ | パフォーマンス | メモリコピー | 推奨される用途 |
|---|---|---|---|---|
| LINQ (Skip/Take) | 非常に高い | 低い | あり | 小規模データ、読みやすさ優先 |
| Array.Copy | 低い | 高い | あり | 明示的に独立したコピーが必要な場合 |
| Range [a..b] | 非常に高い | 中程度 | あり | 簡潔に新しい部分配列を作りたい場合 |
| Span.Slice | 高い | 最高 | なし | パフォーマンスが重要な処理 |
| ArraySegment | 低い | 高い | なし | 非同期処理やフィールド保存が必要な場合 |
使い分けの基準
- Range演算子
元の配列を新しい配列として独立させたい(元配列の書き換えの影響を避けたい)場合は、
source[3..7]のようなRange演算子を使用します。これは最も現代的で読みやすい書き方です。
- Span<T> / ReadOnlySpan<T>
画像処理、音声解析、ネットワークのバッファ処理など、1ミリ秒を争うような巨大データの高速処理では、
Span<T>またはReadOnlySpan<T>を使用してください。これによりメモリの割り当て(Allocation)をほぼゼロに抑えられます。
- メソッドの引数の型選択
汎用的なユーティリティメソッドを作る際、引数の型を
IEnumerable<T>にすると LINQ が使いやすくなり、処理速度やメモリ効率を最優先するならReadOnlySpan<T>にするとパフォーマンスが最大化されます。
6. 実践シナリオ:文字列の部分取得
配列と同様の考え方は、文字列(string)にも適用できます。
C#において文字列は文字の配列に近い構造を持っていますが、文字列は不変(Immutable)であるという特徴があります。
文字列から部分文字列を取得する場合、従来は Substring を使用していましたが、これも新しい文字列をヒープに生成します。
これを回避するために ReadOnlySpan<char> が活用されます。
using System;
class Program
{
static void Main()
{
string text = "C# Programming Language";
// Substring(新しい文字列を生成)
string sub1 = text.Substring(3, 11);
// Span(コピーを生成せず、元の文字列のメモリを参照)
ReadOnlySpan<char> sub2 = text.AsSpan()[3..14];
Console.WriteLine($"Substring: {sub1}");
Console.WriteLine($"Span: {sub2.ToString()}");
}
}
大量の文字列をパースする処理(CSVの解析やログファイルの読み込みなど)において、AsSpan() を利用することで、ガベージコレクション(GC)の負荷を劇的に軽減することが可能です。
7. まとめ
C#で部分配列を効率的に取得する方法は、言語の進化とともに「簡潔さ」と「低コスト」を両立する方向へ進んできました。
- Range演算子(..) は、直感的で美しいコードを実現しますが、配列に対してはコピーが発生することを忘れてはいけません。
- Span<T> は、モダンC#におけるパフォーマンス最適化の鍵であり、メモリコピーを排除した効率的なデータ操作を可能にします。
- LINQ は、パフォーマンスよりも開発効率や読みやすさを重視する場面で依然として強力です。
開発しているアプリケーションの要件に合わせて、これらの手法を適切に使い分けることが、高品質なC#プログラムを書くための第一歩となります。
特に大規模なデータを扱う場合は、「不必要なコピーを避けるために Span を検討する」という思考プロセスを常に持つようにしましょう。
