C# で async/await する:N秒後に発火するイベント
こんにちは、@studio_meowtoon です。今回は、WSL の Ubuntu 環境の C# から async/await を用いてイベント処理を行う方法を紹介します。
はじめに
皆さんはN秒後に爆発する爆弾のプログラムと聞いてどのような実装を想像しますか?この記事では爆弾プログラムの実装を進めていく過程から、非同期処理・イベント処理について確認していこうと思います。
実装したいこと
爆弾の導火線に火を付けたらN秒後に爆発する処理を実装します。
実際に爆弾を作るわけには行かないので以下の仕様に置き換えてみます😋
C# には本来厳格なコーディング規則がありますが、この記事では可読性のために、一部規則に沿わない表記方法を使用しています。ご注意ください。
開発環境
- Windows 11 Home 22H2 を使用しています。
WSL の Ubuntu を操作していきますので macOS の方も参考にして頂けます。
WSL (Microsoft Store アプリ版) ※ こちらの関連記事からインストール方法をご確認いただけます
> wsl --version
WSL バージョン: 1.0.3.0
カーネル バージョン: 5.15.79.1
WSLg バージョン: 1.0.47
Ubuntu ※ こちらの関連記事からインストール方法をご確認いただけます
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 22.04.1 LTS
Release: 22.04
.NET SDK ※ こちらの関連記事からインストール方法をご確認いただけます
$ dotnet --list-sdks
7.0.202 [/usr/share/dotnet/sdk]
$ dotnet --version
7.0.202
この記事では基本的に Ubuntu のターミナルで操作を行います。Vim を使用してコピペする方法を初めて学ぶ人のために、以下の記事で手順を紹介しています。ぜひ挑戦してみてください。
Bomb クラスの仕様
メソッド
no | name | accessibility | 役割 | 動作 |
---|---|---|---|---|
1 | Fire | public | 導火線に着火する | 爆弾起動 |
2 | explode | private | 爆発する | 標準出力にテキストを出力 |
プロパティ
no | name | accessibility | 役割 | 種類 |
---|---|---|---|---|
1 | Interval | public | 爆発までの間隔 | 数値 |
explode メソッドのアクセス修飾子が private なのがポイントです。爆発するのは Bomb オブジェクトに実行する責任があると思います。
実装のアウトライン
続いて実装のアウトラインを考えてみましょう。
Bomb クラスのアウトライン
public class Bombv1 {
float _interval;
public void Fire() {
// TODO:
}
public float Interval {
set => _interval = value;
}
void explode() {
// TODO:
}
}
Bomb オブジェクトを実行するコード
class Program {
static void Main(string[] args) {
Bombv1 bomb = new() { Interval = 5.0f };
bomb.Fire();
ReadLine(); // stop the console.
}
}
このように処理の中身はありませんがコードのインタフェース、アウトラインから実装することはよくあります。※ 今回の記事では単体テストには言及いたしません🙇♂️
初期実装
それでは早速処理の中身を実装していきましょう!
まずシンプルにに実装してみました。
public class Bombv1 {
float _interval;
public void Fire() {
WriteLine(value: "fire!");
explode();
}
public float Interval {
set => _interval = value;
}
void explode() {
Sleep((int) _interval * 1000);
WriteLine(value: "a bomb has exploded.");
}
}
コマンドラインから実行してみます。
$ cd go_to_this_app_dir
$ dotnet run v1
出力結果
fire!
a bomb has exploded.
爆弾プログラムを起動して 5秒後に爆発したテキストが表示されました。とても原始的な処理ですが爆弾っぽい?動作をしました😅
最初の重大な問題点
皆さんお気づきかも知れませんが、このプログラムには重大な問題点があります😭
それは Bomb オブジェクトの Fire メソッドを呼び出したら、爆発するまで制御が返ってこないことです。このような実装を同期処理と呼びます。同期処理自体が悪いということはないですが、爆弾の実装という観点からは不適切だと思われます。
導火線に火を付けたら全世界がフリーズする状況を考えてみて下さい😱
非同期処理
ここでは現状の問題点の解決策として、実装に非同期処理を導入します。
非同期処理には async/await を使用します。
public class Bombv2 {
float _interval;
public async void Fire() {
WriteLine(value: "fire!");
await explode();
}
public float Interval {
set => _interval = value;
}
async Task explode() {
await Delay((int) _interval * 1000);
WriteLine(value: "a bomb has exploded.");
}
}
内容 |
---|
N秒待つ処理を System.Threading.Thread.Sleep() から System.Threading.Tasks.Task.Delay() に変更しています。 |
これは Sleep() が同期処理のメソッドだからです、一方 Delay() は非同期処理のメソッドです。 |
このように同じような動作をするメソッドで同期処理版、非同期処理版が存在するケースがあります。 |
呼び出し元の実装です。
static void run_v2() {
Bombv2 bomb = new() { Interval = 5.0f };
bomb.Fire();
WriteLine(value: "waiting...");
ReadLine();
}
非同期版では Fire メソッドが非同期処理なので、後続の WriteLine メソッドが呼ばれるはずです。
コマンドラインから実行します。
$ cd go_to_this_app_dir
$ dotnet run v2
出力結果
fire!
waiting...
a bomb has exploded.
想定通りに爆弾が爆発する前に waiting... という文字列が表示されました😋
ここまでのまとめ
- 同期処理と非同期処理の違いが分かりました。
- 非同期処理には async / await 構文を使用します。
次の重大な問題点
まだこのプログラムには重大な問題点があります。それは爆弾の出力処理を Bomb クラスに直書きしていることです、この実装だと再利用しにくいと思います😭
それでは Bomb クラスを再利用が可能な実装にする方法はないでしょうか?
まずシンプルにクラスを継承してサブクラスを作るという方法を考えてみます。
基底クラスを継承
あるクラスを継承したサブクラスを実装することは、オブジェクト指向プログラミングの基本的な考え方の一つです。
基底クラスを作成します。
public abstract class BombBase {
float _interval;
public async void Fire() {
onFire();
await explode();
}
public float Interval {
set => _interval = value;
}
protected virtual void onFire() {
}
protected virtual void onExplode() {
}
async Task explode() {
await Delay((int) _interval * 1000);
onExplode();
}
}
継承クラスを作成します。
public class Bombv3 : BombBase {
protected override void onFire() {
WriteLine(value: "fire!");
}
protected override void onExplode() {
WriteLine(value: "a bomb has exploded.");
}
}
基底クラスの抽象メソッド onFire()、onExplode() メソッドを継承クラスで実装しています。このような実装は GOF の TemplateMethod パターンにカテゴライズされます。継承クラスで基底クラスのフックメソッドを実装するパターンです😋
呼び出し元の実装です。
static void run_v3() {
Bombv3 bomb = new() { Interval = 5.0f };
bomb.Fire();
WriteLine(value: "waiting...");
ReadLine();
}
コマンドラインから実行します。
$ cd go_to_this_app_dir
$ dotnet run v3
出力結果
fire!
waiting...
a bomb has exploded.
このように、 N秒後に爆発するという Bomb クラスの普遍的動作は基底クラスで実装し、発火・爆発といった動作のタイミングで標準出力に文字列を出力するといった、その状況で必要とされる動作を継承クラスに切り出すことが出来ました😋
内容 |
---|
上記の例はとてもオブジェクト指向的だと思います。 |
実際に様々なフレームワークでこのような思想で実装されているの見かけます。 |
Bomb クラスを例に挙げれば、発火・爆発といった動作のタイミングで別の処理を行う継承クラスをいくらでも作れます。 |
ここまでのまとめ
基底クラスと継承クラスを作成することにより拡張性の高い実装になりました。
次の問題点
でも継承クラスを書くのってめんどくさくないですか?😭
基底クラスとその継承クラスを使う実装方法自体に問題はありません、時と場合によりとても有用です。しかしこのN秒後に爆発する爆弾といった例題では、別の方法でも拡張性が高い実装を行うことが出来ます。
イベントで実装
C# ではイベントを使用します。
※他の言語でも似たような実装が可能かも知れません。
イベントを持つクラスを実装します。
public class Bombv4 {
float _interval;
public event Action? OnFire;
public event Action? OnExplode;
public async void Fire() {
OnFire?.Invoke();
await explode();
}
public float Interval {
set => _interval = value;
}
async Task explode() {
await Delay((int) _interval * 1000);
OnExplode?.Invoke();
}
}
Action デリゲート型の OnFire、OnExplode イベントを定義しています。
呼び出し元の実装です。
static void run_v4() {
Bombv4 bomb = new() { Interval = 5.0f };
bomb.OnFire += () => { WriteLine(value: "fire!"); };
bomb.OnExplode += () => { WriteLine(value: "a bomb has exploded."); };
bomb.Fire();
WriteLine(value: "waiting...");
ReadLine();
}
イベントに直接ラムダ式で Action デリゲート型の処理を設定しています。
コマンドラインから実行します。
$ cd go_to_this_app_dir
$ dotnet run v4
出力結果
fire!
waiting...
a bomb has exploded.
ここまでのまとめ
C# のイベントを使うことで拡張性の高い実装をシンプルに記述することが出来ました。
※他の言語でも似たような実装が可能かも知れません。
まとめ
- 同期処理と非同期処理の違いと、またなぜ非同期処理が必要になるか理解することが出来ました。
- (C#の)イベントを使用することにより拡張性の高い実装をオブジェクト指向的手法とは別の方法で行うことが出来ました。
- ※C言語の頃から関数ポインタで渡すという手法はありましたが、それらはオブジェクト指向的とは認識されてなかったと思います。
- N秒後に爆発する爆弾を実装するという例題から様々な知見を得ることが出来ました。
どうでしたか? Window 11 の WSL Ubuntu に、.NET の開発環境を手軽に構築することができます。ぜひお試しください。今後も .NET の開発環境などを紹介していきますので、ぜひお楽しみにしてください。