📜

単体ソースジェネレーター

に公開

FGenerator は AI に効率よく Roslyn ソースジェネレーターを生成させる為のフレームワークです。もちろん人間でも使えます。

完成したソースジェネレーター(IIncrementalGenerator)は Unity 2022.3.12 以降でも動きます。

そもそもの問題

AI は Roslyn ジェネレーターを上手く生成できません。

  • Roslyn API は増築に増築を重ねてカオスってるので AI の生成結果が不安定
  • ネスト型/ジェネリック型に対応していたりいなかったり
  • その他多数の考慮不足

増築を重ねた Roslyn API が特に厄介で、大して難しくも無いのに生成結果が安定しない原因になっています。加えて AI の生成結果が正しいかどうか判断するには、指示する側にカオスな Roslyn API の知識が必要になります。

なので、

  • やっている内容は Excel マクロやバッチコマンドと同レベル

にも拘らず敷居が高くなっています。

コンテキスト問題

生成 AI そのものは細部にフォーカスさせることさえ出来れば質の高いコードを出力します。

👇 (AI 曰く)無駄な文字列アロケーションがない対象アトリビュートの探索方法

var lastIdentifier = attribute.Name switch
{
    IdentifierNameSyntax id => id.Identifier.Text,
    QualifiedNameSyntax q => q.Right.Identifier.Text,
    AliasQualifiedNameSyntax a => a.Name.Identifier.Text,
    _ => (attribute.Name as SimpleNameSyntax)?.Identifier.Text ?? attribute.Name.ToString(),
};

if (lastIdentifier == _targetAttributeBaseName ||
    lastIdentifier == _targetAttributeNameWithSuffix)
{
    return true;
}

こんなの知らんがなって感じですね。見ての通り Roslyn 最大の問題点は

  • どこにフォーカスすべきかを指示する側が知っている必要がある

です。この結果もたまたま出力された際に AI に聞いたら、「最もアロケーションが少ない手法です」という話だったので知っているだけです。(※ そう答えた AI に出力させてもコレが出るとは限らないのが厄介)

Roslyn API はこういう構文の枝分かれが至る所に散りばめられています。

生成 AI 向けフレームワーク

FGenerator は宣言的 API になっているので AI がミスしづらくなっています。そしてそれを確認する人間側も理解しやすい形になっています。

単体ファイルとして、

  • FGenerator.Sdk を参照
  • Generator 属性の付与
  • FGeneratorBase を継承
  • Generate 他を実装

するだけで動きます。

TargetAttributeName がヌルを返す場合は「アセンブリー内のすべての型」を対象とするので、ソースコード生成を省けばコード規約チェッカー的なアナライザーも実装可能です。(多分

#:sdk FGenerator.Sdk@1.2.0

using FGenerator;
using Microsoft.CodeAnalysis;

[Generator]
public class MyGenerator : FGeneratorBase
{
    protected override string DiagnosticCategory => "MyGenerator";
    protected override string DiagnosticIdPrefix => "MYGEN";

    protected override string? TargetAttributeName => "MyAttribute";
    protected override string? PostInitializationOutput =>
        "internal sealed class MyAttribute : System.Attribute { }";

    protected override CodeGeneration? Generate(Target target, out AnalyzeResult? diagnostic)
    {
        diagnostic = null;

        if (!target.IsPartial)
        {
            // IDE に MYGEN001 エラーを通知
            diagnostic = new("001", "Type Not Partial", DiagnosticSeverity.Error, "エラー内容");
            return null;
        }

        // コード生成
        return new CodeGeneration(target.ToHintName(), "// Built with FGenerator.Sdk");
    }
}

宣言的 API の恩恵

生成 AI に FGenerator を使わせると、面倒でお馴染みの INotifyPropertyChanged を自動実装するソースジェネレーターは以下の様になります。

https://github.com/sator-imaging/FGenerator/blob/main/sample/AutoNotifyGenerator.cs#L19-L207

生成結果

ソース生成なんだから余計なメソッド呼び出しすんなよ感あります。

[AutoNotify]
public partial class Person<T>
{
    private string _firstName = string.Empty;
    private string _lastName = string.Empty;
    private int _age;
}

// 👇

partial class Person<T> : INotifyPropertyChanged
{
    /// <summary>
    /// Occurs when a property value changes.
    /// </summary>
    public event PropertyChangedEventHandler? PropertyChanged;

    public string FirstName
    {
        get => _firstName;
        set => SetField(ref _firstName, value);
    }

    public string LastName
    {
        get => _lastName;
        set => SetField(ref _lastName, value);
    }

    public int Age
    {
        get => _age;
        set => SetField(ref _age, value);
    }

    /// <summary>
    /// Raises the PropertyChanged event.
    /// </summary>
    /// <param name="propertyName">Name of the property that changed.</param>
    protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }

    /// <summary>
    /// Sets the field value and raises PropertyChanged if the value changed.
    /// </summary>
    /// <returns>True if the value changed, false otherwise.</returns>
    protected bool SetField<A>(ref A field, A value, [CallerMemberName] string? propertyName = null)
    {
        if (System.Collections.Generic.EqualityComparer<A>.Default.Equals(field, value))
        {
            return false;
        }

        field = value;
        OnPropertyChanged(propertyName);
        return true;
    }
}

 

ちょっと複雑な StackArray(C# 標準の InlineArrayIEnumerable を足したモノ) はコチラ。

https://github.com/sator-imaging/FGenerator/blob/main/sample/StackArrayGenerator.cs#L21-L271

生成結果

64バイトの構造体!

[StackArray(16, typeof(int))]
public partial struct StackArray16
{
}

// 👇

[StructLayout(LayoutKind.Sequential, Pack = 1)]
partial struct StackArray16 : IEnumerable<int>, IEnumerator<int>, IEquatable<global::SampleConsumer.StackArray.StackArray16>
{
    private int _value0;
    private int _value1;
    private int _value2;
    private int _value3;
    private int _value4;
    private int _value5;
    private int _value6;
    private int _value7;
    private int _value8;
    private int _value9;
    private int _value10;
    private int _value11;
    private int _value12;
    private int _value13;
    private int _value14;
    private int _value15;
    public const int Length = 16;

    private int _enumeratorIndex;

    public StackArray16(ReadOnlySpan<int> source, bool allowLengthMismatch = false)
        : this()
    {
        int copyLength = source.Length;
        if (!allowLengthMismatch && copyLength != Length)
        {
            throw new ArgumentException("Length mismatch.", nameof(source));
        }

        if (copyLength > Length)
        {
            copyLength = Length;
        }

        var destination = AsSpan();
        source.Slice(0, copyLength).CopyTo(destination);
    }

    public Span<int> AsSpan() => MemoryMarshal.CreateSpan(ref _value0, Length);

    public ref int this[int index] => ref AsSpan()[index];

    [EditorBrowsable(EditorBrowsableState.Never)]
    public global::SampleConsumer.StackArray.StackArray16 GetEnumerator()
    {
        _enumeratorIndex = -1;
        return this;
    }

    IEnumerator<int> IEnumerable<int>.GetEnumerator() => GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    [EditorBrowsable(EditorBrowsableState.Never)]
    public bool MoveNext()
    {
        if (_enumeratorIndex >= Length - 1)
        {
            return false;
        }

        _enumeratorIndex++;
        return true;
    }

    void IEnumerator.Reset() => throw new NotSupportedException();

    [EditorBrowsable(EditorBrowsableState.Never)]
    public int Current => AsSpan()[_enumeratorIndex];

    object IEnumerator.Current => Current!;

    [EditorBrowsable(EditorBrowsableState.Never)]
    public void Dispose() { }

    public bool Equals(global::SampleConsumer.StackArray.StackArray16 other) => AsSpan().SequenceEqual(other.AsSpan());

    public override bool Equals(object? obj) => obj is global::SampleConsumer.StackArray.StackArray16 other && Equals(other);

    public override int GetHashCode()
    {
        // Hash combines Length plus up to 7 evenly spaced elements.
        return HashCode.Combine(Length, _value0, _value2, _value4, _value6, _value9, _value11, _value13);
    }

    public static bool operator ==(global::SampleConsumer.StackArray.StackArray16 left, global::SampleConsumer.StackArray.StackArray16 right) => left.Equals(right);
    public static bool operator !=(global::SampleConsumer.StackArray.StackArray16 left, global::SampleConsumer.StackArray.StackArray16 right) => !left.Equals(right);

    public override string ToString()
    {
        var span = AsSpan();
        var builder = new StringBuilder();
        builder.Append('[');
        for (int i = 0; i < span.Length; i++)
        {
            if (i > 0)
            {
                builder.Append(", ");
            }

            builder.Append(span[i]);
        }

        builder.Append(']');
        return builder.ToString();
    }
}

 

スタックで完結する StackList(便利そうだと思ったけどコピー時の構造体のサイズがヤバいので使い道は限定的)

https://github.com/sator-imaging/FGenerator/blob/main/sample/StackListGenerator.cs#L24-L531

生成結果

なっが!

[StackList(9, SwapRemove = true)]
public partial struct StackListSwapRemove<T> where T : unmanaged, IEquatable<T>
{
}

// 👇

[StructLayout(LayoutKind.Sequential, Pack = 1)]
partial struct StackListSwapRemove<T> : IList<T>, IEnumerable<T>, IEnumerator<T>, IEquatable<global::SampleConsumer.StackList.StackListSwapRemove<T>>
{
    private T _value0;
    private T _value1;
    private T _value2;
    private T _value3;
    private T _value4;
    private T _value5;
    private T _value6;
    private T _value7;
    private T _value8;
    public const int MaxCount = 9;

    private int _count;
    private int _enumeratorIndex;

    public int Count => _count;

    bool ICollection<T>.IsReadOnly => false;

    public Span<T> AsSpan() => MemoryMarshal.CreateSpan(ref _value0, _count);
    public Span<T> AsFullSpan() => MemoryMarshal.CreateSpan(ref _value0, MaxCount);

    public T this[int index]
    {
        get
        {
            return AsSpan()[index];
        }
        set
        {
            AsSpan()[index] = value;
        }
    }

    public void Add(T item)
    {
        if (_count >= MaxCount)
        {
            ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");
        }

        AsFullSpan()[_count] = item;
        _count++;
    }

    public void Clear()
    {
        AsSpan().Clear();
        _count = 0;
    }

    public void CopyTo(T[] array, int arrayIndex)
    {
        if (arrayIndex < 0) ThrowArgumentOutOfRange(nameof(arrayIndex));
        if (array == null) throw new ArgumentNullException(nameof(array));
        if (array.Length - arrayIndex < _count) throw new ArgumentException("Destination array is not long enough.");

        AsSpan().CopyTo(array.AsSpan(arrayIndex));
    }

    IEnumerator<T> IEnumerable<T>.GetEnumerator() => GetEnumerator();
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    [EditorBrowsable(EditorBrowsableState.Never)]
    public global::SampleConsumer.StackList.StackListSwapRemove<T> GetEnumerator()
    {
        _enumeratorIndex = -1;
        return this;
    }

    public bool Contains(T item) => IndexOf(item) >= 0;

    public int IndexOf(T item)
    {
        // Use Span<T>.IndexOf for IEquatable<T> to allow vectorized search and avoid comparer allocations.
        return AsSpan().IndexOf(item);
    }

    public void Insert(int index, T item)
    {
        if (unchecked((uint)index > (uint)_count)) ThrowArgumentOutOfRange(nameof(index));
        if (_count >= MaxCount) ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");

        var fullSpan = AsFullSpan();
        int moveCount = _count - index;
        if (moveCount > 0)
        {
            fullSpan.Slice(index, moveCount).CopyTo(fullSpan.Slice(index + 1));
        }

        fullSpan[index] = item;
        _count++;
    }

    public void RemoveAt(int index)
    {
        if (unchecked((uint)index >= (uint)_count)) ThrowArgumentOutOfRange(nameof(index));

        var fullSpan = AsFullSpan();
        int lastIndex = _count - 1;

        // Swap-remove: move last element into the removed slot to keep removal O(1) without preserving order.
        if (_count > 1 && index != lastIndex)
        {
            fullSpan[index] = fullSpan[lastIndex];
        }

        fullSpan[lastIndex] = default!;
        _count--;
    }

    public bool Remove(T item)
    {
        int index = IndexOf(item);
        if (index < 0)
        {
            return false;
        }

        RemoveAt(index);
        return true;
    }

    [EditorBrowsable(EditorBrowsableState.Never)]
    public T Current => AsSpan()[_enumeratorIndex];

    object IEnumerator.Current => Current!;

    [EditorBrowsable(EditorBrowsableState.Never)]
    public bool MoveNext()
    {
        if (_enumeratorIndex >= _count - 1)
        {
            return false;
        }

        _enumeratorIndex++;
        return true;
    }

    [EditorBrowsable(EditorBrowsableState.Never)]
    public void Dispose() { }

    void IEnumerator.Reset() => throw new NotSupportedException();

    public bool Equals(global::SampleConsumer.StackList.StackListSwapRemove<T> other)
    {
        if (_count != other._count)
        {
            return false;
        }

        return AsSpan().SequenceEqual(other.AsSpan());
    }

    public override bool Equals(object? obj) => obj is global::SampleConsumer.StackList.StackListSwapRemove<T> other && Equals(other);

    public override int GetHashCode()
    {
        if (_count == 0) return HashCode.Combine(0);

        // Hash combines count plus first/middle/last samples (small counts may reuse the same index) to keep HashCode.Combine arity small.
        var span = AsSpan();
        return HashCode.Combine(_count, span[0], span[_count >> 1], span[_count - 1]);
    }

    public static bool operator ==(global::SampleConsumer.StackList.StackListSwapRemove<T> left, global::SampleConsumer.StackList.StackListSwapRemove<T> right) => left.Equals(right);
    public static bool operator !=(global::SampleConsumer.StackList.StackListSwapRemove<T> left, global::SampleConsumer.StackList.StackListSwapRemove<T> right) => !left.Equals(right);

    public override string ToString()
    {
        var span = AsSpan();
        var builder = new StringBuilder();
        builder.Append('[');
        for (int i = 0; i < span.Length; i++)
        {
            if (i > 0)
            {
                builder.Append(", ");
            }

            builder.Append(span[i]);
        }

        builder.Append(']');
        return builder.ToString();
    }

    [DoesNotReturn]
    private static void ThrowArgumentOutOfRange(string paramName, string? message = null)
        => throw new ArgumentOutOfRangeException(paramName, message);

    public bool AddUnique(T item)
    {
        if (IndexOf(item) >= 0) return false;

        Add(item);
        return true;
    }

    /// <summary>
    /// Adds all items from <paramref name="collection"/> and returns the resulting <see cref="Count"/>.
    /// </summary>
    /// <returns>The new total count after the items are appended.</returns>
    public int AddRange(IEnumerable<T> collection)
    {
        if (collection is null) throw new ArgumentNullException(nameof(collection));

        int incomingCount = collection switch
        {
            ICollection<T> x => x.Count,
            IReadOnlyCollection<T> x => x.Count,
            _ => -1,
        };

        if (incomingCount > 0)
        {
            int newCount = _count + incomingCount;
            if (newCount > MaxCount)
            {
                ThrowArgumentOutOfRange("capacity", "List has reached its maximum capacity.");
            }

            int writeIndex = _count;
            var destination = AsFullSpan();
            foreach (var item in collection)
            {
                destination[writeIndex] = item;
                writeIndex++;
            }

            _count = newCount;
            return _count;
        }
        else if (incomingCount < 0)
        {
            return AddRangeSlow(collection);
        }
        else
        {
            return _count;
        }
    }

    private int AddRangeSlow(IEnumerable<T> collection)
    {
        foreach (var item in collection)
        {
            Add(item);
        }

        return _count;
    }

    /// <summary>
    /// Adds items until capacity is reached; excess items are ignored.
    /// </summary>
    /// <returns>The new total count after copying up to available capacity.</returns>
    public int AddRangeTruncateOverflow(ReadOnlySpan<T> items)
    {
        int available = MaxCount - _count;
        if (available <= 0 || items.IsEmpty)
        {
            return _count;
        }

        int copyLength = items.Length;
        if (copyLength > available)
        {
            copyLength = available;
        }

        items.Slice(0, copyLength).CopyTo(AsFullSpan().Slice(_count, copyLength));
        _count += copyLength;
        return _count;
    }

    /// <summary>
    /// Adds or replaces items while retaining only the most recent elements; drops oldest existing items first, or if incoming alone exceeds capacity keeps only its last MaxCount items.
    /// </summary>
    /// <returns>The new total count after the operation (never exceeds capacity).</returns>
    public int AddRangeDropOldest(ReadOnlySpan<T> incoming)
    {
        if (incoming.IsEmpty)
        {
            return _count;
        }

        var existing = AsFullSpan();
        int total = _count + incoming.Length;
        if (total <= MaxCount)
        {
            incoming.CopyTo(existing.Slice(_count));
            return (_count = total);
        }

        // Incoming alone overflows capacity; keep the most recent portion of incoming.
        if (incoming.Length >= MaxCount)
        {
            incoming.Slice(incoming.Length - MaxCount, MaxCount).CopyTo(existing);
            return (_count = MaxCount);
        }
        else
        {
            int dropExisting = _count - (MaxCount - incoming.Length);
            int existingCount = _count - dropExisting;
            existing.Slice(dropExisting, existingCount).CopyTo(existing);

            incoming.CopyTo(existing.Slice(existingCount));
            return (_count = MaxCount);
        }
    }

    /// <summary>
    /// Adds unique items from <paramref name="collection"/>.
    /// </summary>
    /// <returns>The number of items that were added.</returns>
    public int AddRangeUnique(IEnumerable<T> collection)
    {
        if (collection is null) throw new ArgumentNullException(nameof(collection));

        int added = 0;
        foreach (var item in collection)
        {
            if (IndexOf(item) >= 0)
            {
                continue;
            }

            Add(item);
            added++;
        }

        return added;
    }
}

 

Generate だけ見れば良いので、「AI に生成させたは良いけど何やってるか分からん!」が無くなっています。生成 AI は「Roslyn のおやくそく」を上手く扱えないだけで、そこさえ超えれば問題はなく、ファイルが一つなので突然変なロジックを実装し始めることも無いです。

「AddRange、AddUnique、AddRangeUnique が欲しい」と言えば実装してくれますし、「IEquatable 実装しておきたい」と言えば実装してくれます。「TIEquatable<T> なら AsSpan().IndexOf(item) が使える」とかもちゃんとハンドリングしてくれます。(そういう指示は必要ですが!)

対象アトリビュートの探索に制限はないので、理屈上はメソッドにアトリビュートを付ければ構文チェックとかも実装できるハズです。

使い捨て可能

とにかく気軽に生成できるようになるので「複数のプロジェクトから参照されている重要なソースジェネレーター」では無くなります。必要になったら他のプロジェクトとの互換性を考えずにサクッと機能を追加できるようになります。

Git の履歴も「謎のおまじないを含んだ .csproj と良く分からない .cs ファイル群」が紛れ込まないので、特別なモノではなくマクロやバッチと同じレベルで扱うことが可能になります。

ソースジェネレーター本体はビルドに含まれないので、生成結果さえ期待通りなら他は気にしなくて良いというのもデカい。

アトリビュートのパラメーターと戯れる

AI は Roslyn のおまじないが苦手なだけで、「アトリビュートの最初の引数を配列の要素数にしたい」とかの処理は人間よりも詳しいです。言えば良きようにやってくれます。

unmanaged 限定や ref 構造体を弾く等は指示する必要がありましたが、「プロジェクト専用の動けばいいヤツ」と考えればそこまで厳密なチェックは必要ないでしょう。

// The first RawAttributes entry corresponds to TargetAttributeName.
var attr = target.RawAttributes.FirstOrDefault();
if (attr == null)
{
    diagnostic = new AnalyzeResult("004", "Attribute missing", DiagnosticSeverity.Error, "StackArrayGenerator attribute could not be resolved.");
    return null;
}

var length = (attr.ConstructorArguments.Length != 0 && attr.ConstructorArguments[0].Value is int LEN) ? LEN : -1;
if (length <= 0)
{
    diagnostic = new AnalyzeResult("005", "Length must be positive", DiagnosticSeverity.Error, "Specify a positive length in [StackArrayGenerator].");
    return null;
}

そもそもあらゆる構文等々を想定したアナライザー/ジェネレーターを実装するのは Roslyn 作者でもない限り不可能に近いです。

Pure 属性がお気持ち表明にとどまっている感じを見るに、構文の自由度が高すぎて作者でも不可能なんでは?)

https://learn.microsoft.com/ja-jp/dotnet/api/system.diagnostics.contracts.pureattribute?view=net-8.0

Unity 向けビルド

Unity でソースジェネレーターを使うためのセットアップも地味に面倒なので、.meta ファイルの生成と(必要なら)DLL ファイルのマージが出来る CLI ツールを用意してあります。(by AI)

build.bat
dnx -y FGenerator.Cli -- build "./**/*.cs" --unity -f -o ".."

フォルダー構成:

この構成ならバッチを叩くだけで Unity プロジェクトでソースジェネレーターが使えるようになります。

※ ちなみにアナライザー/ソースジェネレーターのスコープは .asmdef で定義できます。

https://docs.unity3d.com/6000.0/Documentation/Manual/analyzer-scope-and-diagnostics.html

おわりに

余計なモノは作らないと決めたはずなんですが……!

以上です。お疲れ様でした。

Discussion