なぜ、ラムダ式と非同期プログラミングの理解が重要か?

近年の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#で定義済みの主な二種類だけをご紹介します。

  1. Action - 戻り値が無い(void)のメソッドを代入するデリゲート型
  2. 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を生成する方法をご紹介します。

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を利用すると、メソッドチェーンでつなげて複数の処理結果を持つリストを作成できます。皆さんも、条件を変えていろいろと試してみてください。

実験

Whereのラムダ式に、&& や || で複数の条件を付けて実行してみましょう





コレクションのマッピング ~要素を別のコレクションに詰め替える~

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を作ったり、その逆を行う処理も簡単に書けるでしょう。コレクションのソート、フィルタリング、マッピングをマスターすれば、あなたのコーディングの幅がグンと広がることは受合います。しっかりと理解して、スキルアップを目指しましょう。次はいよいよ非同期処理を学びます。

実験

1.Selectを使って、Friendのリストから名前だけを取り出したリストを作ってみましょう

2.1の実験を参考に、名前の後に"xx才"と年齢の文字列を連結させたリストを作ってみましょう。


4.async / await による非同期プログラミング

非同期処理とは

非同期処理を理解するために、まずは反対語となる同期処理について解説しましょう。

Webアクセスやファイル読み込みなど、ある程度時間がかかる処理を実行すると、Windowsでは処理中を表すぐるぐる回るアイコンが表示されて何もできない状態になることがありますよね。時間がかかる処理が終了してアイコンが消えると次の操作が可能になります。これが同期処理です。同期処理とは、現在実行中の全ての処理が完了するまで待って次の処理を実行することです。前に実行された処理の終了に「同期」して次の処理が始まるので、同期処理と呼ぶのです。

ちなみに、これまでこの講義に登場してきた全てのサンプルは、同期処理のプログラムです。どのサンプルも、前の処理が終わってから次の処理が実行されるように書いてあるからです。

これを踏まえれば、同期処理の反対語である非同期処理とは、前の処理の終了を待たずに処理を実行することなんだろうな、と推測できます。そうです、それで正解です。ですから、非同期処理を使えば、アイコンがぐるぐる回っている間に他のアプリを開けたり操作したりと、別の処理を並行して進めることができるのです。シチューを煮込む作業をコンロに任せれば、皆さんはシチューを煮込んでいる間にテーブルや食器の準備ができますよね。

非同期処理が活躍する場面

非同期処理が活躍する場面は、その原因によって大きく二つに分かれます。また、原因によって対処法も変わるのです。

  1. Webアクセスやファイル入出力、プリンタ印字やデータベース通信など 、データの入出力に時間がかかる状態
  2. 大量のデータ処理や非常に複雑な計算を繰り返す処理など、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. インスタンスやリソースなどの開放漏れにより、メモリの再利用ができなくなること

■ 概要

C#のasync/awaitキーワードは、非同期処理(Async Programming)を簡単に記述できるようにする構文です。UIのフリーズを防いだり、待機時間を有効活用するようなシナリオで活躍します。

■ async と await の基本構文

async Task SampleAsync()
{
    await Task.Delay(1000);
    Console.WriteLine("1秒経過しました");
}

  • async:このメソッドは非同期処理を行うことを示します。
  • await:非同期メソッドの完了を待ちます(その間、スレッドは他の作業を行える)。

■ 使用例:Web APIの呼び出し

async Task<string> GetDataAsync()
{
    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 GetDataAsync();
    Console.WriteLine(data);
}

非同期メソッドは、通常TaskTask<T>型を返し、MainメソッドもC# 7.1以降は非同期にできます。


ラムダ式と非同期の組み合わせ

ラムダ式の中でもasyncを使うことができます。

Func<Task> asyncLambda = async () =>
{
    await Task.Delay(1000);
    Console.WriteLine("非同期ラムダ式");
};

await asyncLambda();

イベントハンドラや非同期な処理ロジックをインラインで書きたいときに便利です。

<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ ラムダ式は関数を簡潔に記述できる強力な機能で、LINQやコールバックなどに有効。

□ async/awaitは非同期処理を直感的に書けるようにし、アプリケーションのレスポンスを高める。

□ 両者は組み合わせることもでき、モダンなC#アプリ開発には欠かせない存在です。

最後までお読みいただき、ありがとうございます。