土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#Unity ゲームプログラミングにおいて例外処理は必要か?

導入

 TwitterのTLでヤスハラユウジさんのポッドキャストが流れてきたので聞いてみました。 voicy.jp  「ゲームプログラミングでは例外処理の実装は不要なのでは?」というお題で、興味深く拝聴しました。7分弱の音源なのでみなさんも是非お聞きください。

 内容について直接の意見はありません。土屋もUnityでコード書いている時にtry-catchを書く事は無いと思います。秒間60フレームで処理してる時にメモリ確保に失敗して例外が送出された時、対応しようがあるとはちょっと想像できません(例外処理してもしなくてもアプリは落ちるんじゃないかと思います)。

 ただ、「try-catchを書かない事」と「例外処理をしない事」は別の話です。try-catchは例外処理という機構の一部に過ぎず、我々ゲームプログラマも日常的に例外処理コードを書いているんだよという話を書いておこうと思います。

例外処理≒実行時エラーの回避

 「例外(exception)」にも幾つか種類がありまして、Javaだと例外は以下に分類されるそうです*1

  1. エラー例外:VMレベルのエラー(例:メモリ確保失敗/スタックオーバーフロー)
  2. 実行時例外:アプリケーションのバグ(例:オブジェクトのnull参照/配列の範囲外アクセス)
  3. 検査例外:検査例外(例:ファイルI/O(ある筈のファイルが存在しないなど)/データベースアクセスが失敗)

 このうち、1についてはアプリにはどうしようも無いので対処しません*2。3については、ゲームアプリは閉じた環境なので、検査例外が起きない事を前提にコードを組めるのでやはり対処しません*3

 というわけで対応が必要なのは2の実行時例外です。呼び慣れているので以下「実行時エラー」と呼びます。

Unityで例外を送出させる

 実は、C#でコードを書く際、実行時エラーは割と簡単に発生します。

 例えば、以下のコードを空のGameObjectにアタッチして実行してみましょう。

using UnityEngine;
using UnityEngine.UI;

public class exception : MonoBehaviour
{
    void Start()
    {
        Debug.Log("before");
        var button = GetComponent<Button>(); //buttonにはnullが格納される
        Debug.Log(button.ToString()); //ここで例外送出。Log()は実行されない
        Debug.Log("after"); //この行は実行されない
    }
}

 実行すると、以下の様に例外が送出された事がコンソールに表示されます。

 サンプルコードはFind()でGameObjectからButtonコンポーネントを取得しようとしましたが、そんなコンポーネントはアタッチされていないので、buttonにはnullが返ります。このnullオブジェクトを参照しようとしてNullReferenceException例外が送出されたのです。

 このように、nullオブジェクトにアクセスすると実行時エラー、つまり例外が発生します。こんな風に、例外は割と簡単に送出される物なのです。他にも、配列の範囲外アクセスをした場合はIndexOutOfRangeException例外が発生します。C#コードでは開発中よくこれらの例外に遭遇する事が多いのではないでしょうか。

 先のスクリーンショットを見ると、コードの最後のDebug.Log("after")が実行されていない事が分かります。nullオブジェクト参照した時点で例外が送出(いわゆるthrow)されて現在のスコープを脱出しているため、Start()メソッドは最後まで実行されていません。このように、例外が送出された場合、開発者が意図した実行フローが機能しなくなります。Unityアプリが停止していないように見えるのはUnityエンジン側で例外をトラップしているに過ぎず、この時点でもうまともな挙動は期待できません。そのため、これらの例外には対処が必要です。

 とはいえ、その為にこのコードをtry-catchで覆う必要はありません。どういう事かと言いますと、そもそもnullオブジェクトを参照するのがいけないので、取得したオブジェクトがnullだったらアクセスしないようにすればいいのです。これならそもそも例外が送出されないので、try-catchは必要ありません。

 以下、nullオブジェクトへの参照を回避するコードを考えてみます。

1:条件分岐による回避

 一番簡単な回避方法は、取得したオブジェクトをif文でnullチェックする方法です。

void Start()
{
    Debug.Log("before");
    var button = GetComponent<Button>(); //buttonにはnullが格納される
    if(button != null)
    {
        Debug.Log(button.ToString());//実行されない
    }
    Debug.Log("after");
}

 この場合、出力は以下の様になります。

 nullチェックによりbutton.ToString()へのアクセスがスキップされるので、例外は送出されずに"after"が出力されます。

 このnullチェックの意味を改めて考えると、これは「例外が送出されるのを未然に防ぐ」という処理です。もしこのチェックが無ければ例外が送出されている訳ですから、このnullチェックは「例外処理」と言えます。

 上記のコードをtry-catchで書いてみると以下の様になるでしょうか。

void Start()
{
    Debug.Log("before");
    var button = GetComponent<Button>(); //buttonにはnullが格納される
    try{
        Debug.Log(button.ToString()); //ここで例外送出。Log()は実行されない
    }
    catch
    {

    }
    Debug.Log("after");
}

 2つのコードの違いは、事前に例外の送出を回避しているかどうかにあり、どちらも例外処理をしているという点では同じです(恐らくですがパフォーマンスは後者の方がずっと悪いので、通常は前者で実装すべきです)。

 このように、try-catch以外にも例外処理コードという物はあり、我々は日常的にそのようなコードを実装しているのです。

2:null結合演算子による回避

 C#では、先述したような「取得したオブジェクトがnullでなければそのフィールドにアクセスする」というコードが頻出します。その為、この様なコードをシンプルに書けるよう、C#のバージョンアップ時に構文が追加されました。それらの手法も見てみましょう。

void Start()
{
    Debug.Log("before");
    var button = GetComponent<Button>() ?? gameObject.AddComponent<Button>();
    Debug.Log(button.ToString()); //例外は発生しない。
    Debug.Log("after");
}

 ??は、C#2.0から追加された「null結合演算子(null coalescing operator)」です。左辺のオペランドがnullでなければ左辺を、nullなら右辺を返します。

 ここでは左辺がnullを返すので、右辺(動的に追加したbuttonコンポーネント)がbuttonに格納されます(実用的なコードではありませんが、説明のためなのでご容赦下さい)。これによってbutton.ToString()は常に有効になります。

注意:Unityではnull結合演算子の使用が推奨されていません。これについては後述します。

3:null条件演算子による回避

void Start()
{
    Debug.Log("before");
    Debug.Log(GetComponent<Button>()?.ToString()); //例外は発生しない
    Debug.Log("after");
}

 ?.は、C#6.0から追加された「null条件演算子(null-conditional operator)」です。左辺オペランド(ここではGetComponent<Button>)がnullでなければフィールドの評価を続け、nullであればフィールドの評価を行わずにnullを返します。

 このコードで説明すると、GetComponent<Button>()がnullでなければそのままToString()メソッドが実行され、nullであればメソッドの呼び出しはせずにnullを返します。Debug.Log()は引数にnullオブジェクトを許容するので、例外は送出されません。

注意:Unityではnull条件演算子の使用が推奨されていません。これについては後述します。

4:イテレーターによる回避

 ここまでいくつか例を挙げましたが、実践では以下の様にイテレータを用いるのが一般的だし、そうすべきだと思います。

void Start()
{
    Debug.Log("before");
    var buttonList = GetComponents<Button>();
    foreach(var button in  buttonList)
    {
        Debug.Log(button.ToString());
    }
    Debug.Log("after");
}

 これまでと異なりGetComponents<Button>sがついて複数形になっていることに注意してください。このメソッドは条件に合致するコンポーネントのリストを返します。該当する物が無い場合は要素ゼロのリストが帰ります。 そしてforeachは要素ゼロならなにもせずにループを抜けます。このコードではそもそもnullが格納されないので、null参照が起きる危険性もありません。これも、潜在的な例外処理と言えるでしょう*4

注意

 この記事では例を示すために使用しましたが、Unityではnullの扱いが特殊*5なため、MonoBehavior継承クラスへのnull結合演算子/null条件演算子の利用が推奨されていません。

 例えばunityでnull結合演算子を使う場合以下の様な注釈が出ます。

 土屋も通常は使用していませんので、ご注意ください。

参考:Verse言語の場合

 Verse言語にはnullがありません。また、言語仕様レベルで実行時エラーが発生しないように設計されています*6。そのため、Verseには例外も、try-catchに相当する例外処理機構も用意されていません。

 しかしこれは例外処理機構(try-catch)が不要なのではなく、C++やC#と違って例外処理機構が無くても問題が起きないように言語が設計されているからです。Verse言語に限らず、モダンなプログラミング言語ではこのような方針の物もあるようです*7

おわりに

 null参照例外を回避するための方法を色々見て来ました。いかがでしたでしょうか。

 ゲームでは「シーンに配置されているオブジェクトを一定の条件でクエリして、それら全部のアニメーションを止める」というようなコードを頻繁に書く事があり、多くの場合、そのクエリ結果がnullかどうかの判定が必要になると思います。この対応を怠ると例外が送出されるので、土屋はこれも例外処理だと考えています。

 冒頭にも書いたように土屋はtry-catchを基本的には書きません(キャッチした例外をどう処理していいかわからないから)。また、以前はゲームプログラミングで例外処理(try-catch)を書く余地は無いと考えていました。その後、「例外処理」は当初自分が考えていたよりもずっと大きな概念で、try-catchによる例外捕捉メカニズムは例外処理の一部に過ぎないと考えるようになりました。

 この記事で示したように、実際にはゲームエンジニアは日常的に例外処理を書いているという事を意識しておくと良いと思います。なお、null参照例外回避については、基本方針として「そもそもnull参照が起きうるコードを書かない」ことが大事です。今回の例で言うと「4:イテレータによる回避」が一番安全でしょう。

余談:null元凶説について

 ここまで読んで「そもそもnullを返せてしまう事自体が問題だ」と考える方もいるかもしれません。これは大正解で、C#にnullを採用した事は10億ドルの過ちだとも言われています。先述したVerseのように、最初からnullを持たない言語を使うのも選択肢の1つです。しかし、C#にはnullがあるので、好む好まざるにかかわらず、対応が必要です*8

宣伝

 同人誌「Unityシェーダープログラミングの教科書」シリーズ(既刊5巻)のPDF版をBOOTHで頒布しています。

s-games.booth.pm

s-games.booth.pm

*1:ググった知識なので正確な所は知らない https://java-code.jp/146

*2:してる人もいるかもしれないけど土屋はしてない

*3:オンラインゲームだと話は全く別

*4:いやどうかな?

*5:GameObjectの生存期間管理をUnity本体で行う為に、nullの条件判定がUnity本体によってオーバーライドされている。

*6:実際には実装が不完全なためにまだ実行時エラーは起きるのですが。

*7:良く知らない

*8:今回は触れませんでしたがnullable対応というアプローチもあります。ただしUnityでの開発では機能しないんじゃないかと考えています(未確認)。