6
8

【C#】いい加減説明できるようにしたいデリゲート,Action,Func,Expression

Posted at

はじめに

C#を教えていると「デリゲートって結局なんすか??」とよく質問されます。
「メソッドを引数に渡せてあげられる」と言うのは簡単ですが、正直自分も感覚値でしか理解していないので今後説明できるように記事にしようと思います。
本記事では、主にサーバーサイドの開発におけるデリゲートの使用に焦点を当てます。そのため、UIやイベント駆動型のシステムにおけるデリゲートの使用(イベント管理など)については取り扱いません。

環境

C# 12 .NET8
xUnitで検証を行っていきます

デリゲートとは

公式ドキュメントには以下のような記述がしてあります。

デリゲートは、C および C++ の関数ポインターのようなメソッドを安全にカプセル化する型です。
ただし、C 関数ポインターとは異なり、デリゲートはオブジェクト指向で、タイプ セーフで、安全です。

CやC++の関数ポインタを使用したことある方ならこれだけでざっくり理解できますが、そうでないなら意味不明ですね。
というかそれらの言語で関数ポインタまで理解してる人ならこの記事読まないし、「デリゲートって結局なんすか??」とはならないですね。

簡単な使用例

以下のようにメソッドを変数のように扱うことができます。

// デリゲートの定義
public delegate void GreetingDelegate(string name);

[Fact(DisplayName = "そのまま使う")]
public void DelegateTest()
{
    // デリゲートのインスタンスを作成し、メソッドを参照
    GreetingDelegate greeting = new GreetingDelegate(SayHello);

    // デリゲートを使用してメソッドを呼び出す
    greeting("Alice");

    // 別のメソッドをデリゲートに割り当てる
    greeting = SayGoodbye;
    greeting("Bob");
}

void SayHello(string name)
{
    _output.WriteLine($"こんにちは、{name}さん!");
}

void SayGoodbye(string name)
{
    _output.WriteLine($"さようなら、{name}さん!");
}

デリゲートを使わないと以下のような処理になります。

[Fact]
public void DelegateOnceTest()
{
    SayHello("Alice");
    SayGoodbye("Bob");
}

ActionとFunc

前項の書き方を見ると書き方が複雑なんですが、その代わりに、C#では汎用的なデリゲート型としてActionFuncが用意されています。
Action戻り値がないメソッドを扱うために使用され、Func戻り値があるメソッドを扱います。
メンバ変数を省略できたりこっちが一般的な書き方のように思うので、以下からはこちらで書いていきます。
例として、上のコードをそれぞれ置き換えてみます。

Action

Actionは返り値がないデリゲートで使用することができます。
以下のようにラムダ式でメソッドを書いて変数に格納する使い方が一般的なように思います。

[Fact(DisplayName = "Actionで書いてみる")]
public void ActionTest()
{
    Action<string> greet1 = name => _output.WriteLine($"こんにちは、{name}さん!");
    Action<string> greet2 = name => _output.WriteLine($"さようなら、{name}さん!");

    // デリゲートの呼び出し
    greet1("Alice");
    greet1("Bob");
    greet2("Alice");
    greet2("Bob");
}

もちろん普通のデリゲートのように既存のメソッドを変数に格納することもできます。

public void ActionTest()
{
    Action<string> greet1 = SayHello;
    Action<string> greet2 = SayGoodbye;

    // デリゲートの呼び出し
    greet1("Alice");
    greet1("Bob");
    greet2("Alice");
    greet2("Bob");
}

Func

Funcは戻り値のあるメソッドをデリゲートにすることができます。
例えば引数がhoge型、返り値がfuga型の場合はFunc<hoge, fuga>というように記述します。
もちろん既存のメソッドも入れることができます。

[Fact(DisplayName = "Funcで書いてみる")]
public void FuncTest()
{
    Func<string, string> greet1 = name => $"こんにちは、{name}さん!";
    Func<string, string> greet2 = FuncSayGoodbye;

    // デリゲートの呼び出し
    _output.WriteLine(greet1("Alice"));
    _output.WriteLine(greet1("Bob"));
    _output.WriteLine(greet2("Alice"));
    _output.WriteLine(greet2("Bob"));
}

string FuncSayGoodbye(string name)
{
    return($"さようなら、{name}さん!");
}

既存のメソッド呼び出しとの違いと利点

メソッドを変数として扱える

当たり前のことかもしれませんが、これが最も重要なポイントだと思います。
上記のコードを例にするとデリゲートを配列に入れることで処理の差し込みが容易になります。

[Fact(DisplayName = "Collectionで扱う")]
public void CollectionTest()
{
    List<Func<string, string>> funcs = new();
    Func<string, string> greet1 = name => $"こんにちは、{name}さん!";
    funcs.Add(greet1);
    Func<string, string> greet2 = FuncSayGoodbye;
    funcs.Add(greet2);

    foreach(var g in funcs)
    {
        _output.WriteLine(g("Alice"));
    }
}

イメージが付くでしょうか?
メソッドを変数として扱えるので、このように配列に格納することができます。
こうすることによって処理の差し込みが容易になります。

デリゲートを使用しない場合
public void DelegateOnceTest()
{
    SayHello("Alice");
    SayGoodbye("Alice");
    // 主処理に追加しなければならない
    SayGoodMorning("Alice");
}

string SayGoodMorning(string name)
{
    return $"おはようございます、{name}さん!";
}
デリゲートを使用する場合
public void CollectionTest()
{
    List<Func<string, string>> funcs = new();
    Func<string, string> greet1 = name => $"こんにちは、{name}さん!";
    funcs.Add(greet1);
    Func<string, string> greet2 = FuncSayGoodbye;
    funcs.Add(greet2);
    Func<string, string> greet3 = FuncSayGoodMorning;
    funcs.Add(greet3);

    // 主処理に影響しない
    foreach(var g in funcs)
    {
        _output.WriteLine(g("Alice"));
    }
}

string FuncSayGoodMorning(string name)
{
    return $"おはようございます、{name}さん!";
}

このように、デリゲートを使うことでメソッドを抽象化でき、メインの処理に対して柔軟に機能を追加することが可能になります。
主処理への影響を最小限に抑えつつ、機能を拡張できます。

応用:メソッドチェーン

わざわざ配列にしなくてもデリゲート自体にメソッドを貯め込むことができます。

[Fact(DisplayName = "チェーンして呼び出す")]
public void DelegateChainTest()
{
    Action<string> greet1 = name => _output.WriteLine($"こんにちは、{name}さん!");
    greet1 += SayGoodbye;

    greet1("Alice");
}

この場合登録した順に呼び出されます。

こんにちは、Aliceさん!
さようなら、Aliceさん!

メソッドをメソッドの引数として渡すことができる

配列に格納できるならもちろん引数にすることができます。

[Fact(DisplayName = "引数にデリゲートを使う")]
public void DelegateFuncTest()
{
    // 文字列のリスト
    var names = new List<string> { "Alice", "Bob", "Jone" };

    ProcessNames(names, SayHello);

    ProcessNames(names, SayGoodbye);
}

void ProcessNames(List<string> names, Action<string> process)
{
    foreach (var name in names)
    {
        process(name);
    }
}

ProcessNamesがメソッドを抽象的に受け取って実行していることがわかると思います。
こちらのコードをデリゲートを使わないと以下のようになります。

[Fact(DisplayName = "デリゲートを使わない場合")]
public void WithoutDelegateTest()
{
    var names = new List<string> { "Alice", "Bob", "Jone" };

    foreach (var name in names)
    {
        SayHello(name);
    }

    foreach (var name in names)
    {
        SayGoodbye(name);
    }
}

ループが2つできていて保守性が低下していますね。(この程度の処理ならこれでもいいと思うが)

処理を動的に変更することができる

例えばAliceさんには挨拶して、それ以外にはお帰り願いたい場合。

[Theory(DisplayName = "処理を動的に変えたい")]
[InlineData("Alice")]
[InlineData("Jone")]
public void DynamicMethodTest(string name)
{
    Func<string, string> func;
    if(name == "Alice")
    {
        func = name => $"こんにちは、{name}さん!";
    }
    else
    {
        func = name => $"さようなら、{name}さん!";
    }

    var result = func(name);
    _output.WriteLine(result);
}
// Aliceの場合
こんにちは、Aliceさん!
// Joneの場合
さようなら、Joneさん!

メソッドを抽象化しているので、柔軟に使用するメソッドを変えることができます。
デリゲートを使用していないとvar result = func(name);のような書き方をすることはできません。

Expression

上記のActionFuncを理解して初めてExpressionに入門できます。
Expressionを使うことで、コードをデータとして操作し、動的に処理を生成できます。
これにより、高度に抽象化された動的な処理を作成可能になります。
大抵のことはActionFuncで事足りるので後述のオーバーヘッド問題もあり、本当に必要になったとき以外では利用しないほうがいいと思います。

Expressionの使用例

なんでもできるので、いくらでも書きようはあるのですがa + bという関数をExpressionで組み立ててみます。

[Fact(DisplayName = "動的な式の構築")]
public void DynamicExpressionTest()
{
    ParameterExpression paramLeft = Expression.Parameter(typeof(int), "a");
    ParameterExpression paramRight = Expression.Parameter(typeof(int), "b");

    // a + b を表す式ツリーを作成
    BinaryExpression body = Expression.Add(paramLeft, paramRight);

    // 式ツリーをLambda式に変換
    Expression<Func<int, int, int>> addExpression = Expression.Lambda<Func<int, int, int>>(body, paramLeft, paramRight);

    // コンパイルしてデリゲートを作成
    Func<int, int, int> addFunc = addExpression.Compile();

    var result = addFunc(1, 2);
    _output.WriteLine($"1 + 2 = {result}");
}

Expressionで処理を作るときは

  1. 式ツリーを作成する
  2. Lamba式に変換する
  3. コンパイルしてFuncにする
    という1から処理を組み立てる流れが基本になります。

用途

動的に処理を作れる

例で出した加算のコードを無理やり四則演算に対応するようにExpressionで組み立ててみましょう。

[Theory(DisplayName = "四則演算")]
[InlineData("+")]
[InlineData("-")]
[InlineData("*")]
[InlineData("/")]
public void ExpressionNumTest(string operation)
{
    // 動的に式を構築
    Func<int, int, int> func = BuildExpression(operation);

    if (func != null)
    {
        int a = 10;
        int b = 5;
        int result = func(a, b);
        _output.WriteLine($"{a} {operation} {b} = {result}");
    }
    else
    {
        _output.WriteLine("無効な演算子が入力されました。");
    }
}

static Func<int, int, int> BuildExpression(string operation)
{
    // パラメーターの定義 (a, b)
    ParameterExpression paramA = Expression.Parameter(typeof(int), "a");
    ParameterExpression paramB = Expression.Parameter(typeof(int), "b");

    // 演算子に応じた式のボディを定義
    BinaryExpression body = null;

    switch (operation)
    {
        case "+":
            body = Expression.Add(paramA, paramB);
            break;
        case "-":
            body = Expression.Subtract(paramA, paramB);
            break;
        case "*":
            body = Expression.Multiply(paramA, paramB);
            break;
        case "/":
            body = Expression.Divide(paramA, paramB);
            break;
        default:
            return null;
    }

    // ラムダ式を構築
    var expression = Expression.Lambda<Func<int, int, int>>(body, paramA, paramB);

    // コンパイルしてデリゲートを生成
    return expression.Compile();
}

// "+"の場合
10 + 5 = 15
// "-"の場合
10 - 5 = 5
// "*"の場合
10 * 5 = 50
// "/"の場合
10 / 5 = 2

高度に抽象化できる分、非常に複雑な処理になります。
ここまで複雑になるのなら普通に使用するメソッドを変える処理にしたほうがいいですね。

式の中身を物理名として取り出せる

主にこちらがExpressionを利用を検討する要因かと思います。
私はエラーハンドリングでこちらが必要になりました。

Userという自作クラスがあったとします。

public class User
{
    public string Name { get; set; }
    public int? Age { get; set; }
}

UserNameもしくはAgeがnullのときは動的に物理名を取得して例外にする出力する処理を書きます。

public static void ValidateNotNull<T>(T obj, params Expression<Func<T, object>>[] expressions)
{
    var nullProperties = new List<string>();

    foreach (var expression in expressions)
    {
        var propertyName = GetPropertyName(expression);
        var compiledExpression = expression.Compile();
        var value = compiledExpression(obj);

        if (value == null)
        {
            // 個別のメッセージを表示
            Console.WriteLine($"{propertyName} は null であってはなりません。");
            nullProperties.Add(propertyName);
        }

        if (nullProperties.Count > 0)
        {
            throw new ArgumentNullException(string.Join(", ", nullProperties), "必須のプロパティが null です。");
        }
    }
}

// プロパティ名を取得するメソッド(前述の通り)
public static string GetPropertyName<T>(Expression<Func<T, object>> expression)
{
    MemberExpression memberExpression;

    if (expression.Body is UnaryExpression unaryExpression && unaryExpression.Operand is MemberExpression member)
    {
        memberExpression = member;
    }
    else if (expression.Body is MemberExpression memberExp)
    {
        memberExpression = memberExp;
    }
    else
    {
        throw new InvalidOperationException("無効な式です。");
    }

    return memberExpression.Member.Name;
}


User user = new User { Name = null, Age = 25 };

try
{
    ValidateNotNull(user, x => x.Name, x => x.Age);
}
catch (ArgumentNullException ex)
{
    _output.WriteLine(ex.Message);
}
Name は null であってはなりません。
必須のプロパティが null です。

ValidateNotNullに式を渡して中身をExpressionとして解析していくというコードです。
ここから応用することで色々なパターンに対応できるようになります。
リフレクションみたいな使い方ですね。

注意点

ExpressionはデリゲートにするためにCompile()する必要があります。
これは非常にコストが高いものになるので、無計画にExpressionを多用すると、コンパイル時にオーバーヘッドが発生し、パフォーマンスに深刻な影響を与える可能性があります。
以下に問題と対策をまとめているので、気になった方は読んでみてください。

まとめ

C#のデリゲート(ActionFunc) 、Expressionは、それぞれ異なる場面で柔軟な処理を実現するために使われます。

  1. デリゲート : メソッドを変数として扱うことができ、動的にメソッドを差し替えたり、リストに格納して連続で呼び出すなどの用途に適しています。
  2. Action,Func : デリゲートを簡潔に扱うための.NETで用意されている汎用的な形です。
    Actionは戻り値がないメソッド、Funcは戻り値があるメソッドを扱います。どちらもメソッドを動的に差し替えたり、連結して複数の処理をまとめて実行する際に便利です。
  3. Expression : 式ツリーを使って、コード自体をデータとして扱い、より柔軟で動的な処理を作成するのに使われます。たとえば、動的な式の生成や、リフレクションのようにプロパティ名を取得してエラーハンドリングに応用することも可能です。

適切な場面で使用することで、コードの再利用性、柔軟性、保守性を向上させることができます。
使いこなすことで、より強力で拡張性のあるC#プログラムを作成できるでしょう。

6
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
8