【ゲーム開発のためのC#入門講座・応用強化編】コルーチンを理解しよう【#3】

6.0_C#応用強化編

条件に合うデータだけをループ処理したいとしたら

例えば、1~引数で与えられた数値までの間で3の倍数の値だけを返す、世界のナベアツみたいなプログラムを作りたいとしましょう(古いですねー、もうご存じない方もたくさんいそう)。

もし引数が10だったとしたら、「3 6 9」の3つを返すことになります。

皆さんならどんな風にプログラムを作りますか?

これまで学習してきた内容だけで考えるなら、

  • 複数の値を返す必要性がある(コレクションを使おう!)
  • 引数によって返す値の数が変わる(配列だと困っちゃうね)

ということで、Listを使うのがよさそうですね。

早速実装してみましょう。

static List<int> GetMultipleOfThree(int max)
{
    List<int> multipleOfThreeList = new List<int>();
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            multipleOfThreeList.Add(i);   
        }
    }
    return multipleOfThreeList;
}

3で割った時の余りが0、すなわち3の倍数の値だけをリストに格納しています。

実際に使ってみて、正しく実装できるか確認してみましょう。

foreach (int number in GetMultipleOfThree(10))
{
    Console.WriteLine(number);
}
​
static List<int> GetMultipleOfThree(int max)
{
    List<int> multipleOfThreeList = new List<int>();
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            multipleOfThreeList.Add(i);
        }
    }
    return multipleOfThreeList;
}
コンソール画面

3
6
9

問題なさそうですね。

もし条件に合うデータが大量にあったら

確かに問題はなさそうですが、もし仮に条件に合うデータが十万、あるいは百万あったとしたらどうでしょうか?

すべてのデータをリストに格納している間は他のことをできません。また、百万個ものデータをずっと消さずに残しておかなければならないため、かなり大量にメモリを消費していそうです。

全部まとめて必要ならやむを得ないことですが、今回はただ3の倍数の値を順番にコンソール画面に出力しているだけですよね。であれば、全部まとめてではなく、その都度ひとつひとつ結果を返してもらうことができれば十分です。

// ↓戻り値はint型で、
static int? GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            // ↓3の倍数だった時だけ、値を返せると理想的!
            ???
            // でも、そんなこと可能なのかな?
        }
    }
    return 0;
}

とはいえ、それができるなら一番いいけれども、果たしてそんなことできるのか? という疑問は残りますよね。

例えば、単純に3の倍数だったら値を返すというだけの処理ならできます。

static int GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            // 単純に値を返すだけなら可能!
            return i;
        }
    }
    return 0;
}

でもそれは「3の倍数に該当する最初の値だけ」ですよね。

もう一度メソッドを実行しても内部の変数等は当然リセットされていますから、値を返したところから処理を再開するなんてことはできません。

何度実行しても同じ結果になってしまいます。

// ↓一回目で実行した時と、
int number = GetMultipleOfThree(10);
Console.WriteLine(number);
// ↓2回目で実行した時で、結果は同じ!
number = GetMultipleOfThree(10);
Console.WriteLine(number);
​
static int GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            return i;
        }
    }
    return 0;
}
コンソール画面

3
3

そう、これまで学んできたことだけでやるなら、です。

この、

  • 任意のタイミングで戻り値を返す
  • 前回値を返したところから処理を再開する

なんてことをやってのける新しい技術yieldを習得しましょう。

yieldを使ってひとつずつ値を返そう

早速実例です。

IEnumerator<int> enumerator = GetMultipleOfThree(10);
​
enumerator.MoveNext();
int number = enumerator.Current;
// 1回目の値出力
Console.WriteLine(number);
​
enumerator.MoveNext();
number = enumerator.Current;
// 2回目の値出力
Console.WriteLine(number);
​
// ↓見慣れない戻り値の型と、
static IEnumerator<int> GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            // ↓新しいキーワード「yield」!
            yield return i;
        }
    }
}
コンソール画面

3
6

きちんと1回目と2回目で異なる値が出力されていますね。

キーワード「yield」を使う場合、戻り値の型は下記を記載する必要があります。

IEnumerator<返したい値の型>

この型はちょっと特殊な型で、下記の通り動作します。

  • メソッド「MoveNext」を実行すると、前回処理を実行したところから次に値を返すところまで処理を実行(前回処理がない場合は先頭から実行)
  • メソッド「MoveNext」を実行した際に返された値をプロパティ「Current」に保持

そのため、1回目の「MoveNext」ではメソッド「GetMultipleOfThree」の先頭から始まり、初めて「yield return」によって値3が返されるところまで処理が実行されます。

// 1回目の「MoveNext」で、1回目のyield returnが発生するまで処理を実行
enumerator.MoveNext();
// 実行結果は「Current」に保持されている
int number = enumerator.Current;

続いて2回目の「MoveNext」では、前回に値3を返したところから処理を再開し、次に値6を返すところまで処理が実行されます。

// 2回目の「MoveNext」で、再度yield returnが発生するまで処理を再開
enumerator.MoveNext();
// 実行結果は「Current」に保持されている
number = enumerator.Current;

だからそれぞれ「3」と「6」という結果がコンソール画面に出力されたんですね。

このように、「IEnumerator<返したい値の型>」という特殊な戻り値と「yield」というキーワードを使うと、

  • 任意のタイミング(yield returnを記述した箇所)で値を返す
  • (「MoveNext」が実行される度に)前回値を返したところから処理を再開する

ということができます。

つまり新しいキーワード「yield」は通常の「return」と違い、何らかの値を返した上で処理を一時中断するポイント(=次回実行時の再開ポイント)を示すものなんですね。

なお、メソッド「MoveNext」の戻り値はbool型で、次に返す値がない=処理がすべて完了した時にfalseとなります。

そのため、先程のコーディングをより望ましい形にすると下記のようになります。

IEnumerator<int> enumerator = GetMultipleOfThree(10);
​
// 「MoveNext」がtrueの間だけ=何らかの値が返される間だけ、
while (enumerator.MoveNext())
{
    // 返された値を使って処理を実行!
    Console.WriteLine(enumerator.Current);
}
​
static IEnumerator<int> GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            yield return i;
        }
    }
}

この機能を活用すれば、合計の数が10万だろうが100万だろうが、ひとつずつ値を返すことができますね!

処理の中断・再開ができるのってそんなに便利なこと?

確かに便利かもしれないけど、今時のコンピュータはそれこそメモリもたくさんあるんだし、別にリストでいいんじゃないかな、と思った方もいるかもしれません。

ですが、例えばゲームのDLパッチなどを想像してみてください。

もしダウンロード開始ボタンを押したのにしばらくの間何の反応も返ってこなかったとしたら、めちゃくちゃ不安になりませんか?

「バグった?」「フリーズした?」とプレイヤーを不安にさせてしまいますよね。

なので「現在何%完了済……」などの進捗状況は表示しておきたいものです。が、プログラムの都合としてもメソッド完了まで待機するしかないとなると、プログラマーとしてもお手上げ状態です。

ですが、「IEnumerator<返したい値の型>」と「yield」を使えばひとつひとつの処理の間に別の処理を挟むことができます。

進捗状況の報告も簡単にできるんですね。

int max = 100;
IEnumerator<int> enumerator = GetMultipleOfThree(max);
​
int count = 0;
int totalCount = max / 3;
// ↓処理を実行しながら、
while (enumerator.MoveNext())
{
    // ひとつずつ作業が完了する度に進捗状況を報告するのも簡単!
    count++;
    Console.WriteLine("現在" + count + "/" + totalCount + "実行済...");
    Console.WriteLine(enumerator.Current);
}
​
static IEnumerator<int> GetMultipleOfThree(int max)
{
    for (int i = 1; i <= max; i++)
    {
        if (i % 3 == 0)
        {
            yield return i;
        }
    }
}

このように「長い間ひとつの処理だけに占有されずに済む」ということは、実はすごく価値あることなのです。

そしてその価値を活用して作られた技術がUnityのコルーチンです。

Unityでの活躍ポイント

例えば「キャラクターを3秒かけながらフェードインしたい(透明な状態から3秒かけて徐々に表示するようにしたい)」とします。

もし単純にこの処理をメソッド実行しようとすると、キャラクターを表示している3秒の間、それ以外の処理は一切できないことになってしまいますよね。新しいキャラクターが登場する度にそれ以外の要素(敵とかNPCとか)が一切動かなくなるのでは、ゲームとして困ってしまいますよね。

そこで、Unityには専用のメソッド「StartCoroutine」が用意されています。

これはIEnumerator型を引数として渡すと、Unity側で1フレーム毎(30fpsなら1/30秒、60fpsなら1/60秒)にメソッド「MoveNext」を実行してくれるという機能です。

つまり1フレーム毎に透明度を変更しながらyield returnしつつ、3秒後に処理が完了するメソッドをコルーチンに引き渡すと、3秒かけてキャラクターの透明度を徐々に増加(=フェードイン)させることができるのです。

// ↓このメソッドにIEnumerator型の引数を渡すと、
StartCoroutine(FadeIn());
​
// ↓対象のメソッドを1フレームずつ勝手に「MoveNext」してくれるよ!
]
private IEnumerator FadeIn()
{
    float elapsedSeconds = 0f;
    // ↓だから時間経過が3秒を超えるまで、
    while (elapsedSeconds < 3.0f))
    {
        elapsedSeconds += Time.deltaTime;
        // ↓1フレーム毎に、経過時間/処理時間%の透明度が設定すれば、
        float opacity = 1.0f * (elapsedSeconds / 3.0f);
        // 結果的に3秒かけてキャラクターをフェードインさせることができるよ!
        SpriteRenderer.color = new Color(1f, 1f, 1f, opacity);
        yield return null;
     }
    spriteRenderer.color = new Color(1f, 1f, 1f, 1f);
}

この場合、処理を1フレーム毎に中断・再開できることが重要なので、処理毎に返される値自体には全く意味がないというか、何でもいいんですよね。

そのため、この場合はメソッドの戻り値が「IEnumerator」と返したいデータ型の記載がないものとなり、「yield return」で返す値も「null」になります。

Unityではわりとお馴染みの機能ではあるのですが、何も知らずにこの機能に触れると、

  • 「IEnumerator」って何?
  • 「yield」って?
  • 何でreturnするのは「null」なの?

と色んな謎が飛び出してきてしまいます。

そこで今回、その謎を解き明かすための知識をご紹介することにしました。

皆さんは今回学習したことで、「StartCoroutine」は1フレームずつ「MoveNext」を実行してくれるメソッドなんだな、ということが理解できたかと思います。

一定の時間をかけて処理を実行したいものや複数の処理を同時並行で実行したい場合に活用する機会もあると思うので、ぜひマスターしておきましょう。

実践演習

それでは実際に「IEnumerator」と「yield」を使ってみましょう。

文字の位置を列挙しよう

「IEnumerator」は「enumerate=列挙する」から名付けられた型名です。

先程は3の倍数の数値を列挙しましたが、今回は文字列の中に含まれている特定の文字の位置を列挙してみましょう。

仕様

文字列「Pen-Pineapple-Apple-Pen」において、大文字の「P」の文字が使われている文字位置(0スタート)をすべて列挙してください。

テンプレート
IEnumerator<int> enumerator = GetCharacterPositions();
​
while (enumerator.MoveNext())
{
    Console.WriteLine(enumerator.Current);
}
​
static IEnumerator<int> GetCharacterPositions()
{
    string text = "Pen-Pineapple-Apple-Pen";
    for (int i = 0; i < text.Length; i++)
    {
        // ↓i番目の文字が格納されています
        string character = text[i].ToString();
        // ↓文字がPだったら、文字の位置番号であるiを返そう!
​
        // ここまで
    }
}

演習②もうひとつの型を使ってみよう

結果的に複数の値を返す機能ならforeachループのように書けると便利ですよね。

実は「IEnumerable<返したい値の型>」という型を戻り値にすると、直接foreachループに記述することができます

試しに演習①の戻り値を「IEnumerable」に変更してみてください。

テンプレート
// ↓こんな風にforeachループで処理できるようになるよ!
foreach (int characterPosition in GetCharacterPositions())
{
    Console.WriteLine(characterPosition);
}
​
// ↓演習①の内容を反映した上で戻り値の型を「IEnumerable」にしよう!
static IEnumerator<int> GetCharacterPositions()
{
    string text = "Pen-Pineapple-Apple-Pen";
    for (int i = 0; i < text.Length; i++)
    {
        // ↓i番目の文字が格納されています
        string character = text[i].ToString();
        // ↓文字がPだったら、文字の位置番号であるiを返そう!
​
        // ここまで
    }
}

答え合わせ

演習①の答え

IEnumerator<int> enumerator = GetCharacterPositions();
​
while (enumerator.MoveNext())
{
    Console.WriteLine(enumerator.Current);
}
​
static IEnumerator<int> GetCharacterPositions()
{
    string text = "Pen-Pineapple-Apple-Pen";
    for (int i = 0; i < text.Length; i++)
    {
        // ↓i番目の文字が格納されています
        string character = text[i].ToString();
        // ↓文字がPだったら、文字の位置番号であるiを返そう!
        if (character == "P")
        {
            yield return i;
        }
        // ここまで
    }
}
コンソール画面

0
4
20

演習②の答え

// ↓こんな風にforeachループで処理できるようになるよ!
foreach (int characterPosition in GetCharacterPositions())
{
    Console.WriteLine(characterPosition);
}
​
static IEnumerable<int> GetCharacterPositions()
{
    string text = "Pen-Pineapple-Apple-Pen";
    for (int i = 0; i < text.Length; i++)
    {
        // ↓i番目の文字が格納されています
        string character = text[i].ToString();
        // ↓文字がPだったら、文字の位置番号であるiを返そう!
        if (character == "P")
        {
            yield return i;
        }
        // ここまで
    }
}
コンソール画面

0
4
20

どういう風に動いているかを理解する分には「IEnumerator」の方がわかりやすいですが、使う分には「IEnumerable」の方がすっきり書けて便利ですね!

まとめ

  • 「IEnumerator」または「IEnumerable」は処理の中断と再開ができる便利な型
  • 「yield」は「IEnumerator」または「IEnumerable」型を返すメソッドにおいて、何らかの値を返して処理を一時中断するポイントを示すもの(次回実行時はそのポイントから再開する)
  • Unityではコルーチンという機能を活用するのに使う
  • 長い間ひとつの処理だけに占有されたくない場合や大量データをひとつずつこまめに処理したい時に便利

次回はデータを外部に保存する方法について学習します。お楽しみに!

それでは、今回もお疲れ様でした!

また次の記事でお会いしましょう!

Posted by 夕目紅