なぜ、ラムダ式と非同期プログラミングの理解が重要か?
近年のC#開発では、「ラムダ式」と「非同期プログラミング(async/await)」という2つの言葉に出会うことが多くなりました。初心者の方にはいかにも難しそうな用語ですが、実用性が高く、開発において重要な役割を果たすからこそ、皆さんが良く目にすることになるのでしょう。この章では、ラムダ式と非同期処理を解説します。
ラムダ式とは、その場限りで使うだけの名前の無い小さなメソッドを簡潔に作る書き方です。
非同期処理とは、時間がかかる処理の完了を待たずに並行して次の処理を進めることです。
ラムダ式と非同期処理は相性が良く、同時に利用されることも多いので、この章でまとめてご紹介することにしました。はじめは難しく感じるかもしれませんが、どちらも昨今のC#アプリ開発で避けては通れない重要な技術です。しっかりと理解して、より実践的なスキルを身に付けましょう。
1.単純なラムダ式を動作させてみる
ラムダ式は、名前を持たない小さなメソッド(匿名メソッドと呼びます)を、普通にメソッドを定義するよりも簡略化した書式で記述するための構文です。コレクションの操作(特定の要素を取り出すWhereメソッドや要素を変換するSelectメソッド)や、非同期処理など、さまざまな場面で活用されています。何はともあれ、どう書くのかを見てみましょう。
■ 書式
(引数リスト) => 式またはステートメントブロック
これがラムダ式の書式です。次に実際の例を挙げます。以下は、2つの引数を持ち、その合計を返すラムダ式で書いた匿名メソッドです。
(x, y) => x + y
書き方自体はずいぶん単純ですが、簡略化された記述が抽象的過ぎて意味がわかりにくいですよね。これをもう少し普通のメソッドに近づけるとこんな書き方になります。
(int x, int y) => { return x + y }
どうでしょう。これなら意味がわかりますね。=> の左に引数、右に処理を書く、ということです。最初の例には引数や戻り値の型すら書いてありませんが、これでもちゃんと機能するのです。これは、C#が持つ型推論と呼ばれる型推測機能のおかげです。実際に動作させてみましょう。
using System;
namespace Chap15;
internal class Example01 {
public static void Run() {
// ラムダ式で書いた匿名メソッドをデリゲート型変数に代入
Func<int, int, int> func = (x, y) => (x + y);
// 匿名メソッドの呼び出し結果を表示
Console.WriteLine(func(1, 3));
}
}
デリゲート型という見慣れない用語が出てきましたが、これはこの後すぐ解説します。まずは11行目のfuncの引数を色々と変えてみて、ちゃんと合計を計算していることを確かめてみましょう。
2.デリゲート型とは
デリゲート型は、メソッドを代入できるデータ型です。「はぁ?」と思った方もいらっしゃるかもしれません。でも、よく考えてみましょう。これまでもC#の変数にはインスタンスなどのオブジェクトの参照(つまりメモリ上の位置)を代入して使えています。オブジェクトが代入できるのなら、メソッドが代入できても(つまりメソッドへの参照が代入できても)なんら不思議はないのです。
C#ではインスタンスメソッドもスタティックメソッドもメソッド領域と呼ばれる専用の場所に一つだけ実体が作られ、全アプリケーションで共有されて動作します。前の例ではラムダ式で作った匿名メソッドへの参照がfuncというデリゲート型変数に代入され、動作した、ということです。
ラムダ式で作った匿名メソッドは、書いただけでは動作しません。メソッドの引数に記述するか、デリゲート型の変数に代入して明示的に実行しなければ、定義だけされてどこからも呼び出されない普通のメソッドと同じです。前の例で、匿名メソッドをデリゲート型変数に代入したのは、そのためです。ラムダ式とデリゲート型変数は切っても切れぬ仲なので、ここで取り上げることにしました。
さて、メソッドが代入できる変数があるのならば、変数に代入するメソッドを変えることで、その場に応じた処理が実行できそうですね。早速実践してみましょう。
using System;
namespace Chap15;
internal class Example02 {
public static void Run() {
// 実行するメソッド切り替え(0 - 3)
int funcNum = 0;
Func<int, int, int> func = null;
switch (funcNum) {
case 0:
// 加算
func = Add;
break;
case 1:
// 減算
func = Sub;
break;
case 2:
// 乗算(ラムダ式)
func = (x, y) => (x * y); ;
break;
case 3:
default:
// 除算(ラムダ式)
func = (x, y) => (x / y); ;
break;
}
// 演算結果を表示
Console.WriteLine(func(10, 2));
}
// 加算
private static int Add(int x, int y) {
return x + y;
}
// 減算
private static int Sub(int x, int y) {
return x - y;
}
}
このサンプルでは、加算と減算のメソッドを普通に定義し、乗算と除算のメソッドはラムダ式で定義してあります。8行目のfuncNumの値を変えて、それぞれのメソッドを選んで実行できることを確かめてみましょう。また、このような使い方をすれば処理の柔軟性が上ることも覚えておきましょう。次は、主なデリゲート型変数の種類と書式について解説します。
主な定義済みデリゲート型
C#の歴史においてデリゲート型は古くから用いられており、様々な種類が存在します。ただ、現在はジェネリック型と呼ばれるタイプのものを使えば殆どの場合が事足りるので、ここではC#で定義済みの主な二種類だけをご紹介します。
- Action - 戻り値が無い(void)のメソッドを代入するデリゲート型
- Func - 戻り値があるメソッドを代入するデリゲート型
Actionの書式と利用例
Action<引数のジェネリック>
Actionは、戻り値が無い(つまりvoid型の)メソッドを代入するデリゲート型です。ジェネリックの<>の中には、順番に引数の型を、引数の数分だけ記述します。以下は、int型とstring型の引数を一つずつ取るActionの記述例です。
Action<int, string>
それでは実際の例を見てみましょう。
using System;
namespace Chap15;
internal class Example03 {
public static void Run() {
// あなたの名前
string yourName = "山田太郎";
// 実行するメソッド切り替え(0 - 2)
int funcNum = 0;
Action<string> action = null;
switch (funcNum) {
case 0:
// 朝の挨拶
action = (name) => Console.WriteLine("おはよう、" + name + "さん");
break;
case 1:
// 昼の挨拶
action = (name) => Console.WriteLine("こんにちは、" + name + "さん");
break;
case 2:
default:
// 夜の挨拶
action = (name) => Console.WriteLine("こんばんは、" + name + "さん");
break;
}
// 挨拶実行
action(yourName);
// 引数無しのAction
Action bye = () => Console.WriteLine("おつかれさまでした");
bye();
}
}
8行目のyourNameにあなたの名前を入れ、11行目のfuncNumを切り替えて、挨拶を表示してみて下さい。35行目にあるように、引数が無い場合はActionのジェネリックは省略できます。また、ラムダ式の()の中も記述無しで動作します。
Funcの書式と利用例
Func<in 引数のジェネリック, out 戻り値のジェネリック>
Funcは戻り値のあるメソッドを代入するデリゲート型です。ジェネリックの<>の中には、順番に引数と戻り値の型を、引数には in を、戻り値には out を付けて記述します。下の例は、二つのint型の引数を取り、戻り値がstring型のFuncの例です。
Func<in int, in int, out string>
Example02のように、inとoutも省略できます。以下の例は2つのint型の引数を取り、int型の戻り値を持つFuncの書式です。
Func<int, int, int>
では、サンプルコードを実行してみましょう。
using System;
namespace Chap15;
internal class Example04 {
// お客の台詞
private static string[] happyMansLines = [
"やぁこんばんは!",
"いつものバーボンね。",
"いやぁデカい契約とれちゃってさぁ。もう一杯!",
"今日みたいな日に飲まないでいつ飲むんだよぉ…もう一杯だけ!",
"わかったよぉ…じゃあ、お勘定!"
];
// 女性バーテンの台詞
private static string[] barmaidsLines = [
"あらいらっしゃい。なんにする?",
"いい飲みっぷりねぇ。",
"ちょっと、だいぶ飲んでるわよ。大丈夫?",
"ねぇ飲み過ぎよ。その辺にしといたら?",
"フラフラじゃないの。お勘定は今度でいいから気をつけてね。"
];
// ターン最大値の設定(一台詞毎にturnが加算されるので両方の台詞数の合計となる)
private static int MAX_TURN = happyMansLines.Length + barmaidsLines.Length;
public static void Run() {
// 客とバーテンのメソッドを持つFunc型変数を生成
Func<int, string> happyMan = (n) => ("幸せな男:" + happyMansLines[n]);
Func<int, string> barmaid = (n) => ("バーテン:" + barmaidsLines[n]);
// 会話の進行カウンタ
int turn = 0;
// 全ての台詞を出力するまでループ
do {
// Enterキー受付
Console.ReadLine();
// ターンによってメソッドを切り替える
Func<int, string>who = 0 == turn % 2 ? happyMan : barmaid;
// 台詞を表示
Console.WriteLine(who(turn / 2));
} while (++turn < MAX_TURN);
}
}
Enterキーを押す毎に、次々と客とバーテンの台詞が表示され、客が帰るまでループします。このように、Func型を使えば同じ変数が持つメソッドを動的に切り替えて実行することも可能になります。いかにも色々な場面で活躍しそうな技術だとは思いませんか?皆さんも、台詞を変えたり増やしたりしてデリゲート型の力を実感してみて下さい。
3.ラムダ式によるコレクションの操作
非同期処理の解説に移る前に、ラムダ式がよく利用されるコレクションの操作について解説しておきます。
ランダムな並べ替え ~シャッフル~
実は、オブジェクト指向の三大要素③ 多態性のサンプルにも、こっそりラムダ式が使われていたことに気付いた方もいらっしゃると思います。こんな利用例でしたね。
// シャッフルしたキャストのリストを返す
// 元のリストの順番を変えずに新たにランダムに並び順を変えたリストを生成する
public static List<Cast> GetShuffledCasts() {
return Casts.OrderBy(a => Guid.NewGuid()).ToList();
}
このメソッドは、キャストのリスト内をシャッフル(ランダムな順序に並べ替え)した新しいリストを生成して返しています。問題のラムダ式は39行目に書いてあるのですが、コードを見ただけでは何をやっているのかが伝わりにくいですよね。
ListのOrderByメソッドは、LINQという、フィルタ処理(コレクションの要素を条件で選別する)やマッピング処理(コレクションの要素を詰め替える)が得意な統合言語クエリというものによって拡張されたメソッドの一種です。
もうちょっと平たく言えば、Listのような多くのデータを持つオブジェクトの各要素を、色々な条件で抽出したり並び替えたり、各要素に名前を付けて参照しやすくしたりできる便利な機能群だと思って下されば間違いではありません。
OrderByメソッドの本来の機能は、与えられた値によってリストの中を昇順に並べ替えることです。ではなぜ上のコードでランダムな順序にシャッフルできるのでしょうか。機能を考えればOrderByに渡しているキーに秘密があるはずです。問題のキーは、Guid.NewGuid()というメソッドが返した値になっています。
Guid.NewGuid()を検索すると、グローバリー・ユニーク・アイデンティファイア(GUID)を生成するためのメソッドだという記事が出てきます。また、GUIDは128ビットの長さを持ち、世界中で一意であることが保証されている、という記事もありますね。
毎回世界中で一意になる128ビットの値が返り、その値をキーにしてリスト内を昇順に並び替えたらどうなるでしょうか。キーが毎回重複しないランダムな値になるので、並び替えた結果もシャッフルしたことと同じになる、というわけです。
なお、OrderByはリストが持つ要素全体に対して処理を行うことが前提のメソッドなので、特に繰り返し構文を書かずとも、上記のコードを一行書くだけで全部の要素を並び替えてくれます。そして、最後の.ToList()のメソッドチェーンで、元のListを壊さずに新たなListを生成して戻り値にしている、というのがこのメソッドの全容です。ランダムな並べ替えの例は、既に多態性の章でご紹介済みですので、次はフィルタリングの例を解説していきます。
コレクションのフィルタリング ~条件による要素の抽出~
次は、Listの中からある条件に合った要素だけを抽出したListを生成する方法をご紹介します。
list.Where(s => 抽出条件)
上記の書式のように、Where()にラムダ式を記述することで、条件に合った要素だけをリストから取り出すことができます。それでは、実例を見てみましょう。
using System;
namespace Chap15 {
internal class Example05 {
public static void Run() {
// 友達リスト
List<Friend> friends = new List<Friend>();
friends.Add(new Friend("井本喜代", 28, Genders.Female));
friends.Add(new Friend("磯崎信長", 20, Genders.Male));
friends.Add(new Friend("大坪留美子", 24, Genders.Female));
friends.Add(new Friend("下山賢二", 22, Genders.Male));
friends.Add(new Friend("諸星菜緒", 21, Genders.Neither));
friends.Add(new Friend("西尾真央", 23, Genders.Female));
friends.Add(new Friend("深谷俊夫", 32, Genders.Male));
friends.Add(new Friend("大平峻輝", 24, Genders.Male));
friends.Add(new Friend("稲田忠夫", 26, Genders.Male));
friends.Add(new Friend("富樫朝子", 27, Genders.Female));
friends.Add(new Friend("宮城達行", 29, Genders.Male));
friends.Add(new Friend("尾形昌宏", 33, Genders.Male));
friends.Add(new Friend("梶原一翔", 20, Genders.Neither));
friends.Add(new Friend("伊東利子", 21, Genders.Female));
// 年齢でフィルタリング
Console.WriteLine("年齢25以上でフィルタリング");
List<Friend> filteredByAge = friends.Where(s => s.Age >= 25).ToList();
foreach(Friend friend in filteredByAge) {
Console.WriteLine(friend);
}
// 改行を入れる
Console.WriteLine();
// 性別女性でフィルタリングして年齢の昇順にソート
Console.WriteLine("性別女性でフィルタリングして年齢の昇順にソート");
List<Friend> filteredByAgeAndGender =
friends.Where(s => s.Gender == Genders.Female).OrderBy(s => s.Age).ToList();
foreach (Friend friend in filteredByAgeAndGender) {
Console.WriteLine(friend);
}
}
}
// 性別の列挙
internal enum Genders { Male, Female, Neither }
// 友達クラス
internal class Friend {
// 名前
public string Name { get; set; }
// 年齢
public int Age { get; set; }
// 性別
public Genders Gender { get; set; }
// コンストラクタ
public Friend(string name, int age, Genders gender) {
Name = name;
Age = age;
Gender = gender;
}
// ToStringオーバーライド
public override string ToString() {
string g = this.Gender == Genders.Male ? "男性" :
(this.Gender == Genders.Female ? "女性" : "どちらでもない" );
return $"{Name}: {Age}才: 性別:{g}";
}
}
}
実行して、コメント通りにリストがフィルタリングされたか確認してみましょう。友達リストを一回目は年齢で、二回目は性別でフィルタリングしています。二回目は、フィルタリング結果をさらに年齢の昇順に並べ替えています。このように、WhereやOrderByを利用すると、メソッドチェーンでつなげて複数の処理結果を持つリストを作成できます。皆さんも、条件を変えていろいろと試してみてください。
コレクションのマッピング ~要素を別のコレクションに詰め替える~
csvファイルやデータベースなどから読みだしたデータを、プロパティを変更したり増やしたりした別のコレクションに詰め替えたいという局面が実装では結構あります。これを、コレクションのマッピングと言います。マッピングにはAutoMapperというツールを使うことも多いのですが、マッピングの基本を学ぶには、LINQのSelectメソッドの実例を見るのが良いでしょう。以下のサンプルを実行してみてください。
using System;
using Chap15;
namespace Chap15 {
internal class Example06 {
public static void Run() {
// 友達リスト
List<Friend> friends = new List<Friend>();
friends.Add(new Friend("井本喜代", 28, Genders.Female));
friends.Add(new Friend("磯崎信長", 20, Genders.Male));
friends.Add(new Friend("大坪留美子", 24, Genders.Female));
friends.Add(new Friend("下山賢二", 22, Genders.Male));
friends.Add(new Friend("諸星菜緒", 21, Genders.Neither));
friends.Add(new Friend("西尾真央", 23, Genders.Female));
friends.Add(new Friend("深谷俊夫", 32, Genders.Male));
friends.Add(new Friend("大平峻輝", 24, Genders.Male));
friends.Add(new Friend("稲田忠夫", 26, Genders.Male));
friends.Add(new Friend("富樫朝子", 27, Genders.Female));
friends.Add(new Friend("宮城達行", 29, Genders.Male));
friends.Add(new Friend("尾形昌宏", 33, Genders.Male));
friends.Add(new Friend("梶原一翔", 20, Genders.Neither));
friends.Add(new Friend("伊東利子", 21, Genders.Female));
// FriendにGUIDを付与してFriend2にマッピング
List<Friend2> friends2 = friends.Select(x => new Friend2() {
Id = Guid.NewGuid(),
Name = x.Name,
Age = x.Age,
Gender = x.Gender,
}).ToList();
Console.WriteLine("FriendにGUIDを付与してFriend2にマッピング");
foreach(Friend2 friend in friends2) {
Console.WriteLine(friend);
}
}
}
// GUIDプロパティを増やした友達クラス
internal class Friend2 {
public Guid Id { get; set; }
// 名前
public string Name { get; set; }
// 年齢
public int Age { get; set; }
// 性別
public Genders Gender { get; set; }
// ToStringオーバーライド
public override string ToString() {
string g = this.Gender == Genders.Male ? "男性" :
(this.Gender == Genders.Female ? "女性" : "どちらでもない" );
return $"GUID:{Id} {Name}: {Age}才: 性別:{g}";
}
}
}
この例では、フィルタリングの例で使ったFriendのインスタンスを、GUIDをプロパティに追加したFriend2のインスタンスに詰め替えて、新たなListを生成しています。29行目からの処理に注目してください。これは今まで使っていたラムダ式に比べると、ブロックもありますし長い記述になっていますね。コードの行数が多いだけで、中の処理で何をやっているかはわかりやすいと思います。SelectでFriendのリストから要素を取り出し、そのプロパティとNewGuid()の戻り値を利用して、newで生成したFriend2のインスタンスにプロパティ値を設定しているわけです。
// FriendにGUIDを付与してFriend2にマッピング
List<Friend2> friends2 = friends.Select(x => new Friend2() {
Id = Guid.NewGuid(),
Name = x.Name,
Age = x.Age,
Gender = x.Gender,
}).ToList();
これがマッピングの基本操作です。このやり方がわかれば、ListからDictionaryを作ったり、その逆を行う処理も簡単に書けるでしょう。コレクションのソート、フィルタリング、マッピングをマスターすれば、あなたのコーディングの幅がグンと広がることは受合います。しっかりと理解して、スキルアップを目指しましょう。次はいよいよ非同期処理を学びます。
4.async / await による非同期プログラミング
非同期処理とは
非同期処理を理解するために、まずは反対語となる同期処理について解説しましょう。
Webアクセスやファイル読み込みなど、ある程度時間がかかる処理を実行すると、Windowsでは処理中を表すぐるぐる回るアイコンが表示されて何もできない状態になることがありますよね。時間がかかる処理が終了してアイコンが消えると次の操作が可能になります。これが同期処理です。同期処理とは、現在実行中の全ての処理が完了するまで待って次の処理を実行することです。前に実行された処理の終了に「同期」して次の処理が始まるので、同期処理と呼ぶのです。
ちなみに、これまでこの講義に登場してきた全てのサンプルは、同期処理のプログラムです。どのサンプルも、前の処理が終わってから次の処理が実行されるように書いてあるからです。
これを踏まえれば、同期処理の反対語である非同期処理とは、前の処理の終了を待たずに処理を実行することなんだろうな、と推測できます。そうです、それで正解です。ですから、非同期処理を使えば、アイコンがぐるぐる回っている間に他のアプリを開けたり操作したりと、別の処理を並行して進めることができるのです。シチューを煮込む作業をコンロに任せれば、皆さんはシチューを煮込んでいる間にテーブルや食器の準備ができますよね。
非同期処理が活躍する場面
非同期処理が活躍する場面は、その原因によって大きく二つに分かれます。また、原因によって対処法も変わるのです。
- Webアクセスやファイル入出力、プリンタ印字やデータベース通信など 、データの入出力に時間がかかる状態
- 大量のデータ処理や非常に複雑な計算を繰り返す処理など、CPUに大きな負担が生じて時間がかかる状態
1は、入出力(Input/Output)の頭文字を取ってI/Oバウンド と呼ばれます。2は文字通りCPUバウンドと呼ばれます。皆さんが対応する非同期処理は、1のI/Oバウンドの場合が殆どです。
CPUバウンドに対応する非同期処理はデッドロック(※1)やメモリリーク(※2)など高度な知識とスキルが必要になるリスクが高く、初心者向けではありません。この講義ではI/Oバウンドに対応する非同期処理に絞って解説を進めます。
C#は、様々なデスクトップアプリケーションやPC版のゲームなどの開発に幅広く利用されています。I/Oバウンドとは平たく言うと、CPUの負担率も使用メモリも正常なのに、ただデータの送受信に時間がかかっているだけの状態です。PCにはまだまだ余力があるわけです。
それなのにその間、何も操作ができなかったらどうなるでしょう。ユーザにストレスが溜まって「このアプリは重いし不便だ」「操作性が悪くてつまらないゲームだ」と、悪印象を持たれてしまいます。お客様にストレスフリーで快適なUIを提供するために、非同期処理は必要不可欠な技術なのです。
※1. 複数の処理が互いの処理終了を待つ形になり、処理が進まなくなること
※2. インスタンスやリソースなどの開放漏れにより、メモリの再利用ができなくなること
非同期処理を使って複数タスクを同時に実行してみましょう
以下のサンプルを実行してみて下さい。
using System;
using System.Threading.Tasks;
namespace Chap15;
internal class Example07 {
public static async void Run() {
Console.WriteLine("\n===非同期処理を使って二つの処理を同時に実行します===\n");
// 2つのタスクを生成して実行する
Task ta = Task.Run(OperationA);
Task tb = Task.Run(OperationB);
// 両方のタスクが完了するまで待つ
Task.WaitAll(ta, tb);
Console.WriteLine("===全ての処理が終了しました===");
}
// 一つ目のタスクを行うメソッド
private async static Task OperationA() {
ConsoleColor col = ConsoleColor.Green;
WriteLine("★★Aの処理を開始します★★\n", col);
for (int i = 1; i <= 5; i++) {
Console.WriteLine("Aの処理" + i + "回目\n", col);
await Task.Delay(1000);
}
WriteLine("★★Aの処理が終了しました★★\n", col);
}
// 二つ目のタスクを行うメソッド
private async static Task OperationB() {
ConsoleColor col = ConsoleColor.Yellow;
WriteLine("★★Bの処理を開始します★★\n", col);
for (int i = 1; i <= 5; i++) {
WriteLine("Bの処理" + i + "回目\n", col);
await Task.Delay(1000);
}
WriteLine("★★Bの処理が終了しました★★\n", col);
}
// 指定された文字色で文字列をコンソールに出力する
private static void WriteLine(string text, ConsoleColor color) {
Console.ForegroundColor = color;
Console.WriteLine(text);
Console.ResetColor();
}
}
実行結果の例
二つの処理が同時に実行されていることがよくわかりますね。新しく出てきたキーワードを解説していきましょう。
asyncとawait
asyncは、続く処理が非同期である、という宣言で、英語で非同期を意味する[asynchronous]という単語の略です。Taskというのは、処理の塊を作って実行や実行結果を管理するためのクラスです。このサンプルではtaというTaskにOperationAメソッドの処理を、tbというTaskにOperationBの処理を行わせています。次のTask.WaitAll()は、引数のタスクが全て完了するまで待ちなさい、という意味です。
async宣言された処理を待つキーワードがawaitなのですが、このサンプルではawaitを使う代わりにTask.WaitAll()を使っているということです。awaitキーワードは、次のサンプルでご紹介します。
非同期処理は、複数の処理を並行して行うことですから、「片方の処理が終わるまで待つ」という記述と、「待っている間にこれを行う」という記述が両方必要なわけですね。asyncが並行処理を行う宣言で、awaitはその処理を待つ、という宣言です。この2つは対で使いますので、しっかりと覚えて下さい。後ほど、少々複雑な並行処理の例をお見せします。
スレッドプールについて
少し高度なサンプルをご紹介する前に、C#のスレッドプールというものについても触れておきましょう。皆さんは、Thread(スレッド)という言葉を聞いたことがあるでしょうか。英語で糸を意味する単語ですが、IT業界では処理のラインを意味します。歴史的なお話をすると、昔は並行処理を行う=複数のスレッドを操るという意味でした。処理を行うラインがいくつもあって、それぞれで別の処理が同時に実行されている、という状況です。これをマルチスレッドと呼びます。
マルチスレッドの処理を行うコーディングとは、前述のCPUバウンドへの対応のことです。つまり、高度な知識やスキルが必要な上級者向けの仕事であり、注意すべきことがたくさんあって神経をすり減らす大変な作業でした。
しかし、C#の非同期処理が全てマルチスレッドで走っているのかというと、必ずしもそうではないのです。「???」と頭の中が疑問符でいっぱいになった方もいらっしゃるかもしれませんね。C#は、スレッドプールといって、いくつかのスレッドを内部で確保しておき、使わなくなったスレッドを再利用して高速化を図ったり、場合に応じて使い分けたり切り替えたりして無駄なスレッドの生成や破棄を抑えたり、という離れ業を行っているのです。
軽い並行処理であれば、一つのスレッドで処理を切り替えて対応することもあります。またCPUバウンドに対応するため待ち時間中に他の処理を走らせたりもします。なるべくCPUや皆さんに負担が無いよう、中の人が頑張ってくれているわけですね。
スレッドプールやそれを利用する仕組みのおかげで、私たちはそれほどスレッドの存在を意識せずに非同期処理を行うことができるようになったのです。筆者も、開発陣の苦労に頭が下がる思いでいます。
少し複雑な非同期処理のサンプルと、await の使い方
それでは、少し複雑な例を見てみましょう。次の三つのファイルを作成して下さい。
using System;
using System.Reflection;
namespace Chap15;
// 列挙型に属性(アトリビュート)を持たせるためのクラス定義
public class ColAtr : Attribute {
public ConsoleColor Color { get; set; }
public ColAtr(ConsoleColor color) => this.Color = color;
}
public class NameAtr : Attribute {
public string Name { get; set; }
public NameAtr(string name) => this.Name = name;
}
// 台詞とト書きの主体を定義するための列挙
public enum Whos {
[ColAtr(ConsoleColor.Cyan)] [NameAtr("忙しい男:")] Lead,
[ColAtr(ConsoleColor.Yellow)] [NameAtr("AIメイド:")] Ai,
[ColAtr(ConsoleColor.White)] [NameAtr("==== ")] Direction
}
// 列挙型の属性を取得するための拡張メソッドを定義するクラス
public static class LineTypeEx {
// リフレクションを使って属性を取得するメソッド
public static T GetAtr<T>(this Enum e) where T : Attribute {
T atr = e.GetType().GetField(e.ToString()).GetCustomAttribute<T>();
return atr is T ? atr : null;
}
// 文字色属性のゲッター
public static ConsoleColor GetColor(this Whos whos) {
return whos.GetAtr<ColAtr>().Color;
}
// 名前属性のゲッター
public static string GetName(this Whos lineType) {
return lineType.GetAtr<NameAtr>().Name;
}
}
// 台詞やト書きを一つ保持するクラス
public class Line {
// 台詞の持ち主の列挙
public Whos type { get; set; }
// 台詞
public string item { get; set; }
// キャプション表示の停止フラグ
public bool stopCaption { get; set; }
// サブシーンの開始を指示するSceneのインスタンス
public Scene trigger { get; set; }
// コンストラクタ
public Line(Whos whos, string item, bool stopCaption, Scene trigger = null) {
this.type = whos;
this.item = item;
this.stopCaption = stopCaption;
this.trigger = trigger;
}
// 台詞を表示
public void speak() {
Console.ForegroundColor = this.type.GetColor();
Console.WriteLine(this.ToString());
Console.ResetColor();
}
// ToStringメソッドのオーバーライド
public override string ToString() {
return this.type.GetName() + this.item + (this.type == Whos.Direction ? " ====\n" : "\n");
}
}
using System;
using System.Timers;
namespace Chap15;
public class Scene {
// 台詞のリスト
private List<Line> lines = new List<Line>();
// 台詞のインデックス
private int index = 0;
// 進行タイマー
private System.Timers.Timer timer = null;
// 分岐シーンのキャプション
private string caption = string.Empty;
// サブシーンのインスタンス
private Scene subScene = null;
// シーン終了フラグ
private bool finished = false;
// キャプション停止フラグ
public bool noCaption { get; set; }
// コンストラクタ 台詞の配列とインターバル、キャプションを引数に取る
public Scene(Line[] itmems, int interval, string caption = "") {
this.lines = itmems.ToList<Line>();
timer = new System.Timers.Timer(interval);
this.caption = caption;
}
// 次の台詞を表示
public void next() {
// 台詞が最後まで進めばタイマーを停止
if (lines.Count == index) {
Stop();
} else {
// サブシーンのタスクが終了したらインスタンスをクリア
if (subScene != null && subScene.isFinished()) {
subScene = null;
}
// 次の台詞を取得する
Line line = lines[index++];
// キャプション表示を停止する指示があれば停止する
if (line.stopCaption) {
this.noCaption = true;
}
// 台詞の表示
line.speak();
// サブシーン開始の指示があればインスタンスを設定してサブシーンを開始する
if (null != line.trigger) {
subScene = line.trigger;
subScene.Start();
}
}
}
// シーンが進行中かの真偽値を返す
public bool isBusy() {
return timer.Enabled;
}
// シーンが終了したかの真偽値を返す
public bool isFinished() {
return finished;
}
// シーンを開始する
public void Start() {
// タイマーにイベントを追加し、スタート
timer.Elapsed += async (sender, e) => await goChatOnAsync(sender, e);
timer.Start();
}
// シーンを終了する
public void Stop() {
// タイマーを停止して破棄
timer.Stop();
timer.Dispose();
// 処理完了フラグを立てる
finished = true;
}
// キャプションを表示
public void showCaption() {
if (!this.noCaption) {
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("★★★" + this.caption + "★★★");
Console.ResetColor();
}
}
// 非同期処理の会話進行エントリメソッド
public async Task goChatOnAsync(object sender, ElapsedEventArgs e) {
await nextLineAsync();
}
// 非同期処理の会話進行本体
public async Task nextLineAsync() {
// 実際にはここでI/Oバウンドが発生する処理を行う
// 1.5秒の遅延を発生させる
await Task.Delay(1500);
// キャプションが設定されており、停止されていなければキャプションを表示
if (null != subScene && string.Empty != subScene.caption) {
subScene.showCaption();
}
// 次の台詞を表示する
if (null == subScene || (null != subScene && subScene.isFinished())) {
this.next();
}
}
}
namespace Chap15;
using System;
using System.Timers;
public class Example08 {
// 各シーンのインターバル定数
const int MAIN_INTERVAL = 2000;
const int TICKET_INTERVAL = 3000;
const int DINER_INTERVAL = 2500;
// 各シーンの生成
private static Scene ticketScene = makeTicketScene();
private static Scene dinerScene = makeDinerScene();
private static Scene mainScene = makeMainScene();
public static void Run() {
// メインシーンを開始
mainScene.Start();
// アプリケーションが終了しないように入力待ち状態にする
Console.ReadLine();
}
// チケット予約シナリオを作成
private static Scene makeTicketScene() {
Line[] items = [
new Line(Whos.Ai, "ご主人様、チケットが取れました", true),
new Line(Whos.Lead, "そうか!よかった。これで社長に怒鳴られずにすむ", false),
];
return new Scene(items, TICKET_INTERVAL, "航空チケット問い合わせ中");
}
// ダイナー予約シナリオを作成
private static Scene makeDinerScene() {
Line[] items = [
new Line(Whos.Ai, "ご主人様、ダイナーの予約が取れました", true),
new Line(Whos.Lead, "ありがとう!なんとしてでも間に合うように帰ってくるよ", false),
];
return new Scene(items, DINER_INTERVAL, "ダイナー予約問い合わせ中");
}
// メインシナリオを作成
private static Scene makeMainScene() {
Line[] items = [
new Line(Whos.Ai, "おはようございます、ご主人様", false),
new Line(Whos.Lead, "ふあああぁ…おはよう", false),
new Line(Whos.Ai, "お目覚めはいかがですか?", false),
new Line(Whos.Lead, "まだ眠いよ…なんか予定入ってたっけ?", false),
new Line(Whos.Ai, "今日は9時の便でサンフランシスコの会議にご出席されるのでは?", false),
new Line(Whos.Lead, "え?あぁそうだった!…しまったチケット取るのを忘れていたぞ", false),
new Line(Whos.Ai, "では、至急いつもの業者に問い合わせますね", false),
new Line(Whos.Lead, "ああ、頼む。こんなことなら君に任せておけばよかった", false),
new Line(Whos.Ai, "チケットが確保できたらお知らせしますので、その間にご準備を", false),
new Line(Whos.Direction, "慌ただしく荷造りを始める男", false, ticketScene),
new Line(Whos.Ai, "あのぉ…ご主人様?", false),
new Line(Whos.Lead, "なんだい?今、忙しいんだけど", false),
new Line(Whos.Ai, "お邪魔して申し訳ないのですが、お戻りは何時頃になりますでしょうか", false),
new Line(Whos.Lead, "さぁな、会議のなりゆきでどうなるか…なんでそんなこと聞くんだ?", false),
new Line(Whos.Ai, "19時から会食のご予定も入っておりますので…", false),
new Line(Whos.Lead, "うわっそうだった!席の予約もしてないぞ", false),
new Line(Whos.Ai, "ご予約は、いつものダイナーでよろしいですか?", false),
new Line(Whos.Lead, "ああ、それでいい。頼めるか?", false),
new Line(Whos.Ai, "はい。では予約できるか問い合わせてみます", false, dinerScene),
new Line(Whos.Lead, "よぉし準備完了!それじゃあ行ってくるよ", false),
new Line(Whos.Ai, "はい、どうぞお気を付けて", false),
new Line(Whos.Lead, "ほんとに助かったよ!愛してる", false),
new Line(Whos.Ai, "お役に立てて、私もうれしいです。行ってらっしゃいませ", false),
];
return new Scene(items, MAIN_INTERVAL);
}
}
今回も、寸劇形式のサンプルです。これまでのオブジェクト指向学習の総復習と、新たな技術のご紹介も兼ねた構成になっています。まずは、実行してみて下さい。
全体の流れは、主人公が目覚めるメインシーンからチケットの予約シーンに枝分かれし、メインシーンに戻って再びダイナーの予約シーンに枝分かれして、最終的にまたメインシーンに戻って完結する寸劇になっています。
台詞の進行はタイマーで行っており、指定時間が経過すると発生するイベントによって次々と台詞が表示されるようになっています。
サブシーンに枝分かれして会話が進行する間もメインシーンは動作を続けています。コードをよく見て流れを追って行くと、「~問い合わせ中」のキャプションをメインシーンのインスタンスが出力していることに気付くでしょう。つまり、メインシーンとサブシーンが同時に処理を行う、並行処理になっているわけです。
それでは順に、Line.csから内部を詳しく見ていきましょう。
Line.cs ~台詞と追加情報を保持してシナリオ分岐とキャプションを制御する~
Line.csは、台詞(又はト書き)を一つ保持するインスタンスを生成するクラスです。最初に定義されているColAtr、NameAtrというクラスは、続くWhosという列挙が二つの追加情報(=表示文字色と台詞主の名前)を持てるようにするための拡張を行っています。
なぜこんな下準備をしているかと言うと、台詞の持ち主によって変える表示色と、台詞をしゃべる人物の名前は、「誰が」という列挙が持っているべきだからです。この人の台詞はこの色で表示し、名前はこれだという情報をWhosに持たせておけば、いちいち台詞を表示する度にif文やswitch文を使って条件分岐をやらなくてもそれぞれの文字色や名前を使って台詞を表示できますね。
このようにデータ構造を工夫して、処理を楽に書けるようにする、という考え方はアプリケーションの設計を行う上で、大変有用です。読むだけでも一苦労な延々と続く条件分岐の構文を、工夫した配列を使ったら、たった一行にできたなどということが、実際にあるのです。
この考え方を、データ指向プログラミングと呼びます。いわゆるプログラミングパラダイムと呼ばれるものの一種です。手続き指向型、データ指向型、オブジェクト指向型と色々な考え方がありますが、その場その場での最適解を柔軟に考え、バランス良く各パラダイムの良い点を取り入れられるようになりましょう。
さて、この追加情報を、アトリビュート(属性)と言います。二つのクラスがAttributeクラスを継承していますね。また、これまでの講義で学習したクラスのプロパティと同じように、アクセサ(get/set)が定義されています。そして、9行目と14行目のコンストラクタには、ラムダ式で属性を設定するよう書かれています。
こう書くと、いかにも難しいことをやっているように思われるかもしれませんが、そんなことはありません。要するに、列挙型を普通のクラスのようにアクセス可能なプロパティを持てるようにしているだけの話です。
この下準備を行うと、18行目からの列挙宣言で、ご覧のような書き方で各列挙型のメンバーに、二つの追加情報を持たせることが出来るようになるのです。
続くLineTypeExクラスでは、リフレクションという技術を使って各列挙型が持つ追加情報を読み出せるようにしています。リフレクションとは本来は不可視なスコープのメンバーにアクセスするための中級者向けのスキルです。情報隠蔽とカプセル化によってブラックボックス化されたクラス内を、設計を超えて意図的に操作するために使われます。例えばprivateやprotected宣言されたメンバーの読み出しや書き込みをクラス外から行うことができるのです。
29行目がわかりづらく感じると思いますが、ざっくり説明すると、フィールド名でインスタンスの中の参照を見つけ出し、中の値を返すメソッドになっています。皆さんは今すぐリフレクションを使えるようになる必要はありません。今後のスキルアップのために、参考として軽くご紹介いたしました。
列挙型は何かを分類するために使われるものなので、このように追加情報を持たせたい場面がよくあります。Javaのenumは既にもっと簡単に追加情報やメソッドを追加できる仕様になっており、筆者もC#が今後のバージョンアップでenumの機能が拡張されることを期待しています。
36行目と41行目のゲッターは、文字色と台詞主の名前を短い記述で取得できるようにするためのインターフェース(つまり便利メソッド)です。このように、ある処理を行うメソッドの書き方が少々長くなるな、という場合には、単純な書き方ができる上位メソッドを一つ作る、というのも可読性を上げる大事なテクニックです。一行に書くコードはなるべく短い方が読みやすいですよね。
デザインパターンと呼ばれる様々なプログラム設計のやり方で、メソッドの階層構造を作ることがよくあるのですが、これは上記のような対応をプログラム全体で構造的に行っているのです。
47行目からはLineクラスの定義が始まります。おなじみのプロパティとアクセサの次にコンストラクタが定義されています。Lineクラスのプロパティは、次の4つで構成されます。1.台詞主のタイプの列挙、2.名前、3.台詞を表示したらキャプションを止めるフラグ、そして、4.台詞を表示したらサブシーンへ分岐するためのサブシーンのインスタンスです。
コンストラクタの引数の最後、Scene trigger = null
という記述は初めて見る書き方ですね。これは、引数が指定されなかった場合のデフォルト値をあらかじめメソッド定義のシグネチャに書いておくことで、メソッドを呼び出す側の記述を省略できるようにしているのです。
これを、引数のデフォルト値を指定すると言います。頻繁に使用するメソッドだけれども、この引数はたまにしか指定しないな、という場合に有効です。その効果は、Example08.csのシーン生成メソッドをご覧頂ければ一目瞭然かと思います。
デフォルト値の指定にはルールがあって、引数の並び順の最後に一つだけしか指定できません。なぜ最後に一つだけかと言うと、それ以外の引数にデフォルト値を指定できてしまうと、省略した場合の引数の並び順で整合が取れなくなってしまうからです。そう考えるとちょっと不便ですが、これは仕方がありませんね。
これらのプロパティをLineクラスに持たせることで、このサンプルは、ある台詞を表示したらサブシーンへ分岐する、サブシーン側では、この台詞を表示したらキャプションの表示を止める、という制御を台詞の一行一行に設定できるように作られているのです。
具体的には、主人公がチケットを取り忘れたことを思い出した後のト書きを表示した時点でサブシーンに分岐します。また、チケット予約のサブシーンでは、AIメイドがチケットが取れましたと言った時点で、「~問い合わせ中」の表示を止める、という制御をしています。
クラスの責務をいう言葉を覚えていますか?このサンプルは、どこでシーンを分岐させるか、どこでキャプションを表示し、表示を止めるのか、という情報を、台詞が持っているべきだと判断してクラス設計をした例だということです。
69行目のspeakメソッドは、台詞主の名前を含めた表示を、場合によって文字色を変えて表示しています。列挙に持たせた追加情報のおかげで、わかりやすく短い記述になっていますね。
それでは次に、シーンクラスの解説をして行きましょう。このサンプル中、非同期処理の本体を実装するクラスです。
Scene.cs ~内部に独立したタイマーを持って非同期の並行処理を行う~
シーンクラスのメンバーを順に紹介していきます。まずは7行目から、シーンが持つ台詞のリストと現在表示している台詞のインデックスが実装されています。
11行目では内部で使用するタイマークラスがprivate宣言されていますね。なのでこのサンプルではシーン毎に別々のタイマーを持って台詞の進行を管理しています。非同期に同時進行で並行処理を行うために、他のシーンとは独立したタイマーをそれぞれのシーンが持っている構造です。
続く13行目では、シナリオ分岐後表示する「チケット問い合わせ中」のようなキャプションの文字列を保持するフィールドが宣言されています。
15行目はサブシーンのインスタンスを保持するフィールドです。初期状態ではこのフィールドはnullですが、サブシーンに分岐すると、インスタンスへの参照が設定されて、サブシーンが進行中であることを示します。
17行目は、このシーンの処理が完了したか否かの真偽値を保持するフィールドです。メインシーンからサブシーンの処理が完了したかを確かめるために使用します。シーンの処理完了は、他の方法でも調べられそうなものですが、タイマーの経過時間で発生するイベントを頼りに台詞を進行させますから、最後の台詞を表示した時点でこのフラグを立て、明示的にシーンの完了のタイミングを決めているのです。
19行目はキャプションの表示/非表示を切り替えるフラグです。前述のLineが持っているプロパティにより、値が設定されます。
22行目からはコンストラクタが定義されています。ここでもキャプションを設定する必要の無いシーン用に、引数のデフォルト値が設定されていますね。今回のサンプルでは、メインシーンはキャプションが不要のシーンです。引数で受け取った台詞の配列で内部のリストを生成し、引数のインターバル(経過時間)でイベントを発生させる内部タイマーのインスタンスを生成しています。
29行目のメソッドは、次の台詞を表示します。このメソッドがシナリオ進行の核になります。32行目で、まずは台詞が最後まで表示されていればStopメソッドを呼び出してタイマーを停止、破棄後、処理完了フラグを立てています。
まだ台詞が残っている場合、サブシーンの処理が完了していればサブシーンのインスタンスを持つフィールドをクリアします。次の台詞を取得してインデックスを進めた後、キャプション表示のフラグを設定し、台詞を表示します。台詞を表示後、サブシーン開始の情報を台詞が持っていれば、サブシーンの処理を開始しています。
シーンクラスは、メインシーンとサブシーン両方の役割をこなしますから、どの処理がどちらのシーン向けのものなのかをよく読んで理解して下さい。このメソッドでは、36行目と51行目からの処理がメインシーン向けで、その他の処理はメインとサブ両方で共通の処理になります。
親処理と子処理を同じクラスのインスタンスで行うという設計は、実務でも採用されることがよくあります。同じような処理を階層構造を持って繰り返し実行するようなタイプのアプリ向けの設計です。初心者の方にとっては大変混乱しやすい構造ですが、親処理向けの処理と子処理向けの処理を明確に一つずつ頭の中でブレイクダウンしていけば、自ずとどこにどんな処理を書けば良いかがわかってきます。これはもう慣れの問題ですから、心配せずに、落ち着いて考えましょう。
続く59行目からは、処理中か、完了後かを返すメソッドが並んでいます。次はいよいよ非同期処理の解説です。
タイマーによる非同期処理の開始
69行目のStartメソッドがシーン開始処理です。ここに、async
とawait
両方が使われていることに注目して下さい。
timer.Elapsed += async (sender, e) => await goChatOnAsync(sender, e);
この記述はTimer
の Elapsed
イベント発生時に実行する処理を+=
演算子で登録しています。Elapsed
は、タイマーを生成したときに指定したインターバル時間毎に発生するイベントです。
ここで、イベントという用語についても解説しておきましょう。イベントとは、平たく言うと「何かが起こった」ことを知らせるものです。Excelのような、GUIのあるアプリを想像して下さい(その方がわかりやすいので)。例えば「入力欄に入力があった」、「入力欄の値が変更された」、「ボタンが押された」、「カーソルが画面部品上を通過した」、「キー入力があった」、等々、画面上で起こるありとあらゆる事象は全てイベントとして処理されます。
イベントは、「これが起こったら」「この処理を実行してね」と処理と紐付けて使います。イベントと処理を紐付ける仕組みを、イベントハンドラと呼びます。デスクトップアプリでは、ユーザの操作やタイマーなどによるイベントがそこかしこで常に起こっていることになりますね。見方によっては、デスクトップアプリの開発とは数限りなく発生し続けるイベントのうち、必要なものに処理を紐付けていく作業だと表現することもできるでしょう。
要するに上記のコードには、タイマーのElapsed
イベントハンドラに非同期メソッド(async)であるgoChatOnAsync
を紐付けて、何ミリ秒毎にこの処理を実行して終わるまで待ってね(await)、と書いてあるのです。
続くtimer.Start
でタイマーが起動し、イベントの発生が始まって、シーンがスタートしています。
イベントハンドラを利用した非同期処理の実際
94行目のgoChatOnAsync
が非同期処理のエントリメソッドです。戻り値がTaskになっていますね。なぜ戻り値がTaskになっているのか、また、上記の非同期処理登録コードにある(sender, e)
という見慣れない表現についても解説します。
Elapsed
イベントハンドラには同期処理、非同期処理いずれも登録ができるのですが、イベントハンドラの戻り値がvoid
になってしまいます。戻り値がvoidになると、いつ非同期処理が終了したのかが判断できなくなるので、(sender, e)
という書き方を使い、async Task
(非同期処理のTask)を呼び出す形にして、非同期処理の完了を見逃さないように工夫をしているのです。こうしておけばTaskの戻り値があるので、処理の完了が判断できるというわけです。
ここでsender
は、イベントを送出するオブジェクトで、e
はイベントのインスタンスそのものを指しています。
続く99行目のnextLineAsync
が非同期処理の本体です。最初にawait Task.Delay(1500)
とありますが、これがI/Oバウンド処理の代わりに書かれているコードです。実際にはここにI/Oバウンドが起こる処理を書くわけです。その後の処理では、フラグを判別してキャプションと台詞の表示/非表示を切り替えています。
非同期処理を行うメソッドを作る際には、必ずメソッド名の最後にAsync
という文字列を付けることをMicrosoftが推奨しています。これには、同期処理のメソッドと区別するためと、awaitを初めとする待機処理の実装漏れを防ぐため、という二つの理由があります。皆さんも、是非このルールを守って設計とコーディングをするようにして下さい。
Example08 ~下準備したクラスを使って簡潔に全体の処理を行う~
最後に、サンプル実行のエントリポイントとなるExample08クラスについても軽く解説しておきます。複雑な処理はLine.csとScene.csに準備してありますから、このクラスは定数を宣言し、シーンのインスタンスをを生成して実行するだけ、というシンプルな構成になっています。
エントリポイントのコード(通常はトップレベルステートメントに記述します)が、処理の流れを可読性良く見渡せる書き方ができるよう、全体を設計することが何より大切です。責務やサイズ、役割分担を入念に考慮したクラス構成を心がけて、スッキリとしたわかりやすいアプリ開発を目指しましょう。
この業界には、色々な意味で人的リソースの入れ替わりが激しい傾向があります。あなたが一から開発に携わったアプリを、数年後にはどこか別の会社が改修しているかもしれません。また、その逆も十分にあり得るのです。常にしっかりした設計を行い、読みやすいコードを書くことを心がけましょう。これは、技術者として何にも勝る大切なポリシーであるとともに「情けは人のためならず」あなた自身のためにもなることなのだ、と肝に銘じて実務について下さることを祈ります。
■ 実務で使える非同期処理使用例:Web APIの呼び出し
よく使う非同期処理の例をいくつかご紹介して、この講義の締めといたします。
async Task<string> GetWebDataAsync() {
using (HttpClient client = new HttpClient()) {
string result = await client.GetStringAsync("https://example.com/data");
return result;
}
}
実行コードの例です。
static async Task Main(string[] args) {
string data = await GetWebDataAsync();
Console.WriteLine(data);
}
非同期メソッドは、通常Task
やTask<T>
型を返し、Main
メソッドもC# 7.1以降は非同期にできます。
ラムダ式と非同期の組み合わせ
ラムダ式の中でもasyncを使うことができます。
Func<Task> asyncLambda = async () => {
await Task.Delay(1000);
Console.WriteLine("非同期ラムダ式");
};
await asyncLambda();
イベントハンドラや非同期な処理ロジックをインラインで書きたいときに便利です。
最後までお読みいただき、ありがとうございます。