見出し画像

【Unity】UniTaskに善玉例外を投げよう!CancellationToken.ThrowIfCancellationRequested()

全てのUniTaskにCancellationTokenを渡して、
1行目にCancellationToken.ThrowIfCancellationRequested()を叩こう!

public async UniTask PlayAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    ...
}

CancellationToken.ThrowIfCancellationRequested()って?

CancellationToken.ThrowIfCancellationRequested()とは、
CancellationTokenがCancel状態なら、
OperationCanceledException例外をthrowするメソッドです。

OperationCanceledException例外とは、
UniTaskシステムが管理(try-catch)している「UniTaskを中断させる」例外です。


すべては理想のCancellation(中断)のために

理想の中断(Cancellation)とは、
「次のUniTaskが実行されない」ことだと思われます。
それを実現するのに2つのアプローチがあります:
・return
・throw

returnで中断する

CancellationToken.IsCancellationRequested()が成立ならreturnする。

public async UniTask PlayAsync(CancellationToken cancellationToken)
{
    // この時点で cancel されていたら終了
    if (cancellationToken.IsCancellationRequested())
    {
        return;
    }
    await HogeAsync(cancellationToken);

    // この時点で cancel されていたら終了
    if (cancellationToken.IsCancellationRequested())
    {
        return;
    }
    await WaraAsync(cancellationToken);

    // この時点で cancel されていたら終了
    if (cancellationToken.IsCancellationRequested())
    {
        return;
    }
    await FooAsync(cancellationToken);
}

「全てのUniTask」が上記サンプルコードのように、
「子UniTask直前」、或は「子UniTask直後」でチェックを入れれば、
理想の中断が実現できます。

しかし、超絶面倒くさい!

throwで中断する

UniTaskの起点でtry-catchを仕掛けて、
「全てのUniTaskの1行目」に
CancellationToken.IsCancellationRequested()を確認して成立ならthrowする。

そもそもthrowを使ったことがない

Q:throwって、exceptionを投げるのアレだよね?エラーにならないの?
A:try-catchで例外を受け取ればエラーを避けられます。

private async UniTask PlayAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested())
    {
        // 無視したい自作例外を throw
        throw new MyException();
    }
    ...
}

// Cancel しても Unity エラーが起こらない
private void Start()
{
    // gameObject が破棄された時に cancel する CancellationToken
    var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
    try
    {
        UniTask.Void(async () =>
        {
            await PlayAsync(cancellationToken);
        })
    }
    catch (MyException myException)
    {
        // 無視したい自作例外なので無視する (何もしない)
    }
}

// Cancel したら Unity エラーが起こる
private void Start()
{
    // gameObject が破棄された時に cancel する CancellationToken
    var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
    
    UniTask.Void(async () =>
    {
        await PlayAsync(cancellationToken);
    })
}

try-catchはUniTaskの起点だけでいいのは何故?

こう書くんじゃないの?

private async UniTask FadeAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested())
    {
        // 無視したい自作例外を throw
        throw new MyException();
    }
    ...
}

private async UniTask PlayAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested())
    {
        // 無視したい自作例外を throw
        throw new MyException();
    }
    
    try
    {
        await FadeAsync(cancellationToken);
    }
    catch (MyException myException)
    {
        // 無視したい自作例外なので無視する (何もしない)
    }
}

private void Start()
{
    // gameObject が破棄された時に cancel する CancellationToken
    var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
    try
    {
        UniTask.Void(async () =>
        {
            await PlayAsync(cancellationToken);
        })
    }
    catch (MyException myException)
    {
        // 無視したい自作例外なので無視する (何もしない)
    }
}

実は、throwは最も近いcatchに直接に飛びます!つまり、

private async UniTask FadeAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested())
    {
        // 無視したい自作例外を throw
        // PlayAsync() を経由せず, Start() の catch に直接飛びます!
        throw new MyException();
    }
    ...
}

private async UniTask PlayAsync(CancellationToken cancellationToken)
{
    if (cancellationToken.IsCancellationRequested())
    {
        // 無視したい自作例外を throw
        throw new MyException();
    }
    
    await FadeAsync(cancellationToken);
}

private void Start()
{
    // gameObject が破棄された時に cancel する CancellationToken
    var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
    try
    {
        UniTask.Void(async () =>
        {
            await PlayAsync(cancellationToken);
        })
    }
    catch (MyException myException)
    {
        // 無視したい自作例外なので無視する (何もしない)
    }
}

大変便利!

だからCancellation.ThrowIfCancellationRequested()

鋭い方々はすでに気付いてると思います。

if (cancellationToken.IsCancellationRequested())
{
    // MyException => OperationCanceledException
    throw new OperationCanceledException();
}

上記サンプルコードはまさにCancellationToken.ThrowIfCancellationRequested()そのもの。
そして、UniTaskのForget()などの中にはtry-catchが含まれている。
ですので、上記サンプルコードは更に簡潔に書き換えられます:

private async UniTask FadeAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();

    ...
}

private async UniTask PlayAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested();
    
    await FadeAsync(cancellationToken);
}

private void Start()
{
    // gameObject が破棄された時に cancel する CancellationToken
    var cancellationToken = gameObject.GetCancellationTokenOnDestroy();
    
    // Forget は try-catch を含んでいます
    // catch する対象は cancellationToken.ThrowIfCancellationRequested() が throw する OperationCanceledException
    PlayAsync(cancellationToken).Forget();
}

returnよりは遥かにシンプルではないでしょうか?


1行目だけで本当に大丈夫?

public async UniTask PlayAsync(CancellationToken cancellationToken)
{
    await FooAsync(cancellationToken);
    await BarAsync(cancellationToken);  // <-- ここで Cancellation 発生
    await HogeAsync(cancellationToken);
    await WaraAsync(cancellationToken);
}

まず、上記スコープだけを見てみましょう。
BarAsync()の実行途中でCancellation発生とします。
理想では、後続のHogeAsync()とWaraAsync()をスキップしますよね?

そこで、HogeAsync()とWaraAsync()の実行を阻止する方法は2つ:
1、UniTaskの間にCancellationToken.ThrowIfCancellationRequested()を挟む
2、UniTaskの頭にCancellationToken.ThrowIfCancellationRequested()を書く

UniTaskの間にCancellationToken.ThrowIfCancellationRequested()を挟む

public async UniTask PlayAsync(CancellationToken cancellationToken)
{
    await FooAsync(cancellationToken);

    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await BarAsync(cancellationToken);                // <-- ここで Cancellation 発生

    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await HogeAsync(cancellationToken);

    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await WaraAsync(cancellationToken);
}

考え方自体は問題ないですが、最大の懸念点は「書き漏れ」です。

UniTaskを使う度にCancellationToken.ThrowIfCancellationRequested()を書く義務が付随されます。
例えばPlayAsync()以外の所もBarAsync()を呼ぶと…

public async UniTask StopAsync(CancellationToken cancellationToken)
{
    await BarAsync(cancellationToken);

    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await FooAsync(cancellationToken);
}

このように、BarAsync()が100回呼び出される場合は、
CancellationToken.ThrowIfCancellationRequested()も100回書かなければなりません。

UniTaskの頭にCancellationToken.ThrowIfCancellationRequested()を書く

private async UniTask BarAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    ...
}

public async UniTask PlayAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await FooAsync(cancellationToken);  // <- ここで throw して終了
    await BarAsync(cancellationToken);  // <- ここで throw して終了
    await HogeAsync(cancellationToken); // <- ここで throw して終了
    await WaraAsync(cancellationToken); // <- ここで throw して終了
}

public async UniTask StopAsync(CancellationToken cancellationToken)
{
    cancellationToken.ThrowIfCancellationRequested(); // <- ここで throw して終了

    await BarAsync(cancellationToken); // <- ここで throw して終了
    await FooAsync(cancellationToken); // <- ここで throw して終了
}

このアプローチでは、無駄を見事に避けられましたね!
簡単明瞭ですので、コーディング規約にしやすいではないでしょうか。
「全てのUniTask」がこのルールを守っていれば、
「次のUniTaskの1行目で終了する=次のUniTaskが実行されない」ことが保証できます。

と、いうワケで

「全てのUniTask」の1行目にCancellationToken.ThrowIfCancellationRequested()を叩きましょう!
そのために、「全てのUniTask」にCancellationToken引数を渡しましょう!


おまけ:CancellationTokenこそUniTaskの主役

CancellationTokenはUniTaskを中断するのに唯一の方法だと思われます。
その重要性はUniRxにおけるIDisposableと同じくらいでしょう。

参画しているプロジェクトはCancellationTokenを強調するため、
UniTaskの「1番目の引数」として渡す規約まで定めています。

この記事が気に入ったらサポートをしてみませんか?