C#のFunc・Actionデリゲート:落とし穴と呼び出しコストの実測
FuncとActionの違い、out引数やPredicate<T>と互換がない理由、マルチキャストの3つの罠、インターフェースとの呼び出しコスト比較。2021年の例がコンパイルできなかった原因も整理。
2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語で書き直したものです。.NET 10.0.14(C# 14)でコンパイルし直したところ、Funcの例はコードブロック内の
0.1fが0.1 fに分かれていて4件のエラーでコンパイルできませんでした(error CS1002)。また「FuncとActionはあらかじめ宣言されたデリゲート変数」という説明は誤りで、変数ではなく型です。修正したコードに加え、Funcでできないこと、マルチキャストの罠、呼び出しコストの実測を足しています。
LINQやコールバックで Func<T, bool> を毎日書いていても、自前のデリゲートをいつ宣言すべきか、+= でつないだら何が起きるかは意外と曖昧なままになりがちです。この記事はそこを実際に動かして確かめます。要点は、Func/Action は .NET が用意しているジェネリックなデリゲート型で、たいていは自分で宣言する必要がないこと。ただし out/ref 引数、名前で役割を示したい公開API、マルチキャストの戻り値の扱いでは限界があります。ラムダ式そのものは C#のラムダ式 を先に読むと分かりやすいはずです。
FuncとActionは「変数」ではなく、宣言済みの型
ラムダや匿名メソッドを使うには、それを参照するデリゲート変数が必要で、その前にデリゲート型の宣言が必要です。シグネチャごとに型を宣言するのは面倒なので、.NET は System 名前空間にジェネリックなデリゲート型を用意しています。Func<int, float> func1 で変数なのは func1 のほうで、Func<int, float> は型です。
- Func:戻り値があるメソッドを参照します。型引数の最後が戻り値の型です。
- Action:戻り値がない(
void)メソッドを参照します。
何種類あるかリフレクションで数えました。
var core = typeof(Func<>).Assembly; // System.Private.CoreLib
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && t.Name.StartsWith("Func`")));
Console.WriteLine(core.GetExportedTypes().Count(t => t.Namespace == "System" && (t.Name == "Action" || t.Name.StartsWith("Action`"))));
17
17
引数0個から16個まで、それぞれ17種類です。17個以上の引数が必要になったら、先にメソッドの設計を疑ったほうがいいでしょう。
Funcの例:空白1つでビルドが通らない
引数の型を前に、戻り値の型を最後に書きます。
Func<float> func0 = () => 0.1f; // 引数なし、float を返す
Func<int, float> func1 = (a) => a * 0.1f; // int 1つ
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f; // int 2つ
Func<int, int, int, float> func3 = (a, b, c) => (a + b + c) * 0.1f;
元記事の全体の例では 0.1f が 0.1 f に、Func<int, float> が Func < int, / float > のように複数行に分かれていました。ジェネリックの山かっこ内の空白は問題ありませんが、数値の接尾辞は数字にくっつけなければいけません。
Program.cs(4,37): error CS1002: ; が必要です
Program.cs(7,46): error CS1002: ; が必要です
Program.cs(9,44): error CS1002: ; が必要です
Program.cs(12,53): error CS1002: ; が必要です
修正して整理したコードです。普通のメソッドを参照するときは new Func<…>(temp) と書かずに、メソッド名だけで構いません(メソッドグループ変換)。
static float Temp(int a, int b, int c) => (a + b + c) * 0.1f;
Func<float> func0 = () => 0.1f;
Func<int, float> func1 = a => a * 0.1f;
Func<int, int, float> func2 = (a, b) => (a + b) * 0.1f;
Func<int, int, int, float> func3 = Temp; // 普通のメソッドを参照
Console.WriteLine("func0 の戻り値: {0}", func0());
Console.WriteLine("func1 の戻り値: {0}", func1(10));
Console.WriteLine("func2 の戻り値: {0}", func2(10, 10));
Console.WriteLine("func3 の戻り値: {0}", func3(10, 10, 10));
func0 の戻り値: 0.1
func1 の戻り値: 1
func2 の戻り値: 2
func3 の戻り値: 3
余談ですが、同じ式を double にすると出力が変わります。Func<int, double> d = a => a * 0.1; の d(3) は 0.30000000000000004、float 版の func1(3) は 0.3 と表示されました。float のほうが正確なわけではなく、.NET Core 3.0 以降の ToString() がそれぞれの型で値を復元できる最短の文字列を出すからです。金額計算ならどちらでもなく decimal を使いましょう。
Actionの例
Action は戻り値がないこと以外 Func と同じです。元記事の Action の例は using System; がないこと以外は問題なく(.NET 6 以降のプロジェクトは暗黙の using で通る)、出力も同じでした。
static void Temp(string name) => Console.WriteLine("name: {0}", name);
int sum = 0;
Action act0 = () => Console.WriteLine("name: act0");
Action<string> act1 = Temp; // 普通のメソッドを参照
Action<string, string> act2 = (name, age) =>
{
Console.WriteLine("name: {0}", name);
Console.WriteLine("age: {0}", age);
};
Action<int, int, int> act3 = (a, b, c) => sum = a + b + c; // 外側の sum をキャプチャ
act0();
act1("act1");
act2("act2", "35");
act3(1, 2, 3);
Console.WriteLine("sum: {0}", sum);
name: act0
name: act1
name: act2
age: 35
sum: 6
act3 は戻り値の代わりに、キャプチャした外側の変数 sum に書き込んで結果を渡しています。このキャプチャのコストは C#のラムダ式 で実測しました。
Funcでできないこと:out/ref、Predicateとの変換
out 引数
Func/Action の型引数には ref/out を付けられません。そのため int.TryParse を入れようとすると失敗します。
Func<string, int, bool> tp = int.TryParse;
error CS0123: デリゲート 'Func<string, int, bool>' に一致する 'TryParse' のオーバーロードはありません
ここが自前のデリゲートを宣言する場面です。
delegate bool TryParser<T>(string s, out T value);
TryParser<int> tp = int.TryParse;
Console.WriteLine(tp("123", out var v) + " " + v); // True 123
シグネチャが同じでも別の型
Predicate<int> と Func<int, bool> はシグネチャがまったく同じでも別の型なので、そのままでは代入できません。List<T>.FindAll は Predicate<T>、LINQ の Where は Func<T, bool> を受け取るため、実務でよくぶつかります。
Predicate<int> isEven = n => n % 2 == 0;
Func<int, bool> f = isEven;
error CS0029: 型 'System.Predicate<int>' を 'System.Func<int, bool>' に暗黙的に変換できません
isEven.Invoke(メソッドグループ)か new Func<int, bool>(isEven) で包めば通ります。
Func<int, bool> f = isEven.Invoke;
var list = new List<int> { 1, 2, 3, 4 };
Console.WriteLine(string.Join(",", list.FindAll(isEven)) + " / " + string.Join(",", list.Where(f)));
// 2,4 / 2,4
マルチキャストの3つの罠
デリゲートは += で複数のメソッドをつなげます。Action でイベントのような仕組みを作るときに便利ですが、動かしてみると意外な挙動があります。
// 1. Func をつなぐと戻り値は最後のものだけ
Func<int> multi = () => { Console.Write("A "); return 1; };
multi += () => { Console.Write("B "); return 2; };
Console.WriteLine("=> " + multi());
// 2. 途中で例外が出ると残りは実行されない
Action chain = () => Console.Write("first ");
chain += () => throw new InvalidOperationException("boom");
chain += () => Console.Write("third ");
try { chain(); } catch (Exception e) { Console.WriteLine("| caught: " + e.Message); }
// 3. ラムダを -= で外そうとしても外れない
Action log = () => Console.Write("log ");
log += () => Console.Write("tick ");
log -= () => Console.Write("tick ");
log(); Console.WriteLine($"| count={log.GetInvocationList().Length}");
A B => 2
first | caught: boom
log tick | count=2
- 両方のメソッドが実行されましたが、結果は
2だけです。それぞれの値が必要ならforeach (Func<int> d in multi.GetInvocationList()) d();のように1つずつ呼びます(出力はA 1 B 2)。 - 2番目で例外が出たため、
thirdは実行されませんでした。1つの購読者の失敗で残りを止めたくないなら、GetInvocationList()を回して1つずつtryで囲みます。 - 見た目が同じラムダでも毎回別のデリゲートインスタンスなので、外れません。変数に入れておき、同じインスタンスで
+=/-=すれば外れます(count=1)。メソッドグループ(m -= Hello)はメソッドと対象が同じなら等しいとみなされるので外せます。
おまけとして、Null 許容参照型が有効なプロジェクトで -= を使うと warning CS8601: Null 参照代入の可能性があります。 が出ます。最後のメソッドまで外すとデリゲートが null になるからで、呼び出しを log?.Invoke() と書く理由はこれです。null のデリゲートをそのまま呼ぶと NullReferenceException になります。
呼び出しコスト:直接・インターフェース・デリゲート(実測)
「デリゲートは遅い」という話を確かめるため、x * 2 を計算するメソッドを2億回呼びました。直接呼び出しが JIT にインライン化されないよう [MethodImpl(MethodImplOptions.NoInlining)] を付けています。
static long RunFunc(Func<int, int> f, int n) { long s = 0; for (int i = 0; i < n; i++) s += f(i); return s; }
// RunDirect: Calc.TwiceNoInline(i) を直接、RunIface: IOp.Apply(i)、RunCustom: delegate int Op(int x)
Func<int, int> func = Calc.Twice; // 静的メソッドを指す Func
Func<int, int> lam = x => x * 2; // ラムダ
.NET 10.0.14、Release、Apple M1 Pro、2億回、3回実行:
| 呼び出し方 | 1回目 | 2回目 | 3回目 | 1回あたり |
|---|---|---|---|---|
| 直接呼び出し(インライン禁止) | 222.5 ms | 221.8 ms | 223.8 ms | 1.11 ns |
| インターフェース | 302.3 ms | 303.1 ms | 303.1 ms | 1.51 ns |
Func ← 静的メソッド |
312.0 ms | 312.6 ms | 311.6 ms | 1.56 ns |
Func ← ラムダ |
265.9 ms | 262.9 ms | 263.0 ms | 1.31 ns |
| 自前のデリゲート ← 静的メソッド | 315.3 ms | 312.5 ms | 312.1 ms | 1.56 ns |
デリゲート呼び出しは直接呼び出しより1回あたり約0.2〜0.45ns高く、インターフェース呼び出しと同程度でした。Func と自前のデリゲート型に差はありません。面白いのは、同じ計算でもラムダのほうが静的メソッドを指すデリゲートより速かったことです。ラムダはコンパイラーがインスタンスメソッドとして生成し、静的メソッドに結び付いたデリゲートは呼び出しごとに引数の位置をずらす処理が1段入ることが知られています。いずれにしても2億回で数十ミリ秒の差なので、速度ではなく読みやすさで選べば十分です。
FuncとActionを使うとき、自前で宣言するとき
| 状況 | おすすめ |
|---|---|
| メソッドの引数でコールバックや条件を受け取る | Func/Action(LINQ と同じ慣習) |
out/ref/in 引数が必要 |
デリゲートを自前で宣言 |
| 公開ライブラリのAPIで、名前で役割を示したい | 自前で宣言(例:delegate decimal PriceRule(Order order)) |
| イベント | event EventHandler<T> か event Action<T>、呼び出しは ?.Invoke |
| すべての購読者の戻り値が必要 | GetInvocationList() で1つずつ呼ぶ |
| 関連する複数のメソッドと状態を持つ | デリゲートではなくインターフェース |
List<T>.FindAll、Array.Find |
Predicate<T>(Func と相互に代入できない) |
Func にラムダを入れるときの文法(自然な型、既定パラメーター)は C#のラムダ式、Func を一番多く受け取る API である LINQ は LINQの基本 を参照してください。
確認環境:.NET SDK 10.0.403 / ランタイム 10.0.14、C# 14、macOS(Apple M1 Pro)。
よくある質問
FuncとActionの違いは?
Funcは戻り値があるメソッド用で、型引数の最後が戻り値の型です(Func<int, float> は int を受け取り float を返す)。Actionは戻り値がない(void)メソッド用です。.NET 10で数えると、どちらも引数0〜16個の17種類ずつあります。
outやref引数を持つメソッドをFuncに入れられる?
入れられません。Func<string, int, bool> tp = int.TryParse; は error CS0123 になります。delegate bool TryParser<T>(string s, out T value); のようにデリゲートを自分で宣言します。C# 10以降なら、out引数を持つラムダを var で受けるとコンパイラーが匿名デリゲート型を生成します。
Funcを += でつないだときの戻り値は?
すべてのメソッドが順に実行されますが、戻り値は最後のメソッドのものだけが残ります。1と2を返す2つをつなぐと結果は2でした。それぞれの値が必要なら GetInvocationList() で1つずつ呼び出します。