【C#】VSCode のデバッグモードでスパンのデバッグビューを表示する

# はじめに

:::note info
この記事の内容は C# 13.0 / .net 9.0.0（2024）時点のものです。
:::

VSCode で C# コードをデバッグしているときに、スパンのデバッグビューを見れないことがあります。

```text
Span [ReadOnlySpan] = スレッドがガベージ コレクションが実行できないポイントで停止したため、式を評価する できません。コードが最適化されている可能性があります。
```

↑ の現象はデバッグビルドでも起こります。いわゆる（リリースビルド等での）コンパイラによるコード最適化が原因ではなさそうです。

デバッグビューが表示されないのが不便だったため、これをなんとかしたいというのが今回の内容です。

## 解決方法とサンプルコード

<details>
<summary>サンプルコード</summary>

```cs
using System.ComponentModel;

/// <summary>
/// 読み取り専用シーケンス
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="Memory"></param>
[System.Runtime.CompilerServices.CollectionBuilder(typeof(ReadOnlySequenceBuilder), "Create")]
[System.Diagnostics.DebuggerTypeProxy(typeof(DebugViewer<>))]
internal readonly record struct ReadOnlySequence<T>(ReadOnlyMemory<T> Memory)
{
    /// <summary>
    /// デバッグビュー
    /// </summary>
#if DEBUG
    internal T[] DebugView { get; } = Memory.Length <= 100 ? Memory.ToArray() : [];
#else
    internal T[] DebugView { get; } = [];
#endif

    /// <summary>
    /// 空かどうか
    /// </summary>
    internal bool IsEmpty => this.Memory.IsEmpty;

    /// <summary>
    /// スパン
    /// </summary>
    internal ReadOnlySpan<T> Span => this.Memory.Span;

    /// <summary>
    /// 長さ
    /// </summary>
    internal int Length => this.Memory.Length;

    /// <summary>
    /// コンストラクタ
    /// </summary>
    /// <param name="array"></param>
    internal ReadOnlySequence(T[] array) : this(array.AsMemory()) { }

    /// <summary>
    /// スライス
    /// </summary>
    /// <param name="start"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    internal ReadOnlySequence<T> Slice(int start) => new(this.Memory.Slice(start));

    /// <summary>
    /// スライス
    /// </summary>
    /// <param name="start"></param>
    /// <param name="length"></param>
    /// <returns></returns>
    /// <exception cref="ArgumentOutOfRangeException"></exception>
    internal ReadOnlySequence<T> Slice(int start, int length) => new(this.Memory.Slice(start, length));

    /// <summary>
    /// 同値性の比較
    /// </summary>
    /// <param name="other"></param>
    /// <returns></returns>
    public bool Equals(ReadOnlySequence<T> other) => this.Span.SequenceEqual(other.Span);

    /// <summary>
    /// ハッシュコードを取得
    /// </summary>
    /// <returns></returns>
    public override int GetHashCode()
    {
        var hash = new HashCode();
        var span = this.Span;
        if (span.Length > 10)
            span = span.Slice(span.Length - 10);
        foreach (var n in span)
        {
            hash.Add(n);
        }

        return hash.ToHashCode();
    }

    /// <summary>
    /// 文字列化
    /// </summary>
    /// <returns></returns>
    public override string ToString() => $"Count={this.Length}";

    /// <summary>
    /// 実装しない
    /// </summary>
    /// <returns></returns>
    /// <exception cref="NotImplementedException"></exception>
    [Browsable(false)]
    [EditorBrowsable(EditorBrowsableState.Never)]
    [Obsolete("ReadOnlySequence<T>.Span を使用して下さい。", true)]
    public IEnumerator<T> GetEnumerator()
    {
        throw new NotImplementedException();
    }

    /// <summary>
    /// 読み取り専用メモリからの暗黙的な変換
    /// </summary>
    /// <param name="memory"></param>
    public static implicit operator ReadOnlySequence<T>(ReadOnlyMemory<T> memory) => new(memory);

    /// <summary>
    /// メモリからの暗黙的な変換
    /// </summary>
    /// <param name="memory"></param>
    public static implicit operator ReadOnlySequence<T>(Memory<T> memory) => new(memory);

    /// <summary>
    /// 読み取り専用シーケンスへの暗黙的な変換
    /// </summary>
    /// <param name="sequence"></param>
    public static implicit operator ReadOnlyMemory<T>(ReadOnlySequence<T> sequence) => sequence.Memory;

    /// <summary>
    /// 読み取り専用スパンへの暗黙的な変換
    /// </summary>
    /// <param name="sequence"></param>
    public static implicit operator ReadOnlySpan<T>(ReadOnlySequence<T> sequence) => sequence.Span;
}

/// <summary>
/// コレクションビルダー
/// https://learn.microsoft.com/ja-jp/dotnet/csharp/language-reference/operators/collection-expressions#collection-builder
/// </summary>
file static class ReadOnlySequenceBuilder
{
    /// <summary>
    /// コレクションを初期化
    /// </summary>
    /// <typeparam name="T"></typeparam>
    /// <param name="span"></param>
    /// <returns></returns>
    internal static ReadOnlySequence<T> Create<T>(ReadOnlySpan<T> span) => new(span.ToArray());
}

/// <summary>
/// デバッグビューア
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="value"></param>
file class DebugViewer<T>(ReadOnlySequence<T> value)
{
    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.RootHidden)]
    public T[] Items => value.DebugView;
}
```

</details>

<details>
<summary>テストコード</summary>

```cs
using Xunit;

public class _ReadOnlySequenceTest
{
    [Fact]
    void 現在のスタック上にポインタがある場合はデバッグビューを表示できる例()
    {
        ReadOnlySpan<int> span1 = [1, 2, 3];
        Assert.Equal([1, 2, 3], span1);

        ReadOnlySpan<int> CreateSpan() => new[] { 1, 2, 3 };
        var span2 = CreateSpan();
        Assert.Equal([1, 2, 3], span2);
    }

    [Fact]
    void IsEmpty()
    {
        Assert.True(default(ReadOnlySequence<int>).IsEmpty);
        Assert.False(new ReadOnlySequence<int>([1, 2, 3]).IsEmpty);
    }

    [Fact]
    void Span()
    {
        Assert.Equal([1, 2, 3], new ReadOnlySequence<int>([1, 2, 3]).Span);
        Assert.NotEqual([1, 2, 3], new ReadOnlySequence<int>([1, 2, 3, 4]).Span.ToArray());
    }

    [Fact]
    void Length()
    {
        Assert.Equal(3, new ReadOnlySequence<int>([1, 2, 3]).Length);
        Assert.NotEqual(3, new ReadOnlySequence<int>([1, 2, 3, 4]).Length);
    }

    [Fact]
    void Slice()
    {
        Assert.Equal([2, 3], new ReadOnlySequence<int>([1, 2, 3]).Slice(1).Span);
        Assert.Equal([2, 3], new ReadOnlySequence<int>([1, 2, 3, 4]).Slice(1, 2).Span);
    }

    [Fact]
    void Constructor()
    {
        Assert.Equal([1, 2, 3], new ReadOnlySequence<int>([1, 2, 3]).Span);
        Assert.NotEqual([1, 2, 3], new ReadOnlySequence<int>([1, 2, 3, 4]).Span.ToArray());
    }

    [Fact]
    void EqualsTest()
    {
        Assert.True(new ReadOnlySequence<int>([1, 2, 3]).Equals(new ReadOnlySequence<int>([1, 2, 3])));
        Assert.False(new ReadOnlySequence<int>([1, 2, 3]).Equals(new ReadOnlySequence<int>([1, 2, 3, 4])));
    }

    [Fact]
    void GetHashCodeTest()
    {
        Assert.Equal(new ReadOnlySequence<int>([1, 2, 3]).GetHashCode(), new ReadOnlySequence<int>([1, 2, 3]).GetHashCode());
        Assert.Equal(new ReadOnlySequence<int>([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]).GetHashCode(), new ReadOnlySequence<int>([10000, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]).GetHashCode());
        Assert.NotEqual(new ReadOnlySequence<int>([1, 2, 3]).GetHashCode(), new ReadOnlySequence<int>([1, 2, 3, 4]).GetHashCode());
    }

    [Fact]
    void RecordTest()
    {
        var record1 = new Record(new ReadOnlySequence<int>([1, 2, 3]));
        var record2 = new Record(new ReadOnlySequence<int>([1, 2, 3]));
        var record3 = new Record(new ReadOnlySequence<int>([1, 2, 3, 4]));

        Assert.Equal(record1, record1);
        Assert.Equal(record1, record2);
        Assert.NotEqual(record1, record3);

        Assert.True(record1.Equals(record1));
        Assert.True(record1.Equals(record2));
        Assert.False(record1.Equals(record3));

        Assert.Equal(record1.GetHashCode(), record1.GetHashCode());
        Assert.Equal(record1.GetHashCode(), record2.GetHashCode());
        Assert.NotEqual(record1.GetHashCode(), record3.GetHashCode());
    }

    [Fact]
    void FromReadOnlyMemory()
    {
        var memory = new ReadOnlyMemory<int>([1, 2, 3]);
        var sequence = new ReadOnlySequence<int>(memory);
        Assert.Equal<ReadOnlySequence<int>>(sequence, memory);
    }

    [Fact]
    void FromMemory()
    {
        var memory = new Memory<int>([1, 2, 3]);
        var sequence = new ReadOnlySequence<int>(memory);
        Assert.Equal<ReadOnlySequence<int>>(sequence, memory);
    }

    [Fact]
    void ToReadOnlyMemory()
    {
        var memory = new ReadOnlyMemory<int>([1, 2, 3]);
        var sequence = new ReadOnlySequence<int>(memory);
        Assert.Equal<ReadOnlyMemory<int>>(memory, sequence);
    }

    [Fact]
    void ToReadOnlySpan()
    {
        var memory = new ReadOnlyMemory<int>([1, 2, 3]);
        var sequence = new ReadOnlySequence<int>(memory);
        Assert.True(memory.Span.SequenceEqual(sequence));
    }

    [Fact]
    void CollectionBuilder()
    {
        ReadOnlySequence<int> sequence = [1, 2, 3, 4, 5];
        Assert.True(new int[] { 1, 2, 3, 4, 5 }.AsSpan().SequenceEqual(sequence.Span));
    }

    [Fact]
    void GetEnumerator()
    {
        // コンパイルエラーになることを確認
        // new ReadOnlySequence<int>().GetEnumerator();
    }
}

file record struct Record(ReadOnlySequence<int> Sequence);
```

</details>

シーケンス型を作ってスパンのスナップショットを取っておきます。

```cs
using System.ComponentModel;

[System.Diagnostics.DebuggerTypeProxy(typeof(DebugViewer<>))]
readonly record struct ReadOnlySequence<T>(ReadOnlyMemory<T> Memory)
{
    /// <summary>
    /// デバッグビュー
    /// </summary>
#if DEBUG
    internal T[] DebugView { get; } = Memory.Length <= 100 ? Memory.ToArray() : [];
#else
    internal T[] DebugView { get; } = [];
#endif

    // 他のメンバーは省略
}

file class DebugViewer<T>(ReadOnlySequence<T> value)
{
    [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.RootHidden)]
    public T[] Items => value.DebugView;
}
```

- デバッグ用の機能なのでプリプロセッサ指令でデバッグビルドでのみ有効にしています。
- 表示する要素数に上限を設定して、それ以上のときはスナップショットを取得しないようにしています。これはデバッグビューを利用するのはだいたいテストコードであり、スパンの要素数は少ないためです。パフォーマンス上、要素数の多いスパンのスナップショットを取るのは影響がありそうなことを考慮しています。

## デバッグビューが表示される場合とされない場合

次のコードの場合、デバッグビューが表示されます。

```cs
ReadOnlySpan<int> span1 = [1, 2, 3];
Assert.Equal([1, 2, 3], span1);

ReadOnlySpan<int> CreateSpan() => new[] { 1, 2, 3 };
var span2 = CreateSpan();
Assert.Equal([1, 2, 3], span2);
```

デバッグビューが表示されない場合のコードはなかなか再現できませんでした。代わりに要因を考えてみます。

- スタック上に配列のポインタが存在しない場合。↑ の例でも配列のポインタはスタック上に存在しないが、メソッド中で配列のポインタが出現するためそれをデバッガーが記憶してるのかも
- 同じような理由でブレークポイントが置かれているファイル以外のファイルで配列が作成された場合
- 別スレッド上で配列が作成された場合

## スナップショットの問題点

今回は手っ取り早い方法として、スパンのスナップショットを取ってみました。**スナップショット作成後のスパンの変更を確認できない**問題点があります。これに関しては従来的な、コンソール出力や文字列に出力して確認する方法が有効そうです。`Assert.Equal(want, got)` のようなテストの仕方をすれば、問題になりにくい気がします。

## おわりに

今回はデバッグビューが見れないことがある問題を、少し荒っぽい方法で解消しました。

VisualStudio の方では（デバッグビルドなら）スパンの内容を表示できるので、技術的には可能そうです。VSCode 側の C# 拡張機能のアップデートで改善するかもしれないので、注視していきたいです。