次の方法で共有


標準的な .NET イベント パターン

[前へ](events-overview.md)

一般的に、.NET イベントはいくつかの既知のパターンに従います。 これらのパターンを標準化することは、開発者がこれらの標準パターンの知識を適用できることを意味します。これは、任意の .NET イベント プログラムに適用できます。

標準のイベント ソースを作成し、コード内の標準イベントをサブスクライブして処理するために必要なすべての知識を得るために、これらの標準パターンを調べてみましょう。

イベント デリゲートのシグネチャ

.NET イベント デリゲートの標準的なシグネチャは、次のとおりです。

void EventRaised(object sender, EventArgs args);

この標準署名は、イベントが使用されるタイミングに関する分析情報を提供します。

  • 戻り値の型は void です。 イベントには、0 個以上のリスナーを登録できます。 イベントを発生させると、すべてのリスナーに通知されます。 一般に、リスナーはイベントに応答して値を提供しません。
  • イベントは送信者を示します。イベントシグネチャには、イベントを発生させたオブジェクトが含まれます。 これは、任意のリスナーに送信者と通信するメカニズムを提供します。 sender のコンパイル時型は System.Object です。常に正しいより確実な派生型が存在する可能性はありますが、 慣例に従って、`object` を使用します。
  • イベントは、1 つの構造で詳細情報をパッケージ化します。 args パラメーターは、必要な情報を含む System.EventArgs から派生した型です。 ( 次のセクション では、この規則が適用されなくなったことがわかります)。イベントの種類にこれ以上引数が必要ない場合でも、両方の引数を指定する必要があります。 特別な値EventArgs.Emptyがあり、イベントに追加情報が含まれていないことを示すために使用する必要があります。

これから、パターンに従うディレクトリ、またはそのサブディレクトリのいずれかでファイルを一覧表示するクラスをビルドしていきます。 このコンポーネントでは、パターンに一致することが確認されたファイルごとにイベントを発生させます。

イベント モデルを使用すると、設計上の利点が得られます。 要求されたファイルを検出すると、異なるアクションを実行する複数のイベント リスナーを作成できます。 別のリスナーと組み合わせることで、より堅牢なアルゴリズムを作成できます。

検索するファイルを検索するための最初のイベント引数宣言を次に示します。

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

この型はデータのみの小さな型のように見えますが、規則に従って、参照 (`class`) 型にする必要があります。 つまり、引数オブジェクトは参照渡しされ、データに対するすべての更新がすべてのサブスクライバーによって表示されます。 最初のバージョンは、変更不可のオブジェクトです。 イベント引数の型のプロパティを変更不可に設定しておいた方がよいでしょう。 そうすることで、あるサブスクライバーが値を変更してから別のサブスクライバーが値を確認することはできません。 (後で説明するように、この方法には例外があります)。

次に、FileSearcher クラスでイベント宣言を作成する必要があります。 System.EventHandler<TEventArgs>型を使用すると、別の型定義を作成する必要はありません。 汎用的な専門化を使用するだけです。

パターンに一致するファイルを検索し、一致が検出されると、適切なイベントを発生する FileSearcher クラスを記述します。

public class FileSearcher
{
    public event EventHandler<FileFoundArgs>? FileFound;

    public void Search(string directory, string searchPattern)
    {
        foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
        {
            FileFound?.Invoke(this, new FileFoundArgs(file));
        }
    }
}

フィールドに似たイベントを定義して発生させる

クラスにイベントを追加する最も簡単な方法は、上記の例のように、そのイベントをパブリック フィールドとして宣言することです。

public event EventHandler<FileFoundArgs>? FileFound;

これはパブリック フィールドを宣言しているように見えるため、不適切なオブジェクト指向プラクティスと考えられるかもしれません。 プロパティ、またはメソッドでデータ アクセスを保護したくなるところです。 このコードは不適切な方法に見えるかもしれませんが、コンパイラによって生成されたコードではラッパーが作成されるため、イベント オブジェクトには安全な方法でのみアクセスできます。 フィールドに似たイベントで使用できる操作は、 ハンドラーの追加削除 のみです。

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;

ハンドラーのローカル変数があります。 ラムダの本体を使用した場合、 remove ハンドラーは正しく動作しません。 ラムダ本体はデリゲートの別のインスタンスであるため、自動的に何か実行することはありません。

クラス外のコードでは、イベントを発生させることはなく、他の操作を実行することもできません。

C# 14 以降では、イベントを 部分メンバーとして宣言できます。 部分イベント宣言には、 定義宣言実装宣言を含める必要があります。 定義宣言では、フィールドに似たイベント構文を使用する必要があります。 実装宣言では、 add ハンドラーと remove ハンドラーを宣言する必要があります。

イベント サブスクライバーから値を返す

この単純なバージョンは正常に動作しています。 次に、別の機能、キャンセルを追加します。

Found イベントを発生させると、リスナーは、このファイルが最後に求められた場合、それ以降の処理を停止できる必要があります。

イベント ハンドラーは値を返さないため、別の方法で通信する必要があります。 標準的なイベント パターンでは、イベント サブスクライバーがキャンセルを伝達するために使用するフィールドを含めるために、EventArgs オブジェクトを使用します。

使用できるパターンには 2 種類あり、それらは Cancel コントラクトのセマンティクスに基づいています。 どちらの場合も、見つかったファイル イベントの EventArguments にブール型フィールドを追加します。

1 つのパターンでは、任意のサブスクライバーが単独で操作をキャンセルできます。 このパターンでは、新しいフィールドは `false` に初期化されます。 どのサブスクライバーでもこのフィールドを `true` に変更できます。 すべてのサブスクライバーに対してイベントを発生した後、FileSearcher コンポーネントはブール値を調べてアクションを実行します。

2 つ目のパターンでは、すべてのサブスクライバーが操作のキャンセルを認める場合に限り、操作がキャンセルされます。 このパターンでは、新しいフィールドは操作のキャンセルを示すように初期化され、任意のサブスクライバーが操作を続行するように変更できます。 発生したイベントをすべてのサブスクライバーが処理した後、FileSearcher コンポーネントはブール値を調べてアクションを実行します。 このパターンには、追加の手順が 1 つあります。コンポーネントは、サブスクライバーがイベントに応答したかどうかを認識する必要があります。 サブスクライバーが存在しない場合、フィールドが示すキャンセルは誤りとなります。

次に、このサンプルの最初のバージョンを実装します。 `FileFoundArgs` 型に `CancelRequested` という名前のブール型フィールドを追加する必要があります。

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

この新しいフィールドは、誤って取り消さないように、 false に自動的に初期化されます。 コンポーネントに対する他の唯一の変更は、イベントを発生させた後にフラグを確認して、サブスクライバーのいずれかが取り消しを要求したかどうかを確認することです。

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

このパターンの利点の 1 つは、この変更が重大な変更ではないことです。 前に取り消しを要求したサブスクライバーはいませんが、まだ取り消されていません。 新しいキャンセル プロトコルをサポートする必要がない限り、サブスクライバー コードの更新は必要ありません。

次に、最初の実行可能ファイルが検索されると、キャンセルを要求するように、サブスクライバーを更新します。

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

別のイベント宣言の追加

ここで、1 つの機能を追加し、イベント用の他の慣用句について説明します。 すべてのサブディレクトリを走査してファイルを検索する `Search` メソッドのオーバーロードを追加します。

このメソッドは、多くのサブディレクトリを含むディレクトリで長い操作になる可能性があります。 次に、新しいディレクトリの検索が開始するたびに発生するイベントを追加します。 このイベントにより、サブスクライバーは進行状況を追跡し、進行状況に応じてユーザーを更新できます。 これまでに作成したすべてのサンプルはパブリックです。 このイベントを内部イベントにしましょう。 つまり、引数の型を内部にすることもできます。

まず、新しいディレクトリと進行状況を報告するための新しい EventArgs 派生クラスを作成します。

internal class SearchDirectoryArgs : EventArgs
{
    internal string CurrentSearchDirectory { get; }
    internal int TotalDirs { get; }
    internal int CompletedDirs { get; }

    internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
    {
        CurrentSearchDirectory = dir;
        TotalDirs = totalDirs;
        CompletedDirs = completedDirs;
    }
}

ここでも、イベント引数に変更不可の参照型を使用することをお勧めします。

次に、イベントを定義します。 今回は、別の構文を使用します。 フィールド構文を使用するだけでなく、ハンドラーの追加と削除を使用してイベント プロパティを明示的に作成することもできます。 このサンプルでは、これらのハンドラーに追加のコードは必要ありませんが、これはそれらを作成する方法を示しています。

internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
    add { _directoryChanged += value; }
    remove { _directoryChanged -= value; }
}
private EventHandler<SearchDirectoryArgs>? _directoryChanged;

ここで記述するコードは、多くの点で、前に見たフィールド イベント定義に対してコンパイラが生成するコードを反映しています。 イベントは、プロパティのような構文を使用して作成します。 このハンドラーには、`add` および `remove` という別の名前があることに注意してください。 これらのアクセサーは、イベントをサブスクライブしたり、イベントのサブスクライブを解除したりするために呼び出されます。 また、イベント変数を格納するために、プライベートなバッキング フィールドを宣言する必要があることにも注意してください。 この変数は null に初期化されます。

次に、サブディレクトリを走査して両方のイベントを発生させる `Search` メソッドのオーバー ロードを追加します。 最も簡単な方法は、既定の引数を使用して、すべてのディレクトリを検索するように指定する方法です。

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            _directoryChanged?.Invoke(this, new (dir, totalDirs, completedDirs++));
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        _directoryChanged?.Invoke(this, new (directory, totalDirs, completedDirs++));
        SearchDirectory(directory, searchPattern);
    }
    else
    {
        SearchDirectory(directory, searchPattern);
    }
}

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

この時点で、すべてのサブディレクトリを検索するためにオーバーロードを呼び出すアプリケーションを実行できます。 新しい DirectoryChanged イベントにサブスクライバーはいませんが、 ?.Invoke() イディオムを使用すると正しく動作します。

ここで、コンソール ウィンドウに進行状況を表示する行を記述するハンドラーを追加します。

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

.NET エコシステム全体で従っているパターンを確認しました。 これらのパターンと規則を学習することで、慣用的な C# と .NET をすばやく記述できます。

こちらもご覧ください

次に、.NET の最新リリースでこれらのパターンにいくつかの変更が加わります。

[次へ](modern-events.md)