C# リフレクションと属性:Invoke のコスト実測と AOT 警告

Type・MethodInfo によるメンバー取得、カスタム属性の定義と読み取り、Invoke と直接呼び出し・デリゲートの速度を実測。Native AOT の IL2026・IL2057 警告とソースジェネレーターも。

2026年11月改訂: 2021年に韓国語で書いたリフレクションと属性の講座を、.NET 10.0.14(C# 14)で再コンパイル・再実行しました。元記事の警告・エラー一覧は Visual Studio 2012 のエラー一覧をそのまま貼ったもので、個人 PC のユーザーフォルダーのパスが含まれ、診断コードもありませんでした。それらはすべて削除し、dotnet build の実際の出力(CS0618、CS0619、CS0592)に差し替えています。あわせて「Conditional はメソッドをコンパイルするかを決める」「位置指定引数は引用符で書く」という誤った説明を直し、Invoke のコスト実測と Native AOT・トリミングの警告を追加しました。

この記事は、実行時に型情報を調べたりメソッドを名前で呼び出したりする必要がある C# 開発者と、自作の属性を実行時に読んで検証やマッピング、プラグイン検出に使いたい人向けです。先に結論を書くと、リフレクションは 強力だが素朴に使うと目に見えて遅く、2026年の時点ではトリミングや Native AOT とも衝突します。繰り返し呼ぶならデリゲートに一度変換し、新しく書くコードならソースジェネレーターでコンパイル時に済ませられないかをまず検討してください。

リフレクションの基本:Type からメンバー一覧を取り出す

リフレクションは、実行中に型のメタデータ(コンストラクター、メソッド、フィールド、プロパティ、属性)を調べ、その情報でインスタンスを作ったりメンバーを呼び出したりする仕組みです。起点は常に System.Type です。

呼び出し 戻り値 得られるもの
obj.GetType() / typeof(T) Type 実行時の型オブジェクト
GetConstructors() ConstructorInfo[] public コンストラクター
GetMethods() MethodInfo[] public メソッド(継承分を含む)
GetFields() FieldInfo[] public フィールド
GetProperties() PropertyInfo[] public プロパティ
GetMembers() MemberInfo[] 上記すべて

2021年の例は、public フィールド2つ・コンストラクター1つ・メソッド2つを持つ Animal クラスを調べるものです。.NET 10 での出力(ラベルは元コードの韓国語で、コンストラクター・メソッド・フィールドの意味):

Animal animal = new Animal("ねこ", 4);
Type type = animal.GetType();
foreach (ConstructorInfo c in type.GetConstructors()) Console.WriteLine("\t{0}", c);
foreach (MethodInfo m in type.GetMethods()) Console.Write("\t{0}", m);
foreach (FieldInfo f in type.GetFields()) Console.Write("\t{0}", f);
생성자(Constructor) :  Void .ctor(System.String, Int32)

메소드(Method) :   Void eat()  Void sleep()    System.Type GetType()   System.String ToString()    Boolean Equals(System.Object)   Int32 GetHashCode()
필드(Field) :     Int32 age   System.String name

2021年の出力と比べると GetType() と ToString() の順序が入れ替わっています。Microsoft Learn の Type.GetMethods の説明にある通り、メソッドは 特定の順序で返されない ので問題ではありません。順序に依存するコードは書かないでください。1つのメンバーを探すなら GetMethod("eat")、非公開メンバーも含めるなら BindingFlags.NonPublic | BindingFlags.Instance を渡します。

組み込み属性:Conditional、Obsolete、DllImport

属性は [名前(位置指定引数, 名前付き = 値)] の形でコード要素に付けるメタデータです。2021年の記事は位置指定引数を「引用符で書く」と説明していましたが、文字列に限りません。属性の引数はすべて コンパイル時定数 で、使える型は bool、char、整数・浮動小数点のプリミティブ型、string、Type、列挙型、object、およびそれらの1次元配列です。decimal は定数に見えても使えません。

Program.cs(17,2): error CS0181: 属性コンストラクターのパラメーター 'value' には型 'decimal' がありますが、これは無効な属性パラメーター型です

Conditional。 2021年の記事は「メソッドをコンパイルするかどうかを条件で決める」と書いていましたが、正確には 呼び出しを消します。メソッド本体は常にコンパイルされます。#define TEST の有無で実行して確かめました。

#define TEST            // この行を消して再実行
using System.Diagnostics;

class Program
{
    [Conditional("TEST")]
    public void TestConditional() => Console.WriteLine("TestConditional!");

    static void Main()
    {
        new Program().TestConditional();
        var mi = typeof(Program).GetMethod(nameof(TestConditional));
        Console.WriteLine($"method still in assembly: {mi is not null}");
    }
}
TestConditional!
method still in assembly: True
(#define TEST を消したビルド)
method still in assembly: True

Release ビルドで Debug.WriteLine が消えるのも同じ仕組み([Conditional("DEBUG")])です。呼び出しごと消えるので 引数の式も評価されません。引数に副作用のある処理を書かないよう注意してください。

Obsolete。 元記事の一覧は Visual Studio 2012 のエラー一覧の写しでした。.NET 10 での実際の出力です。ObsoleteAttribute の DiagnosticId と UrlFormat プロパティも加えています。

[Obsolete("Use NewMethod() instead.")]
public void OldMethod() { Console.WriteLine("OLD!"); }

[Obsolete("Removed. Use NewMethod().", true)]
public void OlderMethod() { }

[Obsolete("Use NewMethod2.", DiagnosticId = "TLAB001", UrlFormat = "https://example.com/{0}")]
public void OldMethod2() { }
Program.cs(17,9): warning CS0618: 'Program.OldMethod()' は旧形式です ('Use NewMethod() instead.')
Program.cs(19,9): error CS0619: 'Program.OlderMethod()' は旧形式です ('Removed. Use NewMethod().')
Program.cs(20,9): warning TLAB001: 'Program.OldMethod2()' は旧形式です ('Use NewMethod2.') (https://example.com/TLAB001)

第2引数に true を渡すと警告 CS0618 がエラー CS0619 に変わります。DiagnosticId を指定すると CS0618 の代わりにそのコードで報告されるので、ライブラリの利用者は <NoWarn>TLAB001</NoWarn> でその非推奨警告だけを抑止できます。

DllImport。 2021年の例は User32.dll の MessageBox を呼びます。net10.0 向けなら macOS でも警告なしでコンパイルできます。ネイティブライブラリはコンパイル時ではなく最初の呼び出し時に探されるからです。この Mac で実行すると最初の呼び出しで失敗します(出力は一部省略)。

OS: macOS 26.6.2
Unhandled exception. System.DllNotFoundException: Unable to load shared library 'User32.dll' or one of its dependencies. …

Windows では実行できなかったので、メッセージボックスの挙動は Microsoft Learn の説明に譲ります。なお 2021年のシグネチャの int hParent は本来ポインターサイズのハンドル(HWND)なので nint が正しい型です。.NET 7 以降はソース生成ベースの LibraryImport が推奨されています。以下は net10.0 でコンパイルのみ確認したコードで、Windows 以外では呼び出しをスキップします。

static partial class Native
{
    [LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial int MessageBoxW(nint hWnd, string text, string caption, uint type);
}

最初のビルドは、生成されるマーシャリングコードが unsafe なため失敗しました。

CSC : error SYSLIB1062: LibraryImportAttribute には安全でないコードが必要です。プロジェクトは '<AllowUnsafeBlocks>true</AllowUnsafeBlocks>' で更新する必要があります。

カスタム属性を定義し、実際に読む

System.Attribute を継承すれば独自の属性を作れます。2021年の例に [AttributeUsage(AttributeTargets.Method)] を付け、その属性をクラスに付けると次のエラーになります(個人パスを含んでいた2021年の一覧の置き換え)。

Program.cs(1,2): error CS0592: 属性 'CustomAttribute' はこの宣言型では無効です。'メソッド' 宣言でのみ有効です。

AttributeTargets.Method | AttributeTargets.Class のようにビット OR でまとめれば通ります。.NET 10 で AttributeTargets を列挙すると16個で、2021年の表になかった GenericParameter が含まれます。

Assembly, Module, Class, Struct, Enum, Constructor, Method, Property, Field, Event, Interface, Parameter, Delegate, ReturnValue, GenericParameter, All

2021年の例は属性を 付けただけで、読んでいませんでした。属性は、誰かがリフレクションで読むまで何もしません。プロパティに付けた規則を読んで値を検査する小さな検証器です。

using System.Reflection;

[AttributeUsage(AttributeTargets.Property)]
sealed class NotEmptyAttribute : Attribute;

[AttributeUsage(AttributeTargets.Property)]
sealed class RangeAttribute(int min, int max) : Attribute
{
    public int Min { get; } = min;
    public int Max { get; } = max;
    public string? Message { get; init; }   // 名前付き引数
}

sealed class SignupForm
{
    [NotEmpty] public string Email { get; set; } = "";
    [Range(14, 120, Message = "age must be 14-120")] public int Age { get; set; }
}

static class Validator
{
    public static IEnumerable<string> Validate(object obj)
    {
        foreach (PropertyInfo p in obj.GetType().GetProperties())
        {
            object? value = p.GetValue(obj);
            if (p.IsDefined(typeof(NotEmptyAttribute)) && string.IsNullOrEmpty(value as string))
                yield return $"{p.Name}: must not be empty";
            if (p.GetCustomAttribute<RangeAttribute>() is { } r && value is int n && (n < r.Min || n > r.Max))
                yield return $"{p.Name}: {r.Message ?? $"{r.Min}..{r.Max}"} (was {n})";
        }
    }
}

new SignupForm { Email = "", Age = 12 } を検証した結果:

Email: must not be empty
Age: age must be 14-120 (was 12)

AllowMultiple(既定 false)と Inherited(既定 true)も確認しました。複数指定を許可していない属性を2回付けると error CS0579('Tag' 属性が重複しています)です。基底クラスに [Tag("base1"), Tag("base2")](複数可・継承する)と [SealedMark](Inherited = false)を付け、派生クラスから読んだ結果:

Tags on Child (inherit:true): base1,base2
Tags on Child (inherit:false):
IsDefined(Child, Sealed, inherit:true): False

基底クラスの属性を見るには inherit: true で問い合わせる必要があり、Inherited = false の属性はそれでも見えません。2021年の「カスタム属性はコンパイルに影響しない」という説明も今では半分だけ正しいものです。アナライザーとソースジェネレーターは コンパイル時に カスタム属性を読み、診断を出したりコードを生成したりします。上の LibraryImport と下の [JsonSerializable] がその例です。

Invoke はどれくらい遅いか:直接呼び出し・デリゲートと実測

int Add(int a, int b)(NoInlining 指定)をケースごとに1,000万回呼びました。.NET 10.0.14、Release ビルド、Apple M1 Pro、5回測定の中央値です。確保量は GC.GetAllocatedBytesForCurrentThread() の差を呼び出し回数で割った値です。最後のケースは毎回 GetMethod で探すため100万回にしました。

MethodInfo mi = typeof(Calc).GetMethod(nameof(Calc.Add))!;
var invoker = MethodInvoker.Create(mi);                          // .NET 8 以降
var del = mi.CreateDelegate<Func<Calc, int, int, int>>();        // オープンインスタンスデリゲート

s += calc.Add(i, 1);                          // 直接呼び出し
s += del(calc, i, 1);                         // CreateDelegate
s += (int)invoker.Invoke(calc, i, 1)!;        // MethodInvoker
s += (int)mi.Invoke(calc, [i, 1])!;           // MethodInfo.Invoke
方法 1回あたり(中央値) 最小〜最大 1回あたりの確保
直接呼び出し 1.13ns 1.09〜1.15 0B
CreateDelegate のデリゲート 1.60ns 1.56〜1.70 0B
MethodInvoker.Invoke 17.00ns 16.37〜25.06 72B
MethodInfo.Invoke 32.73ns 32.01〜33.82 112B
毎回 GetMethod + Invoke 43.72ns 39.66〜69.31 112B
1回あたりのナノ秒の棒グラフ:直接呼び出し 1.13、CreateDelegate のデリゲート 1.60、MethodInvoker 17.00、MethodInfo.Invoke 32.73、毎回 GetMethod と Invoke 43.72。キャッシュしたデリゲートは直接呼び出しとほぼ同じで、MethodInfo.Invoke は約29倍遅い。
int Add(int, int) の1回あたりの時間。1,000万回(最後の棒は100万回)を5回測った中央値。.NET 10.0.14、Release ビルド、Apple Silicon Mac(M1 Pro)。Invoke は1回112バイト、MethodInvoker は72バイトを確保し、ほかは0。

MethodInfo.Invoke は直接呼び出しの約29倍で、引数配列と int のボックス化のために1回112バイトを確保しました。MethodInvoker は配列なしで引数を渡せるので約半分です(同じファイルを net7.0 でビルドすると MethodInvoker が error CS0103、net8.0 では成功)。最も効くのは CreateDelegate で、一度作れば 直接呼び出しとの差は0.5ns でした。30ns は小さく見えますが、シリアライザーやマッパーは1リクエストで何千回も呼びます。一度探して使い続けるプラグインなら素の Invoke で十分です。

2026年の注意点:トリミング・Native AOT とソースジェネレーター

.NET 8 以降、Native AOT とトリミングが実運用で使われるようになり、リフレクションは新しい問題に直面しています。トリマーは 静的に見える型とメンバーだけを残し、それ以外を削除します。文字列でしか名前が分からない型は消えるかもしれません。プロジェクトに <IsAotCompatible>true</IsAotCompatible> を入れると、通常のビルドでも AOT アナライザーが有効になります。

string name = args.Length > 0 ? args[0] : Console.ReadLine() ?? "";
Type? t = Type.GetType(name);
Console.WriteLine(JsonSerializer.Serialize(new Point(1, 2)));
Program.cs(5,11): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the target type.
Program.cs(10,19): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. …
Program.cs(10,19): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. …

IL 警告は DOTNET_CLI_UI_LANGUAGE=ja でビルドしても英語のままでした。警告を無視して dotnet publish -p:PublishAot=true で実際のネイティブ実行ファイル(約2.1MB)を作り、実行しました。

  • 名前(文字列)でしか参照していない LegacyPlugin クラス:JIT 実行(dotnet run -- LegacyPlugin)では Type.GetType が LegacyPlugin を返し、AOT 実行ファイルでは null でした。
  • リフレクションベースの JsonSerializer.Serialize:AOT 実行ファイルは InvalidOperationException: Reflection-based serialization has been disabled for this application. で終了しました。

対策は「必要なものをトリマーに見せる」ことです。typeof で型を固定して必要なメンバーの種類を [DynamicallyAccessedMembers] で伝えるか、リフレクションそのものをソースジェネレーターに置き換えます。

static string? CallHello(
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods |
                                DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] Type type)
    => type.GetMethod("Hello")?.Invoke(Activator.CreateInstance(type), null) as string;

Console.WriteLine(CallHello(typeof(Plugin)));                                         // 警告なし
Console.WriteLine(JsonSerializer.Serialize(new Point(1, 2), AppJson.Default.Point)); // 警告なし

[JsonSerializable(typeof(Point))] partial class AppJson : JsonSerializerContext;

この2行は IL 警告なしでビルドでき、AOT 実行ファイルでも hello from Plugin と {"X":1,"Y":2} を出力しました。残った警告は文字列で型を探す IL2057 だけです。これは設計上避けられないので、本当に名前で探す必要があるなら、そのアプリでは AOT をあきらめるか、候補の型をあらかじめ登録しておく方法を選ぶことになります。

メンバーを呼び出す・調べる必要があるときの判断図。問1:型はコンパイル時に分かるか。はいならジェネリックかインターフェイス。いいえなら問2:トリミングや Native AOT で発行するか。はいならソースジェネレーター、または typeof と DynamicallyAccessedMembers。いいえなら問3:何度も呼ぶか。はいなら CreateDelegate でデリゲートをキャッシュ。いいえなら MethodInfo.Invoke で十分。
2026年にリフレクションと代替手段を選ぶ流れ。下に進むほど実行時のコストかトリミング・AOT 警告が減る。

リフレクションは「実行時にしか分からないもの」を扱う最後の手段です。コンパイル時に型が分かるならジェネリックかインターフェイス、繰り返し呼ぶならデリゲートのキャッシュ、AOT 対象ならソースジェネレーター、というのが2026年の基本です。デリゲートについては Func と Action デリゲート、リフレクションの代わりになる型制約は C# ジェネリック を参照してください。

検証環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro)。Native AOT は osx-arm64 向けに発行。Windows 専用の DllImport・LibraryImport 呼び出しはコンパイルのみ確認し、Windows では実行していません。

よくある質問

リフレクションの Invoke は直接呼び出しよりどれくらい遅いですか?

MethodInfo.Invoke は1回あたり約 32.7ns・112バイトの確保で、直接呼び出し(1.13ns)の約29倍でした。.NET 8 の MethodInvoker は 17.0ns、CreateDelegate で作ったデリゲートは 1.60ns と直接呼び出しに近い値でした(.NET 10、Release、Apple M1 Pro、1,000万回)。同じメソッドを繰り返し呼ぶならデリゲートに一度変換しておきましょう。

Conditional 属性を付けたメソッドはビルドから消えますか?

消えません。メソッド本体は常にコンパイルされてアセンブリに残り、シンボルが未定義のときに消えるのは呼び出し側のコードです。#define TEST を外したビルドでも typeof(Program).GetMethod("TestConditional") は null になりませんでした。

Native AOT でリフレクションを使うとどうなりますか?

ビルド時に IL2057・IL2026・IL3050 などの警告が出て、実行時にはトリマーが削除した型が見つかりません。文字列でしか参照していないクラスを Type.GetType で探すと、JIT 実行では見つかり AOT 実行ファイルでは null でした。typeof で型を固定して DynamicallyAccessedMembers を付けるか、ソースジェネレーターに置き換えると警告は消えます。

  • #C#
  • #リフレクション
  • #属性
  • #Native AOT
  • #.NET 10

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑