C# Tips | コレクション・LINQ:Span対応変換

C# C#
スポンサーリンク

はじめに:「Span対応変換」は“速さを意識した LINQ の次の一歩”

LINQ は書きやすいけれど、
「配列や文字列をガッツリ触る処理」でパフォーマンスを突き詰めたいときには、
メモリ確保(アロケーション)が気になってきます。

そこで登場するのが Span<T> / ReadOnlySpan<T> です。

配列や文字列などの「連続したメモリ領域」を、
「追加の配列を作らずに」「スライスしながら」扱える型で、
.NET が用意している“低コストなビュー”だと思ってください。

ここでの「Span対応変換」というのは、ざっくり言うと、

普段 T[]List<T>string に対してやっている処理を、
Span<T> / ReadOnlySpan<T> に“乗り換えられるようにしておく”ユーティリティ

のことです。

ここから、

Span / ReadOnlySpan のざっくりイメージ
配列・文字列からの Span 変換
LINQ っぽい処理を Span で書くとどう変わるか
業務で使いやすくするための「Span対応ユーティリティ」の考え方

を、初心者向けにかみ砕いて説明していきます。


Span / ReadOnlySpan の基本イメージ

「中身を持たない、“切り取りビュー”」だと思う

まず、Span<T> は「配列そのもの」ではありません。
「どこからどこまでを見るか」という“範囲情報”だけを持った構造体です。

var array = new int[] { 10, 20, 30, 40, 50 };

Span<int> span = array.AsSpan();      // 全体
Span<int> middle = array.AsSpan(1, 3); // 20, 30, 40 の部分だけ
C#

Span<int> 自体は「メモリを所有していない」ので、
new Span<int>(...) で自由に確保したりはしません。
既存の配列やスタック領域に対して「ビュー」を作るイメージです。

文字列の場合は ReadOnlySpan<char> を使います。

string s = "Hello, World";

ReadOnlySpan<char> span = s.AsSpan();
ReadOnlySpan<char> hello = s.AsSpan(0, 5); // "Hello"
C#

ここでの重要ポイントは、

「Span は“軽量なビュー”であって、“新しい配列”ではない」
「だから、スライスしても追加の配列が増えない」

ということです。


配列・List・文字列から Span に変換する

配列 → Span

配列は AsSpan() でそのまま Span に変換できます。

var numbers = new int[] { 1, 2, 3, 4, 5 };

Span<int> span = numbers.AsSpan();
Span<int> sub = numbers.AsSpan(1, 3); // 2, 3, 4
C#

sub[0] = 99; のように書くと、元の配列 numbers も書き換わります。
ビューなので、当然「同じメモリ」を見ています。

List → Span(配列経由)

List<T> は直接 AsSpan() を持っていませんが、
内部配列を ToArray() で取り出してから Span にできます。

var list = new List<int> { 1, 2, 3, 4, 5 };

var array = list.ToArray();
Span<int> span = array.AsSpan();
C#

ここは「一度配列を作る」ので、アロケーションは発生します。
「その後の処理を Span でガッツリやりたい」ときに意味があります。

string → ReadOnlySpan<char>

文字列は AsSpan()ReadOnlySpan<char> に変換できます。

string s = "ABCDE";

ReadOnlySpan<char> span = s.AsSpan();
ReadOnlySpan<char> bc = s.AsSpan(1, 2); // "BC"
C#

ここでの重要ポイントは、

「配列・文字列は AsSpan で“追加の配列なしに切り取れる”」
「List は ToArray を挟む必要がある」

ということです。


LINQ っぽい処理を Span で書いてみる

例1:条件に合う最初の要素を探す(FirstOrDefault 相当)

LINQ ならこう書きます。

var firstEven = numbers.FirstOrDefault(x => x % 2 == 0);
C#

Span で同じことをやると、こうなります。

Span<int> span = numbers.AsSpan();

int firstEven = -1;

foreach (var x in span)
{
    if (x % 2 == 0)
    {
        firstEven = x;
        break;
    }
}
C#

正直、書きやすさだけなら LINQ の勝ちです。
ただし、Span を使うと「余計な配列を作らずに」「範囲を絞りながら」処理できます。

例2:部分配列に対してだけ処理する

LINQ で「2 番目から 3 要素だけ」を処理したいとき、
素直に書くとこうなります。

var part = numbers
    .Skip(1)
    .Take(3)
    .Where(x => x % 2 == 0)
    .ToArray();
C#

SkipTake 自体は遅延実行ですが、
ToArray() で新しい配列が確保されます。

Span なら、こう書けます。

Span<int> span = numbers.AsSpan(1, 3); // 2, 3, 4

foreach (var x in span)
{
    if (x % 2 == 0)
    {
        Console.WriteLine(x);
    }
}
C#

ここでの重要ポイントは、

「“範囲を絞る”という操作を、LINQ の Skip/Take ではなく、
Span のスライス(AsSpan(offset, length))でやると、
追加の配列を作らずに済む」

ということです。


Span対応ユーティリティの考え方

「配列版」と「Span版」をペアで用意する

業務でよくあるのは、
「配列や文字列に対して、ある処理を何度も行うユーティリティメソッド」です。

例えば、「区切り文字で分割して、トリムして返す」処理を考えます。

public static string[] SplitAndTrim(string s, char separator)
{
    return s
        .Split(separator)
        .Select(x => x.Trim())
        .ToArray();
}
C#

これを「Span対応」にするなら、
内部で ReadOnlySpan<char> を使う版を用意しておく、という発想です。

ざっくりイメージだけ示すと、こういう形になります。

public static List<ReadOnlySpan<char>> SplitAndTrim(ReadOnlySpan<char> span, char separator)
{
    var result = new List<ReadOnlySpan<char>>();

    while (true)
    {
        int index = span.IndexOf(separator);
        if (index < 0)
        {
            var part = span.Trim();
            if (!part.IsEmpty)
            {
                result.Add(part);
            }
            break;
        }

        var head = span.Slice(0, index).Trim();
        if (!head.IsEmpty)
        {
            result.Add(head);
        }

        span = span.Slice(index + 1);
    }

    return result;
}
C#

そして、文字列版は「Span版を呼ぶだけ」にします。

public static string[] SplitAndTrim(string s, char separator)
{
    var spans = SplitAndTrim(s.AsSpan(), separator);
    return spans.Select(x => x.ToString()).ToArray();
}
C#

ここでの重要ポイントは、

本体ロジックを Span 版に寄せておき、
必要に応じて string / 配列版から“ラップして呼ぶ”

という設計です。

こうしておくと、

パフォーマンスが重要なところでは Span 版を直接使う
使いやすさ重視のところでは string / 配列版を使う

という選択ができるようになります。


業務での具体的な使いどころ

大きな配列・バッファを扱う処理

ログのバイナリ解析
ファイルフォーマットのパース
ネットワークパケットの解析

こういった「大きなバイト配列を何度もスライスする」処理は、
Span と相性がとても良いです。

void ParsePacket(ReadOnlySpan<byte> packet)
{
    var header = packet.Slice(0, 4);
    var body   = packet.Slice(4);

    // ここから先も、body をさらに Slice しながら解析していく
}
C#

LINQ で Skip / Take / ToArray を繰り返すより、
Span でスライスしながら進めたほうが、メモリ効率も速度も良くなります。

文字列の部分解析・トークナイズ

ログ 1 行をパースする
CSV の 1 行を手書きで解析する
固定長ファイルの 1 行を切り出す

こういった処理も、ReadOnlySpan<char> で書くと「余計な string を増やさずに」済みます。

void ParseLine(ReadOnlySpan<char> line)
{
    var dateSpan = line.Slice(0, 10);
    var levelSpan = line.Slice(11, 5);
    var messageSpan = line.Slice(17);

    // 必要になったところだけ ToString() する
    var level = levelSpan.ToString();
}
C#

ここでの重要ポイントは、

“全部 string にしてから処理する”のではなく、
“必要になったところだけ ToString する”

という発想に切り替えられることです。


まとめ:「Span対応変換」は“LINQ の書きやすさと、低レベルの速さの橋渡し”

Span対応変換の本質は、

普段 LINQ や配列・文字列で書いている処理を、
必要に応じて Span<T> / ReadOnlySpan<T> ベースでも書けるようにしておき、
「速さが欲しいところだけ、そちらに乗り換えられるようにする」

ことです。

押さえておきたいポイントを整理すると、

Span / ReadOnlySpan は「配列や文字列の“ビュー”」であり、新しい配列を作らない
配列・文字列は AsSpan で簡単に Span 化できる
範囲を絞る処理は、Skip/Take ではなく AsSpan(offset, length) で書ける
本体ロジックを Span 版に寄せておき、string / 配列版はラッパーにすると設計がきれい
大きな配列・文字列をガッツリ触る処理(パース・解析)で特に効果が出やすい

ここまでイメージがつかめていれば、
「まずは LINQ で書いてみて、必要になったら Span 対応版を用意する」という、
一段階上の選択肢を持てるようになります。

タイトルとURLをコピーしました