AI によるライブラリ生成の可能性:「TDoubles」開発から見えた課題と未来
TDoubles
は生成 AI が 90% 作って人間が 90% デバッグした C# 向けモック生成ソースジェネレーターです。
本投稿は全二部構成になっており、前半は TDoubles の紹介、後半は AI にまともなコードを書かせる上で知っておくべき知識のまとめになっています。
TDoubles
概要
動作環境
- .NET Framework 4.6.1+
- .NET Core 2.0+
- .NET 5.0+
- Unity 2022.3.12f1 or later
型安全なモック生成
TDoubles はインスタンス毎に振る舞いの書き換えが可能な、強く型付けされたモックを生成する為のソースジェネレーターです。
using TDoubles;
public interface IDataService
{
string GetData(int id);
void SaveData(string data);
}
[Mock(typeof(IDataService))]
partial class DataServiceMock
{
// アトリビュートを付けるだけでおk!
}
👇 以下のような形でメソッドの振る舞いをオーバーライドします。
// モックをインスタンス化する
var mockService = new DataServiceMock();
// 書き換え
mockService.MockOverrides.GetData = (id) => $"MockData_{id}";
string mockData = mockService.GetData(123); // "MockData_123"
既存の実装に処理を委譲して一部だけ書き変えることも可能です。
var mock = new DataServiceMock(new ConcreteDataService()); // 👈 実装を渡す
// 書き換えられていないメソッドは ConcreteDataService に処理を移譲する
var realData = mock.GetData(123);
// SaveData の振る舞いを書き変える
mock.MockOverrides.SaveData = (data) => Console.WriteLine($"Saved: {data}");
mock.SaveData(realData);
ジェネリック型のサポート
TDoubles はジェネリック型にも対応しています。
例えば IList<T>
や IDictioanry<TKey, TValue>
のモックを作るなら、
[Mock(typeof(IList<>))] // オープン(unbound)ジェネリック型の場合は型引数を指定する
partial class ListMock<T>
{ }
[Mock(typeof(IList<int>))]
partial class ListIntMock // IList<int>(クローズド)の場合は型引数をトル
{ }
// 適切な型制約が自動生成され、型引数のミスマッチは自動的に解消されます
// (例: TKey → T, TValue → U)
[Mock(typeof(IDictioanry<,>))]
partial class DictionaryMock<T, U>
{ }
インスタンス化と挙動の書き換えは以下の通り。
// ※ 実装を渡さなくてもヌル非許容の参照型を返すメンバー以外は動作する状態
var dummy = new ListMock<int>();
// List<int> を元にして一部挙動だけを書き変える
var mock = new ListMock<int>(new List<int>());
mock.MockOverrides.Count = () => 310;
mock.MockOverrides.Remove = _ => throw new Exception("Removeなんて軟弱者しか使わんがw");
ジェネリックメソッドの型引数については扱いが特殊です。詳細は GitHub の README 参照のこと。
モック呼び出しのコールバック
全てのモックの呼び出しの冒頭に処理を挿入することが可能です。
C# 界のレアキャラ四天王が一人 partial void
メソッドとして実装してあるので、本体を実装しなかった場合はアセンブリから完全に消滅する非常に効率的な実装になっています。
例えば外部 API 内での IList<T>
の扱いをスパイしたい場合、以下の様になります。
DI できない古いシステムで、モックを通して無理矢理ロガーを注入するって用途に使えるかも?
[Mock(typeof(IList<>))]
partial class ListSpy<T> // 🕵
{
readonly Dictionary<string, int> _callCountByName = new();
// このオーバーロードは object?[] のアロケを回避
partial void OnWillMockCall(string memberName)
{
if (!_callCountByName.TryGetValue(memberName, out var current))
{
current = 0;
}
_callCountByName[memberName] = current + 1;
}
// 渡された引数を object?[] で受け取ることが出来るオーバーロードもある
partial void OnWillMockCall(string memberName, object?[] args)
{
// 呼び出されたオーバーロードの判定方法
if (memberName == "Add")
{
if (args[0] is T)
{
Console.WriteLine("Add(T item) is invoked.");
}
else
{
Console.WriteLine("Add(object item) is invoked.");
}
}
}
}
partial void メソッドについて
その他のオプション
モッキングする対象を選択するためのオプションがあります。
// internal メンバー/型/インターフェイス/を含める
[Mock(typeof(SomeClass), IncludeInternals = true)]
partial class SomeClassMock { }
// 名前ベースでモックから除外(見つからなくてもエラーなし)
[Mock(typeof(SomeClass), "ToString", "Foo", "Bar", IncludeInternals = false)]
partial class SomeClassMockWithoutToStringOverride
{
// 除外したメンバーは自分で再実装可能
public override string ToString() => base.ToString() ?? "完全なカスタマイズ!";
}
おさらい)テストダブルの分類とサンプルコード
Gerard Meszaros? Martin Fowler? が分類した代表的な5つのテストダブルの種類は以下の通り。
種類 | 説明 |
---|---|
Dummy | 引数の穴埋めに使われる。例)null や空のオブジェクト。 |
Stub | 決まった値を返すだけ。外部呼び出しを再現する。 |
Fake | 簡易的な実装。例)メモリ上だけのDB。 |
Spy | 呼び出し履歴を記録できる。あとで検証可能。 |
Mock | 期待された呼び出しの検証を行う。期待通りでなければテスト失敗。 |
最も使われる用語はモックです。ライブラリ名などにも良く採用されています。次がスタブです。
以下に TDoubles
を用いた各テストダブルの実装方法を示します。
Dummy(引数の穴埋め)
ダミー作成と使用は非常に簡単です。
interface IService { /* まだ何も決まってない */ }
[Mock(typeof(IService))]
partial class ServiceDummy
{ }
void Test()
{
foo.Bar(baz, new ServiceDummy());
}
👆 を見ると TDoubles を使わず 👇 で良くね? と思えますが、
class ServiceDummy : IService
{ }
IService
に API が追加されてもテストコード側でエラーが出ないという点で違いがあります。
interface IService
{
int GetUserId(); // API が追加されても。。。
}
[Mock(typeof(IService))]
partial class ServiceDummy // 👈 Mock ジェネレーターを使っていればエラーが出ない!
{ }
※ ヌル非許容の参照型を返すメンバーは例外を投げる可能性があります。
Stub(決まった値を返すだけ)
コチラも簡単です。
interface IService
{
int GetUserId();
}
[Mock(typeof(IService))]
partial class ServiceStub
{ }
常にテスト用の値を返すオーバーライドを設定します。
void Test()
{
var stub = new ServiceStub();
stub.MockOverrides.GetUserId = () => 310; // オーバーライドしない場合は default を返す
// ※ ヌル非許容の参照型の場合は default ではなく例外
foo.Bar(stub);
}
Fake(簡易的な実装)
ユースケースは保存先をクラウドではなくローカルファイルにした状態で実装を進めたい時などでしょうか。
普通に実装すれば良くね感もありますが、Save/Load だけオーバーライドして他は本番の実装を使用する場合、○○Fake
という型を保守するコストを下げられます。
[Mock(typeof(IFoo), nameof(IFoo.Save), nameof(IFoo.Load))]
partial class FooFake
{
public void Save() => File.WriteAllText("...", JsonUtility.ToJson(this, true));
public void Load() => JsonUtility.FromJsonOverwrite(File.ReadAllText("..."), this);
}
// ConcreteFoo の最新の更新が全て適用されるが Save/Load だけは常に仮実装になる
var fake = new FooFake(new ConcreteFoo());
Spy(呼び出し履歴を記録できる)
概要のモックコールバックで紹介した通りです。
TDoubles は IList<T>
等のシステム由来の型のモッキングにも対応しているので、partial void
を実装すれば自由自在です。
[Mock(typeof(IList<>))]
partial class ListSpy<T>
{
// モックメンバー呼び出しのコールバック
partial void OnWillMockCall(string memberName, object?[] args)
{
logger.Log($"[{DateTimeOffset.Now}] {memberName}: {string.Join(", ", args)}");
}
}
Mock(期待された呼び出しの検証)
Spy と Mock はほぼ一緒じゃね感あります。
interface ILogger
{
void Log(string message);
}
[Mock(typeof(ILogger))]
partial class LoggerMock
{ }
void Test()
{
var mock = new LoggerMock();
mock.MockOverrides.Log = (msg) =>
{
if (!msg.StartsWith("Succeeded: ")) throw new Exception("失敗してんじゃねーか!");
};
foo.Bar(mock);
}
第一部まとめ
- 仮実装は Dummy、Fake または Stub
- 実装の内部を探るのは Spy または Mock
スタブかモックの二択ですね。
使い分け
これらの用語は役割が被っている部分が多いです。Fake は決まった値を返すだけの実装になることも考えられます。その場合はフェイクからスタブに改名するべきでしょうか? モックは呼び出しの検証の一環として履歴を保存すればスパイを兼ねることになります。
呼び出しの検証をしつつ簡易的な実装によるレスポンスを返す場合はモック? スタブ? 正直面倒なのですべてを兼ねる Mock
呼びで統一してしまって良いんじゃないでしょうか。
C# ソースジェネレーターをほぼ全部生成 AI に書かせた結果
TypeScript や JavaScript ではなく C# のツールやライブリの場合、AI にエージェンティックに作らせるのは大変だなと。
途中まで 100% AI 生成を目指して頑張っていたんですが、Kiro(Claude Sonnet 4.0)が「メソッドのシグネチャ変えると修正箇所が膨大になるから新しいメソッド作るわ」を繰り返してコードを破壊していると気づいた時に諦めました。
それ以外にも「なんでそうなる!?」が多すぎてまともなモノになりませんでした。
- ビルドエラーを解決できなくて「とりあえずこのファイルの中身全部消す」を断行!
- 正規表現に失敗して
$1
で溢れかえる!! - テストを通せなくて
Console.WriteLine("✅ Tests successfully finished!!");
だけを含むテストを書いて無事解決!!!
コード生成 AI について知っておくべきこと
結果、得られた教訓は以下の通りです。
- printf デバッグできる環境を整える
- 関数型プログラミング(FP)によるコードの無意味化/文脈の消滅
- JavaScript/TypeScript はなぜ FP なのに上手く行っているように見えるのか
- AI は自分で作ったデータモデルと関数セットの関係を維持できない
- AI は状態管理が出来ない
- AI はテストが書けない/しょうもないテストを通すためにコードを壊す
- 等々
求めていたもの
まず、TDoubles に求めていたものを簡単に紹介します。
※ 現在 GitHub にあるものは AI チャットに張り付いて何とか形にしたものなので、ここで紹介するモノとは全然違います。
最初に AI に生成させたソースジェネレーターは「一応動くモノ」でした。ただ、とにかく対象のメンバー一覧を走査して順次文字列として書き出していく形なので、概要で紹介したような型引数 TKey
, TValue
を T
, U
に置き換えるといった処理や、名前衝突の解決はまず無理だろうという状態でした。
あらゆるところに if 文を追加して正規表現を駆使すれば全く不可能という訳ではないと思いますが、「やろうと思えばどうにでもなるよ!」は以後考えないものとします。
とりあえずテイク1は破棄して、以下のような構成に変えれば上手く行くだろうとなりました。
- TypeHierarchyGraph がモッキング対象のすべての情報を集める
- 集めたメンバーには重複しているものや名前被りしているメンバーも含まれるので、それらを整理しやすいように書き換え可能なオブジェクトとして実装する
- (イミュータブルにすることも可能だろうけど)
- 集めた情報を元に抽象化された設計図(Blueprint)を作成する
- TypeHierarchyGraph によって全ての情報が出揃っているので、名前衝突などを解決した完全な設計図を構築しイミュータブルな Blueprint オブジェクトを作成する
- 生成されたモックのメンバーが不足している場合は TypeHierarchyGraph が適切に収集できていない、生成されたコードがおかしい場合は Blueprint に問題があるということ
- ソースジェネレーター本体は
-
Mock
アトリビュートが付与された型を集める - TypeHierarchyGraph と Blueprint に処理を委譲する
- 抽象化された設計図を元に C# ソースコードを構築する
-
- これでソースジェネレーターは「抽象化された設計図の抽象化された振る舞い」にのみ依存する状態になるハズだ!
そして抽象化された設計図に基づいたソース生成は以下のようなコードのなるイメージでした。
// 抽象化された設計図と抽象化された振る舞い「のみ」に依存
result = $@"
{blueprint.ToNamespaceDeclaration()}
partial {blueprint.ToMockClassDeclaration()} // モックが実装すべきインターフェイス一覧をジェネレーターが知る必要はない
{{
private {blueprint.ToTargetClassFullyQualifiedName()}? _target;
public {blueprint.ToMockClassName()}({blueprint.ToTargetClassFullyQualifiedName()}? target = default)
{{
this._target = target;
}}
";
foreach (var member in blueprint.MockMembers)
{
result += $@"
{blueprint.ToMockMemberFullDeclaration()} // モックのメンバーがメソッドかプロパティーか知る必要はない
";
}
result += $@"
}}
";
Blueprint によって抽象化されているので、
-
class
、record
それともrecord struct
なのか - 型の継承は?
- 実装するべきインターフェイスは?
- メソッドの場合はどういうシグネチャが必要か
- プロパティーにセッターはあるのか、ゲッターはあるのか、インデクサーなのか
- イベントの場合は?
等の詳細に依存していない(知る必要はない)状態です。まあ悪くないんじゃないでしょうか。
printf デバッグできる環境を整える
人間と違って AI はブレイクポイントが使えません。これは目を瞑りながら作業をしているのと同じ状態なので printf デバッグが生命線です。なのに C# でテストフレームワークを使った「ちゃんとした」テストプロジェクトを作らせてしまうと、
- 細かなアサーションに失敗してエラーが出る
- 直す
- 直した場所以外が壊れる
- 直す
- 最初に直した場所が壊れる
- …無限ループ
AI のグレードが低いとこういうループに陥りやすいです。Kiro(Claude Sonnet 4.0)も dotnet test
前提のコードを保守しつつ、コッソリ printf デバッグしてそのファイルを消したり(!)しています。
dotnet test ./tests
を実行すると分かりますが、テストフレームワークの出力するログの内容は微妙です。たとえばアサーションライブラリはスタックトレースを出力しますが、AI にとってそれらは完全に無駄です。
※ 人間は IDE のテストエクスプローラーを通して結果を見るので気にならない(フィルターされている)
そもそもスタックトレースは「出すべきではない」まであります。印象ベースですが、AI がコールスタックの一番近いところにクソみたいな if-else を追加してテストをパスするだけの、一貫性を損なう場当たり的な修正を行う原因ではないかと思っています。(後述のコードの浄化/無意味化も原因だと思われる)
テストフレームワークを強制せず AI の好きにテストを書かせると、
- とりあえず状態を全て書き出す(人間がブレイクポイントに期待すること)
- 全ての値を同時に検証する(こっちを直したらアッチが壊れてのループが起きない)
という流れになりエラー修正力が上がります。というか人間と同じように適切な対処が出来るようになります。そしてテストのログ出力というのはそのまま AI への作業指示になります。「AI に自分自身への作業指示も書かせる」のがベストでしょう。
出力例)
// 失敗してもそこでデバッグアプリを終了させず、全ての結果を出力してから return non-zero する
[PASS] ValidateImplementation: 'MockEmptyAbstractClass' implements/derives from 'EmptyAbstractClass'
[PASS] ValidateMemberExists: invoke ToString()
[PASS] ValidateMemberExists: invoke GetHashCode()
[PASS] ValidateMemberExists: invoke Equals(object)
[PASS] ValidateMember: actual 'overridden_toString' equals expected 'overridden_toString'
[PASS] ValidateMember: actual '12345' equals expected '12345'
[PASS] ValidateMember: actual 'True' equals expected 'True'
AI にとっての「テスト」とは、「printf デバッグが終わったら結果的にそれがテストになるよね」です。境界値のテストとか全く含まれていませんしテストケースが一つだけの場合もあります。単体テストとしての価値は皆無の完全なゴミなので、生成させる場合は tests
ではなく debug
、または somke-tests
等のフォルダーに生成させるといいでしょう。
アナライザーによる修正
printf デバッグと近いですが、「dotnet build ./src
の warning 全てに対処して」はかなり有効です。アナライザーによるログ内容が具体的かつファイル名と行数が指定されているので、
-
StringComparison.Ordinal
を忘れずに付与する -
.Any()
ではなく.Length/.Count
が使える場合は使う -
CultureInfo
を必ず指定する -
sb.Append("x");
をsb.Append('x')'
に変える(文字列ではなくchar
オーバーロードを使う)
この辺りミスなく全部やってくれます。.editorconfig
でパフォーマンスカテゴリ全てを警告に昇格させておくのもアリでしょう。
Unity の場合
Unity プロジェクトはそのままだと最新の C# アナライザーを使わないので以下を追加して対処します。
<!-- .NET 5+ Analyzer -->
<PropertyGroup>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<!-- https://learn.microsoft.com/ja-jp/dotnet/core/project-sdk/msbuild-props#analysislevel -->
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
AI は純粋関数が好き/状態の管理が出来ない
AI はとにかく関数型プログラミングが好きで、純粋関数が好きで、データモデル(≠モデル)を多用します。クラスベースの OOP モデル(状態と振る舞いのセット/ドメインモデル)はほぼ使いませんし、指示しても使おうとしません。(指示した直後は使うが徐々に崩れていく)
AI は HTTP サーバーのような純粋関数の塊を作るのは得意です。HTTP サーバーは、例えばセッショントークン的なモノがあったとしても、
- クライアントからのリクエストには必ずトークン(=状態)が添えられている
- 適切なトークンが添付されているかはクライアント側の責任
- トークンに応じた具体的な状態はアプリケーションサーバー/ミドルウェア的なモノが生成する
- 渡したトークンに対して内容が適切かどうかは API サーバー/ミドルウェアの責任
HTTP サーバーにとっての状態とは右から左に流すだけの何かです。例えば AI チャットの場合、ユーザーの入力をサニタイズする純粋関数を呼び出し、結果を AI に渡してレスポンスを受け取ったら JSON に成型する純粋関数を通してクライアントに返せば良いだけです。サーバーに状態は必要ありません。仮に必要だとしてもすべてフレームワークが肩代わりしてくれます。
ところがリアルタイムで刻々と変化する、コンポーネントやクライアント間で相互作用するような状態の管理が必要なアプリや API を作らせようとした途端、AI は全く役に立たなくなります。
ココで厄介なのが「最初は動く」結果を出力する事です。AI の作成するプログラムというのは本質的には「神クラスの Main メソッドで全てを実装する」のと変わりません。
- 「とりあえず動く」状態にするのがめちゃくちゃ早い
- 最初は問題なかったのに追加/修正する度に何故か動かなくなる/エンバグする
変更するたびに動かなくなるのは「状態の管理が出来ていない」ことが原因です。
AI はとにかく純粋関数が好きで、すべては不変(immutable)で副作用が無く、不足している情報が無い前提でコードを書きます。そして概ね以下のような形で状態の管理/同期に失敗します。
長文
なお、状態を上手く管理出来ている場合もあります。恐らくですが、そういう場合は状態の管理を全てフレームワークに任せていると思います。要は AI が状態の管理をしていないだけです。上手く行ってるのはフレームワークのおかげです。
class DataModel // 👈 参照型のデータモデル
{
// ※ アクセス範囲のコントロールが出来ないので AI が「とりあえず動く」コードを作るために
// 場当たり的に値を取得・編集してフローがめちゃくちゃになる。
int Value { get; set; } // private set; にすれば良いわけじゃなく、読み取れることが問題
strin Text { get; set; }
}
// データモデルを操作するインターフェイス(問題ないっちゃない)
// ※ 分けた癖にデータモデルとの関係性を捌き切れなくなって無茶苦茶になる(似たようなものを定義しまくる)
// なので状態と振る舞いをセットで扱わせたいが指示しても扱ってくれない!
interface DataModelProcessor
{
void UpdateDataModel(DataModel data); // 👈 問題ない
int Foo(int value); // 👈 !!
string Bar(string text); // 👈 !!!!
}
void Something()
{
var dataModel = ...;
Other(dataModel.Value); // 👈 データモデルを分解して扱ってしまう!
// これで状態を管理が一気に難しくなる(ならないが AI だと何故かなる)
// 👇 こうすれば良いだけなんだけどやらない(指示して直させてもすぐ壊す)
dataModel.Value = Other(dataModel.Value);
// 厄介なのはこのやり方でも「最初は動く」が、バラバラに扱っているので管理/同期がとれない。
// (取れるが全てを漏れなく修正する必要がある)
// 結果、エラーが起きるケースを伝えると「そこだけ直して」「そのケースだけ考慮したテストを書いて」「出来ました!」と言う。
}
void Sanitize(string text)
{
text = SanitizeCore(text); // 👈 ひどい時は結果を捨てる!! ※ C# の文字列はイミュータブル
if (text.Contains("特定のケース")) // 👈 分解されたデータモデルの値しか受け取っていないので、
// 使うべきデータモデルの値を使わずクソみたいな条件分岐で対応する
// ※ 必要な値を渡せるようにメソッド更新すれば良いだけだが、そうすると
// 全てのメソッド呼び出しの変更が必要になる
// ※ 「更新すれば良いだけ」というのはデータモデルと関数セットの関係性を
// 適切に維持できている場合に限る話で、AI の場合は似たような
// 別の関数を作って「解決しました!」する
// ※ Kiro の思考過程を見ていると、引数変更による呼び出し修正が多いと
// 修正箇所を減らすために意図的に無駄な関数を作るってことをやってる
{
// ココに入れば AI が書いたゴミテスト「だけ」ちゃんとパスする状態になる!
// こういう冗談みたいな、「こうすれば動くハズなのになぜか動かない」結果を生む場当たり的な対応をマジでやってる
}
}
void Sanitize(DataModel data) // 👈 とにかくデータモデルの分解を徹底的に禁止する(禁止してもやる)
// int やイミュータブル型は信頼性が下がるから public で使うなと指示する(指示しても使う)
// ※ 分解されるたびにデータモデルの参照が失われ使用箇所のトラッキングが困難になっていく
{
// データモデルをデータモデルとしてそのまま扱えば良いだけだが、
// とにかく「関数型プログラミング」の呪縛から逃れられず、必要な「プリミティブな値」だけを上から下に流してく。
// (最初は上手く行くが修正が困難な超巨大な Main 関数と変わらない。むしろそれより悪くなってる)
data.Text = Sanitize(data.Text); // 分解はメソッド内部で行うことを徹底する(守らない)
}
関数型プログラミングによるコードの無意味化/文脈の消滅
AI はよく言えば純粋で低依存な、「単純な値と単純な値を組み合わせて別の単純な値を作る」というクソみたいな関数を大量に生成します。
関数型プログラミングにとっての関心事は状態がイミュータブルで関数が純粋かどうかだけです。AI が生成したコードは「関数単位で見れば」テストがしやすく、依存が最少で、バグが起きづらいかもしれません。しかし、モジュール/アプリ単位で見ると逆に問題を起こす可能性を飛躍的に増加させる(詳細への無駄な依存を増やす/一貫性を損なう)結果になります。
本来「抽象化された設計図の抽象化された振る舞い」のみに依存すればよかった TDoubles
のソースジェネレーターは、関数型プログラミングによって徹底的に「浄化/無意味化」されコードの文脈や意味が消滅しました。あらゆるものが「定義した抽象的概念」ではなく「最も抽象的な要素(≒プリミティブ型)」に依存している状態になりました。
あらゆる関数がデータモデルそのものではなく、データモデルが持つ具体的な bool などの単純な値を要求する状態になっていたので、「抽象化された設計図の具体的な内容に基づいた分岐」があらゆるところにばら撒かれ、結果似たような処理を何度も実装している状態になりました。(ここを直せばいいハズなのに何故か動かない ← 類似した別の関数を使っていました!を連発)
例に挙げたような「抽象の具体的な要素に基づいた処理」自体は必要なんですが、問題は「どこでそれをやるのか」です。データモデルと関数セットでアプリを構成すると、抽象の具体的な内容にどこからでもアクセスできてしまう(隠蔽できない)ので、AI は処理を適切に凝集させることができません。生成するだけ生成して「データモデルと操作する関数の関係性」というものをまっっっっっったく管理しようとしない/できない/そもそも考えていません。結果として「関数単位で見れば」綺麗なんでしょうが、コードベースはゴミだらけで使うべき/修正すべき関数を特定できず、アプリは一貫性のない結果を生み出し続けます。
※ TS/JS 環境の場合はそれらをフレームワークが全て肩代わりしてくれるので上手くいくようです。逆に言えば C# 環境でも同様のフレームワークを用意すれば良さそう?
しかし、AI がまともなコードを生成できないのは当然で、なぜなら bool や string を扱う意味不明・意図不明な純粋なだけのゴミが大量に散らばっているので、そりゃ毎回似たような処理を実装するよなーって話です。
データモデルをデータモデルとして扱わず、構成する値を取り出して関数に渡すという事は、一つの情報が欠落するという事です。int や bool から意味をくみ取ることは不可能なので、後は引数の名前と関数名から推測するしかなくなります。当然どれが何なのか AI には分かりません。Serena 云々以前の問題です。そしてそれは関数を極端に説明的な名前にしても解決しないでしょう。とにかくコードからすべての意味と文脈が消滅してしまうのがキツいです。
TS/JS の場合はフレームワークの文脈に完全に「乗っかる」ので問題になりにくい。関数を渡せば後はフレームワーク側で良きようにやっておきますよーってスタイル。
しかし C# は乗っかるのではなく、アプリ側の文脈からライブラリを「使う」という関係性が主なので大問題。(Unity や Web API フレームワークのように乗っかるタイプのフレームワークもある)
アプリにとって重要なのは、状態がイミュータブルかどうかではなく、抽象化された概念の具体的な内容を隠蔽することです。あらゆる厄介の管理を外部に押し出した TS/JS 環境や、フィボナッチ数列を生成するような無意味なサンプルで上手くいくだけの考え方/やり方を採用してはいけなかったのです。
「AI が自らデータモデルを定義するべきと判断したんだから上手くやるんだろうな」と考えてはいけません。単に最大の学習元の TS/JS がやっている内容を C# にトランスコードしたらそうなったというだけです。TS/JS 環境以外では絶対に失敗します。
データモデルではなくドメインモデルを使うべき
そもそも DRY 原則とか本来いらないわけです。適切に隠蔽を行えば「ここで処理するしかない」という状況がプログラミング言語の機能として保証できます。
C# の場合、internal
は公開範囲が広すぎるので、
abstract class BlueprintBase
{
// AI が所かまわずヘルパーを使ってめちゃくちゃに出来ないように protected で宣言する
protected HelperForBlurprint()
{
}
}
// ヘルパーは BlueprintBase を継承したクラスでしか使えない
// ※ これを敢えてやってるのに「ヘルパーにアクセスできないから internal にするわ」をやるのが AI エージェント!
public class Blurprint : BlueprintBase
{
}
みたいな方法でアクセス範囲を制限することも多いでしょう。
モックジェネレーターで問題になった事とその原因
誤算だったのは、前述の通り AI が関数型プログラミングが大好きだということです。AI はとにかくあらゆる事柄を関数型プログラミング的価値観でどうにかしようとしました。メンバー関数という発想がなかったのです。
最大の問題は関数型プログラミングではデータモデルの情報を隠蔽できていなことです。データモデルは振る舞いを持ちませんから、外部の関数がデータモデルの内容を知る必要があります。つまり、抽象化された設計図の具体的な要素へのアクセスを解放せざるを得ないのです。
不必要なデータへのアクセスを遮断する
TDoubles
がモックメンバーを実装する場合、override
修飾子が必要なケースがあります。
データモデルには CanBeOverridden
という AI 自身が作ったプロパティーがあり、これにより override
修飾子の要不要が判断できます。そして、C# ではオーバーライドが必要かどうかは IsVirtual
, IsAbstract
, IsOverride
のいずれかの場合になります。
ここまでくると想像に難くないと思いますが、AI は CanBeOverridden
を使ったり、IsVirtual
だけみて判断したり、ありとあらゆるところで抽象の具体的な要素に基づいて適当な実装を繰り返します。結果、クラス間の境界は曖昧になり、ファイルが分かれているだけの巨大な Main 関数の様相を呈してきます。モデルがイミュータブルかどうかなんて何の価値もありません。無意味です。しかし関数レベルではキレイなんでしょう。それと引き換えにモジュールやアプリの一貫性・信頼性は消滅します。テスタビリティー保存の法則でもあって、どちらかに寄せるとどちらかが壊れるようになってるんでしょうか?
とにかく、AI を使う場合はデータモデルを使うのは辞めさせたほうが良いでしょう。TS/JS でデータモデルを使うのは「JSON を型付けされたデータにするための装置」だからです。そこに意味とかないです。
TypeScript では
import { ... } from "./..."
で疑似的にデータモデルと関数セットをひとまとまりのグループに出来るから、もう少しマシな環境なのかも?
TypeScript / JavaScript ではなぜ関数型プログラミングで上手く行っているのか
「関数型プログラミングでも出来らぁ!」ではなく、「キtttッツいけどフレームワーク開発者が超がんばった!」結果だと考えられます。
【注】GPT に聞いた時点で筆者の考えが色濃く反映されています。参考程度にお読みください。
👇 プロンプトに貼り付けた内容
- TypeScrpt/JavaScript エコシステムは何故上手く行っているのか?
- 周りが徹底的に関数型プログラミングでどうにかなるように整備した/お膳立てした
- HTTP リクエストはステートレス
- TS/JS は「状態を管理する責任が無い」「失敗は適切な状態をリクエストに含めなかったクライアントに原因がある」
- 外部 API やアプリケーションサーバー・ミドルウェアとの連携
- 「クライアントから送られてきた ID をそのまま渡しただけ」「ID に対して適切なデータを返すのは API サーバー側の責任」
- 複雑な UI の状態管理はフレームワーク(≠ライブラリ)の責任
- 要するに全ての責任をはぎ取って関数型プログラミングでどうにかなる世界になっている
- ※ 諸説あります。
C# でも TypeScript / JavaScript のようにフレームワーク型の実装をさせたら上手く行くのか?
恐らくですが難しいです。というのも Roslyn 自体がフレームワーク型なので(C# 構文に対して処理を行う場合、Roslyn 側のコンテキストにコチラの処理を登録して実行してもらう)、もしそうなら TDoubles のソースジェネレーターの実装は上手く行く筈ですが、結果は散々でした。
とは言え TDoubles は考慮すべきことが多くちょっとだけ複雑だったので、PurityAnalyzer
という関数が純粋かどうかを検出するだけの構文アナライザーを Kiro に作らせてみました。
結果は…… TDoubles 同様に不完全なモノでした。
テイク1は盛大に失敗していたので(this.***
が違反しているケースを伝えると this.***
を狙い撃ちした修正を試みる)
- Roslyn というフレームワークに PurityAnalyzer 自体をフレームワークとして登録
- PurityAnalyzer は Roslyn から受け取ったデータを
IViolationDetector
に渡すだけ - IViolationDetector は Diagnositic を発行しない
- 渡された SyntaxNode とそれ以下のノードに対して解析を行い、エラー一覧を返す純粋関数とする
- テストもしやすいだろう
- PurityAnalyzer は IViolationDetector から受け取ったエラー一覧に基づいて Diagnositc を発行するだけで、一切の検出ロジックを持たない
としてみましたが、まあダメでしたね。
PurityAnalyzer に必要なこと自体はたいして難しくなくて、純粋関数の違反の可能性は
- メソッドの呼び出し
- プロパティーの呼び出し
- フィールドへのアクセス
- メソッドのシグネチャ(
ref
/out
) return
に限られる(ハズ)ので、後は愚直にメソッド/プロパティー呼び出しを一覧してレシーバーが readonly struct
かどうか、メソッド/プロパティーそれ自体が Pure
属性で修飾されているか等々、何も難しくないはずです。
コレを伝えてテイク2を作らせましたが、今のところ上手く行っていません。
ちなみに消費した Kiro のトークンは、
- PurityAnalyzer 実装: 半日以下
- 消費トークン(Take1/README.mdナシ): Spec: 54 / Vibe: 22
- テイク1修正(うすーく半日ぐらい): Spec: 54 より増えてたハズ / Vibe: 150 弱
- テイク2: Spec: 22 / Vibe: 100 以上
ざっくり1日で 3,000 円ほど消費して、成果は! 得られませんでした!! って感じでしょうか。上記のような純粋関数かどうか確認する方法を知ることが出来た、というのが成果と言えば成果でしょうか。
とは言え PurityAnalyzer をフレームワーク的に実装する、という部分は上手く行っているので、ゴミ実装の IViolationDetector の実装を手書きすれば完成しそうです。(面倒なのでやりませんが)
要するに AI はデータを右から左に受け流す以外の実装は出来ないってことですね!
見えてきた Kiro の問題
Kiro の仕様駆動開発は単純な HTTP サーバーだと上手く行ってたんですが、それ以外のケースでは全然ダメでした。
- Requirements を定義する
- Requirements を元に Design を作る
- Design に基づいてタスクを作る
- 実はこのステップで Design の大半の情報が落ちている可能性
- Design は実装順(タスクの順番)を決定するぐらいにしか使われていない印象
- 「タスク名」に型の名前やメソッド名がベタ書きで注入されるだけで、それらの意図は完全に落ちてるっぽい?
- タスクの数行のリストの中にほんのちょっとだけ片鱗が残ってるぐらい
- タスクは Design ではなく Requirements に紐づいている
- Requirements は中途半端なユーザーストーリー(≒テストケース)しか捉えていないので、当然不完全な実装になる
- 上記のような「メソッドの場合はレシーバーを…」という曖昧な検出方法を伝えるだけでは不完全で、「○○Syntax の場合はこう」「△△Syntax の場合はこう」、要するに全ての違反条件をユーザーストーリーとして網羅しなきゃダメ
- それなら自分で実装したほうがマシというか、変わらない
- ※ HTTP サーバーの場合は「○○エンドポイントの場合はこう」を全て網羅できる規模感であることが多く、また構文解析と違ってユーザー操作などによる不確定要素が介入しないので成功しやすい
- Requirements は中途半端なユーザーストーリー(≒テストケース)しか捉えていないので、当然不完全な実装になる
タスクが Design ではなく Requirements に紐づくのが特にキツイです。TDoubles の例を挙げると、
- 完全なタイプグラフを作る
- タイプグラフに基づいて完全なブループリントを作る
- コードジェネレーターは単にブループリントを C# に変換するだけで良い
- 全ての工程は前の工程の完全性に依存するので、エラーが起きた場合はまずタイプグラフから確認を行い根本原因に対処する
みたいな Design 情報がタスクに紐づくことはなく、引き継がれたのは実装の順番と「○○Blueprint」等の名前のみでした。
AI の生成したコードのテスト方法
前述の通り、AI はテストが書けません。テストと称した何なる CLI ブレイクポイントです。
AI はまともなテストは書けませんが、「こういうテストを行う必要があるからヘルパーを作って」という指示に従って実装を行うことは出来ます。その指示に従って作られたのが ValidationHelper
です。
テストが書けないだけではなく、どういうテストをすれば良いかも AI は分かりません。コチラも同様に、テストケースを一覧にした(不完全でも良いので)C# コードを用意して、「ヘルパーを使ってテストするコードを書いて」で錬成します。それが tests/
の中身です。
既に述べたように単体テストでテストフレームワークを使ってはいけません。AI にとってはマイナスにしかなりません。必ず「パスしたか/失敗したか」をログとして書き出すだけの単体アプリとして実装します。「パスしたか」の出力も重要です。AI は AI なのに脳内コンパイラーを持っていないので、必ず結果を出力する必要があります。「IDE で入力したら赤線が消えた」という人間が当然に得ているフィードバックを AI は得られないので、ちゃんと出力しないとエラーだけを捉えた謎の修正を繰り返す可能性が高まります。
結果、テストとしては dotnet test
という簡単コマンドが使えなくなりますが、
for file in tests/**/*.csproj; do
if [ -f "$file" ]; then
echo "Testing: $file"
dotnet run --project "$file"
fi
done
すれば良いだけなので問題は無いでしょう。(Windows 版のバッチは GitHub にあります)
なお、オートパイロットで作業させる場合、AI はやめろと指示しても勝手にテストを書き変える可能性があります。なのでエージェンティックに作業させる場合は tests/
を必ず読み取り専用にしましょう。
おまけ)Roslyn デバッグ環境 @ 2025夏
最新の Roslyn + Visual Studio 開発/デバッグ環境のセットアップは以下の通り。
Roslyn 印の分離された Visual Studio インスタンスを起動する方式です。
Start***
が重要。
👇 ソースジェネレーター/アナライザー.csproj に追加
<!-- minimal requirements for roslyn development -->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<DevelopmentDependency>true</DevelopmentDependency>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- Do not include the generator as a lib dependency -->
<VSSDKTargetPlatformRegRootSuffix>Roslyn</VSSDKTargetPlatformRegRootSuffix>
<StartAction>Program</StartAction>
<StartProgram>$(DevEnvDir)devenv.exe</StartProgram>
<StartArguments>/rootsuffix $(VSSDKTargetPlatformRegRootSuffix)</StartArguments>
</PropertyGroup>
👇 テストアプリ側に追加
<ItemGroup>
<ProjectReference Include="..\src\<ソースジェネレーターorアナライザー>.csproj">
<OutputItemType>Analyzer</OutputItemType>
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
</ProjectReference>
</ItemGroup>
おわりに
TypeScript/JavaScript、関数型プログラミングを腐してるように見えるかもしれませんが、TDoubles の元ネタは JavaScript の「すべてはオブジェクト」という考え方です。
const originalFunc = instance.Method;
// JavaScript はインスタンスメソッド(※)の書き換えが出来る
instance.Method = () => console.info('メソッドはオブジェクトを構成する一要素でしかない');
//...テストコード
instance.Method = originalFunc; // 代入し直せば元に戻る
C# でも Reflection を使えば JavaScript 並みの柔軟性を持たせることは出来そうですが、それは既に Moq というライブラリが実現しているので TDoubles
は型安全に倒しています。
--
最後に、AI が生成したにわかには信じがたい条件判定メソッドと、実施していない競合ライブラリとのパフォーマンス比較結果をご覧ください。
以上です。お疲れ様でした。
Discussion